diff --git a/CLAUDE.md b/CLAUDE.md index 41f77c9..6b7da22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,6 +142,12 @@ Menu-bar mode: the app runs as a normal GUI app (Dock icon + app-switcher entry) Opening a trace from the popover: clicking a recent-session row invokes `tray_open_trace { provider, project, session_id }`. The Rust side calls back into the existing `derive_claude` / `derive_pi` commands, shows the main window, and emits a `trace:opened` event to the main window with the derived `{ doc, source, filename }`. `app.svelte` listens for it and dispatches `DeriveSucceeded`, which routes to the preview. Only `claude` and `pi` have derive commands today — rows for `gemini`, `codex`, `opencode` still appear in the list (so users can see activity) but are rendered disabled. +Pre-derive cache: after each 30s poll the tray kicks off background derives for every recent claude/pi session and stashes the result in `src/cache.rs`'s `TraceCache` (shared via `app.manage(Arc)`). Both the popover's `tray_open_trace` and the main-window's `derive_claude` / `derive_pi` commands route through the same cache (via `derive_claude_impl` / `derive_pi_impl` in `commands/derive.rs`), so clicking a session — whether from Quick View or the Browse view's "Select →" button — usually resolves instantly. Cache freshness keys on the source's `last_activity`; when a session gets new turns its cached entry is replaced on the next poll. Cacheable calls are limited to single-session, `include_thinking=false` derives (the shape the poller prewarms). Warm-up runs with at most 2 concurrent threads. + +Two tiers: (1) memory, a `HashMap` capped at 32 entries; (2) disk, under `/toolpath-desktop/trace-cache/.json`, so caches survive app restarts (macOS/Linux eventually clean `/tmp` themselves). Disk is capped at 200 entries, pruned oldest-first by mtime at startup. Memory misses fall through to disk and promote the hit back into memory. Corrupt files are silently deleted on read so a bad write doesn't poison the cache forever. + +Perf tracer (`frontend/src/lib/perf.svelte.ts`, `PerfOverlay.svelte`): the store, Preview, and the `trace:opened` listener call `perfStart` / `perfMark` / `perfEnd` at each checkpoint of a click → derive → render flow (dispatch, invoke-start, invoke-end, model-updated, preview-mounted, viz-rendered). Every completed trace logs a summary to the devtools console; set `localStorage.perf = "1"` and reload to also show a phase-bar overlay in the bottom-right. Use this to tell whether perceived click latency is the Rust derive vs. the Svelte/dagre render. + Streaming pattern (Claude project/session lists): Rust command spawns a thread that emits `claude:project`, `claude:session`, `claude:projects-done`, `claude:sessions-done` events. The Svelte component subscribes with `$effect(() => { listen(...) ... return unlisten; })` — Svelte tears down listeners automatically when the effect's deps change or the component unmounts. Package manager for the frontend is `bun` (installed at `~/.bun/bin/bun`). `bun install` to set up, `bun run check` for `svelte-check`, `bun run build` for a production Vite build. Never commit `node_modules/` or `dist/` — both are ignored. diff --git a/crates/toolpath-desktop/frontend/src/app.svelte b/crates/toolpath-desktop/frontend/src/app.svelte index 3e0285c..dbdf133 100644 --- a/crates/toolpath-desktop/frontend/src/app.svelte +++ b/crates/toolpath-desktop/frontend/src/app.svelte @@ -10,6 +10,8 @@ import BrowseGithub from "./routes/BrowseGithub.svelte"; import Preview from "./routes/Preview.svelte"; import Result from "./routes/Result.svelte"; + import PerfOverlay from "./lib/PerfOverlay.svelte"; + import { perfStart, perfMark } from "./lib/perf.svelte"; import type { Document, Route } from "./lib/types"; const notInTauri = @@ -68,6 +70,12 @@ $effect(() => { let unlisten: UnlistenFn | undefined; listen("trace:opened", (payload) => { + // Perf: this is the popover's post-derive delivery; Rust did the + // derive out-of-band so what's left is pure model + render time. + // Seeing this trace end at 20ms vs 600ms tells us whether perceived + // lag is the derive or the Svelte/dagre render. + perfStart("trace:opened → preview"); + perfMark("event-received"); store.dispatch({ t: "DeriveSucceeded", doc: payload.doc, @@ -161,3 +169,5 @@ {/if} + + diff --git a/crates/toolpath-desktop/frontend/src/lib/PerfOverlay.svelte b/crates/toolpath-desktop/frontend/src/lib/PerfOverlay.svelte new file mode 100644 index 0000000..076641d --- /dev/null +++ b/crates/toolpath-desktop/frontend/src/lib/PerfOverlay.svelte @@ -0,0 +1,164 @@ + + +{#if enabled && trace} +
+
+ {trace.label} + + {#if trace.durationMs != null} + {fmt(trace.durationMs)}ms + {:else} + running… + {/if} + +
+
+ {#each phases as p (p.name + p.start)} +
+ {p.name} + {fmt(p.end - p.start)} +
+ {/each} +
+
+ marks + + + + + + {#each phases as p (p.name + p.start)} + + + + + + {/each} + +
markatΔ
{p.name}{fmt(p.end)}+{fmt(p.end - p.start)}
+
+
+{/if} + + diff --git a/crates/toolpath-desktop/frontend/src/lib/perf.svelte.ts b/crates/toolpath-desktop/frontend/src/lib/perf.svelte.ts new file mode 100644 index 0000000..7c5b235 --- /dev/null +++ b/crates/toolpath-desktop/frontend/src/lib/perf.svelte.ts @@ -0,0 +1,105 @@ +// Lightweight performance tracer for click → derive → render flows. +// +// Each logical operation (e.g. "derive claude session") has a single running +// trace with one or more named marks. The most-recently-completed trace lives +// in the reactive `perf.latest` field so a small overlay can visualise which +// phase took how long. Every completed trace is also dumped to the devtools +// console. +// +// Enable the on-screen overlay by calling `perfSetOverlayEnabled(true)` (or +// `localStorage.setItem("perf", "1")`) and reloading. Console output is +// always on. +// +// Typical call sequence for a "click Select → preview mounted" flow: +// +// perf.start("derive claude"); +// perf.mark("dispatch"); +// perf.mark("invoke-start"); +// perf.mark("invoke-end"); +// perf.mark("model-updated"); +// perf.mark("preview-mounted"); +// perf.mark("viz-rendered"); +// perf.end(); + +export type PerfMark = { name: string; t: number }; +export type PerfTrace = { + label: string; + startedAt: number; + marks: PerfMark[]; + durationMs: number | null; +}; + +// Reactive module-level state. Svelte 5 tracks reads of these fields across +// .svelte files that import them. +export const perf = $state<{ latest: PerfTrace | null }>({ latest: null }); + +// Non-reactive scratch pad for the in-flight trace. +let current: PerfTrace | null = null; + +function now(): number { + return typeof performance !== "undefined" ? performance.now() : Date.now(); +} + +// `perf.latest` is `$state`, so any assignment inside a Svelte `$derived` +// (e.g. `perfMark` from within `buildTree` reached via a `$derived` in +// ChatView) throws `state_unsafe_mutation`. Defer writes to a microtask so +// the mutation always happens outside derivation. The visible ordering is +// unchanged — multiple marks in one task resolve in order, last write wins. +function publish(trace: PerfTrace): void { + const snapshot = { ...trace, marks: trace.marks }; + queueMicrotask(() => { + perf.latest = snapshot; + }); +} + +export function perfStart(label: string): void { + const t = now(); + current = { label, startedAt: t, marks: [], durationMs: null }; + publish(current); +} + +export function perfMark(name: string): void { + if (!current) return; + const t = now() - current.startedAt; + current.marks = [...current.marks, { name, t }]; + publish(current); +} + +export function perfEnd(): void { + if (!current) return; + const dur = now() - current.startedAt; + current.durationMs = dur; + publish(current); + + // Summary to console. Each mark shows absolute-from-start and delta from + // the previous mark so the slow phase is easy to spot. + const lines: string[] = [`${current.label} (total ${dur.toFixed(1)}ms)`]; + let prev = 0; + for (const m of current.marks) { + const delta = m.t - prev; + lines.push( + ` ${m.name.padEnd(18)} ${m.t.toFixed(1).padStart(8)}ms (+${delta.toFixed(1)}ms)`, + ); + prev = m.t; + } + // eslint-disable-next-line no-console + console.log("%cperf", "color:#b5652b;font-weight:600", "\n" + lines.join("\n")); + current = null; +} + +export function perfOverlayEnabled(): boolean { + try { + return globalThis.localStorage?.getItem("perf") === "1"; + } catch { + return false; + } +} + +export function perfSetOverlayEnabled(on: boolean): void { + try { + if (on) globalThis.localStorage?.setItem("perf", "1"); + else globalThis.localStorage?.removeItem("perf"); + } catch { + // ignore — overlay is a nice-to-have + } +} diff --git a/crates/toolpath-desktop/frontend/src/lib/store.svelte.ts b/crates/toolpath-desktop/frontend/src/lib/store.svelte.ts index 6329166..c1c325b 100644 --- a/crates/toolpath-desktop/frontend/src/lib/store.svelte.ts +++ b/crates/toolpath-desktop/frontend/src/lib/store.svelte.ts @@ -6,12 +6,19 @@ import type { Cmd, Dispatch, Model, Msg } from "./types"; import { initialModel, update } from "./update"; import { invoke } from "./ipc"; import { dbg } from "./debug"; +import { perfStart, perfMark, perfEnd } from "./perf.svelte"; class Store { m = $state(initialModel()); dispatch: Dispatch = (msg: Msg) => { dbg("msg", msg.t, msg); + // Perf: a derive-dispatch begins a new trace; `DeriveSucceeded` lands + // the model update that triggers the preview route to mount. + if (msg.t === "ClaudeDerive") perfStart("derive claude"); + else if (msg.t === "PiDerive") perfStart("derive pi"); + if (msg.t === "ClaudeDerive" || msg.t === "PiDerive") perfMark("dispatch"); + if (msg.t === "DeriveSucceeded") perfMark("model-updated"); const [next, cmd] = update(msg, this.m); const routeChanged = next.route !== this.m.route; this.m = next; @@ -36,14 +43,24 @@ class Store { return; case "invoke": dbg("invoke", cmd.name, cmd.args ?? {}); + if (cmd.name === "derive_claude" || cmd.name === "derive_pi") { + perfMark("invoke-start"); + } invoke(cmd.name, cmd.args).then( (r) => { dbg("invoke.ok", cmd.name, r); + if (cmd.name === "derive_claude" || cmd.name === "derive_pi") { + perfMark("invoke-end"); + } const m = cmd.onOk?.(r); if (m) this.dispatch(m); }, (e) => { dbg("invoke.err", cmd.name, e); + if (cmd.name === "derive_claude" || cmd.name === "derive_pi") { + perfMark("invoke-err"); + perfEnd(); + } const m = cmd.onErr?.(e); if (m) this.dispatch(m); }, diff --git a/crates/toolpath-desktop/frontend/src/lib/tree.ts b/crates/toolpath-desktop/frontend/src/lib/tree.ts index eebafbe..279442b 100644 --- a/crates/toolpath-desktop/frontend/src/lib/tree.ts +++ b/crates/toolpath-desktop/frontend/src/lib/tree.ts @@ -8,6 +8,7 @@ import { classify, type ChatTurnKind } from "./classify"; import { renderMarkdown } from "./markdown"; +import { perfMark } from "./perf.svelte"; import type { StepRef, TreeFilter } from "./types"; import { actorName, actorType, normalize, type Normalized } from "./viz"; @@ -105,11 +106,38 @@ export function matchesFilter( } /** Convenience: normalize + flatten in one call. */ +/** + * `buildTree` is called from multiple `$derived` blocks (StepTree + + * ChatView), so without memoisation the same doc gets normalised + flattened + * twice on every preview open. Memo by doc identity — callers only ever pass + * `store.m.preview.doc` which is a stable reference between re-renders. A + * `WeakMap` keeps the cache GC-friendly: once the doc is replaced (new + * derive) the old entry is collectable. + */ +type BuiltTree = { norm: Normalized; nodes: FlatNode[] }; +const treeCache = new WeakMap(); + export function buildTree( doc: Parameters[0], -): { norm: Normalized; nodes: FlatNode[] } { +): BuiltTree { + // `doc` is a JSON object at the top level (Step/Path/Graph wrapper), so + // WeakMap can key on it directly. + const cached = treeCache.get(doc as object); + if (cached) { + perfMark(`buildTree cache-hit (${cached.norm.steps.length}st)`); + return cached; + } + const t0 = performance.now(); const norm = normalize(doc); - return { norm, nodes: flattenTree(norm) }; + const tNorm = performance.now() - t0; + const nodes = flattenTree(norm); + const tTotal = performance.now() - t0; + perfMark( + `buildTree (${norm.steps.length}st ${tTotal.toFixed(0)}ms: norm ${tNorm.toFixed(0)} + flat ${(tTotal - tNorm).toFixed(0)})`, + ); + const built = { norm, nodes }; + treeCache.set(doc as object, built); + return built; } // ─── Chat / transcript view ────────────────────────────────────────────── @@ -185,8 +213,18 @@ function firstRawDiff( * advance HEAD), so a naive parent-walk would skip them and you'd see * "Used Edit" chips with no actual tool output in the transcript. */ +// Memo by norm identity. Same rationale as buildTree — ChatView's `$derived` +// block calls this, and it's expensive (markdown rendering dominates). +const flattenCache = new WeakMap(); + export function flattenChatHead(norm: Normalized): ChatTurn[] { + const cached = flattenCache.get(norm); + if (cached) { + perfMark(`flattenChatHead cache-hit (${cached.length}t)`); + return cached; + } const { steps, head, actors, stepMap, childrenMap } = norm; + const t0 = performance.now(); let ordered: StepRef[]; if (head && stepMap.has(head)) { @@ -210,6 +248,16 @@ export function flattenChatHead(norm: Normalized): ChatTurn[] { // Build each turn. For assistant turns, also collect tool.invoke sibling // children so the renderer can fold them inline inside the bubble instead // of scattering them as separate cards in the transcript. + let mdMs = 0; + let mdCount = 0; + const timedMarkdown = (src: string | null | undefined): string => { + if (!src) return ""; + const t = performance.now(); + const out = renderMarkdown(src); + mdMs += performance.now() - t; + mdCount += 1; + return out; + }; const turnFor = (s: StepRef): ChatTurn => { const c = classify(s); return { @@ -224,10 +272,10 @@ export function flattenChatHead(norm: Normalized): ChatTurn[] { changeKeys: s.change ? Object.keys(s.change) : [], kind: c.kind, text: c.text, - textHtml: c.text ? renderMarkdown(c.text) : "", + textHtml: timedMarkdown(c.text), toolNames: c.toolNames, thinking: c.thinking, - thinkingHtml: c.thinking ? renderMarkdown(c.thinking) : "", + thinkingHtml: timedMarkdown(c.thinking), model: c.model, toolName: c.toolName, toolDiff: firstRawDiff(s), @@ -236,7 +284,7 @@ export function flattenChatHead(norm: Normalized): ChatTurn[] { }; const onChain = new Set(ordered.map((s) => s.step.id)); - return ordered.map((s) => { + const out = ordered.map((s) => { const turn = turnFor(s); if (turn.kind === "assistant") { const kids = childrenMap.get(s.step.id) ?? []; @@ -250,4 +298,10 @@ export function flattenChatHead(norm: Normalized): ChatTurn[] { } return turn; }); + const total = performance.now() - t0; + perfMark( + `flattenChatHead (${out.length}t ${total.toFixed(0)}ms: md ${mdMs.toFixed(0)}ms × ${mdCount})`, + ); + flattenCache.set(norm, out); + return out; } diff --git a/crates/toolpath-desktop/frontend/src/routes/Preview.svelte b/crates/toolpath-desktop/frontend/src/routes/Preview.svelte index 4476d4b..a14404a 100644 --- a/crates/toolpath-desktop/frontend/src/routes/Preview.svelte +++ b/crates/toolpath-desktop/frontend/src/routes/Preview.svelte @@ -1,12 +1,36 @@