diff --git a/source/cydo/domain/storage/persistence.d b/source/cydo/domain/storage/persistence.d index b09442b..66cf29e 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 2076dba..7cf0849 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 27ad966..1157b62 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 6cb0153..3c9b3ff 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 d798f6c..f450344 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 0000000..b81e2a9 --- /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 68dc07e..30115c4 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 1081a73..83fd8c3 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 f15259c..41d645e 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 7450b2c..7f752f5 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 0466d38..8fa3c8d 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 e008fce..37b0806 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 ffde6d9..670a795 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,