Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/toolpath-desktop/frontend/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface ClaudeProject {
project_path: string;
display_name: string;
session_count: number;
last_activity: string | null;
}

export interface ClaudeSession {
Expand Down Expand Up @@ -121,6 +122,8 @@ export interface ClaudeSlice {
sessionsByPath: Record<string, ClaudeSession[]>;
sessionsLoading: Record<string, boolean>;
titles: Record<string, string>; // `${path}|${sid}` → title
/** project_path → title of its most-recent session (fills in lazily). */
projectTitles: Record<string, string>;
deriving: boolean;
}

Expand All @@ -131,6 +134,8 @@ export interface PiSlice {
expanded: string | null;
sessionsByPath: Record<string, PiSession[]>;
sessionsLoading: Record<string, boolean>;
/** Max session `timestamp` seen per project_path. Drives project-list sort. */
maxTimestampByProject: Record<string, string>;
deriving: boolean;
}

Expand Down Expand Up @@ -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
Expand Down
72 changes: 60 additions & 12 deletions crates/toolpath-desktop/frontend/src/lib/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -18,6 +27,7 @@ export function initialModel(): Model {
sessionsByPath: {},
sessionsLoading: {},
titles: {},
projectTitles: {},
deriving: false,
},
pi: {
Expand All @@ -27,6 +37,7 @@ export function initialModel(): Model {
expanded: null,
sessionsByPath: {},
sessionsLoading: {},
maxTimestampByProject: {},
deriving: false,
},
git: {
Expand Down Expand Up @@ -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":
Expand All @@ -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 },
Expand All @@ -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";
Expand Down Expand Up @@ -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":
Expand All @@ -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,
Expand Down Expand Up @@ -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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,15 @@
<div style="border:0.5px solid var(--ink-5); background:var(--paper-bright)">
{#each claude.projects as p (p.project_path)}
{@const isExpanded = claude.expanded === p.project_path}
{@const projectTitle = claude.projectTitles[p.project_path]}
<div>
<button
class={"row-card" + (isExpanded ? " row-card--selected" : "")}
onclick={() => store.dispatch({ t: "ClaudeExpandProject", path: p.project_path })}
>
<span class="row-card__marker"><SourceLogo kind="claude" size={14} /></span>
<div style="min-width:0">
<div class="row-card__title">{p.display_name}</div>
<div class="row-card__title">{projectTitle ?? p.display_name}</div>
<div class="row-card__sub">{p.project_path}</div>
</div>
<div class="row-card__right">
Expand Down
51 changes: 48 additions & 3 deletions crates/toolpath-desktop/src/commands/sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,29 @@ fn claude_manager() -> toolpath_claude::ClaudeConvo {
toolpath_claude::ClaudeConvo::new()
}

/// Cheap recency hint for a Claude project: max mtime across its `.jsonl`
/// files, formatted RFC3339. Stat-only — no JSONL parsing.
fn newest_jsonl_mtime(
manager: &toolpath_claude::ClaudeConvo,
project_path: &str,
) -> Option<String> {
let dir = manager.resolver().project_dir(project_path).ok()?;
let entries = std::fs::read_dir(&dir).ok()?;
let mut newest: Option<std::time::SystemTime> = None;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
if let Ok(modified) = entry.metadata().and_then(|m| m.modified()) {
if newest.is_none_or(|prev| modified > prev) {
newest = Some(modified);
}
}
}
newest.map(|t| chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339())
}

#[tauri::command]
pub fn list_claude_projects() -> DesktopResult<Vec<ClaudeProjectSummary>> {
let manager = claude_manager();
Expand Down Expand Up @@ -214,14 +237,15 @@ pub fn list_agents() -> DesktopResult<Vec<AgentSummary>> {

/// Minimal per-project payload emitted from [`list_claude_projects_stream`].
///
/// Only cheap-to-compute fields: display name and a file-count proxy for
/// session count. `last_activity` is deliberately omitted — fetching it would
/// re-introduce the same per-session metadata reads we want to avoid.
/// Only cheap-to-compute fields: display name, a file-count proxy for session
/// count, and a stat-based recency hint (max mtime across the project's JSONL
/// files). Avoids per-session JSONL parsing.
#[derive(Debug, Clone, Serialize)]
pub struct ClaudeProjectQuick {
pub project_path: String,
pub display_name: String,
pub session_count: usize,
pub last_activity: Option<String>,
}

/// Streaming variant of [`list_claude_projects`].
Expand Down Expand Up @@ -262,12 +286,15 @@ pub fn list_claude_projects_stream(app: AppHandle) -> DesktopResult<()> {
.map(|v| v.len())
.unwrap_or(0);

let last_activity = newest_jsonl_mtime(&manager, &path);

let _ = app.emit(
"claude:project",
ClaudeProjectQuick {
project_path: path,
display_name,
session_count,
last_activity,
},
);
}
Expand Down Expand Up @@ -339,6 +366,24 @@ pub fn claude_session_title(
Ok(convo.title(80))
}

/// Fetch a human-readable label for a project: the title of its most-recent
/// session. Called lazily per-project after [`list_claude_projects_stream`]
/// emits, so the project list paints fast and the titles fill in after.
#[tauri::command]
pub fn claude_project_latest_title(project_path: String) -> DesktopResult<Option<String>> {
let manager = claude_manager();
let metadata = manager
.list_conversation_metadata(&project_path)
.map_err(|e| DesktopError::Source(format!("list sessions: {e}")))?;
let Some(head) = metadata.into_iter().next() else {
return Ok(None);
};
let convo = manager
.read_conversation(&project_path, &head.session_id)
.map_err(|e| DesktopError::Source(format!("read {}: {e}", head.session_id)))?;
Ok(convo.title(80))
}

// ─── pi.dev ──────────────────────────────────────────────────────────────

fn pi_manager() -> toolpath_pi::PiConvo {
Expand Down
1 change: 1 addition & 0 deletions crates/toolpath-desktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ fn main() {
sources::list_claude_sessions,
sources::list_claude_sessions_stream,
sources::claude_session_title,
sources::claude_project_latest_title,
sources::list_pi_projects_stream,
sources::list_pi_sessions_stream,
sources::list_git_branches,
Expand Down
Loading