From c37c6617145309a0127b53510168d35d7552ab11 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] 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 | 17 ++- source/cydo/domain/tasks/model.d | 6 + source/cydo/runtime/config/package.d | 5 + source/cydo/server/app.d | 37 ++++- source/cydo/web/snapshots.d | 7 +- source/cydo/workflow/history/last_turn.d | 185 +++++++++++++++++++++++ web/src/app.tsx | 5 +- web/src/components/Sidebar.test.ts | 80 +++++++++- web/src/components/Sidebar.tsx | 111 ++++++++++---- web/src/protocol.ts | 3 + web/src/types.ts | 2 + web/src/useExportedTaskManager.ts | 1 + web/src/useSessionManager.ts | 18 ++- 13 files changed, 439 insertions(+), 38 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 b09442b0..66cf29e7 100644 --- a/source/cydo/domain/storage/persistence.d +++ b/source/cydo/domain/storage/persistence.d @@ -122,6 +122,11 @@ struct Persistence "ALTER TABLE tasks ADD COLUMN needs_attention INTEGER NOT NULL DEFAULT 0;", // Migration 20: immutable task-local commit collection boundary "ALTER TABLE tasks ADD COLUMN task_start_head TEXT NOT NULL DEFAULT '';", + // Migration: 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 @@ -273,6 +278,7 @@ struct Persistence long lastActive; string entryPoint; bool needsAttention; + long lastTurnAt; } TaskRow[] loadTasks() @@ -281,10 +287,10 @@ 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 agentType, 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()) { - result ~= TaskRow(tid, agentSessionId, description, taskType, parentTid, relationType, workspace, projectPath, worktreeTid, taskStartHead, title, status, agentType, archived != 0, draft, resultText, createdAt, lastActive, entryPoint, needsAttention != 0); + result ~= TaskRow(tid, agentSessionId, description, taskType, parentTid, relationType, workspace, projectPath, worktreeTid, taskStartHead, title, status, agentType, archived != 0, draft, resultText, createdAt, lastActive, entryPoint, needsAttention != 0, lastTurnAt); } return result; } @@ -343,6 +349,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); diff --git a/source/cydo/domain/tasks/model.d b/source/cydo/domain/tasks/model.d index 2076dba3..7cf0849d 100644 --- a/source/cydo/domain/tasks/model.d +++ b/source/cydo/domain/tasks/model.d @@ -541,6 +541,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. @@ -1015,6 +1019,7 @@ struct TaskListEntry string task_type; string entry_point; string agent_name; + long last_turn_at; bool archived; bool archiving; // true while an archive/unarchive transition is in progress string draft; @@ -1098,6 +1103,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 27ad9667..1157b62f 100644 --- a/source/cydo/runtime/config/package.d +++ b/source/cydo/runtime/config/package.d @@ -62,6 +62,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; } string configPath() diff --git a/source/cydo/server/app.d b/source/cydo/server/app.d index 6cb01539..3c9b3ff5 100644 --- a/source/cydo/server/app.d +++ b/source/cydo/server/app.d @@ -46,6 +46,7 @@ import cydo.web.snapshots : buildAgentsList, buildNoticesList, import cydo.workflow.history.pipeline : HistoryBroadcastPlan, HistoryEventPipeline, HistoryEventPipelineHost; import cydo.workflow.history.abbrev : extractMessageText; +import cydo.workflow.history.last_turn : lastTurnStdTime; import cydo.workflow.history.operations : selectHistoryOperations; import cydo.runtime.logging : installRobustLogger; import cydo.workflow.system_message_normalizer : SystemMessageNormalizer, @@ -787,6 +788,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); @@ -887,6 +889,32 @@ 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 ta = agentForTask(td.tid); + auto jp = ta.historyPath(td.agentSessionId, taskPathResolver.effectiveCwd(&td)); + 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(config, agentsByName); @@ -1058,6 +1086,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) @@ -2861,6 +2890,7 @@ class App authUser.length > 0 || authPass.length > 0, config.dev_mode, webDistDir, + config.sidebar_sort_by_recency, )); infof("Config reloaded successfully"); discoveryService.endScan(); @@ -2920,7 +2950,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 d798f6c8..f4503441 100644 --- a/source/cydo/web/snapshots.d +++ b/source/cydo/web/snapshots.d @@ -19,7 +19,8 @@ TaskListEntry buildTaskEntry(ref TaskData td, bool alive, bool canStop) 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, td.relationType, cast(string) td.status, - td.taskType, td.entryPoint, td.agentType, td.archived, td.archiving, td.draft, td.error, + td.taskType, td.entryPoint, td.agentType, + stdTimeToUnixMillis(td.lastTurnAt), td.archived, td.archiving, td.draft, td.error, stdTimeToUnixMillis(td.createdAt), stdTimeToUnixMillis(td.lastActive)); } @@ -83,13 +84,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, )); } 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.tsx b/web/src/app.tsx index 68dc07ec..30115c45 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -52,6 +52,7 @@ function AppContent() { deleteDraftTask, draftRenderKey, sidebarTasks, + sidebarSortByRecency, workspaces, entryPoints, typeInfo, @@ -306,7 +307,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; @@ -354,6 +355,7 @@ function AppContent() { }; }, [ sidebarTasks, + sidebarSortByRecency, activeTaskId, setActiveTaskId, attention, @@ -432,6 +434,7 @@ function AppContent() { onOpenSearch={handleOpenSearch} onArchive={handleSidebarArchive} hasGlobalAttention={hasOtherProjectAttention} + sortByRecency={sidebarSortByRecency} /> {Array.from(tasks.values()) .filter((t) => { diff --git a/web/src/components/Sidebar.test.ts b/web/src/components/Sidebar.test.ts index 1081a73a..83fd8c36 100644 --- a/web/src/components/Sidebar.test.ts +++ b/web/src/components/Sidebar.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { shouldHandleSidebarAltArchive } from "./Sidebar"; +import { + flatTaskOrder, + shouldHandleSidebarAltArchive, + type SidebarTask, +} from "./Sidebar"; describe("Sidebar archive click guard", () => { it("allows Alt-click archive when task is not archiving", () => { @@ -15,3 +19,77 @@ describe("Sidebar archive click guard", () => { expect(shouldHandleSidebarAltArchive(true, false, false)).toBe(false); }); }); + +function task( + tid: number, + lastActive: number, + extra: Partial = {}, +): SidebarTask { + return { + tid, + alive: false, + canStop: false, + resumable: false, + isProcessing: false, + lastActive, + ...extra, + }; +} + +describe("sidebar recency ordering", () => { + it("leaves the order alone when the option is off", () => { + const tasks = [task(1, 500), task(2, 100), task(3, 900)]; + expect(flatTaskOrder(tasks)).toEqual(["1", "2", "3"]); + }); + + it("puts the most recently active task first", () => { + const tasks = [task(1, 500), task(2, 100), task(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 = [ + task(1, 100), + task(2, 100, { parentTid: 1 }), + task(3, 900, { parentTid: 2 }), + task(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 = [ + task(1, 100), + task(2, 200, { parentTid: 1 }), + task(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 = [ + task(1, 900), + task(2, 100, { archived: true }), + task(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 = [ + task(1, 900), + task(2, 100, { archived: true }), + task(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 f15259cd..41d645e0 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -151,6 +151,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 { @@ -159,7 +161,10 @@ interface TreeNode { children: TreeNode[]; } -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) { @@ -167,13 +172,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) { @@ -193,11 +224,16 @@ function insertArchiveNodes(nodes: TreeNode[]): TreeNode[] { }, children: archived, }; - return { ...node, children: [archiveNode, ...active] }; + return { + ...node, + children: archiveLast + ? [...active, archiveNode] + : [archiveNode, ...active], + }; }); } -function buildTree(tasks: SidebarTask[]): TreeNode[] { +function buildTree(tasks: SidebarTask[], sortByRecency = false): TreeNode[] { const tidSet = new Set(tasks.map((t) => t.tid)); const childMap = new Map(); const roots: SidebarTask[] = []; @@ -221,7 +257,10 @@ 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); @@ -241,7 +280,9 @@ function buildTree(tasks: SidebarTask[]): TreeNode[] { }, children: archivedRoots, }; - tree = [archiveRoot, ...activeRoots]; + tree = sortByRecency + ? [...activeRoots, archiveRoot] + : [archiveRoot, ...activeRoots]; } // Handle importable roots — group under "Import" node @@ -266,10 +307,11 @@ function buildTree(tasks: SidebarTask[]): TreeNode[] { }, children: importableRoots, }; - // 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; @@ -531,6 +573,7 @@ interface Props { onOpenSearch?: () => void; onArchive?: (tid: number) => void; hasGlobalAttention?: boolean; + sortByRecency?: boolean; } export const Sidebar = memo(function Sidebar({ @@ -551,8 +594,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), [tree, activeTaskId, taskTypes], @@ -690,6 +737,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 7450b2c0..7f752f5a 100644 --- a/web/src/protocol.ts +++ b/web/src/protocol.ts @@ -205,6 +205,7 @@ export interface TasksListMessage { task_type?: string; entry_point?: string; agent_name?: string; + last_turn_at?: number; archived?: boolean; archiving?: boolean; draft?: string; @@ -234,6 +235,7 @@ export interface TaskUpdatedMessage { task_type?: string; entry_point?: string; agent_name?: string; + last_turn_at?: number; archived?: boolean; archiving?: boolean; draft?: string; @@ -391,6 +393,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 0466d38f..8fa3c8d3 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -274,6 +274,8 @@ export interface TaskState { entryPoint?: string; /** Current agent type (e.g. "claude", "codex"). */ agentType?: 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 e008fce8..37b08069 100644 --- a/web/src/useExportedTaskManager.ts +++ b/web/src/useExportedTaskManager.ts @@ -257,6 +257,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 ffde6d9e..670a795b 100644 --- a/web/src/useSessionManager.ts +++ b/web/src/useSessionManager.ts @@ -205,6 +205,7 @@ export interface TaskManager { taskType?: string; hasPendingQuestion?: boolean; hasMessages?: boolean; + lastActive?: number; }>; workspaces: WorkspaceInfo[]; entryPoints: EntryPointInfo[]; @@ -220,6 +221,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; @@ -341,6 +343,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()); @@ -958,6 +961,7 @@ export function useTaskManager( uuid: existingUuid ?? base.uuid, serverDraft: entry.draft || undefined, error: entry.error || undefined, + lastTurnAt: entry.last_turn_at || undefined, }; liveStates.set(t.uuid, t); tidToUuid.set(entry.tid, t.uuid); @@ -989,6 +993,7 @@ export function useTaskManager( taskType: entry.task_type || existing.taskType, entryPoint: entry.entry_point || existing.entryPoint, agentType: entry.agent_name || existing.agentType, + lastTurnAt: entry.last_turn_at || existing.lastTurnAt, suggestions: entry.isProcessing && !existing.isProcessing ? undefined @@ -1053,6 +1058,7 @@ export function useTaskManager( uuid: existingUuid ?? base.uuid, serverDraft: entry.draft || undefined, error: entry.error || undefined, + lastTurnAt: entry.last_turn_at || undefined, }; liveStates.set(taskUpdated.uuid, taskUpdated); tidToUuid.set(entry.tid, taskUpdated.uuid); @@ -1080,6 +1086,7 @@ export function useTaskManager( taskType: entry.task_type || existing.taskType, entryPoint: entry.entry_point || existing.entryPoint, agentType: entry.agent_name || existing.agentType, + lastTurnAt: entry.last_turn_at || existing.lastTurnAt, suggestions: entry.isProcessing && !existing.isProcessing ? undefined @@ -1385,6 +1392,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 && @@ -2231,6 +2239,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; @@ -2253,7 +2267,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 ); }) ) { @@ -2310,6 +2325,7 @@ export function useTaskManager( setServerError(null); }, devMode, + sidebarSortByRecency, exportLoadError: null, navigateHome, navigateToProject,