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]}