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 && (