diff --git a/internal/session/index_test.go b/internal/session/index_test.go index 85f7ec2..5533921 100644 --- a/internal/session/index_test.go +++ b/internal/session/index_test.go @@ -44,6 +44,136 @@ func TestRecorderIndexingRequiresContent(t *testing.T) { rec.Close() } +// TestProjectTimestampLifecycle locks the sidebar-ordering contract for the +// per-project "last activity" timestamp (persisted in projects.json next to +// the session index): +// - creating a session stamps the project with the session's start time; +// - moving a session's UpdatedAt (a real turn) moves the project forward; +// - a metadata-only edit (title/pin/…) leaves it untouched; +// - the timestamp is a monotonic max over parsed INSTANTS — an older write +// never moves it backwards, even when its string sorts greater (mixed +// UTC offsets: "+08:00" vs "Z"); +// - deleting a session NEVER rolls the project timestamp back — deleting +// the newest conversation must not reorder the project list; +// - the projects file survives unrelated index rewrites (title updates). +func TestProjectTimestampLifecycle(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + const project = "/proj/timestamps" + + // Legacy install: no projects.json yet → nil map, no error. + meta, err := ListProjectMeta() + if err != nil { + t.Fatalf("ListProjectMeta (legacy): %v", err) + } + if meta != nil { + t.Fatalf("legacy install should yield a nil project map, got %+v", meta) + } + + // Creating a session stamps the project. + if err := addToIndex(project, SessionMeta{UUID: "uuid-a", Project: project, StartTime: "2026-07-01T10:00:00+08:00"}); err != nil { + t.Fatalf("addToIndex: %v", err) + } + meta, err = ListProjectMeta() + if err != nil { + t.Fatalf("ListProjectMeta: %v", err) + } + if got := meta[project].UpdatedAt; got != "2026-07-01T10:00:00+08:00" { + t.Fatalf("project timestamp after create = %q, want the session start time", got) + } + + // A real turn (UpdatedAt moves) moves the project forward. + if _, err := UpdateSessionMeta("uuid-a", func(m *SessionMeta) { + m.UpdatedAt = "2026-07-02T09:30:00+08:00" + }); err != nil { + t.Fatalf("UpdateSessionMeta: %v", err) + } + meta, _ = ListProjectMeta() + if got := meta[project].UpdatedAt; got != "2026-07-02T09:30:00+08:00" { + t.Fatalf("project timestamp after turn = %q, want the bumped UpdatedAt", got) + } + + // A metadata-only edit must not touch the project timestamp. + if _, err := UpdateSessionMeta("uuid-a", func(m *SessionMeta) { + m.Title = "renamed" + m.Pinned = true + }); err != nil { + t.Fatalf("UpdateSessionMeta(title): %v", err) + } + meta, _ = ListProjectMeta() + if got := meta[project].UpdatedAt; got != "2026-07-02T09:30:00+08:00" { + t.Fatalf("project timestamp moved on a metadata edit: %q", got) + } + + // Mixed-offset monotonicity — the case string comparison gets WRONG. + // Stored: 2026-07-02T09:30:00+08:00 (= 01:30Z). Candidate: + // 2026-07-02T02:00:00Z (= 02:00Z) is a genuinely NEWER instant, yet its + // string sorts BELOW the stored one ("02:00Z" < "09:30+08:00"). A + // lexicographic max would keep the stale value; instant compare must + // accept it. + if _, err := UpdateSessionMeta("uuid-a", func(m *SessionMeta) { + m.UpdatedAt = "2026-07-02T02:00:00Z" + }); err != nil { + t.Fatalf("UpdateSessionMeta(utc): %v", err) + } + meta, _ = ListProjectMeta() + if got := meta[project].UpdatedAt; got != "2026-07-02T02:00:00Z" { + t.Fatalf("project timestamp ignored a newer UTC instant (string-compare bug): %q", got) + } + + // And the reverse: an older instant whose string sorts greater must NOT + // win. Stored 02:00Z; candidate "2026-07-02T09:00:00+08:00" = 01:00Z + // (older) but "09:00+08:00" > "02:00Z" lexicographically. + if _, err := UpdateSessionMeta("uuid-a", func(m *SessionMeta) { + m.UpdatedAt = "2026-07-02T09:00:00+08:00" + }); err != nil { + t.Fatalf("UpdateSessionMeta(old): %v", err) + } + meta, _ = ListProjectMeta() + if got := meta[project].UpdatedAt; got != "2026-07-02T02:00:00Z" { + t.Fatalf("project timestamp moved backwards on an older instant: %q", got) + } + + // An unrelated index rewrite (title update) must leave projects.json intact. + if err := updateIndexTitle(project, "uuid-a", "new title"); err != nil { + t.Fatalf("updateIndexTitle: %v", err) + } + meta, _ = ListProjectMeta() + if got := meta[project].UpdatedAt; got != "2026-07-02T02:00:00Z" { + t.Fatalf("projects file clobbered by a title update: %q", got) + } + + // Deleting the session must NOT roll the timestamp back — this is what + // keeps the sidebar's project ordering stable across deletes. + found, err := DeleteSessionByUUID("uuid-a") + if err != nil || !found { + t.Fatalf("DeleteSessionByUUID: found=%v err=%v", found, err) + } + meta, _ = ListProjectMeta() + if got := meta[project].UpdatedAt; got != "2026-07-02T02:00:00Z" { + t.Fatalf("project timestamp changed by delete: %q, want it preserved", got) + } +} + +// TestTouchProjectIgnoresEmptyTimestamp guards the addToIndex path for a +// zero-value SessionMeta: an empty StartTime must not create a projects file +// entry with an empty (unparseable) timestamp. +func TestTouchProjectIgnoresEmptyTimestamp(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + const project = "/proj/empty-ts" + if err := addToIndex(project, SessionMeta{UUID: "uuid-x", Project: project}); err != nil { + t.Fatalf("addToIndex: %v", err) + } + meta, err := ListProjectMeta() + if err != nil { + t.Fatalf("ListProjectMeta: %v", err) + } + if got := meta[project].UpdatedAt; got != "" { + t.Fatalf("empty StartTime must not stamp the project, got %q", got) + } +} + // TestConcurrentIndexWritesNoLostUpdate guards the indexMu serialization: many // goroutines adding distinct sessions concurrently must ALL survive in the // index. Without the lock, the read-modify-rename writers lose updates (and diff --git a/internal/session/session.go b/internal/session/session.go index b1f2e7a..6d29435 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -184,11 +184,124 @@ type SessionMeta struct { ErrorReason string `json:"error_reason,omitempty"` } +// ProjectMeta is project-level metadata kept in its own file (projects.json, +// alongside the session index). UpdatedAt is the project's "last activity" +// timestamp: it is bumped when a session is created or when a session's own +// UpdatedAt moves (a real turn), and — deliberately — is NEVER rolled back +// when a session is deleted. The sidebar sorts projects by this timestamp, so +// deleting a conversation must not reorder the project list. +// +// Stored separately from session.json on purpose: older binaries rewriting +// the index don't know this data exists, and would silently drop an embedded +// "projects" section on their next read-modify-write (the index is shared +// across processes — desktop sidecar + CLI may run different versions). A +// sidecar file they never touch is immune; losing it only degrades the +// sidebar to session-derived recency, and the next turn re-stamps it. +type ProjectMeta struct { + UpdatedAt string `json:"updated_at,omitempty"` // RFC3339 +} + // sessionIndex is the on-disk structure of session.json. type sessionIndex struct { Sessions map[string][]SessionMeta `json:"sessions"` // project path → metas } +// isNewerTimestamp reports whether candidate is a strictly newer instant than +// current, comparing parsed RFC3339 instants — NOT raw strings. String +// comparison breaks across UTC offsets ("05:00Z" is the same instant as +// "13:00+08:00" yet compares smaller), and the index mixes local-offset +// writes (time.Now().Format(time.RFC3339)) with UTC ones. Unparseable +// candidates never win; any candidate beats an unparseable current value. +func isNewerTimestamp(candidate, current string) bool { + ca, err := time.Parse(time.RFC3339, candidate) + if err != nil { + return false + } + if current == "" { + return true + } + cur, err := time.Parse(time.RFC3339, current) + if err != nil { + return true + } + return ca.After(cur) +} + +// touchProjectLocked bumps the project's persisted last-activity timestamp to +// ts when ts is a newer instant than the stored value (monotonic max — an +// out-of-order write never moves the timestamp backwards). Callers must hold +// indexMu, which serializes every read-modify-rename writer of BOTH the +// session index and the projects file. +func touchProjectLocked(project, ts string) error { + if project == "" || ts == "" { + return nil + } + projects, err := loadProjectsLocked() + if err != nil { + return err + } + if projects == nil { + projects = make(map[string]ProjectMeta) + } + if !isNewerTimestamp(ts, projects[project].UpdatedAt) { + return nil + } + projects[project] = ProjectMeta{UpdatedAt: ts} + return saveProjectsLocked(projects) +} + +// projectsFilePath returns the path of the per-project metadata file that +// lives next to the session index. +func projectsFilePath() (string, error) { + dir, err := config.SessionsDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "projects.json"), nil +} + +// loadProjectsLocked reads the projects file. A missing file (legacy install, +// no activity since upgrade) yields a nil map, not an error. +func loadProjectsLocked() (map[string]ProjectMeta, error) { + path, err := projectsFilePath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var projects map[string]ProjectMeta + if err := json.Unmarshal(data, &projects); err != nil { + return nil, err + } + return projects, nil +} + +// saveProjectsLocked atomically persists the projects map (temp + rename, +// private mode — same discipline as the session index writers). +func saveProjectsLocked(projects map[string]ProjectMeta) error { + path, err := projectsFilePath() + if err != nil { + return err + } + if err := ensurePrivateSessionDir(filepath.Dir(path)); err != nil { + return err + } + data, err := json.MarshalIndent(projects, "", " ") + if err != nil { + return err + } + tmpPath := path + ".tmp" + if err := writePrivateSessionFile(tmpPath, data); err != nil { + return err + } + return os.Rename(tmpPath, path) +} + func ensurePrivateSessionDir(path string) error { if err := os.MkdirAll(path, privateSessionDirMode); err != nil { return err @@ -785,7 +898,15 @@ func addToIndex(project string, meta SessionMeta) error { if err := writePrivateSessionFile(tmpPath, newData); err != nil { return err } - return os.Rename(tmpPath, indexPath) + if err := os.Rename(tmpPath, indexPath); err != nil { + return err + } + // A new session is project activity: bump the project-level timestamp so + // the sidebar's project ordering reflects it. Best-effort AFTER the index + // write succeeded — a projects-file hiccup must not fail session creation + // (the sidebar falls back to session-derived recency). + _ = touchProjectLocked(project, meta.StartTime) + return nil } // generateTitle creates a human-readable session title from the first user message. @@ -878,6 +999,10 @@ func DeleteSessionByUUID(uuid string) (bool, error) { filtered = append(filtered, m) } idx.Sessions[project] = filtered + // Deliberately do NOT touch the projects file here: the project-level + // timestamp records "last activity", and a deletion is not activity. + // Leaving it alone keeps the sidebar's project ordering stable across + // deletes (deleting the newest conversation must not sink its project). } if !found { return false, nil @@ -948,6 +1073,7 @@ func UpdateSessionMeta(uuid string, mutate func(*SessionMeta)) (*SessionMeta, er for project, metas := range idx.Sessions { for i := range metas { if metas[i].UUID == uuid { + beforeUpdatedAt := metas[i].UpdatedAt mutate(&metas[i]) idx.Sessions[project] = metas newData, err := json.MarshalIndent(&idx, "", " ") @@ -961,6 +1087,15 @@ func UpdateSessionMeta(uuid string, mutate func(*SessionMeta)) (*SessionMeta, er if err := os.Rename(tmpPath, indexPath); err != nil { return nil, err } + // A moved UpdatedAt means real activity (a turn started/finished — + // the web layer's setTaskStatus). Mirror it onto the project-level + // timestamp so the sidebar's project ordering tracks activity. + // Metadata-only edits (pin/archive/rename) deliberately leave + // UpdatedAt untouched, so they never reorder projects either. + // Best-effort, like addToIndex: the index write already succeeded. + if metas[i].UpdatedAt != beforeUpdatedAt { + _ = touchProjectLocked(project, metas[i].UpdatedAt) + } updated := metas[i] // The index keys sessions by project, so the stored meta may not // carry its own Project. Populate it on the returned copy so callers @@ -993,6 +1128,15 @@ func ListAllSessions() (map[string][]SessionMeta, error) { return idx.Sessions, nil } +// ListProjectMeta returns the per-project metadata (last-activity timestamps) +// keyed by project path. A nil map (legacy install: no projects.json yet) is +// returned as-is; callers fall back to deriving recency from sessions. +// Lock-free like ListSessions/ListAllSessions: writers persist via atomic +// rename, so readers always observe a complete file. +func ListProjectMeta() (map[string]ProjectMeta, error) { + return loadProjectsLocked() +} + // LoadSession reads all entries from a session JSONL file identified by uuid. func LoadSession(id string) ([]Entry, error) { if err := ValidateSessionID(id); err != nil { diff --git a/internal/web/engine.go b/internal/web/engine.go index 31ec38e..06d534e 100644 --- a/internal/web/engine.go +++ b/internal/web/engine.go @@ -554,7 +554,10 @@ func (e *Engine) teardown() { // can mark the task running/idle live) and best-effort persists Status + // UpdatedAt so recency survives a reload. The broadcast carries the task id in // its DATA (not the envelope TaskID) so it is delivered to all clients, not -// filtered to the task's subscribers. +// filtered to the task's subscribers. It also carries the task's project path +// and the exact server-side timestamp being persisted, so clients can bump the +// project-level "last activity" clock with the server's value (not the +// browser's clock) without re-deriving either from their local task list. func (s *Server) setTaskStatus(eng *Engine, running bool) { if eng == nil || eng.taskID == "" { return @@ -563,17 +566,20 @@ func (s *Server) setTaskStatus(eng *Engine, running bool) { if running { status = "running" } + now := time.Now().Format(time.RFC3339) s.wsBroker.Broadcast(WSEvent{Type: "task_status", Data: map[string]any{ - "task_id": eng.taskID, - "running": running, - "status": status, + "task_id": eng.taskID, + "running": running, + "status": status, + "project": eng.pwd, + "updated_at": now, }}) - go func(id, st string) { + go func(id, st, ts string) { _, _ = session.UpdateSessionMeta(id, func(m *session.SessionMeta) { m.Status = st - m.UpdatedAt = time.Now().Format(time.RFC3339) + m.UpdatedAt = ts }) - }(eng.taskID, status) + }(eng.taskID, status, now) } // CloseAllEngines tears down every live engine. Called on server shutdown. diff --git a/internal/web/projects_test.go b/internal/web/projects_test.go new file mode 100644 index 0000000..c7d8865 --- /dev/null +++ b/internal/web/projects_test.go @@ -0,0 +1,80 @@ +package web + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/cnjack/jcode/internal/session" +) + +// seedProjects writes a projects.json (per-project last-activity timestamps) +// next to the session index seeded by seedIndex, so handleListProjects can be +// tested in-process without touching the real ~/.jcode. +func seedProjects(t *testing.T, projects map[string]session.ProjectMeta) { + t.Helper() + home := os.Getenv("HOME") + if home == "" { + t.Fatal("seedProjects must run after seedIndex (HOME unset)") + } + dir := filepath.Join(home, ".jcode", "sessions") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(projects) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "projects.json"), data, 0o600); err != nil { + t.Fatal(err) + } +} + +// GET /api/projects with no projects file (legacy install) returns an empty +// array, not null — the sidebar iterates the response directly. +func TestListProjectsEmpty(t *testing.T) { + seedIndex(t, map[string][]session.SessionMeta{}) + s := &Server{} + rec := httptest.NewRecorder() + s.handleListProjects(rec, httptest.NewRequest(http.MethodGet, "/api/projects", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String()) + } + if got := strings.TrimSpace(rec.Body.String()); got != "[]" { + t.Fatalf("want [], got %q", got) + } +} + +// GET /api/projects echoes the persisted per-project timestamps. +func TestListProjectsReturnsTimestamps(t *testing.T) { + seedIndex(t, map[string][]session.SessionMeta{}) + seedProjects(t, map[string]session.ProjectMeta{ + "/proj/a": {UpdatedAt: "2026-07-19T18:00:00+08:00"}, + "/proj/b": {UpdatedAt: "2026-07-16T03:00:00Z"}, + }) + s := &Server{} + rec := httptest.NewRecorder() + s.handleListProjects(rec, httptest.NewRequest(http.MethodGet, "/api/projects", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String()) + } + var items []projectItem + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("not JSON: %v", err) + } + if len(items) != 2 { + t.Fatalf("want 2 projects, got %+v", items) + } + sort.Slice(items, func(i, j int) bool { return items[i].Path < items[j].Path }) + if items[0].Path != "/proj/a" || items[0].UpdatedAt != "2026-07-19T18:00:00+08:00" { + t.Fatalf("unexpected /proj/a item: %+v", items[0]) + } + if items[1].Path != "/proj/b" || items[1].UpdatedAt != "2026-07-16T03:00:00Z" { + t.Fatalf("unexpected /proj/b item: %+v", items[1]) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 30de0a2..5353a99 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -345,6 +345,7 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("GET /api/git/branches", s.handleGitBranches) mux.HandleFunc("POST /api/git/checkout", s.handleGitCheckout) mux.HandleFunc("GET /api/tasks", s.handleListAllTasks) + mux.HandleFunc("GET /api/projects", s.handleListProjects) mux.HandleFunc("PATCH /api/tasks/{id}", s.handleUpdateTask) mux.HandleFunc("GET /api/usage/stats", s.handleUsageStats) mux.HandleFunc("GET /api/tasks/{id}/stats", s.handleTaskStats) diff --git a/internal/web/sessions.go b/internal/web/sessions.go index e4287f4..234c496 100644 --- a/internal/web/sessions.go +++ b/internal/web/sessions.go @@ -94,6 +94,32 @@ func (s *Server) handleListAllTasks(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, items) } +// projectItem is the sidebar's view of a project: its path plus the persisted +// last-activity timestamp. The timestamp lives at the project level (bumped on +// session create / turn start / turn end) and is deliberately NOT recomputed +// from the surviving sessions, so deleting a conversation never reorders the +// project list. +type projectItem struct { + Path string `json:"path"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// handleListProjects returns every project that has persisted metadata (last +// activity timestamp) so the sidebar can order project groups by a stable, +// delete-resistant timestamp instead of re-deriving it from child sessions. +func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) { + meta, err := session.ListProjectMeta() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + items := make([]projectItem, 0, len(meta)) + for path, pm := range meta { + items = append(items, projectItem{Path: path, UpdatedAt: pm.UpdatedAt}) + } + writeJSON(w, http.StatusOK, items) +} + // handleUpdateTask applies a partial metadata update (pin/archive/unread/title) // to a task by uuid across all projects. func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) { diff --git a/web/src/app/store.ts b/web/src/app/store.ts index c45c470..2597b0c 100644 --- a/web/src/app/store.ts +++ b/web/src/app/store.ts @@ -16,7 +16,7 @@ import { configureStore, createSlice, createAsyncThunk } from '@reduxjs/toolkit' import type { ThreadItem, Message, ToolCall, Approval, TokenSnapshot, Goal, TodoItem, QueuedMessage, AskUserQuestion } from 'jcode-ui-core' import { api } from '../lib/api' import { extractToolDisplayInfo } from '../lib/toolInfo' -import { normalizeMode, type AgentMode, type ProviderInfo, type SessionItem, type TaskItem, type SlashCommandInfo, type SessionEntry, type ModelRef } from '../lib/types' +import { normalizeMode, type AgentMode, type ProviderInfo, type SessionItem, type TaskItem, type ProjectInfo, type SlashCommandInfo, type SessionEntry, type ModelRef } from '../lib/types' // ─── seq counter (stable DOM identity across streaming updates) ─── let _seq = 0 @@ -25,6 +25,20 @@ function genId(prefix: string) { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 6)}` } +/** True when `candidate` is a strictly newer instant than `current` (either + * may be undefined). Compares PARSED instants, not raw strings: RFC3339 + * string order breaks across UTC offsets ("05:00Z" < "13:00+08:00" though + * they're the same instant), and the data mixes server-local and UTC writes. + * An unparseable candidate never wins. */ +function isNewerTs(candidate: string, current: string | undefined): boolean { + const ct = Date.parse(candidate) + if (Number.isNaN(ct)) return false + if (!current) return true + const cur = Date.parse(current) + if (Number.isNaN(cur)) return true + return ct > cur +} + // ═══════════════════════════════════════════════════════════════════════════ // chat slice — timeline + streaming accumulation // ═══════════════════════════════════════════════════════════════════════════ @@ -394,6 +408,11 @@ const chatSlice = createSlice({ interface SessionState { sessions: SessionItem[] tasks: TaskItem[] + /** Per-project last-activity timestamp (RFC3339), keyed by project path. + * Persisted server-side and bumped on session create / turn start / turn + * end — never on delete — so the sidebar's project ordering is stable + * across conversation deletions. */ + projectTimes: Record currentSessionId: string projectPath: string wsConnected: boolean @@ -402,6 +421,7 @@ interface SessionState { const initialSession: SessionState = { sessions: [], tasks: [], + projectTimes: {}, currentSessionId: '', projectPath: '', wsConnected: false, @@ -444,6 +464,23 @@ const sessionSlice = createSlice({ const t = s.tasks.find((x) => x.uuid === a.payload.taskId) if (t) t.running = a.payload.running }, + /** Merge the server's per-project timestamps (GET /api/projects). Merges + * with a monotonic max per key instead of replacing: a live touch that + * lands while the fetch is in flight must not be clobbered by the stale + * snapshot. */ + setProjectTimes(s, a: { payload: ProjectInfo[] }) { + for (const p of a.payload) { + if (!p.path || !p.updated_at) continue + if (isNewerTs(p.updated_at, s.projectTimes[p.path])) s.projectTimes[p.path] = p.updated_at + } + }, + /** Live-bump one project's timestamp (a turn started/ended there). Mirrors + * the server-side touch: monotonic max, never moves backwards. */ + touchProjectTime(s, a: { payload: { path: string; ts: string } }) { + const { path, ts } = a.payload + if (!path || !ts) return + if (isNewerTs(ts, s.projectTimes[path])) s.projectTimes[path] = ts + }, /** Insert or merge a task so the sidebar shows it immediately. */ upsertTask(s, a: { payload: TaskItem }) { const i = s.tasks.findIndex((t) => t.uuid === a.payload.uuid) @@ -869,6 +906,11 @@ export const loadTasks = createAsyncThunk('tasks/load', async (_, { dispatch }) dispatch(sessionActions.setTasks(tasks)) }) +export const loadProjects = createAsyncThunk('projects/load', async (_, { dispatch }) => { + const projects = await api.projects() + dispatch(sessionActions.setProjectTimes(projects)) +}) + export const loadSlashCommands = createAsyncThunk('chat/loadSlash', async (_, { dispatch }) => { const cmds = await api.slashCommands() dispatch(chatActions.setSlashCommands(cmds)) @@ -983,6 +1025,7 @@ export const loadWorkspaceState = createAsyncThunk('app/loadWorkspaceState', asy dispatch(loadModelState()), dispatch(loadSessions()), dispatch(loadTasks()), + dispatch(loadProjects()), dispatch(loadSlashCommands()), dispatch(loadApprovalMode()), dispatch(loadChannelState()), diff --git a/web/src/app/wsBridge.ts b/web/src/app/wsBridge.ts index 078e084..650b569 100644 --- a/web/src/app/wsBridge.ts +++ b/web/src/app/wsBridge.ts @@ -133,7 +133,18 @@ export function createWSHandlers( dispatch(chatActions.addMessage({ role: 'user', content: d.content, source: d.source })) dispatch(chatActions.setRunning(true)) }, - onTaskStatus: (taskId, running) => dispatch(sessionActions.setTaskRunning({ taskId, running })), + onTaskStatus: (taskId, running, project, updatedAt) => { + dispatch(sessionActions.setTaskRunning({ taskId, running })) + // A status flip means real activity (a turn started/ended) — the server + // bumps the project-level timestamp in the same write and sends both the + // project path and its exact timestamp, so mirror them with the SERVER's + // values (never the browser clock, which may be skewed). Fall back to the + // local task list only for older servers that omit the fields. + const path = project || getState().session.tasks.find((t) => t.uuid === taskId)?.project + if (path) { + dispatch(sessionActions.touchProjectTime({ path, ts: updatedAt || new Date().toISOString() })) + } + }, onSessionReset: () => dispatch(chatActions.clearChat()), } } diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index a3d02d8..2d231fe 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -90,6 +90,7 @@ export function Sidebar() { const dispatch = useAppDispatch() const sessions = useAppSelector((s) => s.session.sessions) const tasks = useAppSelector((s) => s.session.tasks) + const projectTimes = useAppSelector((s) => s.session.projectTimes) const currentSessionId = useAppSelector((s) => s.session.currentSessionId) const activePath = useAppSelector((s) => s.session.projectPath) const activeView = useAppSelector((s) => s.ui.activeView) @@ -276,7 +277,7 @@ export function Sidebar() { label: projectName(path), items: map.get(path) || [], })) - return projectGroups.sort((a, b) => compareProjectGroups(a, b, activePath)) + return projectGroups.sort((a, b) => compareProjectGroups(a, b, activePath, projectTimes)) } const map = new Map() @@ -292,7 +293,7 @@ export function Sidebar() { label: t(`sidebar.dateBucket.${k}`), items: map.get(k)!, })) - }, [sorted, filters.groupBy, filters.status, filters.lastActivity, projectFilter, activePath, now, t]) + }, [sorted, filters.groupBy, filters.status, filters.lastActivity, projectFilter, activePath, projectTimes, now, t]) const duplicateProjectNames = useMemo(() => { const counts = new Map() @@ -569,6 +570,9 @@ export function Sidebar() { {g.path && duplicateProjectNames.has(g.label) && !isRemotePath(g.path) && ( {projectParentHint(g.path)} )} + + {relativeTime((g.path && projectTimes[g.path]) || aggregate(g.items).lastTs, now, t)} + {!open && g.items.some((row) => row.running) && (