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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TraceCache>)`). 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 `<temp_dir>/toolpath-desktop/trace-cache/<fnv1a64-of-key>.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.
Expand Down
10 changes: 10 additions & 0 deletions crates/toolpath-desktop/frontend/src/app.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -68,6 +70,12 @@
$effect(() => {
let unlisten: UnlistenFn | undefined;
listen<TraceOpenedPayload>("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,
Expand Down Expand Up @@ -161,3 +169,5 @@
{/if}
</main>
</div>

<PerfOverlay />
164 changes: 164 additions & 0 deletions crates/toolpath-desktop/frontend/src/lib/PerfOverlay.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
<script lang="ts">
import { perf, perfOverlayEnabled } from "./perf.svelte";

const enabled = $derived(perfOverlayEnabled());
const trace = $derived(perf.latest);

// Map marks to phase bars. Each bar spans [prev, t] and is labelled by the
// mark's name — so e.g. "invoke-end" shows the cost of the invoke phase,
// not the instant it happened.
type Phase = { name: string; start: number; end: number; width: number };
const phases = $derived<Phase[]>((() => {
if (!trace) return [];
const out: Phase[] = [];
let prev = 0;
const total = trace.durationMs ?? trace.marks.at(-1)?.t ?? 1;
for (const m of trace.marks) {
out.push({
name: m.name,
start: prev,
end: m.t,
width: Math.max(0.5, ((m.t - prev) / total) * 100),
});
prev = m.t;
}
return out;
})());

function fmt(n: number): string {
return n < 10 ? n.toFixed(1) : n.toFixed(0);
}
</script>

{#if enabled && trace}
<div class="perf" role="status" aria-live="polite">
<div class="perf__head">
<span class="perf__label">{trace.label}</span>
<span class="perf__total">
{#if trace.durationMs != null}
{fmt(trace.durationMs)}ms
{:else}
running…
{/if}
</span>
</div>
<div class="perf__bar">
{#each phases as p (p.name + p.start)}
<div
class="perf__phase"
style="width:{p.width}%"
title="{p.name}: {fmt(p.end - p.start)}ms"
>
<span class="perf__phase-name">{p.name}</span>
<span class="perf__phase-t">{fmt(p.end - p.start)}</span>
</div>
{/each}
</div>
<details class="perf__detail">
<summary>marks</summary>
<table>
<thead>
<tr><th>mark</th><th>at</th><th>Δ</th></tr>
</thead>
<tbody>
{#each phases as p (p.name + p.start)}
<tr>
<td>{p.name}</td>
<td>{fmt(p.end)}</td>
<td>+{fmt(p.end - p.start)}</td>
</tr>
{/each}
</tbody>
</table>
</details>
</div>
{/if}

<style>
.perf {
position: fixed;
right: 12px;
bottom: 12px;
z-index: 9999;
min-width: 340px;
max-width: 560px;
padding: 8px 10px;
background: rgba(20, 20, 22, 0.92);
color: #f4f0e8;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.35;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
pointer-events: auto;
}
.perf__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.perf__label { font-weight: 600; }
.perf__total {
font-variant-numeric: tabular-nums;
color: #f4c078;
}
.perf__bar {
display: flex;
height: 18px;
border-radius: 3px;
overflow: hidden;
background: rgba(255, 255, 255, 0.06);
}
.perf__phase {
position: relative;
overflow: hidden;
padding: 0 4px;
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
line-height: 1;
white-space: nowrap;
border-right: 1px solid rgba(0, 0, 0, 0.3);
color: #1d1d1f;
}
.perf__phase:nth-child(6n + 1) { background: #f4c078; }
.perf__phase:nth-child(6n + 2) { background: #a4c4a0; }
.perf__phase:nth-child(6n + 3) { background: #c48aa8; }
.perf__phase:nth-child(6n + 4) { background: #88b4c0; }
.perf__phase:nth-child(6n + 5) { background: #e0b070; }
.perf__phase:nth-child(6n) { background: #b8a078; }
.perf__phase-name { font-weight: 600; }
.perf__phase-t {
font-variant-numeric: tabular-nums;
opacity: 0.85;
}
.perf__detail {
margin-top: 6px;
opacity: 0.8;
}
.perf__detail summary {
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 10px;
}
.perf__detail table {
width: 100%;
margin-top: 4px;
border-collapse: collapse;
}
.perf__detail th,
.perf__detail td {
padding: 2px 6px 2px 0;
text-align: left;
font-variant-numeric: tabular-nums;
}
.perf__detail th {
opacity: 0.6;
font-weight: normal;
}
</style>
105 changes: 105 additions & 0 deletions crates/toolpath-desktop/frontend/src/lib/perf.svelte.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
17 changes: 17 additions & 0 deletions crates/toolpath-desktop/frontend/src/lib/store.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Model>(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;
Expand All @@ -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);
},
Expand Down
Loading
Loading