From a57330b8c185de1fb03996619e5652dd2d2fe2de Mon Sep 17 00:00:00 2001 From: Eliot Hedeman Date: Fri, 24 Apr 2026 14:35:03 -0400 Subject: [PATCH] feat(toolpath-desktop): recency-sort browse lists; label Claude projects with session title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browse lists (Claude projects/sessions, Pi projects/sessions, Git branches) previously rendered in backend emission order — usually alphabetical or directory-walk. Now they sort most-recently-active first, matching the convention already used by the tray popover's Recent list. For Claude projects specifically, the streaming payload deliberately skipped last_activity to avoid per-session JSONL parsing; adds a cheap stat-based hint (max mtime across the project's .jsonl files). Pi projects have no project-level timestamp, so recency is derived frontend-side from the streamed session timestamps via a maxTimestampByProject map. Also replaces the opaque worktree-basename project titles (e.g. "2f297b", "fa7bdc") with the first-user-message of each project's most-recent session, via a new `claude_project_latest_title` IPC command fetched lazily per project. Falls back to the basename while the title loads or if the conversation has no user text. --- .../frontend/src/lib/types.ts | 6 ++ .../frontend/src/lib/update.ts | 72 +++++++++++++++---- .../frontend/src/routes/BrowseClaude.svelte | 3 +- .../toolpath-desktop/src/commands/sources.rs | 51 ++++++++++++- crates/toolpath-desktop/src/main.rs | 1 + 5 files changed, 117 insertions(+), 16 deletions(-) diff --git a/crates/toolpath-desktop/frontend/src/lib/types.ts b/crates/toolpath-desktop/frontend/src/lib/types.ts index 5f9d34d..b9a28ff 100644 --- a/crates/toolpath-desktop/frontend/src/lib/types.ts +++ b/crates/toolpath-desktop/frontend/src/lib/types.ts @@ -26,6 +26,7 @@ export interface ClaudeProject { project_path: string; display_name: string; session_count: number; + last_activity: string | null; } export interface ClaudeSession { @@ -121,6 +122,8 @@ export interface ClaudeSlice { sessionsByPath: Record; sessionsLoading: Record; titles: Record; // `${path}|${sid}` → title + /** project_path → title of its most-recent session (fills in lazily). */ + projectTitles: Record; deriving: boolean; } @@ -131,6 +134,8 @@ export interface PiSlice { expanded: string | null; sessionsByPath: Record; sessionsLoading: Record; + /** Max session `timestamp` seen per project_path. Drives project-list sort. */ + maxTimestampByProject: Record; deriving: boolean; } @@ -201,6 +206,7 @@ export type Msg = | { t: "ClaudeSessionReceived"; session: ClaudeSession } | { t: "ClaudeSessionsDone"; path: string } | { t: "ClaudeTitleLoaded"; path: string; sid: string; title: string | null } + | { t: "ClaudeProjectTitleLoaded"; path: string; title: string | null } | { t: "ClaudeDerive"; path: string; sid: string } // Pi diff --git a/crates/toolpath-desktop/frontend/src/lib/update.ts b/crates/toolpath-desktop/frontend/src/lib/update.ts index 906a138..61f82ac 100644 --- a/crates/toolpath-desktop/frontend/src/lib/update.ts +++ b/crates/toolpath-desktop/frontend/src/lib/update.ts @@ -5,6 +5,15 @@ import { invoke } from "./ipc"; import type { Cmd, Model, Msg } from "./types"; +// Most-recent-first. Nullish values sort last. ISO-8601 strings compare +// lexicographically, so plain string compare works for RFC3339 timestamps. +const byRecencyDesc = (a: string | null | undefined, b: string | null | undefined): number => { + const aKey = a ?? ""; + const bKey = b ?? ""; + if (aKey === bKey) return 0; + return bKey < aKey ? -1 : 1; +}; + export function initialModel(): Model { return { route: "home", @@ -18,6 +27,7 @@ export function initialModel(): Model { sessionsByPath: {}, sessionsLoading: {}, titles: {}, + projectTitles: {}, deriving: false, }, pi: { @@ -27,6 +37,7 @@ export function initialModel(): Model { expanded: null, sessionsByPath: {}, sessionsLoading: {}, + maxTimestampByProject: {}, deriving: false, }, git: { @@ -136,11 +147,25 @@ export function update(msg: Msg, m: Model): [Model, Cmd | null] { null, ]; } - case "ClaudeProjectReceived": + case "ClaudeProjectReceived": { + const projects = [...m.claude.projects, msg.project].sort((a, b) => + byRecencyDesc(a.last_activity, b.last_activity), + ); + const path = msg.project.project_path; return [ - { ...m, claude: { ...m.claude, projects: [...m.claude.projects, msg.project] } }, - null, + { ...m, claude: { ...m.claude, projects } }, + { + type: "invoke", + name: "claude_project_latest_title", + args: { projectPath: path }, + onOk: (title) => ({ + t: "ClaudeProjectTitleLoaded", + path, + title: (title ?? null) as string | null, + }), + }, ]; + } case "ClaudeProjectsDone": return [{ ...m, claude: { ...m.claude, loadingProjects: false, projectsDone: true } }, null]; case "ClaudeProjectsError": @@ -164,9 +189,12 @@ export function update(msg: Msg, m: Model): [Model, Cmd | null] { case "ClaudeSessionReceived": { const path = msg.session.project_path; const existing = m.claude.sessionsByPath[path] ?? []; + const sessions = [...existing, msg.session].sort((a, b) => + byRecencyDesc(a.last_activity ?? a.started_at, b.last_activity ?? b.started_at), + ); const claude = { ...m.claude, - sessionsByPath: { ...m.claude.sessionsByPath, [path]: [...existing, msg.session] }, + sessionsByPath: { ...m.claude.sessionsByPath, [path]: sessions }, }; return [ { ...m, claude }, @@ -192,6 +220,12 @@ export function update(msg: Msg, m: Model): [Model, Cmd | null] { const titles = { ...m.claude.titles, [`${msg.path}|${msg.sid}`]: msg.title ?? "" }; return [{ ...m, claude: { ...m.claude, titles } }, null]; } + case "ClaudeProjectTitleLoaded": { + const title = msg.title?.trim(); + if (!title) return [m, null]; + const projectTitles = { ...m.claude.projectTitles, [msg.path]: title }; + return [{ ...m, claude: { ...m.claude, projectTitles } }, null]; + } case "ClaudeDerive": { const { path, sid } = msg; const displayName = path.split("/").filter(Boolean).pop() ?? "claude"; @@ -219,11 +253,13 @@ export function update(msg: Msg, m: Model): [Model, Cmd | null] { null, ]; } - case "PiProjectReceived": - return [ - { ...m, pi: { ...m.pi, projects: [...m.pi.projects, msg.project] } }, - null, - ]; + case "PiProjectReceived": { + const map = m.pi.maxTimestampByProject; + const projects = [...m.pi.projects, msg.project].sort((a, b) => + byRecencyDesc(map[a.project_path], map[b.project_path]), + ); + return [{ ...m, pi: { ...m.pi, projects } }, null]; + } case "PiProjectsDone": return [{ ...m, pi: { ...m.pi, loadingProjects: false, projectsDone: true } }, null]; case "PiProjectsError": @@ -245,12 +281,23 @@ export function update(msg: Msg, m: Model): [Model, Cmd | null] { case "PiSessionReceived": { const path = msg.session.project_path; const existing = m.pi.sessionsByPath[path] ?? []; + const sessions = [...existing, msg.session].sort((a, b) => + byRecencyDesc(a.timestamp, b.timestamp), + ); + const prevMax = m.pi.maxTimestampByProject[path]; + const nextMax = prevMax && prevMax > msg.session.timestamp ? prevMax : msg.session.timestamp; + const maxTimestampByProject = { ...m.pi.maxTimestampByProject, [path]: nextMax }; + const projects = [...m.pi.projects].sort((a, b) => + byRecencyDesc(maxTimestampByProject[a.project_path], maxTimestampByProject[b.project_path]), + ); return [ { ...m, pi: { ...m.pi, - sessionsByPath: { ...m.pi.sessionsByPath, [path]: [...existing, msg.session] }, + sessionsByPath: { ...m.pi.sessionsByPath, [path]: sessions }, + maxTimestampByProject, + projects, }, }, null, @@ -319,8 +366,9 @@ export function update(msg: Msg, m: Model): [Model, Cmd | null] { ]; } case "GitBranchesLoaded": { - const selected = msg.list.length ? msg.list[0].name : null; - return [{ ...m, git: { ...m.git, loading: false, branches: msg.list, selected } }, null]; + const branches = [...msg.list].sort((a, b) => byRecencyDesc(a.timestamp, b.timestamp)); + const selected = branches.length ? branches[0].name : null; + return [{ ...m, git: { ...m.git, loading: false, branches, selected } }, null]; } case "GitSelectBranch": return [{ ...m, git: { ...m.git, selected: msg.name } }, null]; diff --git a/crates/toolpath-desktop/frontend/src/routes/BrowseClaude.svelte b/crates/toolpath-desktop/frontend/src/routes/BrowseClaude.svelte index 51e3bbc..842103c 100644 --- a/crates/toolpath-desktop/frontend/src/routes/BrowseClaude.svelte +++ b/crates/toolpath-desktop/frontend/src/routes/BrowseClaude.svelte @@ -112,6 +112,7 @@
{#each claude.projects as p (p.project_path)} {@const isExpanded = claude.expanded === p.project_path} + {@const projectTitle = claude.projectTitles[p.project_path]}