From 198eb1722acc6d557dccf0287774e08aac4daa58 Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 20 Jul 2026 13:23:07 +0800 Subject: [PATCH] feat(sidebar): persisted project-level last-activity timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects now carry their own last-activity timestamp, bumped on session create and on real turns (session UpdatedAt moves), and never rolled back on delete. The sidebar sorts project groups by this timestamp (falling back to deriving from child sessions when absent) and shows a relative time in the project header — so deleting a conversation no longer changes its project's time or reorders the project list. Storage: a projects.json sidecar next to the session index (NOT an embedded key in session.json — an older binary rewriting the shared index would silently drop an embedded section on mixed-version installs; a sidecar it never touches is immune). All comparisons are on parsed RFC3339 instants, not strings: the data mixes server-local offsets with UTC, and lexicographic order breaks across offsets. - session: ProjectMeta + projects.json (atomic private writes under indexMu), monotonic-max touch on addToIndex / UpdateSessionMeta, untouched on DeleteSessionByUUID, ListProjectMeta accessor - web: GET /api/projects endpoint; task_status WS events now carry the project path + the exact server-side timestamp being persisted - web UI: projectTimes in the session slice (merge-on-load, live touch from task_status with the server's values), project header time + delete-stable ordering in the Sidebar - tests: lifecycle contract incl. mixed-offset monotonicity, legacy fallback, delete/title round-trip; GET /api/projects handler --- internal/session/index_test.go | 130 +++++++++++++++++++++++++++++ internal/session/session.go | 146 ++++++++++++++++++++++++++++++++- internal/web/engine.go | 20 +++-- internal/web/projects_test.go | 80 ++++++++++++++++++ internal/web/server.go | 1 + internal/web/sessions.go | 26 ++++++ web/src/app/store.ts | 45 +++++++++- web/src/app/wsBridge.ts | 13 ++- web/src/components/Sidebar.tsx | 43 ++++++++-- web/src/lib/api.ts | 3 +- web/src/lib/types.ts | 8 ++ web/src/lib/ws.ts | 4 +- 12 files changed, 499 insertions(+), 20 deletions(-) create mode 100644 internal/web/projects_test.go diff --git a/internal/session/index_test.go b/internal/session/index_test.go index 85f7ec27..5533921b 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 b1f2e7af..6d29435c 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 31ec38e8..06d534ec 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 00000000..c7d8865c --- /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 30de0a26..5353a99c 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 e4287f44..234c4967 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 c45c4703..2597b0c5 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 078e084e..650b5693 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 a3d02d8e..2d231feb 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) && (