From 2f7a4e90400fa368d6f99d01cd0b0746219d9ccd Mon Sep 17 00:00:00 2001 From: Antisophy <293439221+Antisophy@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:01:30 -0700 Subject: [PATCH 1/2] feat(web): optional activity-ordered sidebar Adds sidebar_sort_by_recency, off by default. With it enabled the sidebar is ordered by activity rather than creation: the most recently worked-on task sits at the top and the order updates as tasks are used. A parent rises with its most recently active descendant at any depth, so a task does not sit stale above or below work happening beneath it. Siblings re-sort among themselves and nothing is reparented, leaving the tree structure untouched. Archive and Import move below the live tasks, and New Task moves to the top. Ordering needs a timestamp for when a task was last worked on, and neither existing signal answers that. last_active is cleared when a session starts, for crash recovery, and recovered from the transcript's mtime; the startup sweep resumes every in-flight session and each resume appends to its transcript, so a single restart rewrites every mtime and collapses all tasks onto one timestamp. A resume writes session and meta records rather than a conversation turn, so the newest user/assistant record in the transcript is unaffected by it. last_turn_at holds that value, read from the tail of the file rather than the whole thing since transcripts can reach tens of megabytes and this runs per task at startup. The scan window grows if the tail holds no turn, so a repeatedly-resumed but unused session still resolves. Claude and Codex transcript shapes are both recognized; an unrecognized shape yields no value and the task falls back to its creation time. last_turn_at is persisted but treated as a cache, recomputed from the transcript at every startup so a stale value cannot persist until the task is next used. Between startups touchTask maintains it, which fires on message-send and turn-completion and never on session lifecycle. Default-off means the creation-ordered list, the Archive and Import placement and the New Task position are all unchanged unless the option is set. --- source/cydo/domain/storage/persistence.d | 21 ++- source/cydo/domain/tasks/model.d | 6 + source/cydo/runtime/config/package.d | 5 + source/cydo/server/app.d | 39 ++++- source/cydo/web/snapshots.d | 9 +- source/cydo/workflow/history/last_turn.d | 185 +++++++++++++++++++++++ web/src/app.test.tsx | 1 + web/src/app.tsx | 5 +- web/src/components/Sidebar.test.ts | 83 ++++++++++ web/src/components/Sidebar.tsx | 114 ++++++++++---- web/src/protocol.ts | 2 + web/src/types.ts | 2 + web/src/useExportedTaskManager.ts | 1 + web/src/useSessionManager.ts | 16 +- 14 files changed, 449 insertions(+), 40 deletions(-) create mode 100644 source/cydo/workflow/history/last_turn.d diff --git a/source/cydo/domain/storage/persistence.d b/source/cydo/domain/storage/persistence.d index cc90a011..6d9f11ff 100644 --- a/source/cydo/domain/storage/persistence.d +++ b/source/cydo/domain/storage/persistence.d @@ -134,6 +134,11 @@ struct Persistence " has_messages INTEGER NOT NULL DEFAULT 1," ~ " PRIMARY KEY (driver, profile_root, session_id)" ~ ");", + // Migration 22: when the task was last actually worked on, as StdTime. + // Distinct from last_active, which is cleared on session start for + // crash recovery and so cannot survive a restart. Treated as a + // cache: recomputed from each transcript's tail at startup. + "ALTER TABLE tasks ADD COLUMN last_turn_at INTEGER NOT NULL DEFAULT 0;", ]); // In CI, disable durability to speed up tests. This trades crash-safety @@ -292,6 +297,7 @@ struct Persistence long lastActive; string entryPoint; bool needsAttention; + long lastTurnAt; } TaskRow[] loadTasks() @@ -300,11 +306,11 @@ struct Persistence foreach (int tid, string agentSessionId, string description, string taskType, int parentTid, string relationType, string workspace, string projectPath, int worktreeTid, string taskStartHead, string title, string status, string agentName, int archived, string draft, - string resultText, long createdAt, long lastActive, string entryPoint, int needsAttention; - db.stmt!"SELECT tid, COALESCE(agent_session_id,''), COALESCE(description,''), COALESCE(task_type,'blank'), COALESCE(parent_tid,0), COALESCE(relation_type,''), COALESCE(workspace,''), COALESCE(project_path,''), COALESCE(worktree_tid,0), COALESCE(task_start_head,''), COALESCE(title,''), COALESCE(status,'completed'), COALESCE(agent_type,'claude'), COALESCE(archived,0), COALESCE(draft,''), COALESCE(result_text,''), COALESCE(created_at,0), COALESCE(last_active,0), COALESCE(entry_point,''), COALESCE(needs_attention,0) FROM tasks".iterate()) + string resultText, long createdAt, long lastActive, string entryPoint, int needsAttention, long lastTurnAt; + db.stmt!"SELECT tid, COALESCE(agent_session_id,''), COALESCE(description,''), COALESCE(task_type,'blank'), COALESCE(parent_tid,0), COALESCE(relation_type,''), COALESCE(workspace,''), COALESCE(project_path,''), COALESCE(worktree_tid,0), COALESCE(task_start_head,''), COALESCE(title,''), COALESCE(status,'completed'), COALESCE(agent_type,'claude'), COALESCE(archived,0), COALESCE(draft,''), COALESCE(result_text,''), COALESCE(created_at,0), COALESCE(last_active,0), COALESCE(entry_point,''), COALESCE(needs_attention,0), COALESCE(last_turn_at,0) FROM tasks".iterate()) { // tasks.agent_type stores the configured agent name from config.agents. - result ~= TaskRow(tid, agentSessionId, description, taskType, parentTid, relationType, workspace, projectPath, worktreeTid, taskStartHead, title, status, agentName, archived != 0, draft, resultText, createdAt, lastActive, entryPoint, needsAttention != 0); + result ~= TaskRow(tid, agentSessionId, description, taskType, parentTid, relationType, workspace, projectPath, worktreeTid, taskStartHead, title, status, agentName, archived != 0, draft, resultText, createdAt, lastActive, entryPoint, needsAttention != 0, lastTurnAt); } return result; } @@ -364,6 +370,11 @@ struct Persistence db.stmt!"UPDATE tasks SET result_text = ? WHERE tid = ?".exec(resultText, tid); } + void setLastTurnAt(int tid, long lastTurnAt) + { + db.stmt!"UPDATE tasks SET last_turn_at = ? WHERE tid = ?".exec(lastTurnAt, tid); + } + void setLastActive(int tid, long lastActive) { db.stmt!"UPDATE tasks SET last_active = ? WHERE tid = ?".exec(lastActive, tid); @@ -610,7 +621,7 @@ unittest int userVersion; foreach (int value; persistence.db.stmt!"PRAGMA user_version".iterate()) userVersion = value; - assert(userVersion == 22); + assert(userVersion == 23); auto rows = persistence.loadTasks(); assert(rows.length == 1); @@ -629,7 +640,7 @@ unittest "relation_type", "workspace", "project_path", "title", "status", "worktree_path", "has_worktree", "agent_type", "archived", "draft", "result_text", "created_at", "last_active", "worktree_tid", "entry_point", - "needs_attention", "task_start_head", + "needs_attention", "task_start_head", "last_turn_at", ]); persistence.upsertSessionMetaCache("claude", "/profiles/one", "same-id", 1, diff --git a/source/cydo/domain/tasks/model.d b/source/cydo/domain/tasks/model.d index ffaab34c..4ed405ac 100644 --- a/source/cydo/domain/tasks/model.d +++ b/source/cydo/domain/tasks/model.d @@ -557,6 +557,10 @@ struct TaskData bool archived; long createdAt; // StdTime; 0 = not set long lastActive; // StdTime; 0 = not set + /// When this task was last actually worked on (StdTime), for recency + /// ordering. Unlike lastActive it is never cleared by session lifecycle, + /// and is recomputed from the transcript tail at startup. + long lastTurnAt; /// Git repository root for the selected project. /// Falls back to projectPath if git resolution fails. @@ -1086,6 +1090,7 @@ struct TaskListEntry string entry_point; string agent_name; string driver; // resolved runtime driver ("claude"/"codex"/"copilot"); empty for orphaned agents + long last_turn_at; bool archived; bool archiving; // true while an archive/unarchive transition is in progress string draft; @@ -1170,6 +1175,7 @@ struct ServerStatusMessage bool auth_enabled; bool dev_mode; string build_id; + bool sidebar_sort_by_recency; } struct ScanStatusMessage diff --git a/source/cydo/runtime/config/package.d b/source/cydo/runtime/config/package.d index d2c21393..915160f3 100644 --- a/source/cydo/runtime/config/package.d +++ b/source/cydo/runtime/config/package.d @@ -115,6 +115,11 @@ struct CydoConfig @Optional bool dev_mode; @Optional string log_level = "info"; @Optional string system_keyword = "SYSTEM"; + /// Order the sidebar by activity rather than creation: the most recently + /// worked-on task sits at the top and the order updates as tasks are used, + /// a parent rising with its most recent descendant. Archive and Import move + /// below the live tasks. Off keeps the creation-ordered list. + @Optional bool sidebar_sort_by_recency; /// Called by configy during parsing (configy/read.d:650), so a semantic /// error surfaces on the same path as a YAML syntax error. diff --git a/source/cydo/server/app.d b/source/cydo/server/app.d index fb5a6e70..b929ac1c 100644 --- a/source/cydo/server/app.d +++ b/source/cydo/server/app.d @@ -53,6 +53,7 @@ import cydo.workflow.history.native_history : ConfiguredNativeHistoryContext, TaskHistoryResolution, TaskHistoryResolutionKind, UnavailableHistory, UnavailableHistoryKind, resolveNativeHistoryContext; import cydo.workflow.history.abbrev : extractMessageText; +import cydo.workflow.history.last_turn : lastTurnStdTime; import cydo.workflow.history.operations : CodexForkSourceState, selectHistoryOperations; import cydo.runtime.logging : installRobustLogger; @@ -873,6 +874,7 @@ class App td.createdAt = row.createdAt; td.lastActive = row.lastActive; td.needsAttention = row.needsAttention; + td.lastTurnAt = row.lastTurnAt; td.titleGenDone = row.title.length > 0; auto rowTid = row.tid; tasks[rowTid] = move(td); @@ -960,6 +962,34 @@ class App // Final fallback: if still no lastActive but has createdAt, use that if (td.lastActive == 0 && td.createdAt != 0) td.lastActive = td.createdAt; + + // Recompute when the task was last actually worked on. Always, not + // just when unset: the stored value is a cache, and re-deriving it + // from the transcript keeps a task that went stale from staying + // stale until it happens to be used again. Resumes append session + // records rather than turns, so this steps over the restart. + if (td.agentSessionId.length > 0) + { + try + { + auto resolution = resolveTaskHistory(td.tid); + auto jp = resolution.kind == TaskHistoryResolutionKind.access + ? resolution.requireAccess().path + : ""; + if (jp.length > 0) + { + auto turnAt = lastTurnStdTime(jp); + if (turnAt != 0 && turnAt != td.lastTurnAt) + { + td.lastTurnAt = turnAt; + persistence.setLastTurnAt(td.tid, turnAt); + } + } + } + catch (Exception) {} // best-effort; falls back to createdAt below + } + if (td.lastTurnAt == 0) + td.lastTurnAt = td.createdAt; } discoveryService.enumerateSessions(); @@ -1136,6 +1166,7 @@ class App authUser.length > 0 || authPass.length > 0, config.dev_mode, webDistDir, + config.sidebar_sort_by_recency, ).representation)); ws.send(Data(buildNoticesList(activeNotices).representation)); if (discoveryService.scanInProgress) @@ -3434,6 +3465,7 @@ class App authUser.length > 0 || authPass.length > 0, config.dev_mode, webDistDir, + config.sidebar_sort_by_recency, )); infof("Config reloaded successfully"); discoveryService.endScan(); @@ -3523,7 +3555,12 @@ class App private void touchTask(int tid) { import std.datetime : Clock; - tasks[tid].lastActive = Clock.currStdTime; + auto now = Clock.currStdTime; + tasks[tid].lastActive = now; + // real activity (a message sent, a turn finished), never session + // lifecycle, so this is safe to persist and survives restarts + tasks[tid].lastTurnAt = now; + persistence.setLastTurnAt(tid, now); } private AgentSession sessionForTask(int tid) diff --git a/source/cydo/web/snapshots.d b/source/cydo/web/snapshots.d index 5d540a5c..e2dfa071 100644 --- a/source/cydo/web/snapshots.d +++ b/source/cydo/web/snapshots.d @@ -23,7 +23,8 @@ TaskListEntry buildTaskEntry(ref TaskData td, size_t childCount, bool alive, td.agentSessionId.length > 0 && !alive && td.status != "importable", td.isProcessing, td.stdinClosed, canStop, td.needsAttention, td.hasPendingQuestion, td.notificationBody, td.title, td.workspace, td.projectPath, td.parentTid, childCount, td.relationType, cast(string) td.status, - td.taskType, td.entryPoint, td.agentName, driver, td.archived, td.archiving, td.draft, td.error, + td.taskType, td.entryPoint, td.agentName, driver, + stdTimeToUnixMillis(td.lastTurnAt), td.archived, td.archiving, td.draft, td.error, stdTimeToUnixMillis(td.createdAt), stdTimeToUnixMillis(td.lastActive)); } @@ -202,13 +203,15 @@ string readBuildId(string webDistDir) return m[1].idup; } -string buildServerStatus(bool authEnabled, bool devMode, string webDistDir) +string buildServerStatus(bool authEnabled, bool devMode, string webDistDir, + bool sidebarSortByRecency = false) { return toJson(ServerStatusMessage( "server_status", authEnabled, devMode, readBuildId(webDistDir), + sidebarSortByRecency, )); } @@ -299,7 +302,7 @@ unittest } auto exact = buildTasksList([entry(1, 0, false, false, "completed", 2)], true); - assert(exact == `{"type":"tasks_list","complete":true,"tasks":[{"tid":1,"alive":false,"resumable":false,"isProcessing":false,"stdinClosed":false,"canStop":false,"needsAttention":false,"hasPendingQuestion":false,"notificationBody":null,"title":null,"workspace":null,"project_path":null,"parent_tid":0,"child_count":2,"relation_type":null,"status":"completed","task_type":null,"entry_point":null,"agent_name":null,"driver":null,"archived":false,"archiving":false,"draft":null,"error":null,"created_at":0,"last_active":0}]}`, + assert(exact == `{"type":"tasks_list","complete":true,"tasks":[{"tid":1,"alive":false,"resumable":false,"isProcessing":false,"stdinClosed":false,"canStop":false,"needsAttention":false,"hasPendingQuestion":false,"notificationBody":null,"title":null,"workspace":null,"project_path":null,"parent_tid":0,"child_count":2,"relation_type":null,"status":"completed","task_type":null,"entry_point":null,"agent_name":null,"driver":null,"last_turn_at":0,"archived":false,"archiving":false,"draft":null,"error":null,"created_at":0,"last_active":0}]}`, exact); assert(!exact.canFind(`"stage"`), exact); assert(buildTasksList([], false).canFind(`"complete":false`)); diff --git a/source/cydo/workflow/history/last_turn.d b/source/cydo/workflow/history/last_turn.d new file mode 100644 index 00000000..b81e2a93 --- /dev/null +++ b/source/cydo/workflow/history/last_turn.d @@ -0,0 +1,185 @@ +module cydo.workflow.history.last_turn; + +// When a task was last actually worked on, read from the tail of its +// transcript. +// +// The obvious signals do not survive a backend restart. The startup sweep +// resumes every in-flight session, and each resume appends to that session's +// transcript, so the file's mtime becomes the restart time for every task at +// once. last_active is worse still: it is cleared on session start and +// recovered from that same mtime, so a restart collapses every task onto one +// timestamp and the ordering it feeds is noise. +// +// A resume writes session and meta records, never a conversation turn, so the +// newest user/assistant record in the file steps over the restart entirely. +// That is the value this module recovers. + +import std.datetime.systime : SysTime; + +/// Newest user/assistant timestamp in a transcript, as StdTime. Returns 0 when +/// the file is missing, unreadable, or holds no conversation turn (an empty or +/// resume-only transcript), which callers treat as "unknown" and fall back on. +/// +/// Only the tail is read: transcripts run to tens of megabytes and this is +/// called for every task at startup. The window grows if the tail holds no +/// turn, so a session that was resumed repeatedly without being used still +/// resolves rather than silently reporting 0. +long lastTurnStdTime(string path, size_t maxBytes = 8 << 20) nothrow +{ + static immutable size_t[] windows = [64 << 10, 1 << 20, 8 << 20]; + foreach (window; windows) + { + if (window > maxBytes) + break; + bool wholeFile; + auto found = scanTail(path, window, wholeFile); + if (found != 0 || wholeFile) + return found; + } + return 0; +} + +/// Scan the last `window` bytes for the newest conversation turn. Sets +/// `wholeFile` when the window covered the entire file, so the caller knows a +/// miss is final rather than an artifact of the window size. +private long scanTail(string path, size_t window, out bool wholeFile) nothrow +{ + import std.stdio : File; + + wholeFile = false; + try + { + auto f = File(path, "rb"); + scope(exit) f.close(); + auto size = f.size(); + if (size == 0) + { + wholeFile = true; + return 0; + } + + ulong start = size > window ? size - window : 0; + wholeFile = start == 0; + f.seek(start); + auto buf = new ubyte[cast(size_t)(size - start)]; + auto chunk = f.rawRead(buf); + + auto text = cast(string) chunk.idup; + // a mid-file window almost certainly starts inside a record; that + // partial first line would fail to parse anyway, but dropping it keeps + // the intent explicit + if (!wholeFile) + { + import std.string : indexOf; + auto nl = text.indexOf('\n'); + text = nl < 0 ? "" : text[nl + 1 .. $]; + } + + long newest = 0; + import std.algorithm : splitter; + foreach (line; text.splitter('\n')) + { + auto ts = turnTimestamp(line); + if (ts > newest) + newest = ts; + } + return newest; + } + catch (Exception) + return 0; + catch (Error) + return 0; +} + +/// StdTime of one transcript line, or 0 if it is not a conversation turn. +/// +/// Parsed by hand rather than by deserializing: this runs over every line of +/// every task's tail at startup, and the records carry large nested payloads +/// that would be built and thrown away. +/// +/// The agents write different shapes, so both are recognized: +/// claude: {"type":"user"|"assistant", ..., "timestamp":"..."} +/// codex: {"timestamp":"...", "type":"response_item", "payload":{...}} +/// What matters either way is excluding the records a resume writes (claude's +/// summary and queue-operation, codex's session_meta and turn_context), since +/// counting those is what made every task look equally recent. +private long turnTimestamp(const(char)[] line) nothrow +{ + import std.string : indexOf; + + if (line.length == 0) + return 0; + if (line.indexOf(`"type":"user"`) < 0 + && line.indexOf(`"type":"assistant"`) < 0 + && line.indexOf(`"type":"response_item"`) < 0) + return 0; + + auto key = line.indexOf(`"timestamp":"`); + if (key < 0) + return 0; + auto valueStart = key + `"timestamp":"`.length; + auto rest = line[valueStart .. $]; + auto close = rest.indexOf('"'); + if (close < 0) + return 0; + + try + return SysTime.fromISOExtString(rest[0 .. close]).stdTime; + catch (Exception) + return 0; +} + +unittest +{ + import std.file : write, remove, tempDir; + import std.path : buildPath; + import std.exception : collectException; + + auto path = buildPath(tempDir(), "cydo-last-turn-test.jsonl"); + scope(exit) collectException(remove(path)); + + // a transcript whose newest records are the meta ones a resume writes: + // the reported time must be the last real turn, not the resume + write(path, + `{"type":"user","timestamp":"2026-07-20T10:00:00.000Z"}` ~ "\n" ~ + `{"type":"assistant","timestamp":"2026-07-25T18:52:09.643Z"}` ~ "\n" ~ + `{"type":"summary","timestamp":"2026-07-27T22:50:00.000Z"}` ~ "\n" ~ + `{"type":"queue-operation","timestamp":"2026-07-27T22:50:01.000Z"}` ~ "\n"); + auto expected = SysTime.fromISOExtString("2026-07-25T18:52:09.643Z").stdTime; + assert(lastTurnStdTime(path) == expected); + + // a transcript with no turns at all reports unknown rather than guessing + write(path, `{"type":"session","timestamp":"2026-07-27T22:50:00.000Z"}` ~ "\n"); + assert(lastTurnStdTime(path) == 0); + + // malformed lines are skipped, not fatal + write(path, + "not json\n" ~ + `{"type":"user","timestamp":"garbage"}` ~ "\n" ~ + `{"type":"user","timestamp":"2026-07-26T08:00:00.000Z"}` ~ "\n"); + assert(lastTurnStdTime(path) == + SysTime.fromISOExtString("2026-07-26T08:00:00.000Z").stdTime); + + assert(lastTurnStdTime(buildPath(tempDir(), "cydo-no-such-file.jsonl")) == 0); + + // codex writes a different shape: response_item is the real work, while + // session_meta and turn_context are what a resume leaves behind + write(path, + `{"timestamp":"2026-07-21T21:51:07.249Z","type":"response_item","payload":{"type":"message","role":"assistant"}}` ~ "\n" ~ + `{"timestamp":"2026-07-27T23:50:00.000Z","type":"session_meta","payload":{"session_id":"x"}}` ~ "\n" ~ + `{"timestamp":"2026-07-27T23:50:01.000Z","type":"turn_context","payload":{"cwd":"/tmp/project"}}` ~ "\n"); + assert(lastTurnStdTime(path) == + SysTime.fromISOExtString("2026-07-21T21:51:07.249Z").stdTime); + + // a turn buried behind more than the first window of resume records is + // still found, because the window grows + import std.array : replicate; + string padded; + padded ~= `{"type":"user","timestamp":"2026-07-26T08:00:00.000Z"}` ~ "\n"; + foreach (i; 0 .. 2000) + padded ~= `{"type":"session","timestamp":"2026-07-27T22:50:00.000Z","pad":"` + ~ "x".replicate(64) ~ `"}` ~ "\n"; + write(path, padded); + assert(lastTurnStdTime(path) == + SysTime.fromISOExtString("2026-07-26T08:00:00.000Z").stdTime); +} diff --git a/web/src/app.test.tsx b/web/src/app.test.tsx index cfb102f0..9f1038ab 100644 --- a/web/src/app.test.tsx +++ b/web/src/app.test.tsx @@ -116,6 +116,7 @@ vi.mock("./useSessionManager", () => ({ getByTid: state.getByTid, refreshWorkspaces: vi.fn(), scanState: "idle", + sidebarSortByRecency: false, }) satisfies TaskManager, })); diff --git a/web/src/app.tsx b/web/src/app.tsx index 995699c0..53ff2156 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -63,6 +63,7 @@ function AppContent() { editRawEvent, draftView, sidebarTasks, + sidebarSortByRecency, workspaces, entryPoints, typeInfo, @@ -329,7 +330,7 @@ function AppContent() { return; } if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return; - const order = flatTaskOrder(sidebarTasks); + const order = flatTaskOrder(sidebarTasks, sidebarSortByRecency); if (e.shiftKey) { // Jump to next/prev task with attention, wrapping around if (order.length === 0) return; @@ -377,6 +378,7 @@ function AppContent() { }; }, [ sidebarTasks, + sidebarSortByRecency, activeTaskId, setActiveTaskId, attention, @@ -460,6 +462,7 @@ function AppContent() { onOpenSearch={handleOpenSearch} onArchive={handleSidebarArchive} hasGlobalAttention={hasOtherProjectAttention} + sortByRecency={sidebarSortByRecency} /> {draftView && (
diff --git a/web/src/components/Sidebar.test.ts b/web/src/components/Sidebar.test.ts index ccdd77a6..31077f97 100644 --- a/web/src/components/Sidebar.test.ts +++ b/web/src/components/Sidebar.test.ts @@ -491,3 +491,86 @@ describe("Sidebar loading marker markup", () => { expect(container.textContent).not.toContain("(loading…)"); }); }); + +function recencyTask( + tid: number, + lastActive: number, + extra: Partial = {}, +): SidebarTask { + return { + tid, + alive: false, + canStop: false, + resumable: false, + isProcessing: false, + childCount: 0, + lastActive, + ...extra, + }; +} + +describe("sidebar recency ordering", () => { + it("leaves the order alone when the option is off", () => { + const tasks = [ + recencyTask(1, 500), + recencyTask(2, 100), + recencyTask(3, 900), + ]; + expect(flatTaskOrder(tasks)).toEqual(["1", "2", "3"]); + }); + + it("puts the most recently active task first", () => { + const tasks = [ + recencyTask(1, 500), + recencyTask(2, 100), + recencyTask(3, 900), + ]; + expect(flatTaskOrder(tasks, true)).toEqual(["3", "1", "2"]); + }); + + it("raises a parent to the top when a descendant is active, at any depth", () => { + // tid 1 itself is stale, but its grandchild 3 is the newest thing anywhere + const tasks = [ + recencyTask(1, 100), + recencyTask(2, 100, { parentTid: 1 }), + recencyTask(3, 900, { parentTid: 2 }), + recencyTask(4, 500), + ]; + // 1 leads on its grandchild's activity; hierarchy is unchanged + expect(flatTaskOrder(tasks, true)).toEqual(["1", "2", "3", "4"]); + }); + + it("re-sorts siblings without reparenting them", () => { + const tasks = [ + recencyTask(1, 100), + recencyTask(2, 200, { parentTid: 1 }), + recencyTask(3, 800, { parentTid: 1 }), + ]; + // 3 sorts above its sibling 2, and both stay under 1 + expect(flatTaskOrder(tasks, true)).toEqual(["1", "3", "2"]); + }); + + it("pins Archive then Import below the live tasks", () => { + const tasks = [ + recencyTask(1, 900), + recencyTask(2, 100, { archived: true }), + recencyTask(3, 800, { status: "importable" }), + ]; + expect(flatTaskOrder(tasks, true)).toEqual([ + "1", + "archive", + "2", + "import", + "3", + ]); + }); + + it("keeps Archive and Import above the tasks when the option is off", () => { + const tasks = [ + recencyTask(1, 900), + recencyTask(2, 100, { archived: true }), + recencyTask(3, 800, { status: "importable" }), + ]; + expect(flatTaskOrder(tasks)).toEqual(["archive", "2", "import", "3", "1"]); + }); +}); diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 11902c4f..0cffdc57 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -175,6 +175,8 @@ export interface SidebarTask { taskType?: string; hasPendingQuestion?: boolean; hasMessages?: boolean; + /// activity timestamp used by the recency ordering; falls back to creation + lastActive?: number; } interface TreeNode { @@ -184,7 +186,10 @@ interface TreeNode { knownChildCount: number; } -export function flatTaskOrder(tasks: SidebarTask[]): string[] { +export function flatTaskOrder( + tasks: SidebarTask[], + sortByRecency = false, +): string[] { const ids: string[] = []; function walk(nodes: TreeNode[]) { for (const n of nodes) { @@ -192,13 +197,39 @@ export function flatTaskOrder(tasks: SidebarTask[]): string[] { walk(n.children); } } - walk(buildTree(tasks)); + walk(buildTree(tasks, sortByRecency)); return ids; } -function insertArchiveNodes(nodes: TreeNode[]): TreeNode[] { +/** + * Recency of a node: its own activity, or its most recently active descendant, + * whichever is later. A parent therefore rises when work happens anywhere + * beneath it, at any depth, without the hierarchy itself changing. + */ +function subtreeRecency(node: TreeNode): number { + let newest = node.task.lastActive ?? 0; + for (const child of node.children) + newest = Math.max(newest, subtreeRecency(child)); + return newest; +} + +/** + * Order every level by recency, newest first. Siblings re-sort among + * themselves; nothing is reparented. Group nodes (Archive, Import) carry no + * activity of their own, so they are left to the caller to place. + */ +function sortNodesByRecency(nodes: TreeNode[]): TreeNode[] { + return nodes + .map((node) => ({ ...node, children: sortNodesByRecency(node.children) })) + .sort((a, b) => subtreeRecency(b) - subtreeRecency(a)); +} + +function insertArchiveNodes( + nodes: TreeNode[], + archiveLast: boolean, +): TreeNode[] { return nodes.map((node) => { - const processed = insertArchiveNodes(node.children); + const processed = insertArchiveNodes(node.children, archiveLast); const archived = processed.filter((c) => c.task.archived); const active = processed.filter((c) => !c.task.archived); if (archived.length === 0) { @@ -220,11 +251,19 @@ function insertArchiveNodes(nodes: TreeNode[]): TreeNode[] { children: archived, knownChildCount: 0, }; - return { ...node, children: [archiveNode, ...active] }; + return { + ...node, + children: archiveLast + ? [...active, archiveNode] + : [archiveNode, ...active], + }; }); } -export function buildTree(tasks: SidebarTask[]): TreeNode[] { +export function buildTree( + tasks: SidebarTask[], + sortByRecency = false, +): TreeNode[] { const tidSet = new Set(tasks.map((t) => t.tid)); const childMap = new Map(); const roots: SidebarTask[] = []; @@ -252,7 +291,10 @@ export function buildTree(tasks: SidebarTask[]): TreeNode[] { } let tree = toNodes(roots); - tree = insertArchiveNodes(tree); + // Recency ordering happens before the group nodes are inserted, so Archive + // and Import (which have no activity of their own) keep their fixed places. + if (sortByRecency) tree = sortNodesByRecency(tree); + tree = insertArchiveNodes(tree, sortByRecency); // Handle archived roots const archivedRoots = tree.filter((n) => n.task.archived); @@ -274,7 +316,9 @@ export function buildTree(tasks: SidebarTask[]): TreeNode[] { children: archivedRoots, knownChildCount: 0, }; - tree = [archiveRoot, ...activeRoots]; + tree = sortByRecency + ? [...activeRoots, archiveRoot] + : [archiveRoot, ...activeRoots]; } // Handle importable roots — group under "Import" node @@ -301,10 +345,11 @@ export function buildTree(tasks: SidebarTask[]): TreeNode[] { children: importableRoots, knownChildCount: 0, }; - // Array order (sidebar renders reversed): - // [groupNodes..., importRoot, regularNonImportable...] - // After .reverse(): regularNonImportable (top), importRoot (middle), groupNodes (bottom) - tree = [...groupNodes, importRoot, ...regularNonImportable]; + // The list's two reversals (.reverse() in the JSX and the column-reverse on + // .sidebar-list) cancel, so array order is display order, top to bottom. + tree = sortByRecency + ? [...regularNonImportable, ...groupNodes, importRoot] + : [...groupNodes, importRoot, ...regularNonImportable]; } return tree; @@ -678,6 +723,7 @@ interface Props { onOpenSearch?: () => void; onArchive?: (tid: number) => void; hasGlobalAttention?: boolean; + sortByRecency?: boolean; } export const Sidebar = memo(function Sidebar({ @@ -699,8 +745,12 @@ export const Sidebar = memo(function Sidebar({ onOpenSearch, onArchive, hasGlobalAttention, + sortByRecency = false, }: Props) { - const tree = useMemo(() => buildTree(tasks), [tasks]); + const tree = useMemo( + () => buildTree(tasks, sortByRecency), + [tasks, sortByRecency], + ); const flatItems = useMemo( () => flattenTree(tree, activeTaskId, taskTypes, tasksLoading), [tree, activeTaskId, taskTypes, tasksLoading], @@ -838,6 +888,23 @@ export const Sidebar = memo(function Sidebar({ }; }, [flatItems, attention]); + const newTaskRow = onNewTask && ( + { + if (!isPlainLeftClick(e)) return; + onNewTask(); + }} + > + + New Task + + ); + return ( diff --git a/web/src/protocol.ts b/web/src/protocol.ts index b1bed92f..42be1adc 100644 --- a/web/src/protocol.ts +++ b/web/src/protocol.ts @@ -207,6 +207,7 @@ export interface TaskListEntry { task_type?: string; entry_point?: string; agent_name?: string; + last_turn_at?: number; driver?: string; archived?: boolean; archiving?: boolean; @@ -380,6 +381,7 @@ export interface ServerStatusMessage { auth_enabled: boolean; dev_mode?: boolean; build_id?: string; + sidebar_sort_by_recency?: boolean; } export interface TaskDeletedMessage { type: "task_deleted"; diff --git a/web/src/types.ts b/web/src/types.ts index b53d8c78..2186669f 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -312,6 +312,8 @@ export interface TaskState { agentName?: string; /** Runtime driver identity, from session/init or the task listing snapshot (e.g. "claude", "codex"). */ driver?: string; + /** When the task was last actually worked on (unix ms), for recency order. */ + lastTurnAt?: number; archived?: boolean; archiving?: boolean; /** Last stderr text from non-zero exit; cleared on restart. */ diff --git a/web/src/useExportedTaskManager.ts b/web/src/useExportedTaskManager.ts index 66438dbe..712ece9a 100644 --- a/web/src/useExportedTaskManager.ts +++ b/web/src/useExportedTaskManager.ts @@ -260,6 +260,7 @@ export function useExportedTaskManager(): TaskManager { serverError: null, dismissServerError: noop, devMode: false, + sidebarSortByRecency: false, exportLoadError, navigateHome: noop, navigateToProject: noop, diff --git a/web/src/useSessionManager.ts b/web/src/useSessionManager.ts index 3d0d899c..86002ad1 100644 --- a/web/src/useSessionManager.ts +++ b/web/src/useSessionManager.ts @@ -248,6 +248,7 @@ export interface TaskManager { taskType?: string; hasPendingQuestion?: boolean; hasMessages?: boolean; + lastActive?: number; }>; workspaces: WorkspaceInfo[]; entryPoints: EntryPointInfo[]; @@ -263,6 +264,7 @@ export interface TaskManager { serverError: { message: string; tid?: number } | null; dismissServerError: () => void; devMode: boolean; + sidebarSortByRecency: boolean; exportLoadError?: string | null; navigateHome: () => void; navigateToProject: (workspace: string, projectName: string) => void; @@ -431,6 +433,7 @@ export function taskStateFromEntry( childCount: entry.child_count, serverDraft: hasDraft ? entry.draft || undefined : base.serverDraft, error: entry.error || undefined, + lastTurnAt: entry.last_turn_at || undefined, }; } // If a task becomes resumable but has no messages loaded, @@ -458,6 +461,7 @@ export function taskStateFromEntry( taskType: entry.task_type || existing.taskType, entryPoint: entry.entry_point || existing.entryPoint, agentName: entry.agent_name || existing.agentName, + lastTurnAt: entry.last_turn_at || existing.lastTurnAt, driver: entry.driver || existing.driver, suggestions: entry.isProcessing && !existing.isProcessing @@ -597,6 +601,7 @@ export function useTaskManager( tid?: number; } | null>(null); const [devMode, setDevMode] = useState(false); + const [sidebarSortByRecency, setSidebarSortByRecency] = useState(false); const addToastRef = useRef(addToast); addToastRef.current = addToast; const prevNoticeIdsRef = useRef>(new Set()); @@ -2016,6 +2021,7 @@ export function useTaskManager( } case "server_status": { setDevMode(msg.dev_mode ?? false); + setSidebarSortByRecency(msg.sidebar_sort_by_recency ?? false); const serverBuildId = msg.build_id ?? ""; if ( serverBuildId.length > 0 && @@ -3341,6 +3347,12 @@ export function useTaskManager( taskType: t.taskType, hasPendingQuestion: t.hasPendingQuestion, hasMessages: t.messages.length > 0, + // recency ordering key: when the task was last actually worked on. + // lastActive is deliberately not used, being cleared on session start + // and recovered from a transcript mtime that every restart rewrites. + // A task that never ran falls back to its creation time, so a fresh + // draft sorts near the top rather than the floor. + lastActive: t.lastTurnAt || t.createdAt || 0, })); const prev = prevSidebarTasksRef.current; @@ -3364,7 +3376,8 @@ export function useTaskManager( t.archiving === p.archiving && t.taskType === p.taskType && t.hasPendingQuestion === p.hasPendingQuestion && - t.hasMessages === p.hasMessages + t.hasMessages === p.hasMessages && + t.lastActive === p.lastActive ); }) ) { @@ -3430,6 +3443,7 @@ export function useTaskManager( setServerError(null); }, devMode, + sidebarSortByRecency, exportLoadError: null, navigateHome, navigateToProject, From 716a41630b8940e48c7d129275025431f94087bc Mon Sep 17 00:00:00 2001 From: Antisophy <293439221+Antisophy@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:53:46 -0700 Subject: [PATCH 2/2] feat(sidebar): open at the top when sorting by recency .sidebar-list is column-reverse, so its resting scroll position is the visual bottom; in recency mode that is the least recently active end, the opposite of where the interesting tasks are. Scroll the top-most child into view when the sidebar opens: on mount, when the task list finishes loading (the first load, and every reconnect, which empties and refills the list without the sidebar ever hiding), when the sidebar becomes visible on mobile, and when recency ordering is switched on. Keyed on the load completing rather than on the list having content, since the list arrives in packets. It runs before the active-item effect, so deep-linking an off-screen task still scrolls to it. --- web/src/components/Sidebar.tsx | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 0cffdc57..5508a760 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -774,6 +774,35 @@ export const Sidebar = memo(function Sidebar({ ensureIconStyles(); ensureRelationIconStyles(); + // Recency mode puts the newest tasks and the New Task row at the visual top, + // but column-reverse rests the scroll at the visual bottom, so open at the + // top instead. The last DOM child is the top-most one; scrollIntoView avoids + // the sign conventions browsers use for scrollTop in reversed containers. + // Opening happens on mount, each time the list finishes loading (the first + // load, and every reconnect, which empties and refills it) and each time + // the sidebar becomes visible on mobile. Keyed on the load completing + // rather than on the list having content, since the list arrives in packets + // and a reconnect never toggles visibility. Runs before the active-item + // effect below so that one still wins when the active task sits off-screen. + const openScrollRanRef = useRef(false); + const prevTasksLoadingRef = useRef(tasksLoading); + const prevOpenVisibleRef = useRef(visible); + const prevSortByRecencyRef = useRef(sortByRecency); + useEffect(() => { + const firstRun = !openScrollRanRef.current; + const finishedLoading = prevTasksLoadingRef.current && !tasksLoading; + const becameVisible = prevOpenVisibleRef.current === false && visible; + const switchedToRecency = !prevSortByRecencyRef.current && sortByRecency; + openScrollRanRef.current = true; + prevTasksLoadingRef.current = tasksLoading; + prevOpenVisibleRef.current = visible; + prevSortByRecencyRef.current = sortByRecency; + if (!sortByRecency || !visible || tasksLoading) return; + if (!firstRun && !finishedLoading && !becameVisible && !switchedToRecency) + return; + listRef.current?.lastElementChild?.scrollIntoView({ block: "nearest" }); + }, [sortByRecency, visible, tasksLoading]); + useEffect(() => { if (activeTaskId === null) return; const selector = `.sidebar-item[data-tid="${activeTaskId}"]`;