From 9ab52be24c173568b64b8ceec7b785f9ae07e16b Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:29:03 +0100 Subject: [PATCH 01/44] perf(app): the mock fixtures load only in mock mode, not at every start The ?mock fixtures (lib/mock*.ts, the acme team files, the demo terminal) were statically imported by the connection hook, chat controls, the composer and the conversation pane, so every start of the app parsed ~400 KB of them. They are now imported when mock mode asks for them. Initial JS: 3218 -> 2922 KB raw, 836 -> 753 KB brotli. Co-Authored-By: Claude Opus 5.5 --- .../components/conversation/conversation-pane.tsx | 15 ++++++++++----- .../components/conversation/conversation-view.tsx | 3 +-- app/src/hooks/use-berth-connection.ts | 5 +++-- app/src/lib/chat-controls.ts | 3 +-- app/src/lib/start-work.ts | 3 +-- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/app/src/components/conversation/conversation-pane.tsx b/app/src/components/conversation/conversation-pane.tsx index c581c763..0c757bd1 100644 --- a/app/src/components/conversation/conversation-pane.tsx +++ b/app/src/components/conversation/conversation-pane.tsx @@ -35,7 +35,6 @@ import { keyOf, useConversations } from "@/lib/conversation-store"; import { agentLabel, agentOf, firstPrompt, guessAgent, sessionState, worktreeOf } from "@/lib/derive"; import type { NextStep } from "@/lib/errors"; import { errorMessage } from "@/lib/format"; -import { finishTurn, mockToolDetail, seedTranscript } from "@/lib/mock-conversation"; import { useNotifications } from "@/lib/notifications"; import { updateBoxes } from "@/lib/outdated"; import { addComment, type LineComment, pending, removeComment, sendComments, useComments } from "@/lib/review-comments"; @@ -52,6 +51,9 @@ import { ConfirmDialog } from "@/views/settings/confirm"; import { cn } from "@/lib/utils"; import { useReview } from "@/views/review/review-store"; +// The mock's scripted conversations (?mock), loaded only in mock mode. +const mockConversation = () => import("@/lib/mock-conversation"); + // ConversationPane shows an agent's pane as a conversation: the transcript, // and a reply box docked at its foot. On a box that streams transcripts it // reads the agent's own; "Thinking…" and the question come from the @@ -138,7 +140,10 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS useEffect(() => { if (!mock || !s || useConversations.getState().items[key]) return; const wt = worktreeOf(locations, s); - seedTranscript(box, session, sessionState(s, stats), wt ? (wt.worktree.main ? wt.location.name : wt.worktree.name) : session); + const state = sessionState(s, stats); + void mockConversation().then((m) => { + if (!useConversations.getState().items[key]) m.seedTranscript(box, session, state, wt ? (wt.worktree.main ? wt.location.name : wt.worktree.name) : session); + }); }, [mock, s, key, box, session, stats, locations]); // The prompt it was started with (a long one, its start). @@ -207,7 +212,7 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS if (!client || (!canDiff && !mock)) return undefined; return { load: (file) => boxApi.diff(client, box, session, file), - tool: (id) => (mock ? mockToolDetail(id) : boxApi.toolDetail(client, box, session, id)), + tool: (id) => (mock ? mockConversation().then((m) => m.mockToolDetail(id)) : boxApi.toolDetail(client, box, session, id)), comments: (file) => reviewKey ? { @@ -329,7 +334,7 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS const answer = (id: string, choice: string) => { if (mock) { useConversations.getState().update(key, id, { decided: choice }); - void finishTurn(box, session); + void mockConversation().then((m) => m.finishTurn(box, session)); return; } if (!client) return; @@ -359,7 +364,7 @@ export function ConversationPane({ box, session, agent: remembered, visible, onS const reply = async (text: string) => { if (mock && state !== "running") { useConversations.getState().push(key, { kind: "user", id: `u${Date.now()}`, text }); - void finishTurn(box, session); + void mockConversation().then((m) => m.finishTurn(box, session)); return; } if (!client) return; diff --git a/app/src/components/conversation/conversation-view.tsx b/app/src/components/conversation/conversation-view.tsx index f0f47e69..b5b44964 100644 --- a/app/src/components/conversation/conversation-view.tsx +++ b/app/src/components/conversation/conversation-view.tsx @@ -32,7 +32,6 @@ import { isMock } from "@/hooks/use-berth-connection"; import { keyOf } from "@/lib/conversation-store"; import { rowKeyOf } from "@/lib/draft-text"; import { applyCut, dropOlder, loadOlder, meta, restoreOlder, setCut, useHasHistory, useHistory, useOlder } from "@/lib/history"; -import { seedLongChat } from "@/lib/mock-history"; import "@/components/conversation/conversation.css"; import "@/components/conversation/history.css"; import { TurnSync } from "@/components/workspace/compare-sync"; @@ -88,7 +87,7 @@ export function ConversationView({ items: live, onAnswer, edits, who = "The agen }, [key, chat?.visible]); // The demo's long chat (?long=5000), for measuring. useEffect(() => { - if (chat && isMock()) seedLongChat(chat.box, chat.session); + if (chat && isMock()) void import("@/lib/mock-history").then((m) => m.seedLongChat(chat.box, chat.session)); }, [chat?.box, chat?.session]); // A rewound prompt stays hidden until the agent's record no longer has it. useEffect(() => { diff --git a/app/src/hooks/use-berth-connection.ts b/app/src/hooks/use-berth-connection.ts index b53b69f3..5d608085 100644 --- a/app/src/hooks/use-berth-connection.ts +++ b/app/src/hooks/use-berth-connection.ts @@ -4,7 +4,6 @@ import { mockAgentDown, waitForRetry } from "@/lib/agent-start"; import { endpoint, httpClient, type Client } from "@/lib/api"; import { handleEvent } from "@/lib/events"; import { errorMessage } from "@/lib/format"; -import { mockClient } from "@/lib/mock"; import { useStore } from "@/lib/store"; import { loadNav } from "@/lib/nav"; import { loadNotifications } from "@/lib/notifications"; @@ -35,7 +34,9 @@ export function useBerthConnection() { try { // ?mock=offline is the "not running" screen until its agent starts. if (mockAgentDown()) throw new Error("the Shipyard agent has not started yet (mock)"); - if (isMock()) return mockClient(); + // The fixtures load only in mock mode: they stay out of the app's + // startup. + if (isMock()) return (await import("@/lib/mock")).mockClient(); const client = httpClient(await endpoint()); await client.status(); return client; diff --git a/app/src/lib/chat-controls.ts b/app/src/lib/chat-controls.ts index ad411204..d1bcab18 100644 --- a/app/src/lib/chat-controls.ts +++ b/app/src/lib/chat-controls.ts @@ -5,7 +5,6 @@ import { isMock } from "@/hooks/use-berth-connection"; import { boxApi } from "@/lib/api"; import { keyOf, useConversations } from "@/lib/conversation-store"; import { useEventLog } from "@/lib/events"; -import { mockDemo } from "@/lib/mock"; import { useStore } from "@/lib/store"; import type { ToolDetail, TranscriptItem } from "@/lib/transcript"; @@ -263,7 +262,7 @@ function mockSignals(session: string): ChatSignals { function mockInterrupt(box: string, session: string) { useMockSignals.setState((s) => ({ stopped: { ...s.stopped, [session]: true } })); - mockDemo.setAgent(box, session, "finished"); + void import("@/lib/mock").then((m) => m.mockDemo.setAgent(box, session, "finished")); const key = keyOf(box, session); const st = useConversations.getState(); for (const it of st.items[key] ?? []) if (it.kind === "thinking") st.remove(key, it.id); diff --git a/app/src/lib/start-work.ts b/app/src/lib/start-work.ts index 91e7eec3..edfad43f 100644 --- a/app/src/lib/start-work.ts +++ b/app/src/lib/start-work.ts @@ -10,7 +10,6 @@ import { startBroadcast } from "@/lib/broadcast"; import type { AgentPick, ComposerTarget } from "@/lib/composer"; import { agentLabel } from "@/lib/derive"; import { plainError } from "@/lib/errors"; -import { playTurn } from "@/lib/mock-conversation"; import { handoff, loop, review } from "@/lib/orchestrate"; import { worktreeSlug } from "@/lib/projects"; import { usePrompts } from "@/lib/prompts"; @@ -140,7 +139,7 @@ export async function startWork(d: StartDraft): Promise { } // The demo plays a scripted turn; it starts before the pane opens, so // the pane finds the conversation already begun. - if (isMock() && d.text) void playTurn(d.box, session, d.text); + if (isMock() && d.text) void import("@/lib/mock-conversation").then((m) => m.playTurn(d.box, session, d.text)); await useStore.getState().refreshBox(d.box, ["locations", "sessions"]); await focusSession(d.box, session); save(`berth.composer.picks.${d.box}/${d.location}`, d.picks); From 2b1f7b8cb3916d39eb391b1c2f493cfb458b4923 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:30:08 +0100 Subject: [PATCH 02/44] test: benchmarks for the box's hot paths Opening and polling a long conversation, paging it, a tool call's details, assigning transcripts, a chat's transcript poll, the screen poll and the ledger's write, on synthetic acme fixtures. The box ones count the tmux commands each op runs (execs/op). Co-Authored-By: Claude Opus 5.5 --- internal/box/perf_bench_test.go | 145 ++++++++++++++++++++++++ internal/transcript/bench_test.go | 176 ++++++++++++++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 internal/box/perf_bench_test.go create mode 100644 internal/transcript/bench_test.go diff --git a/internal/box/perf_bench_test.go b/internal/box/perf_bench_test.go new file mode 100644 index 00000000..60c5bd00 --- /dev/null +++ b/internal/box/perf_bench_test.go @@ -0,0 +1,145 @@ +package box + +import ( + "context" + "fmt" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/cosscom/shipyard/internal/events" + "github.com/cosscom/shipyard/internal/transcript" +) + +// Benchmarks for what the box does over and over while chats are open and +// agents work: answering a chat's poll, reading screens, writing the +// ledger. Each reports how many tmux commands one op ran (execs/op), as a +// fork and exec of tmux costs far more than anything else here. + +// benchSessions is testSessions for a benchmark: tmux under a private +// TMUX_TMPDIR, never the developer's own server. +func benchSessions(b *testing.B) (*Sessions, *atomic.Int64) { + b.Helper() + if _, err := exec.LookPath("tmux"); err != nil { + b.Skip("tmux not installed") + } + tmp, err := os.MkdirTemp("/tmp", "cpb") + if err != nil { + b.Fatal(err) + } + b.Setenv("TMUX_TMPDIR", tmp) + b.Setenv("TMUX", "") + b.Setenv("SHELL", "/bin/sh") + s, err := NewSessions(b.TempDir()) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { + exec.Command("tmux", "-L", tmuxSocket, "kill-server").Run() + os.RemoveAll(tmp) + }) + var execs atomic.Int64 + s.trace = func([]string) { execs.Add(1) } + return s, &execs +} + +// BenchmarkTranscriptPoll is one chat's poll of GET …/transcript with +// nothing new, in a folder where three Claude sessions run. +func BenchmarkTranscriptPoll(b *testing.B) { + ctx := context.Background() + s, execs := benchSessions(b) + cfg := b.TempDir() + b.Setenv("CLAUDE_CONFIG_DIR", cfg) + dir := b.TempDir() + for i := range 3 { + if _, err := s.create(ctx, fmt.Sprintf("acme-%d", i), "acme", dir, "sleep 600", "claude", nil, nil); err != nil { + b.Fatal(err) + } + } + proj := transcript.ClaudeDir(dir) + os.MkdirAll(proj, 0o700) + ts := time.Now().UTC().Format(time.RFC3339Nano) + var rec strings.Builder + for i := range 200 { + fmt.Fprintf(&rec, `{"type":"assistant","timestamp":%q,"uuid":"u%d","message":{"role":"assistant","content":[{"type":"text","text":"step %d of the acme fix"}]}}`+"\n", ts, i, i) + } + for i := range 3 { + os.WriteFile(filepath.Join(proj, fmt.Sprintf("0000000%d-acme-4c1e-9d55-2f7e0c1a9b01.jsonl", i)), []byte(rec.String()), 0o600) + } + bx := &Box{Name: "devbox", Sessions: s, Events: &events.Bus{}} + poll := func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/v1/sessions/acme-1/transcript", nil) + r.SetPathValue("name", "acme-1") + if err := bx.transcript(w, r); err != nil || !strings.Contains(w.Body.String(), `"source":"claude"`) { + b.Fatalf("transcript: %v %s", err, w.Body.String()) + } + } + poll() + execs.Store(0) + b.ReportAllocs() + n := 0 + for b.Loop() { + poll() + n++ + } + b.ReportMetric(float64(execs.Load())/float64(n), "execs/op") +} + +// BenchmarkPollScreens is the 2-second look at the screens of agents no +// hook reports for: four of them, each mid-turn. +func BenchmarkPollScreens(b *testing.B) { + ctx := context.Background() + s, execs := benchSessions(b) + dir := b.TempDir() + t := &Turns{} + t.init() + for i := range 4 { + name := fmt.Sprintf("acme-%d", i) + if _, err := s.create(ctx, name, "acme", dir, "sleep 600", "", nil, nil); err != nil { + b.Fatal(err) + } + t.sess[name] = &sessTrack{Name: name, Agent: "acme-cli", Dir: dir, State: "running", Turns: []*Turn{{ID: name + "#1", Session: name, N: 1, State: "running", Fidelity: "screen"}}} + } + bx := &Box{Name: "devbox", Sessions: s, Events: &events.Bus{}, Turns: t} + bx.pollScreens(ctx) + execs.Store(0) + b.ReportAllocs() + n := 0 + for b.Loop() { + bx.pollScreens(ctx) + n++ + } + b.ReportMetric(float64(execs.Load())/float64(n), "execs/op") +} + +// BenchmarkTurnsSave is the ledger written after a change, on a box with +// 20 agent sessions of 50 turns each. +func BenchmarkTurnsSave(b *testing.B) { + dir := b.TempDir() + t := &Turns{Path: filepath.Join(dir, "turns.json"), LegacyPath: filepath.Join(dir, "agent-states.json")} + t.init() + at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + for i := range 20 { + name := fmt.Sprintf("acme-%d", i) + s := &sessTrack{Name: name, Agent: "claude", Dir: "/home/acme/w" + fmt.Sprint(i), State: "running", Since: at, N: 50} + for n := 1; n <= 50; n++ { + s.Turns = append(s.Turns, &Turn{ID: fmt.Sprintf("%s#%d", name, n), Session: name, N: n, State: "finished", Sent: at, Started: at, Ended: at, Fidelity: "hooks", Origin: "laptop:acme"}) + } + t.sess[name] = s + } + b.ReportAllocs() + for b.Loop() { + // A tool started: the ledger moved on, no agent changed state. + t.mu.Lock() + t.applied++ + t.dirty = true + t.mu.Unlock() + t.save() + } +} diff --git a/internal/transcript/bench_test.go b/internal/transcript/bench_test.go new file mode 100644 index 00000000..9f1a77b4 --- /dev/null +++ b/internal/transcript/bench_test.go @@ -0,0 +1,176 @@ +package transcript + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Benchmarks on a synthetic Claude Code record shaped like a real one: each +// step an assistant line (thinking, text, a tool call) and the user line +// with its result, whose toolUseResult carries what Claude Code keeps +// beside it (a Read's whole file, an Edit's original file and patch). +// Every name and path is made up ("acme"). + +// synthClaude writes a record of about n lines to a temp file and returns +// its path and the ID of a tool call near its end. +func synthClaude(tb testing.TB, n int) (string, string) { + tb.Helper() + dir := tb.TempDir() + p := filepath.Join(dir, "0b8f3a52-acme-4c1e-9d55-2f7e0c1a9b01.jsonl") + f, err := os.Create(p) + if err != nil { + tb.Fatal(err) + } + defer f.Close() + at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + file := strings.Repeat("export function acme(x: number): number {\n return x * 2 // keep it simple\n}\n", 60) // ~5KB + out := strings.Repeat("ok github.com/acme/widgets/pkg/thing\t0.123s\n", 40) + parent := "" + uuid := 0 + line := func(v map[string]any) { + uuid++ + id := fmt.Sprintf("00000000-0000-4000-8000-%012d", uuid) + v["uuid"], v["parentUuid"], v["isSidechain"] = id, parent, false + v["timestamp"] = at.Format(time.RFC3339Nano) + v["cwd"], v["sessionId"], v["version"], v["gitBranch"], v["userType"] = "/home/acme/widgets", "0b8f3a52-acme", "2.1.0", "main", "external" + at = at.Add(1500 * time.Millisecond) + parent = id + b, _ := json.Marshal(v) + f.Write(append(b, '\n')) + } + usage := map[string]any{"input_tokens": 12, "cache_creation_input_tokens": 3400, "cache_read_input_tokens": 88000, "output_tokens": 420} + assistant := func(blocks ...map[string]any) { + line(map[string]any{"type": "assistant", "requestId": "req_acme", "message": map[string]any{ + "id": "msg_acme", "type": "message", "role": "assistant", "model": "claude-acme-1", "content": blocks, "usage": usage}}) + } + result := func(tool string, content string, extra any) { + line(map[string]any{"type": "user", "message": map[string]any{"role": "user", "content": []map[string]any{ + {"type": "tool_result", "tool_use_id": tool, "content": content}}}, "toolUseResult": extra}) + } + lastTool := "" + for i := 0; uuid < n; i++ { + if i%25 == 0 { + line(map[string]any{"type": "user", "permissionMode": "default", "message": map[string]any{"role": "user", "content": fmt.Sprintf("Please fix widget %d in the acme repo and run the tests", i)}}) + } + assistant(map[string]any{"type": "thinking", "thinking": "", "signature": strings.Repeat("Q2xhdWRl", 120)}) + assistant(map[string]any{"type": "text", "text": fmt.Sprintf("Looking at widget %d now; I'll read the file first.", i)}) + tool := fmt.Sprintf("toolu_%06d", i) + lastTool = tool + path := fmt.Sprintf("/home/acme/widgets/src/widget%d.ts", i%40) + switch i % 4 { + case 0: + assistant(map[string]any{"type": "tool_use", "id": tool, "name": "Read", "input": map[string]any{"file_path": path}}) + result(tool, file, map[string]any{"type": "text", "file": map[string]any{"filePath": path, "content": file, "numLines": 180, "startLine": 1, "totalLines": 180}}) + case 1: + assistant(map[string]any{"type": "tool_use", "id": tool, "name": "Edit", "input": map[string]any{"file_path": path, "old_string": "x * 2", "new_string": "x * 3"}}) + result(tool, "The file "+path+" has been updated.", map[string]any{"filePath": path, "oldString": "x * 2", "newString": "x * 3", "originalFile": file, + "structuredPatch": []map[string]any{{"oldStart": 2, "oldLines": 1, "newStart": 2, "newLines": 1, "lines": []string{"- return x * 2", "+ return x * 3"}}}}) + case 2: + assistant(map[string]any{"type": "tool_use", "id": tool, "name": "Bash", "input": map[string]any{"command": "go test ./...", "description": "Run tests"}}) + result(tool, out, map[string]any{"stdout": out, "stderr": "", "interrupted": false, "isImage": false}) + case 3: + assistant(map[string]any{"type": "tool_use", "id": tool, "name": "Grep", "input": map[string]any{"pattern": "acme", "path": "/home/acme/widgets"}}) + result(tool, "Found 3 files\nsrc/a.ts\nsrc/b.ts\nsrc/c.ts", map[string]any{"mode": "files_with_matches", "filenames": []string{"src/a.ts", "src/b.ts", "src/c.ts"}, "numFiles": 3}) + } + } + return p, lastTool +} + +func fileSize(tb testing.TB, p string) int64 { + st, err := os.Stat(p) + if err != nil { + tb.Fatal(err) + } + return st.Size() +} + +// BenchmarkFollowCold is a chat opening a long conversation: the last +// maxStart bytes read and parsed from scratch. +func BenchmarkFollowCold(b *testing.B) { + p, _ := synthClaude(b, 20000) + b.SetBytes(min(fileSize(b, p), maxStart)) + b.ReportAllocs() + for b.Loop() { + if _, err := NewReader().Read("claude", p, "/home/acme/widgets", 0); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkFollowWarm is the 2-second poll of an open chat with nothing +// new: the answer is built from what is kept. +func BenchmarkFollowWarm(b *testing.B) { + p, _ := synthClaude(b, 5000) + r := NewReader() + res, err := r.Read("claude", p, "/home/acme/widgets", 0) + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + for b.Loop() { + if _, err := r.Follow("claude", p, "/home/acme/widgets", res.Next, res.Gen); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkBefore is a page of 80 older items from the end of a long file. +func BenchmarkBefore(b *testing.B) { + p, _ := synthClaude(b, 20000) + b.ReportAllocs() + for b.Loop() { + if _, err := Before("claude", p, "/home/acme/widgets", 0, 80); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkLastTurn finds the latest prompt and reads its edits. +func BenchmarkLastTurn(b *testing.B) { + p, _ := synthClaude(b, 20000) + b.ReportAllocs() + for b.Loop() { + if _, err := LastTurn("claude", p, "/home/acme/widgets"); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkDetail opens one tool call near the end of a long file. +func BenchmarkDetail(b *testing.B) { + p, id := synthClaude(b, 20000) + b.SetBytes(fileSize(b, p)) + b.ReportAllocs() + for b.Loop() { + if _, err := Detail("claude", p, "/home/acme/widgets", id); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkAssignClaude assigns a folder's transcripts to its sessions, +// as every chat poll does, in a project folder holding 200 records. +func BenchmarkAssignClaude(b *testing.B) { + cfg := b.TempDir() + b.Setenv("CLAUDE_CONFIG_DIR", cfg) + dir := "/home/acme/widgets" + proj := ClaudeDir(dir) + if err := os.MkdirAll(proj, 0o755); err != nil { + b.Fatal(err) + } + start := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + for i := range 200 { + ts := start.Add(time.Duration(i) * time.Minute).Format(time.RFC3339Nano) + os.WriteFile(filepath.Join(proj, fmt.Sprintf("%08d-acme-4c1e-9d55-2f7e0c1a9b01.jsonl", i)), []byte(`{"type":"user","timestamp":"`+ts+`"}`+"\n"), 0o600) + } + claims := []Claim{{Name: "a", Started: start.Add(150 * time.Minute)}, {Name: "b", Started: start.Add(170 * time.Minute)}} + b.ReportAllocs() + for b.Loop() { + AssignClaude(dir, claims) + } +} From f62e024735b1741244733113f20320ad8c7ba139 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:30:35 +0100 Subject: [PATCH 03/44] perf(app): lucide's icon-by-name map loads with the first plugin icon plugins/ui.tsx imported lucide-react/dynamic for its Icon, which brings the map of all ~1,700 icons (a lazy import each) into the startup bundle. Icon now loads it, and DynamicIcon, on first use; like DynamicIcon it draws nothing until the icon has loaded. Initial JS: 2922 -> 2669 KB raw, 753 -> 715 KB brotli. Co-Authored-By: Claude Opus 5.5 --- app/src/plugins/dynamic-icon.tsx | 21 +++++++++++++++++++++ app/src/plugins/ui.tsx | 21 ++++++++------------- 2 files changed, 29 insertions(+), 13 deletions(-) create mode 100644 app/src/plugins/dynamic-icon.tsx diff --git a/app/src/plugins/dynamic-icon.tsx b/app/src/plugins/dynamic-icon.tsx new file mode 100644 index 00000000..f366844b --- /dev/null +++ b/app/src/plugins/dynamic-icon.tsx @@ -0,0 +1,21 @@ +import { DynamicIcon, iconNames, type IconName } from "lucide-react/dynamic"; + +// lucide's map of every icon by name (some 1,700 lazy imports) is large, so +// it loads with the first icon a plugin or a sidebar item names, not at +// start (plugins/ui.tsx's Icon). + +const known = new Set(iconNames); + +// iconName turns "PanelsTopLeft" or "panels-top-left" into lucide's name. +export function iconName(name: string): IconName | undefined { + const kebab = name + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/([A-Z])([A-Z][a-z])/g, "$1-$2") + .toLowerCase(); + return known.has(kebab) ? (kebab as IconName) : undefined; +} + +export default function NamedIcon({ name, className }: { name: string; className?: string }) { + const n = iconName(name); + return n ? : ; +} diff --git a/app/src/plugins/ui.tsx b/app/src/plugins/ui.tsx index 4f08c161..5d7f6a65 100644 --- a/app/src/plugins/ui.tsx +++ b/app/src/plugins/ui.tsx @@ -1,4 +1,4 @@ -import { DynamicIcon, iconNames, type IconName } from "lucide-react/dynamic"; +import { lazy, Suspense } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -34,21 +34,16 @@ import { cn } from "@/lib/utils"; import { WidgetEmpty, WidgetRow, WidgetSkeleton } from "@/views/home/widgets/parts"; import { PluginPage, ViewHeader } from "@/views/view-header"; -const known = new Set(iconNames); - -// iconName turns "PanelsTopLeft" or "panels-top-left" into lucide's name. -export function iconName(name: string): IconName | undefined { - const kebab = name - .replace(/([a-z0-9])([A-Z])/g, "$1-$2") - .replace(/([A-Z])([A-Z][a-z])/g, "$1-$2") - .toLowerCase(); - return known.has(kebab) ? (kebab as IconName) : undefined; -} +const NamedIcon = lazy(() => import("@/plugins/dynamic-icon")); // Icon draws any lucide icon by name, for plugins and their sidebar items. +// Like lucide's DynamicIcon it draws nothing until the icon has loaded. export function Icon({ name, className }: { name: string; className?: string }) { - const n = iconName(name); - return n ? : ; + return ( + + + + ); } // pluginUi is what @berth/plugin/ui resolves to inside a plugin. From 930fe0de71a0d55755d2d2673268370ddbbd201f Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:30:43 +0100 Subject: [PATCH 04/44] perf(transcript): opening a long conversation no longer copies its whole window for every item conv.add rebuilt the 300-item window on every item past the limit, so reading the last 4 MB of a record allocated ~120 MB. The window now slides along a backing array with room for as many again, and only moves when that fills: ~11 MB for the same read. Co-Authored-By: Claude Opus 5.5 --- internal/transcript/transcript.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/internal/transcript/transcript.go b/internal/transcript/transcript.go index 6687b8fc..cb93a3f3 100644 --- a/internal/transcript/transcript.go +++ b/internal/transcript/transcript.go @@ -257,13 +257,21 @@ func (c *conv) add(it Item) int { c.items[n-1].pending = nil } it.Off = c.lineOff - c.items = append(c.items, it) n := keep if c.limit > 0 { n = c.limit } + // The window slides along a backing array with room for n more: the + // oldest item drops off by reslicing, and only when the array is full + // do the kept items move to a fresh one. Copying the whole window for + // every item past n made opening a long conversation quadratic. + if len(c.items) == cap(c.items) && len(c.items) >= n { + c.items = append(make([]Item, 0, 2*n), c.items...) + } + c.items = append(c.items, it) if over := len(c.items) - n; over > 0 { - c.items = append(c.items[:0:0], c.items[over:]...) + clear(c.items[:over]) // let what they hold go + c.items = c.items[over:] c.base += over } return c.base + len(c.items) - 1 From bfb31cde11f8ffb9a2ced1eb121178d9c030a816 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:34:07 +0100 Subject: [PATCH 05/44] perf(box): a chat's transcript poll runs one tmux command instead of five EnvVar remembered a session's variable only when it was set, so the usual answer (no CLAUDE_CONFIG_DIR of its own) asked tmux again for every Claude session in the folder on every poll. Unset is now remembered too; a session that is gone still isn't. The poll also listed the sessions twice (to find the session, then its neighbours): one list now serves both, and the tool-detail, helper and touched-files reads reuse theirs the same way. Co-Authored-By: Claude Opus 5.5 --- internal/box/history.go | 4 ++-- internal/box/notify_text.go | 2 +- internal/box/sessions.go | 21 +++++++++++++---- internal/box/sessions_test.go | 43 +++++++++++++++++++++++++++++++++++ internal/box/touched.go | 2 +- internal/box/transcriptapi.go | 43 +++++++++++++++++++---------------- 6 files changed, 88 insertions(+), 27 deletions(-) diff --git a/internal/box/history.go b/internal/box/history.go index fafda8ae..9a60fdab 100644 --- a/internal/box/history.go +++ b/internal/box/history.go @@ -51,11 +51,11 @@ func olderPage(w http.ResponseWriter, r *http.Request, agent, path, dir string) // claudeRecord is a session's Claude Code record, or why there is none. func (b *Box) claudeRecord(r *http.Request) (Session, string, error) { - sess, err := b.Sessions.Get(r.Context(), r.PathValue("name")) + sess, all, err := b.Sessions.getWithAll(r.Context(), r.PathValue("name")) if err != nil { return Session{}, "", err } - agent, path, _ := b.transcriptFile(r, sess) + agent, path, _ := b.transcriptFile(r, sess, all) switch { case agent != "claude": return sess, "", httpError{http.StatusBadRequest, "this works with Claude Code's conversations; this session runs " + firstNonEmpty(agent, "no agent")} diff --git a/internal/box/notify_text.go b/internal/box/notify_text.go index b11f6a3f..f3e0c1bb 100644 --- a/internal/box/notify_text.go +++ b/internal/box/notify_text.go @@ -358,7 +358,7 @@ func (h *boxNotifyHost) describe(ctx context.Context, r *Report) { sumFiles(r) return } - agent, path, _ := b.transcriptFile(requestFor(ctx), sess) + agent, path, _ := b.transcriptFile(requestFor(ctx), sess, nil) if path == "" || (agent != "claude" && agent != "codex") { sumFiles(r) return diff --git a/internal/box/sessions.go b/internal/box/sessions.go index 44591b7d..0c1dcbe8 100644 --- a/internal/box/sessions.go +++ b/internal/box/sessions.go @@ -130,7 +130,13 @@ func (s *Sessions) EnvVar(ctx context.Context, sess Session, key string) string } out, err := s.tmux(ctx, "show-environment", "-t", "="+sess.Name, key) if err != nil { - // Unset, or the session is gone: nothing to remember. + // Unset is remembered too: it is the usual answer (no account + // folder of its own), and every chat's poll asks it again for + // each agent in the folder. A session that is gone, or a tmux + // that failed, leaves nothing to remember. + if strings.Contains(string(out), "unknown variable") { + s.envs.Store(cacheKey, "") + } return "" } v, ok := strings.CutPrefix(strings.TrimSpace(string(out)), key+"=") @@ -420,16 +426,23 @@ func (s *Sessions) SetTitle(ctx context.Context, name, title string) error { } func (s *Sessions) Get(ctx context.Context, name string) (Session, error) { + sess, _, err := s.getWithAll(ctx, name) + return sess, err +} + +// getWithAll is Get that also returns every session it listed, for a +// caller that needs the others too without asking tmux again. +func (s *Sessions) getWithAll(ctx context.Context, name string) (Session, []Session, error) { all, err := s.List(ctx) if err != nil { - return Session{}, err + return Session{}, nil, err } for _, sess := range all { if sess.Name == name { - return sess, nil + return sess, all, nil } } - return Session{}, ErrUnknownSession + return Session{}, nil, ErrUnknownSession } // Kill ends a session and, in the background, everything it started diff --git a/internal/box/sessions_test.go b/internal/box/sessions_test.go index 78641f9a..5479ccc1 100644 --- a/internal/box/sessions_test.go +++ b/internal/box/sessions_test.go @@ -234,3 +234,46 @@ func TestScreenShowsTheSessionsOutput(t *testing.T) { time.Sleep(50 * time.Millisecond) } } + +// A session's environment never changes, so EnvVar asks tmux once per +// variable, an unset one included: every chat poll asks it for each agent +// in the folder. +func TestEnvVarAsksTmuxOnce(t *testing.T) { + s := testSessions(t) + ctx := context.Background() + sess, err := s.Create(ctx, "envs", "acme", t.TempDir(), "sleep 30", []string{"ACME_HOME=/srv/acme"}) + if err != nil { + t.Fatal(err) + } + var mu sync.Mutex + asked := 0 + s.trace = func(args []string) { + mu.Lock() + defer mu.Unlock() + if len(args) > 0 && args[0] == "show-environment" { + asked++ + } + } + for range 3 { + if v := s.EnvVar(ctx, sess, "ACME_HOME"); v != "/srv/acme" { + t.Fatalf("ACME_HOME = %q", v) + } + if v := s.EnvVar(ctx, sess, "ACME_UNSET"); v != "" { + t.Fatalf("ACME_UNSET = %q", v) + } + } + mu.Lock() + if asked != 2 { + t.Fatalf("asked tmux %d times, want 2", asked) + } + mu.Unlock() + // A session that is gone is not remembered as having it unset. + gone := Session{Name: "gone", Created: sess.Created} + s.EnvVar(ctx, gone, "ACME_HOME") + s.EnvVar(ctx, gone, "ACME_HOME") + mu.Lock() + defer mu.Unlock() + if asked != 4 { + t.Fatalf("asked tmux %d times for a gone session, want 2 more", asked-2) + } +} diff --git a/internal/box/touched.go b/internal/box/touched.go index ac50409d..ce22e4d4 100644 --- a/internal/box/touched.go +++ b/internal/box/touched.go @@ -107,7 +107,7 @@ func (b *Box) touchedIn(r *http.Request, wt Worktree) []FileTurn { if _, inside := relInside(root, dir); !inside && dir != root { continue } - agent, path, _ := b.transcriptFile(r, s) + agent, path, _ := b.transcriptFile(r, s, sessions) if path == "" || (agent != "claude" && agent != "codex") { continue } diff --git a/internal/box/transcriptapi.go b/internal/box/transcriptapi.go index 93d173b2..cc2d39ef 100644 --- a/internal/box/transcriptapi.go +++ b/internal/box/transcriptapi.go @@ -13,7 +13,9 @@ import ( // transcriptFile is which agent a session runs and the file holding its // conversation, for the conversation and for a tool call's details alike. -func (b *Box) transcriptFile(r *http.Request, sess Session) (agent, path, where string) { +// all is the box's sessions when the caller has just listed them (each +// list is a tmux command); nil lists them here. +func (b *Box) transcriptFile(r *http.Request, sess Session, all []Session) (agent, path, where string) { // Sessions.Get doesn't name the agent (the list does, in enrich): the // preset a session was started with, else its command's first word, // which is all a session from before presets has. @@ -41,22 +43,23 @@ func (b *Box) transcriptFile(r *http.Request, sess Session) (agent, path, where // accounts set CLAUDE_CONFIG_DIR per box or project). configDir := b.Sessions.EnvVar(r.Context(), sess, "CLAUDE_CONFIG_DIR") claims := []transcript.Claim{{Name: sess.Name, ID: id, Started: sess.Created, ConfigDir: configDir}} - if all, err := b.Sessions.List(r.Context()); err == nil { - for _, o := range all { - if o.Name == sess.Name || o.Dir != sess.Dir || o.Exited || o.Service != "" { - continue - } - if a := firstNonEmpty(o.Preset, firstNonEmpty(o.Agent, agentOf(o.Command))); a != "claude" { - continue - } - var oid string - if b.Turns != nil { - if st, ok := b.Turns.State(o.Name); ok { - oid = st.AgentSessionID - } + if all == nil { + all, _ = b.Sessions.List(r.Context()) + } + for _, o := range all { + if o.Name == sess.Name || o.Dir != sess.Dir || o.Exited || o.Service != "" { + continue + } + if a := firstNonEmpty(o.Preset, firstNonEmpty(o.Agent, agentOf(o.Command))); a != "claude" { + continue + } + var oid string + if b.Turns != nil { + if st, ok := b.Turns.State(o.Name); ok { + oid = st.AgentSessionID } - claims = append(claims, transcript.Claim{Name: o.Name, ID: oid, Started: o.Created, ConfigDir: b.Sessions.EnvVar(r.Context(), o, "CLAUDE_CONFIG_DIR")}) } + claims = append(claims, transcript.Claim{Name: o.Name, ID: oid, Started: o.Created, ConfigDir: b.Sessions.EnvVar(r.Context(), o, "CLAUDE_CONFIG_DIR")}) } path = transcript.AssignClaude(sess.Dir, claims)[sess.Name] where = transcript.ClaudeDirIn(configDir, sess.Dir) @@ -80,12 +83,14 @@ var ( func (b *Box) transcript(w http.ResponseWriter, r *http.Request) error { transcriptsOnce.Do(func() { transcripts = transcript.NewReader() }) - sess, err := b.Sessions.Get(r.Context(), r.PathValue("name")) + // One list of the sessions finds this one and the others in its + // folder: a chat polls this every few seconds. + sess, all, err := b.Sessions.getWithAll(r.Context(), r.PathValue("name")) if err != nil { return err } since, _ := strconv.Atoi(r.URL.Query().Get("since")) - agent, path, where := b.transcriptFile(r, sess) + agent, path, where := b.transcriptFile(r, sess, all) none := func(reason string) error { writeJSON(w, transcript.Result{Source: "none", Items: []transcript.Item{}, Crew: []transcript.CrewMember{}, Reason: reason}) return nil @@ -115,11 +120,11 @@ func (b *Box) transcript(w http.ResponseWriter, r *http.Request) error { // call opened up (the full command and its output, an edit's exact change), // read when someone expands it and never kept. func (b *Box) toolDetail(w http.ResponseWriter, r *http.Request) error { - sess, err := b.Sessions.Get(r.Context(), r.PathValue("name")) + sess, all, err := b.Sessions.getWithAll(r.Context(), r.PathValue("name")) if err != nil { return err } - agent, path, _ := b.transcriptFile(r, sess) + agent, path, _ := b.transcriptFile(r, sess, all) if path == "" { return httpError{http.StatusNotFound, "this session's conversation can't be read"} } From 7aaecb1e57bf7a8e6d11415a03873112653e1cf8 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:34:13 +0100 Subject: [PATCH 06/44] perf(box): large json answers cross the link to the laptop gzipped, about ten times smaller A box's JSON answers of 1 KB or more go gzipped to a client that asks; Go's HTTP client (the laptop agent, the CLI) asks and unpacks on its own, so every caller reads the same JSON. 300 worktrees' locations go from 65 KB to 6 KB, 100 sessions from 41 KB to 2.7 KB, a chat's first window from 25 KB to 2.3 KB. Streams, files and errors pass through untouched, and an answer flushed early goes out plain. Co-Authored-By: Claude Opus 5.5 --- internal/box/api.go | 5 +- internal/box/gzip.go | 170 ++++++++++++++++++++++++++ internal/box/gzip_test.go | 247 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 internal/box/gzip.go create mode 100644 internal/box/gzip_test.go diff --git a/internal/box/api.go b/internal/box/api.go index 41650fec..d15d3811 100644 --- a/internal/box/api.go +++ b/internal/box/api.go @@ -118,11 +118,12 @@ func (b *Box) own(path string) { // laptops and, through ServeLocal, by the box's own user. func (b *Box) Mount(s *wire.Server) { route := func(pattern string, h func(http.ResponseWriter, *http.Request) error) { - s.Handle(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Large JSON answers go gzipped to a client that asks (gzip.go). + s.Handle(pattern, gzipJSON(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if err := h(w, r); err != nil { writeErr(w, err) } - })) + }))) } route("GET /v1/ports", b.ports) route("GET /v1/locations", b.listLocations) diff --git a/internal/box/gzip.go b/internal/box/gzip.go new file mode 100644 index 00000000..e06d59d7 --- /dev/null +++ b/internal/box/gzip.go @@ -0,0 +1,170 @@ +package box + +import ( + "compress/gzip" + "io" + "net/http" + "strings" + "sync" +) + +// A box's JSON answers cross the link to the laptop, often a tailnet or an +// SSH tunnel: a long conversation's first read or a box with hundreds of +// worktrees is tens or hundreds of KB that gzip cuts by about ten times. +// Go's HTTP client asks for gzip on its own and unpacks it on its own, so +// the laptop agent, the CLI and the phone's browser all get the same JSON +// as before; anything that doesn't ask gets it plain. +// +// Only a whole 200 answer of application/json of at least gzipMin bytes is +// packed. Streams (events, NDJSON, terminals), files, images and errors +// pass through as they are, and an answer flushed before gzipMin goes out +// plain from there on, so a long poll or a stream is never held back. + +const gzipMin = 1024 + +var gzipPool = sync.Pool{New: func() any { + w, _ := gzip.NewWriterLevel(io.Discard, gzip.BestSpeed) + return w +}} + +// acceptsGzip says whether the request asks for gzip (and not with q=0). +func acceptsGzip(r *http.Request) bool { + for _, part := range strings.Split(r.Header.Get("Accept-Encoding"), ",") { + name, params, _ := strings.Cut(strings.TrimSpace(part), ";") + if strings.EqualFold(strings.TrimSpace(name), "gzip") { + q := strings.ReplaceAll(strings.TrimSpace(params), " ", "") + return q != "q=0" && q != "q=0.0" && q != "q=0.00" && q != "q=0.000" + } + } + return false +} + +// gzipJSON packs h's large JSON answers for clients that ask for gzip. +func gzipJSON(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !acceptsGzip(r) || r.Header.Get("Upgrade") != "" { + h.ServeHTTP(w, r) + return + } + g := &gzipWriter{w: w} + defer g.finish() + h.ServeHTTP(g, r) + }) +} + +type gzipMode int + +const ( + gzDeciding gzipMode = iota // holding the first bytes of a JSON answer + gzPlain // passing everything through + gzPacking // writing through gzip +) + +type gzipWriter struct { + w http.ResponseWriter + status int + header bool // WriteHeader has been called (by the handler or a Write) + mode gzipMode + buf []byte + gz *gzip.Writer +} + +func (g *gzipWriter) Header() http.Header { return g.w.Header() } + +// Unwrap lets http.ResponseController reach the connection (full duplex, +// deadlines, hijacking) behind the wrapper. +func (g *gzipWriter) Unwrap() http.ResponseWriter { return g.w } + +func (g *gzipWriter) WriteHeader(status int) { + if g.header { + return + } + g.header = true + g.status = status + h := g.w.Header() + ct := h.Get("Content-Type") + if status != http.StatusOK || h.Get("Content-Encoding") != "" || !strings.HasPrefix(ct, "application/json") { + g.mode = gzPlain + g.w.WriteHeader(status) + } +} + +func (g *gzipWriter) Write(p []byte) (int, error) { + if !g.header { + g.WriteHeader(http.StatusOK) + } + switch g.mode { + case gzPlain: + return g.w.Write(p) + case gzPacking: + return g.gz.Write(p) + } + g.buf = append(g.buf, p...) + if len(g.buf) < gzipMin { + return len(p), nil + } + h := g.w.Header() + h.Set("Content-Encoding", "gzip") + h.Add("Vary", "Accept-Encoding") + h.Del("Content-Length") + g.w.WriteHeader(g.status) + g.gz = gzipPool.Get().(*gzip.Writer) + g.gz.Reset(g.w) + g.mode = gzPacking + buf := g.buf + g.buf = nil + if _, err := g.gz.Write(buf); err != nil { + return 0, err + } + return len(p), nil +} + +// plain sends what is held as it is, and everything after it too. +func (g *gzipWriter) plain() error { + g.mode = gzPlain + g.w.WriteHeader(g.status) + if len(g.buf) == 0 { + return nil + } + buf := g.buf + g.buf = nil + _, err := g.w.Write(buf) + return err +} + +// Flush sends what is written so far: a handler that flushes is streaming, +// so one that does before gzipMin goes out plain. +func (g *gzipWriter) Flush() { + _ = g.FlushError() +} + +func (g *gzipWriter) FlushError() error { + if !g.header { + g.WriteHeader(http.StatusOK) + } + switch g.mode { + case gzDeciding: + if err := g.plain(); err != nil { + return err + } + case gzPacking: + if err := g.gz.Flush(); err != nil { + return err + } + } + return http.NewResponseController(g.w).Flush() +} + +func (g *gzipWriter) finish() { + switch g.mode { + case gzDeciding: + if g.header { + _ = g.plain() + } + case gzPacking: + _ = g.gz.Close() + g.gz.Reset(io.Discard) + gzipPool.Put(g.gz) + g.gz = nil + } +} diff --git a/internal/box/gzip_test.go b/internal/box/gzip_test.go new file mode 100644 index 00000000..52bbd61f --- /dev/null +++ b/internal/box/gzip_test.go @@ -0,0 +1,247 @@ +package box + +import ( + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cosscom/shipyard/internal/transcript" +) + +// big is a JSON answer well past gzipMin. +func big() any { + out := make([]map[string]string, 60) + for i := range out { + out[i] = map[string]string{"name": fmt.Sprintf("worktree-%d", i), "path": fmt.Sprintf("/home/me/code/shop/worktree-%d", i)} + } + return out +} + +func serveGzip(t *testing.T, h http.HandlerFunc, method string, header http.Header) *http.Response { + t.Helper() + req := httptest.NewRequest(method, "/v1/x", nil) + for k, v := range header { + req.Header[k] = v + } + rec := httptest.NewRecorder() + gzipJSON(h).ServeHTTP(rec, req) + return rec.Result() +} + +func readBody(t *testing.T, resp *http.Response) string { + t.Helper() + var r io.Reader = resp.Body + if resp.Header.Get("Content-Encoding") == "gzip" { + gz, err := gzip.NewReader(resp.Body) + if err != nil { + t.Fatal(err) + } + r = gz + } + b, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +var askGzip = http.Header{"Accept-Encoding": {"gzip"}} + +func TestLargeJSONIsGzippedForAClientThatAsks(t *testing.T) { + want, _ := json.Marshal(big()) + resp := serveGzip(t, func(w http.ResponseWriter, r *http.Request) { writeJSON(w, big()) }, "GET", askGzip) + if resp.Header.Get("Content-Encoding") != "gzip" || !strings.Contains(resp.Header.Get("Vary"), "Accept-Encoding") { + t.Fatalf("headers = %v", resp.Header) + } + if got := strings.TrimSpace(readBody(t, resp)); got != string(want) { + t.Fatalf("body changed:\n%s\n%s", got, want) + } +} + +func TestAnswersPassThroughPlainWhenGzipWouldNotHelpOrIsNotAsked(t *testing.T) { + cases := map[string]struct { + h http.HandlerFunc + method string + header http.Header + }{ + "no Accept-Encoding": {func(w http.ResponseWriter, r *http.Request) { writeJSON(w, big()) }, "GET", nil}, + "gzip refused": {func(w http.ResponseWriter, r *http.Request) { writeJSON(w, big()) }, "GET", http.Header{"Accept-Encoding": {"gzip;q=0, identity"}}}, + "small": {func(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]string{"screen": "$ "}) }, "GET", askGzip}, + "not GET": {func(w http.ResponseWriter, r *http.Request) { writeJSON(w, big()) }, "POST", askGzip}, + "an error": {func(w http.ResponseWriter, r *http.Request) { + writeErr(w, httpError{http.StatusNotFound, strings.Repeat("gone ", 400)}) + }, "GET", askGzip}, + "plain text": {func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + io.WriteString(w, strings.Repeat("log line\n", 400)) + }, "GET", askGzip}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + resp := serveGzip(t, c.h, c.method, c.header) + if resp.Header.Get("Content-Encoding") != "" { + t.Fatalf("Content-Encoding = %q", resp.Header.Get("Content-Encoding")) + } + if readBody(t, resp) == "" { + t.Fatal("empty body") + } + }) + } +} + +func TestAStreamFlushedEarlyGoesOutPlainAndAtOnce(t *testing.T) { + srv := httptest.NewServer(gzipJSON(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"first":true}`+"\n") + http.NewResponseController(w).Flush() + time.Sleep(300 * time.Millisecond) + io.WriteString(w, strings.Repeat(" ", 4096)+`{"second":true}`+"\n") + }))) + defer srv.Close() + req, _ := http.NewRequest("GET", srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + start := time.Now() + resp, err := http.DefaultTransport.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.Header.Get("Content-Encoding") != "" { + t.Fatal("a flushed answer was gzipped") + } + buf := make([]byte, 64) + n, _ := resp.Body.Read(buf) + if !strings.Contains(string(buf[:n]), "first") || time.Since(start) > 250*time.Millisecond { + t.Fatalf("the first part waited: %q after %v", buf[:n], time.Since(start)) + } +} + +// Over the real link: the laptop's client asks for gzip and unpacks it on +// its own, so callers read the same JSON as before. +func TestBoxAnswersReachTheLaptopGzippedAndReadTheSame(t *testing.T) { + c, _ := servedBox(t) + root := t.TempDir() + for i := range 15 { + dir := filepath.Join(root, fmt.Sprintf("project-with-a-long-name-%02d", i)) + os.MkdirAll(dir, 0o755) + if code := call(t, c, "POST", "/v1/locations", "", map[string]string{"name": fmt.Sprintf("project-%02d", i), "path": dir}, nil); code != 200 { + t.Fatalf("add location: %d", code) + } + } + resp, err := c.Do(t.Context(), "GET", "/v1/locations", nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if !resp.Uncompressed { + t.Fatal("the answer wasn't gzipped on the link") + } + var locs []Location + if err := json.NewDecoder(resp.Body).Decode(&locs); err != nil || len(locs) != 15 { + t.Fatalf("decoded %d locations: %v", len(locs), err) + } +} + +// ---- What it saves, on sampleAnswers shaped as a busy box's ---------------------- + +func fleetLocations(worktrees int) []Location { + var out []Location + for p := 0; len(out)*10 < worktrees; p++ { + name := fmt.Sprintf("project-%02d", p) + loc := Location{Name: name, Path: "/home/me/code/" + name, Repo: true, Scripts: Scripts{Setup: "pnpm install", From: "repo"}, Remote: "git@github.com:acme/" + name + ".git", Slug: "acme/" + name, DefaultBranch: "main", RepoTrust: "trusted", Check: "pnpm test", CheckFrom: "config"} + loc.Worktrees = append(loc.Worktrees, Worktree{Name: name, Path: loc.Path, Branch: "main", Head: "4f1c2a9be0d3", Main: true}) + for w := 1; w < 10; w++ { + wn := fmt.Sprintf("%s-fix-%d", name, w) + loc.Worktrees = append(loc.Worktrees, Worktree{Name: wn, Path: "/home/me/.berth/worktrees/" + name + "/" + wn, Branch: "me/" + wn, Head: fmt.Sprintf("%012x", p*100+w), Port: 3000 + p*10 + w, Title: "Make checkout retries safe to repeat"}) + } + out = append(out, loc) + } + return out +} + +func fleetSessions(n int) []Session { + out := make([]Session, n) + t0 := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) + for i := range out { + name := fmt.Sprintf("project-%02d-fix-%d", i/9, i%9+1) + out[i] = Session{Name: name + "-claude", Location: fmt.Sprintf("project-%02d/%s", i/9, name), Dir: "/home/me/.berth/worktrees/" + name, Command: "claude", Created: t0, Agent: "claude", AgentState: "finished", StateSince: t0.Add(time.Duration(i) * time.Minute), Preset: "claude", Turn: fmt.Sprintf("%s-claude#%d", name, i), StateSeq: int64(i), Fidelity: "hooks", Title: "Make checkout retries safe to repeat"} + } + return out +} + +func chatWindow(items int) transcript.Result { + words := strings.Fields("The ledger retries now carry an idempotency key so a slow answer from the payment provider can't make a second order and the test sends the same webhook twice and checks there is one order and one charge") + res := transcript.Result{Source: "claude", Next: items, Crew: []transcript.CrewMember{}, Gen: "1.0", File: "6b1f0c2e-1d2c-4a8e-9a51-0c3e2f1d9b7a"} + for i := range items { + it := transcript.Item{ID: fmt.Sprintf("i%d", i), Off: int64(i) * 2048} + switch i % 4 { + case 0: + it.Kind, it.Text, it.UUID = "user", fmt.Sprintf("Please look at step %d of the checkout flow.", i), fmt.Sprintf("u-%d", i) + case 1: + it.Kind, it.Verb, it.Done = "tools", "Read", true + it.Items = []transcript.ToolCall{{Verb: "Read", Target: fmt.Sprintf("src/checkout/step%d.ts", i)}, {Verb: "Read", Target: fmt.Sprintf("src/payments/retry%d.ts", i)}} + default: + var b strings.Builder + for j := range 60 { + b.WriteString(words[(i+j)%len(words)]) + b.WriteByte(' ') + } + it.Kind, it.Text = "text", b.String() + } + res.Items = append(res.Items, it) + } + return res +} + +var sampleAnswers = map[string]func() any{ + "locations_300_worktrees": func() any { return fleetLocations(300) }, + "sessions_100": func() any { return fleetSessions(100) }, + "transcript_window_100": func() any { return chatWindow(100) }, +} + +// TestGzipSavings logs what each answer is, plain and gzipped (go test -run +// GzipSavings -v ./internal/box). +func TestGzipSavings(t *testing.T) { + for name, v := range sampleAnswers { + h := func(w http.ResponseWriter, r *http.Request) { writeJSON(w, v()) } + plain := serveGzip(t, h, "GET", nil) + packed := serveGzip(t, h, "GET", askGzip) + p, _ := io.ReadAll(plain.Body) + g, _ := io.ReadAll(packed.Body) + if len(g) >= len(p)/3 { + t.Errorf("%s: gzip %d of %d bytes", name, len(g), len(p)) + } + t.Logf("%s: %d bytes plain, %d gzipped (%.1fx)", name, len(p), len(g), float64(len(p))/float64(len(g))) + } +} + +func BenchmarkJSONAnswer(b *testing.B) { + for _, name := range []string{"locations_300_worktrees", "sessions_100", "transcript_window_100"} { + v := sampleAnswers[name]() + h := gzipJSON(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeJSON(w, v) })) + for _, mode := range []string{"plain", "gzip"} { + b.Run(name+"/"+mode, func(b *testing.B) { + req := httptest.NewRequest("GET", "/v1/x", nil) + if mode == "gzip" { + req.Header.Set("Accept-Encoding", "gzip") + } + b.ReportAllocs() + var n int + for b.Loop() { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + n = rec.Body.Len() + } + b.ReportMetric(float64(n), "B/answer") + }) + } + } +} From a0e31cd198c64c7982daac08ce3cb1967f640e55 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:35:54 +0100 Subject: [PATCH 07/44] perf(box): reading a screen the box just listed no longer lists the sessions again Sessions.Screen lists every session to check the one asked for exists before capturing its pane. The 2-second screen poll, the startup watch (every 250 ms while an agent starts) and the controls read had just found the session in a list, so each read cost two tmux commands. They now capture directly: the screen poll of four agents runs 5 tmux commands instead of 9. Co-Authored-By: Claude Opus 5.5 --- internal/box/controls.go | 2 +- internal/box/sessions.go | 6 ++++++ internal/box/startup.go | 4 ++-- internal/box/turns.go | 3 ++- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/internal/box/controls.go b/internal/box/controls.go index d4b6bf61..2be3a3ad 100644 --- a/internal/box/controls.go +++ b/internal/box/controls.go @@ -185,7 +185,7 @@ func (b *Box) sessionControls(w http.ResponseWriter, r *http.Request) error { agent := controlAgent(sess) out := Controls{Agent: agent} if !sess.Exited { - screen, err := b.Sessions.Screen(r.Context(), sess.Name, 0) + screen, err := b.Sessions.capture(r.Context(), sess.Name, 0) if err != nil { return err } diff --git a/internal/box/sessions.go b/internal/box/sessions.go index 0c1dcbe8..a36725c5 100644 --- a/internal/box/sessions.go +++ b/internal/box/sessions.go @@ -499,6 +499,12 @@ func (s *Sessions) Screen(ctx context.Context, name string, history int) (string if _, err := s.Get(ctx, name); err != nil { return "", err } + return s.capture(ctx, name, history) +} + +// capture is Screen for a session the caller has just found in a list: +// without asking tmux for the list again. +func (s *Sessions) capture(ctx context.Context, name string, history int) (string, error) { out, err := s.tmux(ctx, "capture-pane", "-p", "-J", "-t", "="+name+":", "-S", "-"+strconv.Itoa(max(history, 0))) if err != nil { return "", tmuxError("capture-pane", out, err) diff --git a/internal/box/startup.go b/internal/box/startup.go index 83f87b4b..5be18ca8 100644 --- a/internal/box/startup.go +++ b/internal/box/startup.go @@ -177,7 +177,7 @@ func (b *Box) atStartupQuestion(ctx context.Context, sess Session) bool { s, _ := v.(*startup).get() return s == startAsking } - screen, err := b.Sessions.Screen(ctx, sess.Name, 0) + screen, err := b.Sessions.capture(ctx, sess.Name, 0) if err != nil { return false } @@ -227,7 +227,7 @@ func (b *Box) watchStartup(from string, sess Session, st *startup) { if err != nil || cur.Exited { return } - screen, err := b.Sessions.Screen(ctx, sess.Name, 0) + screen, err := b.Sessions.capture(ctx, sess.Name, 0) if err != nil { return } diff --git a/internal/box/turns.go b/internal/box/turns.go index b5da72dd..22554fa1 100644 --- a/internal/box/turns.go +++ b/internal/box/turns.go @@ -1562,7 +1562,8 @@ func (b *Box) pollScreens(ctx context.Context) { // not a turn that ended. continue } - screen, err := b.Sessions.Screen(ctx, name, 0) + // Found in the list just read: capture it without listing again. + screen, err := b.Sessions.capture(ctx, name, 0) if err != nil { continue } From 090b56f2084681a0a5b61b9721e7c5e104aa4493 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:37:29 +0100 Subject: [PATCH 08/44] perf(box): the ledger skips rewriting agent-states.json when its states are the same Every ledger save (up to ten a second while agents work) also rewrote the legacy per-folder states file, each with a full fsync, though most saves (a prompt sent, the inbox, the journal moving on) leave it the same. It is written now only when its bytes change: a save that leaves it alone costs one fsync instead of two. Co-Authored-By: Claude Opus 5.5 --- internal/box/turns.go | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/internal/box/turns.go b/internal/box/turns.go index 22554fa1..6e3af0ee 100644 --- a/internal/box/turns.go +++ b/internal/box/turns.go @@ -2,6 +2,7 @@ package box import ( "bufio" + "bytes" "context" "encoding/json" "fmt" @@ -135,6 +136,11 @@ type Turns struct { saveSoon chan struct{} loaded bool stop func() + + // writeMu orders the ledger's writes; legacyOut is what LegacyPath + // was last written with. + writeMu sync.Mutex + legacyOut []byte } type sessTrack struct { @@ -1358,12 +1364,19 @@ func (t *Turns) save() { archive := t.archive t.archive = nil t.mu.Unlock() + t.writeMu.Lock() + defer t.writeMu.Unlock() if err == nil && t.Path != "" { statefile.Write(t.Path, b) } if t.LegacyPath != "" { - if lb, err := json.Marshal(legacy); err == nil { - statefile.Write(t.LegacyPath, lb) + // Its states change far less often than the ledger: each write + // is a full fsync, so one that would write the same bytes is + // left out. + if lb, err := json.Marshal(legacy); err == nil && !bytes.Equal(lb, t.legacyOut) { + if statefile.Write(t.LegacyPath, lb) == nil { + t.legacyOut = lb + } } } if inbox != nil && t.InboxPath != "" { From 3450dc55d0be82f3148a81b03e2168893fed25c3 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:37:41 +0100 Subject: [PATCH 09/44] test: benchmarks for the laptop agent's proxy, forwards, box streams and fan-out Co-Authored-By: Claude Opus 5.5 --- internal/agent/agent_test.go | 23 ++-- internal/agent/bench_test.go | 195 +++++++++++++++++++++++++++++++++ internal/forward/bench_test.go | 95 ++++++++++++++++ internal/proxy/bench_test.go | 111 +++++++++++++++++++ internal/wire/bench_test.go | 74 +++++++++++++ internal/wire/wire_test.go | 10 +- 6 files changed, 493 insertions(+), 15 deletions(-) create mode 100644 internal/agent/bench_test.go create mode 100644 internal/forward/bench_test.go create mode 100644 internal/proxy/bench_test.go create mode 100644 internal/wire/bench_test.go diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 560b057f..e0b0c0a6 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -32,7 +32,7 @@ import ( type testBox struct { services []box.Service bus *events.Bus - t *testing.T + t testing.TB dir string address string server *wire.Server @@ -41,6 +41,8 @@ type testBox struct { done chan error // extra mounts more box routes each time the box starts. extra func(*wire.Server) + // servicesCalls counts GET /v1/services. + servicesCalls atomic.Int64 } type countingListener struct { @@ -56,13 +58,13 @@ func (l countingListener) Accept() (net.Conn, error) { return c, err } -func newBox(t *testing.T) *testBox { +func newBox(t testing.TB) *testBox { t.Helper() return newBoxWith(t, nil) } // newBoxWith is newBox with more routes, such as sessions. -func newBoxWith(t *testing.T, extra func(*wire.Server)) *testBox { +func newBoxWith(t testing.TB, extra func(*wire.Server)) *testBox { t.Helper() b := &testBox{t: t, dir: t.TempDir(), extra: extra} b.start("127.0.0.1:0") @@ -86,6 +88,7 @@ func (b *testBox) start(addr string) { b.bus = &events.Bus{} } b.server.Handle("GET /v1/services", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b.servicesCalls.Add(1) json.NewEncoder(w).Encode(b.services) })) b.server.Handle("GET /v1/events", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -185,7 +188,7 @@ func (c *clock) jump(d time.Duration) { // shortSocket returns a socket path under /tmp: test temp dirs on macOS are // longer than the 104-byte limit for Unix socket paths. -func shortSocket(t *testing.T) string { +func shortSocket(t testing.TB) string { t.Helper() dir, err := os.MkdirTemp("/tmp", "cp") if err != nil { @@ -195,19 +198,19 @@ func shortSocket(t *testing.T) string { return filepath.Join(dir, "a.sock") } -func startAgent(t *testing.T, dir string) *runningAgent { +func startAgent(t testing.TB, dir string) *runningAgent { t.Helper() return startAgentWith(t, dir, &fakeNetworks{}) } -func startAgentWith(t *testing.T, dir string, nets Networks) *runningAgent { +func startAgentWith(t testing.TB, dir string, nets Networks) *runningAgent { t.Helper() return startAgentConfig(t, dir, nets, nil) } // startAgentConfig is startAgentWith with changes to the agent's Config, // such as its log or how long a health check waits. -func startAgentConfig(t *testing.T, dir string, nets Networks, change func(*Config)) *runningAgent { +func startAgentConfig(t testing.TB, dir string, nets Networks, change func(*Config)) *runningAgent { t.Helper() proxyLn, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -266,7 +269,7 @@ func (a *runningAgent) stop() { } } -func eventually(t *testing.T, what string, cond func() bool) { +func eventually(t testing.TB, what string, cond func() bool) { t.Helper() deadline := time.Now().Add(5 * time.Second) for !cond() { @@ -277,7 +280,7 @@ func eventually(t *testing.T, what string, cond func() bool) { } } -func stateOf(t *testing.T, a *runningAgent) string { +func stateOf(t testing.TB, a *runningAgent) string { t.Helper() s, err := a.client.Status(context.Background()) if err != nil || len(s.Boxes) != 1 { @@ -305,7 +308,7 @@ func echoServer(t *testing.T) int { return ln.Addr().(*net.TCPAddr).Port } -func freePort(t *testing.T) int { +func freePort(t testing.TB) int { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { diff --git a/internal/agent/bench_test.go b/internal/agent/bench_test.go new file mode 100644 index 00000000..2c4cab02 --- /dev/null +++ b/internal/agent/bench_test.go @@ -0,0 +1,195 @@ +package agent + +import ( + "context" + "encoding/json" + "io" + "net" + "net/http" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/cosscom/shipyard/internal/box" + "github.com/cosscom/shipyard/internal/identity" + "github.com/cosscom/shipyard/internal/pairing" + "github.com/cosscom/shipyard/internal/trust" + "github.com/cosscom/shipyard/internal/wire" +) + +// pairAs pairs the laptop in dir with b under name, so one laptop can hold +// several boxes. +func (b *testBox) pairAs(t testing.TB, dir, name string) { + t.Helper() + id, err := identity.LoadOrCreate(filepath.Join(dir, "identity.pem")) + if err != nil { + t.Fatal(err) + } + code, err := b.server.Pending.Issue(time.Minute, time.Now()) + if err != nil { + t.Fatal(err) + } + tok := pairing.Token{Address: b.address, Fingerprint: b.server.Identity.Fingerprint(), Code: code} + if _, err := wire.Pair(context.Background(), id, tok, "laptop"); err != nil { + t.Fatal(err) + } + peer := trust.Peer{Name: name, Address: b.address, Fingerprint: tok.Fingerprint, PairedAt: time.Now()} + if err := trust.NewStore(filepath.Join(dir, "boxes.json")).Add(peer); err != nil { + t.Fatal(err) + } +} + +func benchDevServer(b *testing.B) int { + b.Helper() + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "ok") + })} + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + b.Fatal(err) + } + go srv.Serve(ln) + b.Cleanup(func() { srv.Close() }) + return ln.Addr().(*net.TCPAddr).Port +} + +// BenchmarkProxyHosts relays small GETs through a running agent's proxy to +// a dev server on a box, for each form of address a page uses. A second +// box, without the worktree, is paired too, and a few routes are saved, +// as on a laptop in use. services/op is how often the boxes were asked +// for their services. +func BenchmarkProxyHosts(b *testing.B) { + port := benchDevServer(b) + one := newBox(b) + one.services = []box.Service{{Location: "shop", Worktree: "checkout", Port: port}, {Location: "shop", Worktree: "shop", Port: port, Main: true}} + two := newBox(b) + two.services = []box.Service{{Location: "other", Worktree: "other", Port: port + 1}} + dir := b.TempDir() + one.pairAs(b, dir, "devbox") + two.pairAs(b, dir, "spare") + a := startAgentConfig(b, dir, &fakeNetworks{}, func(c *Config) { c.HealthInterval = time.Hour }) + eventually(b, "boxes online", func() bool { + s, err := a.client.Status(context.Background()) + if err != nil || len(s.Boxes) != 2 { + return false + } + return s.Boxes[0].State == StateOnline && s.Boxes[1].State == StateOnline + }) + for _, r := range []Route{ + {Pattern: "*.cal.test.localhost", Box: "devbox", Port: port}, + {Pattern: "*.docs.test.localhost", Box: "devbox", Port: port}, + {Pattern: "admin.test.localhost", Box: "spare", Port: port}, + } { + if err := a.client.Call(context.Background(), "POST", "/v1/routes", r, nil); err != nil { + b.Fatal(err) + } + } + c := &http.Client{Transport: &http.Transport{MaxIdleConnsPerHost: 64}} + for _, form := range []struct{ name, host string }{ + {"port.box", strconv.Itoa(port) + ".devbox.localhost"}, + {"worktree.location.box", "checkout.shop.devbox.localhost"}, + {"worktree.location", "checkout.shop.localhost"}, + } { + host := form.host + b.Run(form.name, func(b *testing.B) { + before := one.servicesCalls.Load() + two.servicesCalls.Load() + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + req, _ := http.NewRequest(http.MethodGet, "http://"+a.proxy+"/app.js", nil) + req.Host = host + resp, err := c.Do(req) + if err != nil { + b.Error(err) + return + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b.Errorf("%s: %d", host, resp.StatusCode) + return + } + } + }) + b.ReportMetric(float64(one.servicesCalls.Load()+two.servicesCalls.Load()-before)/float64(b.N), "services/op") + }) + } +} + +// BenchmarkRouteLookup resolves a request host against the saved routes, +// which the proxy does first for every request it relays. +func BenchmarkRouteLookup(b *testing.B) { + a := &Agent{routes: routeStore{path: filepath.Join(b.TempDir(), "routes.json")}} + all := []Route{ + {Pattern: "*.cal.test.localhost", Box: "devbox", Port: 3000}, + {Pattern: "*.docs.test.localhost", Box: "devbox", Port: 3001}, + {Pattern: "admin.test.localhost", Box: "spare", Port: 3002}, + } + if err := a.routes.update(func([]Route) ([]Route, error) { return all, nil }); err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, _, ok := a.route("checkout.shop.devbox.localhost"); ok { + b.Fatal("matched") + } + } +} + +// BenchmarkInstalledKits asks every box for its kits, as the app's Kits +// view does, with boxes that each take 50ms to answer. +func BenchmarkInstalledKits(b *testing.B) { + const n, delay = 4, 50 * time.Millisecond + dir := b.TempDir() + for i := range n { + tb := newBoxWith(b, func(s *wire.Server) { + s.Handle("GET /v1/kits", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(delay) + json.NewEncoder(w).Encode([]box.InstalledKitAt{{Location: "shop", Kit: box.InstalledKit{ID: "acme"}}}) + })) + }) + tb.pairAs(b, dir, "box"+strconv.Itoa(i)) + } + a := startAgentConfig(b, dir, &fakeNetworks{}, func(c *Config) { c.HealthInterval = time.Hour }) + eventually(b, "boxes online", func() bool { + s, err := a.client.Status(context.Background()) + if err != nil || len(s.Boxes) != n { + return false + } + for _, bs := range s.Boxes { + if bs.State != StateOnline { + return false + } + } + return true + }) + b.ResetTimer() + for range b.N { + var out []InstalledKitOn + if err := a.client.Call(context.Background(), "GET", "/v1/kits/installed", nil, &out); err != nil { + b.Fatal(err) + } + if len(out) != n { + b.Fatalf("got %d kits", len(out)) + } + } + b.ReportMetric(float64(b.Elapsed().Milliseconds())/float64(b.N), "ms/op") +} + +// BenchmarkStatus is GET /v1/status, which the app reads on every box +// event and on its backstop poll. +func BenchmarkStatus(b *testing.B) { + one := newBox(b) + dir := one.pairLaptop() + a := startAgentConfig(b, dir, &fakeNetworks{}, func(c *Config) { c.HealthInterval = time.Hour }) + eventually(b, "box online", func() bool { return stateOf(b, a) == StateOnline }) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, err := a.client.Status(context.Background()); err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/forward/bench_test.go b/internal/forward/bench_test.go new file mode 100644 index 00000000..9b1531f0 --- /dev/null +++ b/internal/forward/bench_test.go @@ -0,0 +1,95 @@ +package forward + +import ( + "context" + "io" + "net" + "os" + "path/filepath" + "testing" +) + +// pairs hands out both ends of Unix socket connections: like loopback TCP +// connections, with half-close, but using no ephemeral ports, which a +// benchmark opening thousands would run out of. +type pairs struct{ ln net.Listener } + +func newPairs(b *testing.B) pairs { + b.Helper() + dir, err := os.MkdirTemp("", "fwd") + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { os.RemoveAll(dir) }) + ln, err := net.Listen("unix", filepath.Join(dir, "s")) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { ln.Close() }) + return pairs{ln} +} + +func (p pairs) next(b *testing.B) (net.Conn, net.Conn) { + c, err := net.Dial("unix", p.ln.Addr().String()) + if err != nil { + b.Fatal(err) + } + s, err := p.ln.Accept() + if err != nil { + b.Fatal(err) + } + return c, s +} + +// BenchmarkBridgeConn bridges one short connection per op (a request and +// its answer), as a forwarded port's clients make them. +func BenchmarkBridgeConn(b *testing.B) { + p := newPairs(b) + msg := make([]byte, 4<<10) + b.SetBytes(int64(len(msg))) + b.ReportAllocs() + for range b.N { + client, local := p.next(b) + upstream, server := p.next(b) + done := make(chan struct{}) + go func() { Bridge(context.Background(), local, upstream); close(done) }() + go func() { + // The far end answers what it reads, with a buffer of its own. + buf := make([]byte, len(msg)) + for { + n, err := server.Read(buf) + server.Write(buf[:n]) + if err != nil { + break + } + } + server.Close() + }() + client.Write(msg) + client.(*net.UnixConn).CloseWrite() + io.Copy(io.Discard, client) + client.Close() + <-done + } +} + +// BenchmarkBridgeStream pushes data one way through a long bridge. +func BenchmarkBridgeStream(b *testing.B) { + p := newPairs(b) + client, local := p.next(b) + upstream, server := p.next(b) + go Bridge(context.Background(), local, upstream) + go io.Copy(io.Discard, server) + chunk := make([]byte, 64<<10) + b.SetBytes(int64(len(chunk))) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, err := client.Write(chunk); err != nil { + b.Fatal(err) + } + } + b.StopTimer() + client.Close() + server.Close() +} diff --git a/internal/proxy/bench_test.go b/internal/proxy/bench_test.go new file mode 100644 index 00000000..1eaeca9d --- /dev/null +++ b/internal/proxy/bench_test.go @@ -0,0 +1,111 @@ +package proxy + +import ( + "bytes" + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "strconv" + "testing" +) + +// benchUpstream is a dev server on the "box" that answers every request +// with size bytes. +func benchUpstream(b *testing.B, size int) int { + b.Helper() + body := bytes.Repeat([]byte("x"), size) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.Copy(io.Discard, r.Body) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + w.Write(body) + })) + b.Cleanup(srv.Close) + return srv.Listener.Addr().(*net.TCPAddr).Port +} + +// benchProxy is a proxy whose one box, devl, is this machine's loopback. +func benchProxy(b *testing.B) string { + b.Helper() + var d net.Dialer + p := &Proxy{Dialer: func(box string) (DialFunc, bool) { + if box != "devl" { + return nil, false + } + return func(ctx context.Context, port int) (net.Conn, error) { + return d.DialContext(ctx, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + }, true + }} + srv := httptest.NewServer(p) + b.Cleanup(srv.Close) + return srv.Listener.Addr().String() +} + +func benchClient() *http.Client { + return &http.Client{Transport: &http.Transport{MaxIdleConnsPerHost: 64, DisableCompression: true}} +} + +// BenchmarkProxyGet relays GETs of a few sizes through the proxy to a dev +// server, as a page's assets are. +func BenchmarkProxyGet(b *testing.B) { + for _, size := range []int{1 << 10, 64 << 10, 1 << 20} { + b.Run(strconv.Itoa(size>>10)+"KB", func(b *testing.B) { + port := benchUpstream(b, size) + addr := benchProxy(b) + c := benchClient() + host := strconv.Itoa(port) + ".devl.localhost" + b.SetBytes(int64(size)) + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + req, _ := http.NewRequest(http.MethodGet, "http://"+addr+"/asset.js", nil) + req.Host = host + resp, err := c.Do(req) + if err != nil { + b.Error(err) + return + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + }) + }) + } +} + +// BenchmarkRequestLogAdd logs requests for one busy page host, past the +// per-host limit, as a dev server's live page does all day. +func BenchmarkRequestLogAdd(b *testing.B) { + var l requestLog + req := Request{Method: "GET", Host: "checkout.shop.devl.localhost", Path: "/assets/app.js", Status: 200, Type: "script", MIME: "text/javascript"} + for range logLimit { + l.add(req, req.Host, "") + } + b.ReportAllocs() + b.ResetTimer() + for range b.N { + l.add(req, req.Host, "shop.devl.localhost") + } +} + +// BenchmarkRequests reads a full host's log after the app's last seq, as +// the Network drawer polls it. +func BenchmarkRequests(b *testing.B) { + p := &Proxy{} + req := Request{Method: "GET", Host: "checkout.shop.devl.localhost", Path: "/assets/app.js", Status: 200} + for range 3 * logLimit { + p.requests.add(req, req.Host) + } + _, last := p.Requests(req.Host, 0) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + list, _ := p.Requests(req.Host, last-10) + if len(list) != 10 { + b.Fatalf("got %d", len(list)) + } + } +} diff --git a/internal/wire/bench_test.go b/internal/wire/bench_test.go new file mode 100644 index 00000000..615278f1 --- /dev/null +++ b/internal/wire/bench_test.go @@ -0,0 +1,74 @@ +package wire + +import ( + "context" + "io" + "net" + "testing" +) + +// benchEcho is a server on the box's loopback that echoes what it reads. +func benchEcho(b *testing.B) int { + b.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { ln.Close() }) + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func() { defer c.Close(); io.Copy(c, c) }() + } + }() + return ln.Addr().(*net.TCPAddr).Port +} + +// BenchmarkDialPortRoundTrip opens a stream to a box port, sends a small +// request, reads the answer and closes it: one proxied connection. +func BenchmarkDialPortRoundTrip(b *testing.B) { + c := paired(b, startBox(b)) + port := benchEcho(b) + msg := make([]byte, 1<<10) + buf := make([]byte, len(msg)) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + conn, err := c.DialPort(context.Background(), port) + if err != nil { + b.Fatal(err) + } + if _, err := conn.Write(msg); err != nil { + b.Fatal(err) + } + if _, err := io.ReadFull(conn, buf); err != nil { + b.Fatal(err) + } + conn.Close() + } +} + +// BenchmarkStreamEcho pushes data through one long stream and back. +func BenchmarkStreamEcho(b *testing.B) { + c := paired(b, startBox(b)) + port := benchEcho(b) + conn, err := c.DialPort(context.Background(), port) + if err != nil { + b.Fatal(err) + } + defer conn.Close() + chunk := make([]byte, 64<<10) + buf := make([]byte, len(chunk)) + b.SetBytes(int64(len(chunk))) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + go conn.Write(chunk) + if _, err := io.ReadFull(conn, buf); err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/wire/wire_test.go b/internal/wire/wire_test.go index 5ecc9406..f172018b 100644 --- a/internal/wire/wire_test.go +++ b/internal/wire/wire_test.go @@ -47,9 +47,9 @@ func (l countingListener) Accept() (net.Conn, error) { return c, err } -func startBox(t *testing.T) *box { return startBoxWith(t, nil) } +func startBox(t testing.TB) *box { return startBoxWith(t, nil) } -func startBoxWith(t *testing.T, configure func(*Server)) *box { +func startBoxWith(t testing.TB, configure func(*Server)) *box { t.Helper() dir := t.TempDir() id, err := identity.LoadOrCreate(filepath.Join(dir, "identity.pem")) @@ -82,7 +82,7 @@ func startBoxWith(t *testing.T, configure func(*Server)) *box { return &box{server: s, address: ln.Addr().String(), dir: dir, accepted: accepted} } -func (b *box) issue(t *testing.T) pairing.Token { +func (b *box) issue(t testing.TB) pairing.Token { t.Helper() code, err := b.server.Pending.Issue(10*time.Minute, time.Now()) if err != nil { @@ -95,7 +95,7 @@ func (b *box) peer() trust.Peer { return trust.Peer{Name: "dev-test", Address: b.address, Fingerprint: b.server.Identity.Fingerprint()} } -func laptop(t *testing.T) *identity.Identity { +func laptop(t testing.TB) *identity.Identity { t.Helper() id, err := identity.LoadOrCreate(filepath.Join(t.TempDir(), "identity.pem")) if err != nil { @@ -105,7 +105,7 @@ func laptop(t *testing.T) *identity.Identity { } // paired returns a client for a laptop that has completed pairing with b. -func paired(t *testing.T, b *box) *Client { +func paired(t testing.TB, b *box) *Client { t.Helper() me := laptop(t) if _, err := Pair(context.Background(), me, b.issue(t), "alex-mbp"); err != nil { From 5042307d3632c9398baf9909bc6cab74f73aa60a Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:37:46 +0100 Subject: [PATCH 10/44] perf(proxy): log each relayed request without copying the host's whole log Past 200 requests a host's log was copied into a new slice on every request (about 64 KB and 8 us each for a page and its API host). It now grows to twice the limit and drops the old half in place. Co-Authored-By: Claude Opus 5.5 --- internal/proxy/requestlog.go | 32 +++++++++++++++++++++++-------- internal/proxy/requestlog_test.go | 27 ++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/internal/proxy/requestlog.go b/internal/proxy/requestlog.go index c8be231b..1b44af3d 100644 --- a/internal/proxy/requestlog.go +++ b/internal/proxy/requestlog.go @@ -5,6 +5,7 @@ import ( "net" "net/http" "net/url" + "slices" "strings" "sync" "time" @@ -60,10 +61,21 @@ type requestLog struct { } type hostLog struct { + // list holds the host's requests, oldest first: the last logLimit of + // them are its log (recent). It grows to twice that before the old half + // is dropped, in place, so logging a request costs no copy of the log. list []Request used time.Time } +// recent is the host's log: its last logLimit requests, oldest first. +func (hl *hostLog) recent() []Request { + if n := len(hl.list); n > logLimit { + return hl.list[n-logLimit:] + } + return hl.list +} + func (l *requestLog) add(req Request, hosts ...string) { l.mu.Lock() defer l.mu.Unlock() @@ -73,12 +85,10 @@ func (l *requestLog) add(req Request, hosts ...string) { l.hosts = map[string]*hostLog{} } now := time.Now() - seen := map[string]bool{} - for _, h := range hosts { - if h == "" || seen[h] { + for i, h := range hosts { + if h == "" || slices.Contains(hosts[:i], h) { continue } - seen[h] = true hl := l.hosts[h] if hl == nil { if len(l.hosts) >= logHosts { @@ -88,10 +98,16 @@ func (l *requestLog) add(req Request, hosts ...string) { l.hosts[h] = hl } hl.used = now - hl.list = append(hl.list, req) - if len(hl.list) > logLimit { - hl.list = append([]Request(nil), hl.list[len(hl.list)-logLimit:]...) + switch n := len(hl.list); { + case n == 2*logLimit: + copy(hl.list, hl.list[logLimit:]) + clear(hl.list[logLimit:]) + hl.list = hl.list[:logLimit] + case n == cap(hl.list): + // Grown by hand, to never more than the 2*logLimit it needs. + hl.list = append(make([]Request, 0, min(max(2*n, 16), 2*logLimit)), hl.list...) } + hl.list = append(hl.list, req) } } @@ -115,7 +131,7 @@ func (p *Proxy) Requests(host string, after int64) ([]Request, int64) { defer l.mu.Unlock() out := []Request{} if hl := l.hosts[hostOnly(host)]; hl != nil { - for _, r := range hl.list { + for _, r := range hl.recent() { if r.Seq > after { out = append(out, r) } diff --git a/internal/proxy/requestlog_test.go b/internal/proxy/requestlog_test.go index 99a76963..d8ed2aa5 100644 --- a/internal/proxy/requestlog_test.go +++ b/internal/proxy/requestlog_test.go @@ -137,15 +137,38 @@ func TestRequestLogMarksACanceledRequest(t *testing.T) { } } +func TestRequestLogKeepsTheLatestAcrossManyTrims(t *testing.T) { + p := &Proxy{} + const total = 5*logLimit + 7 + for i := range total { + p.requests.add(Request{Path: "/" + strconv.Itoa(i)}, "a.localhost") + if hl := p.requests.hosts["a.localhost"]; cap(hl.list) > 2*logLimit { + t.Fatalf("log grew to %d", cap(hl.list)) + } + } + list, last := p.Requests("a.localhost", 0) + if len(list) != logLimit || last != total { + t.Fatalf("got %d requests, last %d", len(list), last) + } + for i, r := range list { + if want := "/" + strconv.Itoa(total-logLimit+i); r.Path != want { + t.Fatalf("request %d is %q, want %q", i, r.Path, want) + } + } + if list, _ := p.Requests("a.localhost", total-3); len(list) != 3 || list[2].Path != "/"+strconv.Itoa(total-1) { + t.Fatalf("after: %+v", list) + } +} + func TestRequestLogIsBounded(t *testing.T) { var l requestLog for i := range logLimit + 50 { l.add(Request{Path: "/" + strconv.Itoa(i)}, "a.localhost") } - if got := len(l.hosts["a.localhost"].list); got != logLimit { + if got := len(l.hosts["a.localhost"].recent()); got != logLimit { t.Fatalf("kept %d", got) } - if first := l.hosts["a.localhost"].list[0].Path; first != "/50" { + if first := l.hosts["a.localhost"].recent()[0].Path; first != "/50" { t.Errorf("oldest kept %q", first) } for i := range logHosts + 5 { From 4536150f0279121d97ba0af5a6a2e3ee599417e1 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:37:47 +0100 Subject: [PATCH 11/44] perf: reuse copy buffers for proxied requests, forwards and box streams httputil.ReverseProxy and io.Copy between a socket and an HTTP/2 stream each made a fresh 32 KB buffer per request or connection. They now share a pool (internal/copybuf). Co-Authored-By: Claude Opus 5.5 --- internal/copybuf/copybuf.go | 47 +++++++++++++++++++ internal/copybuf/copybuf_test.go | 75 +++++++++++++++++++++++++++++++ internal/copybuf/race_off_test.go | 5 +++ internal/copybuf/race_on_test.go | 7 +++ internal/forward/forward.go | 5 ++- internal/proxy/proxy.go | 8 +++- internal/wire/server.go | 5 ++- 7 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 internal/copybuf/copybuf.go create mode 100644 internal/copybuf/copybuf_test.go create mode 100644 internal/copybuf/race_off_test.go create mode 100644 internal/copybuf/race_on_test.go diff --git a/internal/copybuf/copybuf.go b/internal/copybuf/copybuf.go new file mode 100644 index 00000000..a4aec3a9 --- /dev/null +++ b/internal/copybuf/copybuf.go @@ -0,0 +1,47 @@ +// Package copybuf copies between connections with pooled buffers. io.Copy +// between a TCP connection and anything but another socket (an HTTP/2 +// stream, a hijacked HTTP connection) makes a fresh 32 KB buffer each time, +// and so does httputil.ReverseProxy for every request it relays: the +// *.localhost proxy, port forwards and box streams do that for every +// connection and request, so the buffers are kept here and reused. +package copybuf + +import ( + "io" + "net" + "sync" +) + +// Size is the buffer size, io.Copy's own. +const Size = 32 << 10 + +var pool = sync.Pool{New: func() any { return new([Size]byte) }} + +// Copy is io.Copy with a pooled buffer. Between two TCP connections it is +// io.Copy itself, which on Linux moves the data in the kernel instead. +func Copy(dst io.Writer, src io.Reader) (int64, error) { + if _, ok := dst.(*net.TCPConn); ok { + if _, ok := src.(*net.TCPConn); ok { + return io.Copy(dst, src) + } + } + buf := pool.Get().(*[Size]byte) + defer pool.Put(buf) + // Hiding ReadFrom and WriteTo makes CopyBuffer use buf: the + // connections' own fallbacks would make a buffer of their own. + return io.CopyBuffer(writerOnly{dst}, readerOnly{src}, buf[:]) +} + +type writerOnly struct{ io.Writer } +type readerOnly struct{ io.Reader } + +// Pool is a httputil.BufferPool over the same buffers. +type Pool struct{} + +func (Pool) Get() []byte { return pool.Get().(*[Size]byte)[:] } + +func (Pool) Put(b []byte) { + if cap(b) >= Size { + pool.Put((*[Size]byte)(b[:Size])) + } +} diff --git a/internal/copybuf/copybuf_test.go b/internal/copybuf/copybuf_test.go new file mode 100644 index 00000000..e83180c9 --- /dev/null +++ b/internal/copybuf/copybuf_test.go @@ -0,0 +1,75 @@ +package copybuf + +import ( + "bytes" + "io" + "net" + "runtime" + "strings" + "testing" +) + +func TestCopyCopiesEverything(t *testing.T) { + src := strings.Repeat("shipyard ", 20000) // several buffers' worth + var dst bytes.Buffer + n, err := Copy(&dst, strings.NewReader(src)) + if err != nil || n != int64(len(src)) || dst.String() != src { + t.Fatalf("copied %d, %v", n, err) + } +} + +func TestCopyBetweenConnectionsReusesItsBuffer(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func() { io.Copy(c, bytes.NewReader(make([]byte, 4096))); c.Close() }() + } + }() + copyOne := func() { + c, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer c.Close() + if n, err := Copy(io.Discard, c); err != nil || n != 4096 { + t.Fatalf("copied %d, %v", n, err) + } + } + copyOne() + if raceEnabled { + return + } + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + const runs = 20 + for range runs { + copyOne() + } + runtime.ReadMemStats(&after) + // io.Copy from a TCP connection makes a 32 KB buffer every time; the + // pooled one is reused (the dial itself allocates a little). + if per := (after.TotalAlloc - before.TotalAlloc) / runs; per > 16<<10 { + t.Errorf("%d bytes allocated per copy", per) + } +} + +func TestPoolHandsBackFullBuffers(t *testing.T) { + var p Pool + b := p.Get() + if len(b) != Size { + t.Fatalf("got %d bytes", len(b)) + } + p.Put(b) + p.Put(make([]byte, 10)) // too small: dropped, not kept + if b := p.Get(); len(b) != Size { + t.Fatalf("got %d bytes", len(b)) + } +} diff --git a/internal/copybuf/race_off_test.go b/internal/copybuf/race_off_test.go new file mode 100644 index 00000000..02a452e6 --- /dev/null +++ b/internal/copybuf/race_off_test.go @@ -0,0 +1,5 @@ +//go:build !race + +package copybuf + +const raceEnabled = false diff --git a/internal/copybuf/race_on_test.go b/internal/copybuf/race_on_test.go new file mode 100644 index 00000000..c8189145 --- /dev/null +++ b/internal/copybuf/race_on_test.go @@ -0,0 +1,7 @@ +//go:build race + +package copybuf + +// The race detector drops pooled items at random, so pooling cannot be +// measured under it. +const raceEnabled = true diff --git a/internal/forward/forward.go b/internal/forward/forward.go index 177a3600..d94d5bcf 100644 --- a/internal/forward/forward.go +++ b/internal/forward/forward.go @@ -5,11 +5,12 @@ import ( "context" "errors" "fmt" - "io" "net" "strconv" "sync" "syscall" + + "github.com/cosscom/shipyard/internal/copybuf" ) // DialFunc opens a stream to a port on the box. It is looked up per @@ -79,7 +80,7 @@ func Bridge(ctx context.Context, a, b net.Conn) { wg.Add(2) pipe := func(dst, src net.Conn) { defer wg.Done() - _, err := io.Copy(dst, src) + _, err := copybuf.Copy(dst, src) if cw, ok := dst.(closeWriter); ok && err == nil { cw.CloseWrite() return diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index c32df199..27dde12f 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -11,6 +11,8 @@ import ( "strings" "sync" "time" + + "github.com/cosscom/shipyard/internal/copybuf" ) // DialFunc opens a stream to a port on a box. @@ -107,7 +109,8 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request) { // (devtools.go). devtools := preview == "" && devtoolsPage(r) rp := &httputil.ReverseProxy{ - Transport: p.transport(target.Box), + Transport: p.transport(target.Box), + BufferPool: copybuf.Pool{}, Rewrite: func(pr *httputil.ProxyRequest) { pr.SetXForwarded() pr.Out.URL.Scheme = "http" @@ -165,7 +168,8 @@ func (p *Proxy) passThrough(w http.ResponseWriter, r *http.Request, box string, return } rp := &httputil.ReverseProxy{ - Transport: p.transport(box), + Transport: p.transport(box), + BufferPool: copybuf.Pool{}, Rewrite: func(pr *httputil.ProxyRequest) { pr.SetXForwarded() pr.Out.URL.Scheme = "http" diff --git a/internal/wire/server.go b/internal/wire/server.go index e5de1226..2bceb40a 100644 --- a/internal/wire/server.go +++ b/internal/wire/server.go @@ -16,6 +16,7 @@ import ( "sync/atomic" "time" + "github.com/cosscom/shipyard/internal/copybuf" "github.com/cosscom/shipyard/internal/identity" "github.com/cosscom/shipyard/internal/pairing" "github.com/cosscom/shipyard/internal/trust" @@ -341,12 +342,12 @@ func (s *Server) handleTCP(w http.ResponseWriter, r *http.Request) { done := make(chan struct{}) go func() { defer close(done) - io.Copy(upstream, r.Body) + copybuf.Copy(upstream, r.Body) if tc, ok := upstream.(*net.TCPConn); ok { tc.CloseWrite() } }() - io.Copy(flushWriter{w: w, rc: rc}, upstream) + copybuf.Copy(flushWriter{w: w, rc: rc}, upstream) upstream.Close() r.Body.Close() <-done From 904f59965246f8b0082189b588be37c94350da3b Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:38:10 +0100 Subject: [PATCH 12/44] perf(transcript): opening a tool call's details reuses one line buffer Detail scans up to the last 32 MB of a record for one call, and readLine allocated every line afresh: ~36 MB of garbage per click on a long conversation. The scan (and the latest turn's) now reads each line into the same buffer: ~0.3 MB and 35 allocations, and faster. Co-Authored-By: Claude Opus 5.5 --- internal/transcript/detail.go | 15 +++++++++++---- internal/transcript/turn.go | 4 +++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/internal/transcript/detail.go b/internal/transcript/detail.go index 732788ef..91c4c7c6 100644 --- a/internal/transcript/detail.go +++ b/internal/transcript/detail.go @@ -83,8 +83,10 @@ func Detail(source, path, dir, id string) (ToolDetail, error) { needle := []byte(`"` + id + `"`) d := ToolDetail{ID: id} found, answered := false, false + var line []byte for { - line, err := readLine(r) + var err error + line, err = readLineInto(r, line) if len(line) > 0 && bytes.Contains(line, needle) { var call, result bool if source == "codex" { @@ -109,14 +111,19 @@ func Detail(source, path, dir, id string) (ToolDetail, error) { // readLine reads one line, skipping (not keeping) any longer than // detailLine. -func readLine(r *bufio.Reader) ([]byte, error) { - var buf []byte +func readLine(r *bufio.Reader) ([]byte, error) { return readLineInto(r, nil) } + +// readLineInto is readLine into buf's space: a scan of a long record reuses +// one buffer rather than allocating every line again. The line is good +// until the next call. +func readLineInto(r *bufio.Reader, buf []byte) ([]byte, error) { + buf = buf[:0] over := false for { chunk, err := r.ReadSlice('\n') if !over { if len(buf)+len(chunk) > detailLine { - over, buf = true, nil + over, buf = true, buf[:0] } else { buf = append(buf, chunk...) } diff --git a/internal/transcript/turn.go b/internal/transcript/turn.go index 81146b47..c301c8a9 100644 --- a/internal/transcript/turn.go +++ b/internal/transcript/turn.go @@ -107,8 +107,10 @@ func claudeTurn(path string, off int64) (Turn, error) { byFile := map[string]int{} r := bufio.NewReaderSize(f, 256<<10) first := true + var line []byte for { - line, err := readLine(r) + var err error + line, err = readLineInto(r, line) if len(line) > 0 { if first { var l struct { From fe740cf7ad8842c69754e962d8459c124000bdf8 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:38:18 +0100 Subject: [PATCH 13/44] perf(app): pages other than the workspace load after the app has started Settings, Automations, Dashboard, Review, Kits, Worktrees, Project settings, Team setup, Onboarding and plugin pages were all in the startup bundle. They are now separate chunks (lib/lazy-view.tsx), fetched one at a time once the app is up and idle, so a first visit still draws at once; a page whose code isn't in yet waits under a Suspense for it. openSettings and OrgField move to small modules so the eager code that uses them doesn't pull their pages back in. Initial JS: 2669 -> 2288 KB raw, 715 -> 676 KB brotli. Co-Authored-By: Claude Opus 5.5 --- app/src/App.tsx | 51 +++++++++++--------- app/src/components/command-palette.tsx | 2 +- app/src/components/editors/open.ts | 2 +- app/src/lib/lazy-view.tsx | 59 ++++++++++++++++++++++++ app/src/views/settings/open.ts | 10 ++++ app/src/views/settings/settings-view.tsx | 9 ++-- app/src/views/team/org-field.tsx | 13 ++++++ app/src/views/team/team-entry.tsx | 2 +- app/src/views/team/team-view.tsx | 13 +----- 9 files changed, 119 insertions(+), 42 deletions(-) create mode 100644 app/src/lib/lazy-view.tsx create mode 100644 app/src/views/settings/open.ts create mode 100644 app/src/views/team/org-field.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index 4428588e..4aa387d2 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -44,29 +44,32 @@ import { startUpdater } from "@/lib/updater"; import { useWhatsNewAfterUpdate } from "@/lib/whats-new"; import { cn } from "@/lib/utils"; import { homeBox, useWorkspaces } from "@/lib/workspaces"; -import { AutomationsView } from "@/views/automations"; -import { WorktreesView } from "@/views/worktrees/worktrees-view"; import { useKitDeepLinks } from "@/views/kits/deep-link"; import { useTeamDeepLinks, useTeamWatch } from "@/views/team/team-entry"; -import { TeamSetupView } from "@/views/team/team-view"; -import { KitsView } from "@/views/kits/kits-view"; import { ReviewSheet } from "@/views/kits/review-sheet"; import { useReviewDeepLinks } from "@/views/pr-review/deep-link"; import { PrReviewSheet } from "@/views/pr-review/review-sheet"; import { useReviewStatuses } from "@/views/pr-review/use-review-status"; -import { ReviewView } from "@/views/review/review-view"; -import { ProjectView } from "@/views/project/project-view"; -import { DashboardView } from "@/views/dashboard"; -import { PluginScreenView } from "@/views/plugin-screen-view"; import { AddBoxDialog } from "@/views/onboarding/add-box-dialog"; import { useOnboardingActive } from "@/views/onboarding/onboarding-state"; -import { OnboardingView } from "@/views/onboarding/onboarding-view"; -import { SettingsView } from "@/views/settings/settings-view"; import { HomeView } from "@/views/home/home-view"; import { usePrefs } from "@/lib/prefs"; +import { lazyView, preloadViews } from "@/lib/lazy-view"; import { placeLabel } from "@/lib/worktree-names"; import { useRescueRemovedFocus } from "@/lib/focus-home"; +// The pages other than the workspace load on first use (lib/lazy-view.tsx). +const AutomationsView = lazyView(() => import("@/views/automations").then((m) => m.AutomationsView)); +const WorktreesView = lazyView(() => import("@/views/worktrees/worktrees-view").then((m) => m.WorktreesView)); +const TeamSetupView = lazyView(() => import("@/views/team/team-view").then((m) => m.TeamSetupView)); +const KitsView = lazyView(() => import("@/views/kits/kits-view").then((m) => m.KitsView)); +const ReviewView = lazyView(() => import("@/views/review/review-view").then((m) => m.ReviewView)); +const ProjectView = lazyView(() => import("@/views/project/project-view").then((m) => m.ProjectView)); +const DashboardView = lazyView(() => import("@/views/dashboard").then((m) => m.DashboardView)); +const PluginScreenView = lazyView(() => import("@/views/plugin-screen-view").then((m) => m.PluginScreenView)); +const OnboardingView = lazyView(() => import("@/views/onboarding/onboarding-view").then((m) => m.OnboardingView)); +const SettingsView = lazyView(() => import("@/views/settings/settings-view").then((m) => m.SettingsView)); + // The live demo's guide and script (pnpm build:demo); not in the app. const DemoGuide = __BERTH_DEMO__ ? lazy(() => import("@/demo/guide")) : null; @@ -81,6 +84,8 @@ export default function App() { useEffect(startUpdater, []); // Spinners and shimmers hold still while the window is in the background. useEffect(watchStillness, []); + // The other pages' code, fetched once the app has started and is idle. + useEffect(preloadViews, []); // Runs on the boxes (loops, attempts, flows): kept fresh for the loops // panel, Automations and Review (lib/runs.ts). const connectedToAgent = useStore((s) => !!s.client); @@ -129,7 +134,7 @@ export default function App() {
- {view.kind === "team" ? useStore.getState().setView({ kind: "workspace" })} /> : } + {view.kind === "team" ? useStore.getState().setView({ kind: "workspace" })} /> : }
@@ -237,7 +242,9 @@ function MainView() { if (view.kind === "workspace" && onboarding) { return (
- + + +
); } @@ -250,15 +257,17 @@ function MainView() { // In zen there is no sidebar: a view keeps the width it has beside one, // centred, rather than stretching across the window.
- {view.kind === "dashboard" && } - {view.kind === "automations" && } - {view.kind === "review" && } - {view.kind === "kits" && } - {view.kind === "worktrees" && } - {view.kind === "project" && } - {view.kind === "settings" && } - {view.kind === "plugin" && } - {view.kind === "team" && } + + {view.kind === "dashboard" && } + {view.kind === "automations" && } + {view.kind === "review" && } + {view.kind === "kits" && } + {view.kind === "worktrees" && } + {view.kind === "project" && } + {view.kind === "settings" && } + {view.kind === "plugin" && } + {view.kind === "team" && } +
); } diff --git a/app/src/components/command-palette.tsx b/app/src/components/command-palette.tsx index 447ed607..c207f10c 100644 --- a/app/src/components/command-palette.tsx +++ b/app/src/components/command-palette.tsx @@ -63,7 +63,7 @@ import { FROM_TABLE } from "@/lib/palette-shortcuts"; import { describe, keysFor, SHORTCUTS } from "@/lib/shortcuts"; import { runShortcut } from "@/hooks/use-shortcuts"; import { openRenameWorktree } from "@/components/sidebar/rename-worktree"; -import type { SettingsSectionId } from "@/views/settings/settings-view"; +import type { SettingsSectionId } from "@/views/settings/open"; import { agentOf, sessionAgent, sessionName, sortedWorktrees, worktreeOf } from "@/lib/derive"; import { openBroadcast, openPromptPicker } from "@/lib/prompts"; import { quietNow, setDoNotDisturb, setNotificationsOpen } from "@/lib/notifications"; diff --git a/app/src/components/editors/open.ts b/app/src/components/editors/open.ts index ab6051d9..d12d3d67 100644 --- a/app/src/components/editors/open.ts +++ b/app/src/components/editors/open.ts @@ -2,7 +2,7 @@ import { toastManager } from "@/components/ui/toast"; import { type EditorId, openInEditor, SSHSetupNeeded } from "@/lib/editors"; import { errorMessage } from "@/lib/format"; import { useStore } from "@/lib/store"; -import { openSettings } from "@/views/settings/settings-view"; +import { openSettings } from "@/views/settings/open"; // openEditor opens a worktree, or a file in it, and says what went wrong in // a toast: most often that editors cannot reach the box over SSH yet. diff --git a/app/src/lib/lazy-view.tsx b/app/src/lib/lazy-view.tsx new file mode 100644 index 00000000..7236f309 --- /dev/null +++ b/app/src/lib/lazy-view.tsx @@ -0,0 +1,59 @@ +import { type ComponentType, lazy, useState } from "react"; + +// lazyView is a page (Settings, Automations, Team setup…) that loads on +// first use rather than with the app, so they don't all weigh on its start. +// preloadViews fetches them once the app is up and idle, and a view whose +// code is in by the time it shows draws at once, without suspending (no +// blank frame on a first visit). Render it under a . +const loaders: (() => Promise)[] = []; + +export function lazyView

(load: () => Promise>): ComponentType

{ + let loaded: ComponentType

| undefined; + let pending: Promise> | undefined; + const get = () => + (pending ??= load().then( + (c) => (loaded = c), + (err) => { + // A failed fetch can be tried again on the next visit. + pending = undefined; + throw err; + }, + )); + loaders.push(get); + const Lazy = lazy(() => get().then((c) => ({ default: c }))); + function View(props: P) { + // Chosen once per mount: switching from Lazy to the loaded component + // under a mounted view would remount it. + const [Ready] = useState(() => loaded); + return Ready ? : ; + } + return View; +} + +// preloadViews loads every lazy view's code a while after the app starts, +// one at a time when the main thread is free. WebKit (the macOS and Linux +// app) has no requestIdleCallback; there it is a timer. +export function preloadViews(): () => void { + const ric = window.requestIdleCallback as typeof window.requestIdleCallback | undefined; + const idle = (cb: () => void) => (ric ? ric(cb, { timeout: 2000 }) : window.setTimeout(cb, 50)); + const cancel = (h: number) => (ric ? window.cancelIdleCallback(h) : window.clearTimeout(h)); + let i = 0; + let handle = 0; + let stopped = false; + const next = () => { + const load = loaders[i++]; + if (!load || stopped) return; + void load() + .catch(() => {}) + .finally(() => { + if (!stopped) handle = idle(next); + }); + }; + // After the first screen has settled. + const start = window.setTimeout(() => (handle = idle(next)), 1500); + return () => { + stopped = true; + window.clearTimeout(start); + cancel(handle); + }; +} diff --git a/app/src/views/settings/open.ts b/app/src/views/settings/open.ts new file mode 100644 index 00000000..6fbdbaf5 --- /dev/null +++ b/app/src/views/settings/open.ts @@ -0,0 +1,10 @@ +import { useStore } from "@/lib/store"; + +// Settings' sections and how to open one, apart from the view itself, which +// loads when it is first shown (App.tsx). +export type SettingsSectionId = "general" | "notifications" | "appearance" | "terminal" | "boxes" | "computers" | "phone" | "agents" | "plugins" | "shortcuts" | "labs" | "about" | "developer"; + +// openSettings shows Settings, on a section when given one. +export function openSettings(section?: SettingsSectionId) { + useStore.getState().setView({ kind: "settings", section }); +} diff --git a/app/src/views/settings/settings-view.tsx b/app/src/views/settings/settings-view.tsx index 9cc4b586..e3ed56fb 100644 --- a/app/src/views/settings/settings-view.tsx +++ b/app/src/views/settings/settings-view.tsx @@ -19,7 +19,9 @@ import { ShortcutsSection } from "@/views/settings/shortcuts-section"; import { TerminalSection } from "@/views/settings/terminal-section"; import { ViewHeaderHost } from "@/views/view-header"; -export type SettingsSectionId = "general" | "notifications" | "appearance" | "terminal" | "boxes" | "computers" | "phone" | "agents" | "plugins" | "shortcuts" | "labs" | "about" | "developer"; +import { openSettings, type SettingsSectionId } from "@/views/settings/open"; + +export { openSettings, type SettingsSectionId }; const SECTIONS: { id: SettingsSectionId; title: string; icon: ComponentType<{ className?: string }>; Component: ComponentType }[] = [ { id: "general", title: "General", icon: SlidersHorizontalIcon, Component: GeneralSection }, @@ -37,11 +39,6 @@ const SECTIONS: { id: SettingsSectionId; title: string; icon: ComponentType<{ cl { id: "developer", title: "Developer", icon: WrenchIcon, Component: DeveloperSection }, ]; -// openSettings shows Settings, on a section when given one. -export function openSettings(section?: SettingsSectionId) { - useStore.getState().setView({ kind: "settings", section }); -} - // Below 1200px of window the section list shrinks to its icons, so the // settings themselves keep their width. export function SettingsView() { diff --git a/app/src/views/team/org-field.tsx b/app/src/views/team/org-field.tsx new file mode 100644 index 00000000..98131807 --- /dev/null +++ b/app/src/views/team/org-field.tsx @@ -0,0 +1,13 @@ +import { Input } from "@/components/ui/input"; + +// OrgField takes an org, or a link to a setup (owner/repo/tree/branch/folder +// after the github.com/ it shows, or a whole URL pasted, which hides it). +export function OrgField({ value, onChange, autoFocus }: { value: string; onChange(v: string): void; autoFocus?: boolean }) { + const whole = /github\.com|:\/\//i.test(value); + return ( + + ); +} diff --git a/app/src/views/team/team-entry.tsx b/app/src/views/team/team-entry.tsx index 95c83406..cb6eacb9 100644 --- a/app/src/views/team/team-entry.tsx +++ b/app/src/views/team/team-entry.tsx @@ -11,7 +11,7 @@ import { activeRun, keyOf, loadTeams, onRepoReady, recentlyDone, teamRef, type T import { cn } from "@/lib/utils"; import { GitHubMark } from "@/views/team/team-parts"; import { runSummary } from "@/views/team/team-rail"; -import { OrgField } from "@/views/team/team-view"; +import { OrgField } from "@/views/team/org-field"; // The ways into Team setup, and what shows of it around the app while it // runs: the welcome screen's "Joining a team?", Add a box's "Set this box up diff --git a/app/src/views/team/team-view.tsx b/app/src/views/team/team-view.tsx index 79479902..13e29dd2 100644 --- a/app/src/views/team/team-view.tsx +++ b/app/src/views/team/team-view.tsx @@ -27,6 +27,7 @@ import { GitHubMark, OrgAvatar, OrgHeader, Section } from "@/views/team/team-par import { Plan } from "@/views/team/team-plan"; import { Checklist, RunCard } from "@/views/team/team-rail"; import { ViewHeader } from "@/views/view-header"; +import { OrgField } from "@/views/team/org-field"; type From = "onboarding" | "addbox" | "link" | "sidebar" | "palette" | "suggestion"; @@ -217,18 +218,6 @@ function AskOrg({ onOrg, initial = "", note }: { onOrg(org: string): void; initi ); } -// OrgField takes an org, or a link to a setup (owner/repo/tree/branch/folder -// after the github.com/ it shows, or a whole URL pasted, which hides it). -export function OrgField({ value, onChange, autoFocus }: { value: string; onChange(v: string): void; autoFocus?: boolean }) { - const whole = /github\.com|:\/\//i.test(value); - return ( - - ); -} - function OrgPage({ org, from, wantBox, wantUpdate, github, onOrg }: { org: string; from?: From; wantBox?: string; wantUpdate?: boolean; github: GitHubState; onOrg(org: string): void }) { const client = useStore((s) => s.client); const status = useStore((s) => s.status); From 2270cd86ffb9d81a36f778353be776e6a12b9d84 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:39:04 +0100 Subject: [PATCH 14/44] perf(transcript): a chat's poll with nothing new reads no file Every open chat polls its transcript every few seconds (and the signals bar every three), and each poll opened the record, allocated a 64 KB read buffer and read to EOF though it had not grown. A stat now answers that case, and the buffer for a real read comes from a pool: 15 us and 66 KB per idle poll become 1.8 us and 0.5 KB. Co-Authored-By: Claude Opus 5.5 --- internal/transcript/transcript.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/internal/transcript/transcript.go b/internal/transcript/transcript.go index cb93a3f3..9d74fd2d 100644 --- a/internal/transcript/transcript.go +++ b/internal/transcript/transcript.go @@ -367,8 +367,16 @@ func (c *conv) helper(m CrewMember) { } } +// readers are the 64 KB buffers files are followed through, kept between +// reads: every open chat reads its file every few seconds. +var readers = sync.Pool{New: func() any { return bufio.NewReaderSize(nil, 64<<10) }} + // read follows the file from where it left off. func (c *conv) read(path string) error { + // Nothing new, the usual answer to a chat's poll, needs no open. + if st, err := os.Stat(path); err == nil && st.Size() == c.offset { + return nil + } f, err := os.Open(path) if err != nil { return err @@ -378,6 +386,9 @@ func (c *conv) read(path string) error { if err != nil { return err } + if st.Size() == c.offset { + return nil + } if st.Size() < c.offset { // replaced or truncated: start again *c = conv{source: c.source, dir: c.dir, p: c.p, side: c.side, byTool: map[string]int{}, crewByID: map[string]int{}, gen: gens.Add(1)} } @@ -388,7 +399,12 @@ func (c *conv) read(path string) error { if _, err := f.Seek(c.offset, io.SeekStart); err != nil { return err } - r := bufio.NewReaderSize(f, 64<<10) + r := readers.Get().(*bufio.Reader) + r.Reset(f) + defer func() { + r.Reset(nil) + readers.Put(r) + }() skipFirst := c.truncated && c.offset > 0 && len(c.items) == 0 && c.partial == nil for { if len(c.partial) == 0 { From 52c63fae43e0fee76ee3bf8abcd1f583df9f84d1 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:40:31 +0100 Subject: [PATCH 15/44] perf(agent): look up routes without reading routes.json for every proxied request The proxy read and parsed routes.json first thing for each request it relayed (about 33 us on macOS). The routes are now kept until a stat shows the file changed, so edits from the CLI still apply at once (statefile.Cache). Co-Authored-By: Claude Opus 5.5 --- internal/agent/agent.go | 4 +- internal/agent/bench_test.go | 2 +- internal/agent/routes.go | 26 +++++++++-- internal/agent/routes_test.go | 39 ++++++++++++++++ internal/statefile/cache.go | 70 ++++++++++++++++++++++++++++ internal/statefile/cache_darwin.go | 13 ++++++ internal/statefile/cache_linux.go | 13 ++++++ internal/statefile/cache_test.go | 74 ++++++++++++++++++++++++++++++ 8 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 internal/agent/routes_test.go create mode 100644 internal/statefile/cache.go create mode 100644 internal/statefile/cache_darwin.go create mode 100644 internal/statefile/cache_linux.go create mode 100644 internal/statefile/cache_test.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index bb6d12bf..9439bd1f 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -334,7 +334,7 @@ func Run(ctx context.Context, cfg Config) error { id: id, boxes: trust.NewStore(filepath.Join(cfg.Dir, "boxes.json")), forwards: forwardStore{path: filepath.Join(cfg.Dir, "forwards.json")}, - routes: routeStore{path: filepath.Join(cfg.Dir, "routes.json")}, + routes: newRouteStore(filepath.Join(cfg.Dir, "routes.json")), clients: map[string]*boxState{}, seqs: newSeqStore(cfg.Dir), running: map[string]*runningForward{}, @@ -942,7 +942,7 @@ func (a *Agent) status() Status { a.mu.Lock() defer a.mu.Unlock() s := Status{Boxes: []BoxStatus{}, Forwards: []ForwardStatus{}, Routes: []Route{}, Proxy: a.proxySt, TeamSuggestions: suggestions} - if routes, err := a.routes.list(); err == nil && routes != nil { + if routes, err := a.routes.current(); err == nil && routes != nil { s.Routes = routes } s.Proxy.URLPort = s.Proxy.Port diff --git a/internal/agent/bench_test.go b/internal/agent/bench_test.go index 2c4cab02..96499ceb 100644 --- a/internal/agent/bench_test.go +++ b/internal/agent/bench_test.go @@ -120,7 +120,7 @@ func BenchmarkProxyHosts(b *testing.B) { // BenchmarkRouteLookup resolves a request host against the saved routes, // which the proxy does first for every request it relays. func BenchmarkRouteLookup(b *testing.B) { - a := &Agent{routes: routeStore{path: filepath.Join(b.TempDir(), "routes.json")}} + a := &Agent{routes: newRouteStore(filepath.Join(b.TempDir(), "routes.json"))} all := []Route{ {Pattern: "*.cal.test.localhost", Box: "devbox", Port: 3000}, {Pattern: "*.docs.test.localhost", Box: "devbox", Port: 3001}, diff --git a/internal/agent/routes.go b/internal/agent/routes.go index 7ecfb9e9..93dc78c7 100644 --- a/internal/agent/routes.go +++ b/internal/agent/routes.go @@ -21,7 +21,25 @@ type Route struct { var errUnknownRoute = errors.New("no route with that pattern") -type routeStore struct{ path string } +type routeStore struct { + path string + // cache keeps the routes until the file changes: the proxy looks them + // up for every request it relays. Nil reads the file every time. + cache *statefile.Cache[[]Route] +} + +func newRouteStore(path string) routeStore { + return routeStore{path: path, cache: new(statefile.Cache[[]Route])} +} + +// current is list, read again only once the file has changed. Its result +// is shared: callers must not change it. +func (s routeStore) current() ([]Route, error) { + if s.cache == nil { + return s.list() + } + return s.cache.Load(s.path, s.list) +} func (s routeStore) list() ([]Route, error) { b, err := os.ReadFile(s.path) @@ -93,10 +111,10 @@ func (a *Agent) removeRoute(pattern string) error { }) } -// route resolves a request host. Routes are read from disk each time: they -// are few and small, and edits then apply without restarting the agent. +// route resolves a request host. Routes are read from disk whenever the +// file changes (a stat tells), so edits apply without restarting the agent. func (a *Agent) route(host string) (string, int, bool) { - all, err := a.routes.list() + all, err := a.routes.current() if err != nil { return "", 0, false } diff --git a/internal/agent/routes_test.go b/internal/agent/routes_test.go new file mode 100644 index 00000000..ceb7461e --- /dev/null +++ b/internal/agent/routes_test.go @@ -0,0 +1,39 @@ +package agent + +import ( + "encoding/json" + "path/filepath" + "testing" + + "github.com/cosscom/shipyard/internal/statefile" +) + +// The proxy keeps the routes between requests, yet a route another +// process saves (the CLI) applies to the very next request. +func TestRoutesEditedOnDiskApplyAtOnce(t *testing.T) { + path := filepath.Join(t.TempDir(), "routes.json") + a := &Agent{routes: newRouteStore(path)} + write := func(routes ...Route) { + t.Helper() + b, _ := json.Marshal(routes) + if err := statefile.Write(path, b); err != nil { + t.Fatal(err) + } + } + if _, _, ok := a.route("x.cal.test.localhost"); ok { + t.Fatal("routed with no routes file") + } + write(Route{Pattern: "*.cal.test.localhost", Box: "devl", Port: 3000}) + if box, port, ok := a.route("x.cal.test.localhost"); !ok || box != "devl" || port != 3000 { + t.Fatalf("route = %s %d %v", box, port, ok) + } + // Same length, straight after: another box. + write(Route{Pattern: "*.cal.test.localhost", Box: "devm", Port: 3000}) + if box, _, ok := a.route("x.cal.test.localhost"); !ok || box != "devm" { + t.Fatalf("after an edit: %s %v", box, ok) + } + write() + if _, _, ok := a.route("x.cal.test.localhost"); ok { + t.Fatal("a removed route still routes") + } +} diff --git a/internal/statefile/cache.go b/internal/statefile/cache.go new file mode 100644 index 00000000..b5181bfc --- /dev/null +++ b/internal/statefile/cache.go @@ -0,0 +1,70 @@ +package statefile + +import ( + "errors" + "io/fs" + "os" + "sync" +) + +// Cache keeps what was read from one small state file and reads it again +// only once the file has changed. Some files are read on every request +// (the proxy's routes on each one it relays), so edits made by another +// process (the CLI) still apply at once, without a read of the file each +// time: a stat says whether it changed. +// +// Files here are replaced whole (Write renames a new file over the old), +// so a change shows as a new inode, size, modification or change time. +type Cache[T any] struct { + mu sync.Mutex + key fileKey + ok bool + val T +} + +// fileKey is what tells one version of a file from another. missing is a +// file that isn't there. +type fileKey struct { + missing bool + dev, ino uint64 + size int64 + mtime, ctim int64 // nanoseconds +} + +func keyOf(path string) (fileKey, error) { + fi, err := os.Stat(path) + if errors.Is(err, fs.ErrNotExist) { + return fileKey{missing: true}, nil + } + if err != nil { + return fileKey{}, err + } + k := fileKey{size: fi.Size(), mtime: fi.ModTime().UnixNano()} + statKey(fi, &k) + return k, nil +} + +// Load returns what read made of path, from the last time it ran if the +// file is as it was then. read's result is shared by every caller until +// the file changes: callers must not change it. Errors are not kept, so a +// file that could not be read is tried again next time. +func (c *Cache[T]) Load(path string, read func() (T, error)) (T, error) { + // The stat comes before the read: a file replaced in between is read + // again next time, rather than its old version kept under its new key. + k, err := keyOf(path) + if err != nil { + return read() + } + c.mu.Lock() + defer c.mu.Unlock() + if c.ok && c.key == k { + return c.val, nil + } + v, err := read() + if err != nil { + c.ok = false + return v, err + } + c.key, c.val, c.ok = k, v, true + return v, nil +} diff --git a/internal/statefile/cache_darwin.go b/internal/statefile/cache_darwin.go new file mode 100644 index 00000000..c0e4c5a7 --- /dev/null +++ b/internal/statefile/cache_darwin.go @@ -0,0 +1,13 @@ +package statefile + +import ( + "io/fs" + "syscall" +) + +func statKey(fi fs.FileInfo, k *fileKey) { + if st, ok := fi.Sys().(*syscall.Stat_t); ok { + k.dev, k.ino = uint64(st.Dev), st.Ino + k.ctim = st.Ctimespec.Nano() + } +} diff --git a/internal/statefile/cache_linux.go b/internal/statefile/cache_linux.go new file mode 100644 index 00000000..62b91e7d --- /dev/null +++ b/internal/statefile/cache_linux.go @@ -0,0 +1,13 @@ +package statefile + +import ( + "io/fs" + "syscall" +) + +func statKey(fi fs.FileInfo, k *fileKey) { + if st, ok := fi.Sys().(*syscall.Stat_t); ok { + k.dev, k.ino = uint64(st.Dev), st.Ino + k.ctim = st.Ctim.Nano() + } +} diff --git a/internal/statefile/cache_test.go b/internal/statefile/cache_test.go new file mode 100644 index 00000000..7837d3fc --- /dev/null +++ b/internal/statefile/cache_test.go @@ -0,0 +1,74 @@ +package statefile + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestCacheReadsAFileAgainOnlyOnceItChanges(t *testing.T) { + path := filepath.Join(t.TempDir(), "routes.json") + var c Cache[string] + reads := 0 + load := func() string { + t.Helper() + v, err := c.Load(path, func() (string, error) { + reads++ + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "none", nil + } + return string(b), err + }) + if err != nil { + t.Fatal(err) + } + return v + } + if v := load(); v != "none" || reads != 1 { + t.Fatalf("missing file: %q after %d reads", v, reads) + } + if v := load(); v != "none" || reads != 1 { + t.Fatalf("still missing: %q after %d reads", v, reads) + } + if err := Write(path, []byte("aaaa")); err != nil { + t.Fatal(err) + } + if v := load(); v != "aaaa" || reads != 2 { + t.Fatalf("new file: %q after %d reads", v, reads) + } + for range 3 { + if v := load(); v != "aaaa" || reads != 2 { + t.Fatalf("unchanged file: %q after %d reads", v, reads) + } + } + // The same size, written straight after: still a new version. + if err := Write(path, []byte("bbbb")); err != nil { + t.Fatal(err) + } + if v := load(); v != "bbbb" || reads != 3 { + t.Fatalf("rewritten file: %q after %d reads", v, reads) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if v := load(); v != "none" || reads != 4 { + t.Fatalf("removed file: %q after %d reads", v, reads) + } +} + +func TestCacheKeepsNoErrors(t *testing.T) { + path := filepath.Join(t.TempDir(), "routes.json") + if err := Write(path, []byte("x")); err != nil { + t.Fatal(err) + } + var c Cache[int] + broken := errors.New("unreadable") + if _, err := c.Load(path, func() (int, error) { return 0, broken }); err != broken { + t.Fatalf("err = %v", err) + } + if v, err := c.Load(path, func() (int, error) { return 7, nil }); err != nil || v != 7 { + t.Fatalf("after an error: %d, %v", v, err) + } +} From 8490723589973532e6b5ebd7418784f19c8c3f77 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:40:31 +0100 Subject: [PATCH 16/44] perf(agent): resolve worktree.location.localhost without building the whole status Each request to a worktree name without a box built the agent's full status, which reads three files, just to list the boxes' names. Co-Authored-By: Claude Opus 5.5 --- internal/agent/worktrees.go | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/internal/agent/worktrees.go b/internal/agent/worktrees.go index fa65f69e..a60a1481 100644 --- a/internal/agent/worktrees.go +++ b/internal/agent/worktrees.go @@ -2,6 +2,7 @@ package agent import ( "context" + "slices" "time" "github.com/cosscom/shipyard/internal/box" @@ -88,15 +89,29 @@ func (a *Agent) worktree(labels []string) (string, int, bool) { } wt, loc := labels[0], labels[1] found, foundPort := "", 0 - for _, b := range a.status().Boxes { - if port, ok := lowest(b.Name, func(s box.Service) bool { return s.Location == loc && s.Worktree == wt }); ok { + // Only the boxes' names: this runs for every request the proxy + // relays, and the whole status reads files. + for _, name := range a.boxNames() { + if port, ok := lowest(name, func(s box.Service) bool { return s.Location == loc && s.Worktree == wt }); ok { if found != "" { return "", 0, false // ambiguous: name the box } - found, foundPort = b.Name, port + found, foundPort = name, port } } return found, foundPort, found != "" } return "", 0, false } + +// boxNames is every paired box's name, sorted. +func (a *Agent) boxNames() []string { + a.mu.Lock() + names := make([]string, 0, len(a.clients)) + for name := range a.clients { + names = append(names, name) + } + a.mu.Unlock() + slices.Sort(names) + return names +} From 9b71108e07ccd3a1c464bcdad79d5eda914c336b Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:41:07 +0100 Subject: [PATCH 17/44] perf(app): adding anything to the page no longer restyles all of it A body:has() rule that paused scenes behind a dialog made Chrome restyle the whole document on every element added anywhere (each streamed draft update, a menu opening): about 9 ms a time in a long chat. The root now carries data-berth-dialog while a scene is drawn, kept by a MutationObserver that only looks at what came, went or changed its role. Co-Authored-By: Claude Opus 5.5 --- app/src/components/art/scenes.css | 7 +++++-- app/src/components/art/scenes.tsx | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/app/src/components/art/scenes.css b/app/src/components/art/scenes.css index 5683675b..9f8de3b9 100644 --- a/app/src/components/art/scenes.css +++ b/app/src/components/art/scenes.css @@ -510,11 +510,14 @@ /* Paused: offscreen, the window hidden, behind an open dialog or alert (a confirm is role="alertdialog"), or at rest. A scene inside the dialog keeps moving; only one moves at a time. A scene rests where it is after - a few loops (scenes.tsx), and wakes when the pointer comes to it. */ + a few loops (scenes.tsx), and wakes when the pointer comes to it. + Whether a dialog is open is the root's data-berth-dialog (scenes.tsx), + not body:has(): that made every element added anywhere in the page (a + streamed reply's words) restyle all of it. */ .berth-art[data-off] *, .berth-art[data-rest] *, :root[data-berth-hidden] .berth-art *, -body:has(:is([role="dialog"], [role="alertdialog"])) .berth-art:not(:is([role="dialog"], [role="alertdialog"]) .berth-art) * { +:root[data-berth-dialog] .berth-art:not(:is([role="dialog"], [role="alertdialog"]) .berth-art) * { animation-play-state: paused; } diff --git a/app/src/components/art/scenes.tsx b/app/src/components/art/scenes.tsx index fea2a811..77fe7a21 100644 --- a/app/src/components/art/scenes.tsx +++ b/app/src/components/art/scenes.tsx @@ -52,6 +52,34 @@ if (typeof document !== "undefined") { document.addEventListener("visibilitychange", sync); } +// While a scene is drawn, the root says whether a dialog or alert is open +// (data-berth-dialog), so scenes behind it hold still (scenes.css). Only +// what came, went or changed its role is looked at, so a page busy with +// other work (a reply streaming in) costs next to nothing here. +const DIALOG = '[role="dialog"], [role="alertdialog"]'; +let drawn = 0; +let dialogs: MutationObserver | undefined; +const syncDialog = () => document.documentElement.toggleAttribute("data-berth-dialog", !!document.body.querySelector(DIALOG)); +const hasDialog = (n: Node) => n instanceof Element && (n.matches(DIALOG) || !!n.querySelector(DIALOG)); +function watchDialogs() { + if (typeof MutationObserver === "undefined" || !document.body) return () => {}; + if (drawn++ === 0) { + dialogs = new MutationObserver((records) => { + for (const r of records) { + if (r.type === "attributes" || [...r.addedNodes].some(hasDialog) || [...r.removedNodes].some(hasDialog)) return syncDialog(); + } + }); + dialogs.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["role"] }); + syncDialog(); + } + return () => { + if (--drawn > 0) return; + dialogs?.disconnect(); + dialogs = undefined; + document.documentElement.removeAttribute("data-berth-dialog"); + }; +} + // pivot turns about a point in the scene's own coordinates. const pivot = (x: number, y: number, extra?: Record) => ({ transformOrigin: `${x}px ${y}px`, ...extra }) as React.CSSProperties; const delay = (s: number) => ({ "--ba-delay": `${s}s` }) as React.CSSProperties; @@ -81,9 +109,11 @@ export function Scene({ name, width = 136, still, className }: { name: SceneName if (!el) return; const unwatch = watch(el); const unrest = rest(el); + const undialogs = watchDialogs(); return () => { unwatch(); unrest(); + undialogs(); }; }, []); const Draw = DRAW[name]; From 9929d29d722513363e38570168e6822aa398e347 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:41:52 +0100 Subject: [PATCH 18/44] perf(agent): the app's preflights are kept for ten minutes, so each call is one request Every app call carries the token, so the webview preflights it; without Access-Control-Max-Age it keeps a preflight 5 s, and most polls cost an OPTIONS round trip first. On a fake agent with two boxes: 75 of 195 requests a minute at idle were preflights, 128 of 429 while a reply streamed. Co-Authored-By: Claude Opus 5.5 --- internal/agent/ui.go | 5 +++++ internal/agent/ui_test.go | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/internal/agent/ui.go b/internal/agent/ui.go index f703091d..311e86cb 100644 --- a/internal/agent/ui.go +++ b/internal/agent/ui.go @@ -121,6 +121,11 @@ func (a *Agent) ui(token, hostport string, inner http.Handler) http.Handler { h.Set("Access-Control-Allow-Origin", o) h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, If-None-Match") h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + // Every call carries the token, so every one is preflighted: + // without this the webview keeps a preflight for 5s only, and + // each poll costs two requests and two round trips. 600s is + // the most WebKit keeps one for. + h.Set("Access-Control-Max-Age", "600") h.Set("Vary", "Origin") } if r.Method == http.MethodOptions { diff --git a/internal/agent/ui_test.go b/internal/agent/ui_test.go index 4d60c293..7581c2ba 100644 --- a/internal/agent/ui_test.go +++ b/internal/agent/ui_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "io" "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -281,3 +282,23 @@ func TestTheAppHearsEventsAsServerSentEvents(t *testing.T) { } } } + +// The app's every call carries its token, so the webview asks before each +// one (a CORS preflight). It may keep that answer for ten minutes, so a +// poll is one request rather than two. +func TestTheAppsPreflightIsKeptForTenMinutes(t *testing.T) { + h := (&Agent{}).ui("token", "127.0.0.1:1378", http.NotFoundHandler()) + req := httptest.NewRequest("OPTIONS", "http://127.0.0.1:1378/v1/boxes/devl/api/sessions", nil) + req.Host = "127.0.0.1:1378" + req.Header.Set("Origin", "tauri://localhost") + req.Header.Set("Access-Control-Request-Method", "GET") + req.Header.Set("Access-Control-Request-Headers", "authorization") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent || rec.Header().Get("Access-Control-Allow-Origin") != "tauri://localhost" { + t.Fatalf("preflight: %d %v", rec.Code, rec.Header()) + } + if got := rec.Header().Get("Access-Control-Max-Age"); got != "600" { + t.Fatalf("Access-Control-Max-Age = %q, want 600", got) + } +} From 590a627ef49ba13e228c004cd62151c609f60d10 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:41:58 +0100 Subject: [PATCH 19/44] perf(app): a chat's controls take their signals from the transcript reads it already makes The chat's mode, model, context and tasks come with every transcript read, yet the controls read the transcript again on their own every 3 s (and on each agent event) just for them. The feed now keeps them, so a shown chat makes about 20 fewer reads of its box a minute, and they are as fresh as the conversation (at once on each write). Co-Authored-By: Claude Opus 5.5 --- .../components/conversation/chat-controls.tsx | 2 +- app/src/lib/chat-controls.ts | 51 +++++-------------- app/src/lib/chat-signals.ts | 18 +++++++ app/src/lib/transcript-feed.ts | 6 +++ 4 files changed, 38 insertions(+), 39 deletions(-) create mode 100644 app/src/lib/chat-signals.ts diff --git a/app/src/components/conversation/chat-controls.tsx b/app/src/components/conversation/chat-controls.tsx index a45141ae..36dfb86f 100644 --- a/app/src/components/conversation/chat-controls.tsx +++ b/app/src/components/conversation/chat-controls.tsx @@ -84,7 +84,7 @@ export interface ChatControlsProps { export function ChatControls({ box, session, agent, state, stateSince, dir, who, visible, ended, onShowTerminal, onStartAgain, onSend, children }: ChatControlsProps) { const mock = isMock(); const supported = useStore((s) => !!s.boxes[box]?.info?.capabilities?.includes("controls")) || mock; - const sig = useChatSignals(box, session, visible && !ended); + const sig = useChatSignals(box, session); const { controls, refresh } = useScreenControls(box, session, visible && !ended && (agent === "claude" || agent === "codex"), stateSince); const working = state === "running"; const [stopping, setStopping] = useState(false); diff --git a/app/src/lib/chat-controls.ts b/app/src/lib/chat-controls.ts index ad411204..88ebd19f 100644 --- a/app/src/lib/chat-controls.ts +++ b/app/src/lib/chat-controls.ts @@ -3,8 +3,8 @@ import { create } from "zustand"; import { isMock } from "@/hooks/use-berth-connection"; import { boxApi } from "@/lib/api"; +import { useFedSignals } from "@/lib/chat-signals"; import { keyOf, useConversations } from "@/lib/conversation-store"; -import { useEventLog } from "@/lib/events"; import { mockDemo } from "@/lib/mock"; import { useStore } from "@/lib/store"; import type { ToolDetail, TranscriptItem } from "@/lib/transcript"; @@ -55,49 +55,24 @@ export interface ScreenControls { export type NoticeItem = Extract; const enc = encodeURIComponent; -// Asking from past the end returns no items, only the crew and signals. -const PAST_END = 1_000_000_000; export const hasControls = (box: string) => !!useStore.getState().boxes[box]?.info?.capabilities?.includes("controls"); -// useChatSignals reads a session's signals every few seconds while its chat -// shows, and at once when something happens to it. -export function useChatSignals(box: string, session: string, enabled: boolean): ChatSignals | undefined { - const client = useStore((s) => s.client); +// useChatSignals is a session's signals while its chat shows. They come +// with each read of its transcript, which the chat's feed makes anyway (on +// open, every few seconds, and at once when something happens to it or +// the agent writes): the feed keeps them (lib/chat-signals), so they cost +// no read of their own. +export function useChatSignals(box: string, session: string): ChatSignals | undefined { const mock = isMock(); const [sig, setSig] = useState(); - const latest = useEventLog((s) => s.events.find((e) => e.box === box && (e.type.startsWith("agent.") || e.type.startsWith("session.")) && (e.data?.session === session || e.data?.name === session))?.time); - useEffect(() => setSig(undefined), [box, session]); + const fed = useFedSignals((s) => s.byKey[keyOf(box, session)]); useEffect(() => { - if (mock) { - setSig(mockSignals(session)); - return useMockSignals.subscribe(() => setSig(mockSignals(session))); - } - if (!client || !enabled) return; - let alive = true; - let busy = false; - const read = async () => { - if (busy || document.hidden) return; - busy = true; - try { - const r = await client.box<{ signals?: ChatSignals; source: string }>(box, "GET", `sessions/${enc(session)}/transcript?since=${PAST_END}`); - if (alive) setSig(r.signals ?? {}); - } catch { - // The conversation itself says when it can't be read. - } finally { - busy = false; - } - }; - void read(); - const soon = window.setTimeout(() => void read(), 900); - const t = window.setInterval(() => void read(), 3000); - return () => { - alive = false; - window.clearTimeout(soon); - window.clearInterval(t); - }; - }, [client, box, session, enabled, mock, latest]); - return sig; + if (!mock) return; + setSig(mockSignals(session)); + return useMockSignals.subscribe(() => setSig(mockSignals(session))); + }, [box, session, mock]); + return mock ? sig : fed; } // useScreenControls reads the mode off the agent's screen when the chat diff --git a/app/src/lib/chat-signals.ts b/app/src/lib/chat-signals.ts new file mode 100644 index 00000000..b9cfb6ec --- /dev/null +++ b/app/src/lib/chat-signals.ts @@ -0,0 +1,18 @@ +import { create } from "zustand"; + +import type { ChatSignals } from "@/lib/chat-controls"; + +// A chat's signals (its mode, model, context, task list, background work) +// come with every read of its transcript (internal/transcript/signals.go), +// so the transcript feed (lib/transcript-feed) keeps the latest here, by +// chat (keyOf), for the chat's controls: one read of the box for both. + +export const useFedSignals = create<{ byKey: Record }>()(() => ({ byKey: {} })); + +export function noteSignals(key: string, sig: ChatSignals | undefined) { + const next = sig ?? {}; + const was = useFedSignals.getState().byKey[key]; + // As it was: nothing to draw again. + if (was && JSON.stringify(was) === JSON.stringify(next)) return; + useFedSignals.setState((s) => ({ byKey: { ...s.byKey, [key]: next } })); +} diff --git a/app/src/lib/transcript-feed.ts b/app/src/lib/transcript-feed.ts index b9798b3e..ebbc5d4a 100644 --- a/app/src/lib/transcript-feed.ts +++ b/app/src/lib/transcript-feed.ts @@ -1,6 +1,8 @@ import { useEffect, useRef, useState } from "react"; import { boxApi, type Client, type QueuedPrompt } from "@/lib/api"; +import type { ChatSignals } from "@/lib/chat-controls"; +import { noteSignals } from "@/lib/chat-signals"; import { keyOf, offOf, useConversations } from "@/lib/conversation-store"; import { dropOlder, historyApi } from "@/lib/history"; import { useEventLog } from "@/lib/events"; @@ -35,6 +37,9 @@ export interface TranscriptResult { reset?: boolean; start?: number; file?: string; + // The agent's mode, model, context and tasks, for the chat's controls + // (lib/chat-signals). + signals?: ChatSignals; } // The record each chat shows (newer boxes), kept while the app runs: a @@ -142,6 +147,7 @@ export function useTranscriptFeed(box: string, session: string, dir: string | un const r = await client.box(box, "GET", `sessions/${encodeURIComponent(session)}/transcript?since=${since}${g}`, undefined, ctl.signal); if (!alive) return; failures = 0; + noteSignals(key, r.signals); if (r.source === "none") { setState("none"); return; From 514e036b806d291b09c666c5dd35bc8d4148ac66 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 22:41:58 +0100 Subject: [PATCH 20/44] perf(app): the dev-servers status count no longer reads every worktree's services The status bar's server count used the screen's hook, which also asked each worktree on every box for its configured services (one request per worktree, again on every service or worktree event). The count needs only what is listening; the screen still reads the rest. On two boxes with 51 worktrees that was ~51 requests at start and after each service event. Co-Authored-By: Claude Opus 5.5 --- plugins/dev-servers/src/index.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/dev-servers/src/index.tsx b/plugins/dev-servers/src/index.tsx index 5257b61b..31a16c2e 100644 --- a/plugins/dev-servers/src/index.tsx +++ b/plugins/dev-servers/src/index.tsx @@ -60,8 +60,10 @@ interface Group { } // useServers polls the listening servers of every online box, and reads the -// repositories' declared services when worktrees or services change. -function useServers(berth: BerthPluginContext) { +// repositories' declared services when worktrees or services change. That +// is a request per worktree on every box, so only the screen that lists +// them asks (withConfigured); the status bar's count needs none of it. +function useServers(berth: BerthPluginContext, withConfigured = true) { const boxes = useBoxes(); const online = useMemo(() => boxes.filter((b) => b.state === "online").map((b) => b.name), [boxes]); const key = online.join(","); @@ -94,6 +96,7 @@ function useServers(berth: BerthPluginContext) { }, [key, stamp, berth]); useEffect(() => { + if (!withConfigured) return; let live = true; void (async () => { const found: Configured[] = []; @@ -116,7 +119,7 @@ function useServers(berth: BerthPluginContext) { live = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [key, stamp, berth]); + }, [key, stamp, berth, withConfigured]); useEvent("service.*", reload); useEvent("worktree.created", reload); @@ -125,7 +128,7 @@ function useServers(berth: BerthPluginContext) { } function ServersStatus({ berth }: ScreenProps) { - const { listening } = useServers(berth); + const { listening } = useServers(berth, false); if (!listening?.length) return null; return ( - - ) : ( - <> - {toSend.length > 0 && reviewKey && sendComments(box, session, reviewKey)} />} - {live.show && } - - - )} - + ; + fail(err: unknown): void; + again(): void; + showTerminal(): void; + hideLive(): void; + sendComments(): Promise<{ sent: number; left: number; queued: boolean }>; +} + +// ChatFoot is everything under the transcript: the controls around the +// reply box (ChatControls), comments to send, the agent's own screen when +// it shows one, and the reply box. It draws again only when one of these +// props changes; actions stay the same object. +const ChatFoot = memo(function ChatFoot({ + box, + session, + agent, + state, + stateSince, + dir, + who, + visible, + ended, + comments, + live, + mode, + blocked, + hint, + actions, +}: { + box: string; + session: string; + agent?: string; + state?: string; + stateSince?: string; + dir?: string; + who: string; + visible: boolean; + ended: boolean; + comments: number; + live: boolean; + mode: "send" | "queue" | "answer"; + blocked: boolean; + hint?: string; + actions: FootActions; +}) { + const attach = useMemo(() => ({ box, session }), [box, session]); + return ( + + {ended ? ( +

+ ) : ( + <> + {comments > 0 && } + {live && } + + + )} + + ); +}); + // The transcript keeps what was typed, at most 4000 characters, but not // always as typed: a paste's spacing changes, an older box keeps Claude // Code's tags, and an attached image's path can read as diff --git a/app/src/components/conversation/subagent-view.tsx b/app/src/components/conversation/subagent-view.tsx index 350b371f..6f459895 100644 --- a/app/src/components/conversation/subagent-view.tsx +++ b/app/src/components/conversation/subagent-view.tsx @@ -1,5 +1,5 @@ import { AppWindowIcon, BotIcon, ChevronDownIcon, EyeIcon, RefreshCwIcon } from "lucide-react"; -import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { memo, useEffect, useId, useMemo, useRef, useState } from "react"; import { create } from "zustand"; import { StateGlyph } from "@/components/agent-glyph"; @@ -101,7 +101,7 @@ export function useHelpers(box: string, session: string, opts: { enabled?: boole return { helpers, error, retry: () => setAttempt((n) => n + 1) }; } -export function HelperSheetHost() { +export const HelperSheetHost = memo(function HelperSheetHost() { const id = useId(); const leader = useHelperSheet((s) => s.hosts[0] === id); const open = useHelperSheet((s) => s.open); @@ -111,7 +111,7 @@ export function HelperSheetHost() { }, [id]); if (!leader || !open) return null; return useHelperSheet.setState({ open: undefined })} />; -} +}); const elapsed = (ms: number) => { const s = Math.max(1, Math.round(ms / 1000)); From 5c6d587de38399159cbddfc0de857033bd1cbd39 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 23:00:42 +0100 Subject: [PATCH 36/44] perf(app): the desktop app's binary is 20% smaller The release profile already had LTO, one codegen unit, panic=abort and strip; opt-level "s" instead of 3 halves the Rust code. The shell only answers the webview's calls and lays out browser panes, so speed there doesn't show. The macOS release binary (the web assets it embeds included): 7.51 -> 6.02 MB. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/Cargo.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 4b81193d..63b54a3f 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -44,6 +44,9 @@ objc2 = "0.6" [profile.release] codegen-units = 1 lto = true -opt-level = 3 +# For size: the shell only answers the webview's calls and lays out browser +# panes (WebKit does the work), and "s" makes its code half as large as 3 +# did (the binary 7.5 -> 6.0 MB, its embedded web assets included). +opt-level = "s" panic = "abort" strip = true From 446d30e474769aeb6db067c52bd713ec47c94c6c Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 23:02:52 +0100 Subject: [PATCH 37/44] perf(box): stats name each agent's worktree with one git command a location, not four GET /v1/stats, which the app reads again on every agent event, listed the locations in full (worktrees, remote, default branch, configs) to find which worktree each agent works in. It now lists only the worktrees, and nothing at all when no agent process is found (always on macOS, where /proc does not exist). The answer is the same. Co-Authored-By: Claude Opus 5.5 --- internal/box/locations.go | 19 ++++++++++ internal/box/perf_bench_test.go | 66 +++++++++++++++++++++++++++++++++ internal/box/stats.go | 26 +++++++++---- 3 files changed, 104 insertions(+), 7 deletions(-) diff --git a/internal/box/locations.go b/internal/box/locations.go index ffadf0c7..b9db9742 100644 --- a/internal/box/locations.go +++ b/internal/box/locations.go @@ -196,6 +196,25 @@ func (l *Locations) List(ctx context.Context) ([]Location, error) { return out, nil } +// worktreePaths is each location with its worktrees' names and paths only, +// as List names them: for finding which worktree a directory is in, it +// skips what else List reads (remote, default branch, configs). +func (l *Locations) worktreePaths(ctx context.Context) []Location { + saved, err := l.read() + if err != nil { + return nil + } + out := make([]Location, 0, len(saved)) + for _, s := range saved { + loc := Location{Name: s.Name, Path: s.Path} + if b, err := git(ctx, "-C", s.Path, "worktree", "list", "--porcelain"); err == nil { + loc.Worktrees = parseWorktrees(b, s.Path) + } + out = append(out, loc) + } + return out +} + // withPorts adds each worktree's first port. func (l *Locations) withPorts(loc Location) Location { for i := range loc.Worktrees { diff --git a/internal/box/perf_bench_test.go b/internal/box/perf_bench_test.go index 536bf345..bb4632ed 100644 --- a/internal/box/perf_bench_test.go +++ b/internal/box/perf_bench_test.go @@ -195,6 +195,72 @@ func BenchmarkTurnsSave(b *testing.B) { } } +// statsBox is a box with four locations of two worktrees each, and an +// agent's directory in the last one's second worktree. +func statsBox(tb testing.TB) (*Box, string) { + tb.Helper() + if _, err := exec.LookPath("git"); err != nil { + tb.Skip("git not installed") + } + root, err := filepath.EvalSymlinks(tb.TempDir()) + if err != nil { + tb.Fatal(err) + } + run := func(dir string, args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com") + if out, err := cmd.CombinedOutput(); err != nil { + tb.Fatalf("git %v: %s", args, out) + } + } + bx := &Box{Name: "devbox", Locations: NewLocations(filepath.Join(root, "locations.json")), Events: &events.Bus{}} + var agentDir string + for i := range 4 { + repo := filepath.Join(root, fmt.Sprintf("acme%d", i)) + os.MkdirAll(repo, 0o755) + run(repo, "init", "-q", "-b", "main") + run(repo, "commit", "-q", "--allow-empty", "-m", "init") + agentDir = filepath.Join(root, fmt.Sprintf("acme%d-feat", i)) + run(repo, "worktree", "add", "-q", "-b", "feat", agentDir) + if _, err := bx.Locations.Add(context.Background(), fmt.Sprintf("acme%d", i), repo); err != nil { + tb.Fatal(err) + } + } + return bx, filepath.Join(agentDir, "src") +} + +// An agent is placed in the same location and worktree as the full list +// of locations names. +func TestPlaceAgentsAsLocationsList(t *testing.T) { + bx, dir := statsBox(t) + agents := []Agent{{Tool: "claude", PID: 1, Path: dir}} + bx.placeAgents(context.Background(), agents) + locs, err := bx.Locations.List(context.Background()) + if err != nil { + t.Fatal(err) + } + loc, wt := worktreeFor(locs, dir) + if agents[0].Location != loc || agents[0].Worktree != wt || loc != "acme3" || wt == "" || agents[0].State != "running" { + t.Fatalf("placed in %q/%q (%s), want %q/%q", agents[0].Location, agents[0].Worktree, agents[0].State, loc, wt) + } +} + +// BenchmarkPlaceAgents is GET /v1/stats naming the worktree of an agent, +// on a box with four locations: the app reads stats on every agent event. +func BenchmarkPlaceAgents(b *testing.B) { + bx, dir := statsBox(b) + b.ReportAllocs() + defer cpuPerOp(b)() + for b.Loop() { + agents := []Agent{{Tool: "claude", PID: 1, Path: dir}} + bx.placeAgents(context.Background(), agents) + if agents[0].Location != "acme3" { + b.Fatalf("placed in %q", agents[0].Location) + } + } +} + // writeTurnRecord writes a Claude Code record whose latest turn edited // three files of about 20 KB each, as Claude Code keeps them. func writeTurnRecord(tb testing.TB, p string) { diff --git a/internal/box/stats.go b/internal/box/stats.go index c514227a..932fcd23 100644 --- a/internal/box/stats.go +++ b/internal/box/stats.go @@ -3,6 +3,7 @@ package box import ( "bufio" "bytes" + "context" "net/http" "os" "path/filepath" @@ -58,9 +59,24 @@ var agentTools = map[string]string{"claude": "claude", "codex": "codex", "cursor func (b *Box) handleStats(w http.ResponseWriter, r *http.Request) error { s := collectStats("/proc") - locs, _ := b.Locations.List(r.Context()) - for i := range s.Agents { - ag := &s.Agents[i] + b.placeAgents(r.Context(), s.Agents) + home, _ := os.UserHomeDir() + s.Hooks = hooksInstalled(home) + writeJSON(w, s) + return nil +} + +// placeAgents names the location and worktree each agent works in, and +// its state. It needs only where the worktrees are: one git command a +// location, where Locations.List runs four and reads every config (the +// app reads stats on every agent event), and none when no agent runs. +func (b *Box) placeAgents(ctx context.Context, agents []Agent) { + if len(agents) == 0 { + return + } + locs := b.Locations.worktreePaths(ctx) + for i := range agents { + ag := &agents[i] ag.Location, ag.Worktree = worktreeFor(locs, ag.Path) ag.State = "running" if b.Turns != nil { @@ -69,10 +85,6 @@ func (b *Box) handleStats(w http.ResponseWriter, r *http.Request) error { } } } - home, _ := os.UserHomeDir() - s.Hooks = hooksInstalled(home) - writeJSON(w, s) - return nil } // worktreeFor names the location and worktree a directory is in, choosing From fcb87f8ffe5fa56b8695585f1acb2cc32956252d Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 23:04:11 +0100 Subject: [PATCH 38/44] perf(app): add-a-box, add-a-project, PR review and What's new load their content later Their frames stay in the app (they open from anywhere), but what they show - the add-a-box flow with its install, SSH and tailnet steps, the add-a-project body, the PR review sheet's body and the What's new card with its pictures - is fetched with the other pages once the app is up (lib/lazy-view.tsx), so it is there by the time one opens. Initial JS: 2137 -> 1979 KB raw, 509 -> 478 KB brotli. Co-Authored-By: Claude Opus 5.5 --- app/src/App.tsx | 2 +- app/src/components/add-location-dialog.tsx | 26 ++- .../add-project/add-project-dialog.tsx | 19 +- app/src/components/whats-new/spotlight.tsx | 185 +++++++++++++++++ .../components/whats-new/whats-new-dialog.tsx | 191 +----------------- app/src/views/onboarding/add-box-dialog.tsx | 48 +++-- app/src/views/pr-review/pr-review-sheet.tsx | 21 ++ app/src/views/pr-review/review-sheet.tsx | 18 +- 8 files changed, 276 insertions(+), 234 deletions(-) create mode 100644 app/src/components/whats-new/spotlight.tsx create mode 100644 app/src/views/pr-review/pr-review-sheet.tsx diff --git a/app/src/App.tsx b/app/src/App.tsx index 4aa387d2..d4baa9c7 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -48,7 +48,7 @@ import { useKitDeepLinks } from "@/views/kits/deep-link"; import { useTeamDeepLinks, useTeamWatch } from "@/views/team/team-entry"; import { ReviewSheet } from "@/views/kits/review-sheet"; import { useReviewDeepLinks } from "@/views/pr-review/deep-link"; -import { PrReviewSheet } from "@/views/pr-review/review-sheet"; +import { PrReviewSheet } from "@/views/pr-review/pr-review-sheet"; import { useReviewStatuses } from "@/views/pr-review/use-review-status"; import { AddBoxDialog } from "@/views/onboarding/add-box-dialog"; import { useOnboardingActive } from "@/views/onboarding/onboarding-state"; diff --git a/app/src/components/add-location-dialog.tsx b/app/src/components/add-location-dialog.tsx index 644a5e0d..58446692 100644 --- a/app/src/components/add-location-dialog.tsx +++ b/app/src/components/add-location-dialog.tsx @@ -1,2 +1,24 @@ -// The dialog lives in components/add-project; this keeps the old import. -export { AddProjectDialog, AddProjectDialog as AddLocationDialog } from "@/components/add-project/add-project-dialog"; +import { Suspense } from "react"; + +import { Dialog, DialogPopup } from "@/components/ui/dialog"; +import { lazyView } from "@/lib/lazy-view"; +import { useStore } from "@/lib/store"; + +// AddProjectDialog's frame (components/add-project/add-project-dialog.tsx +// is what it does): its content loads after the app has started. +const AddProjectBody = lazyView(() => import("@/components/add-project/add-project-dialog").then((m) => m.AddProjectBody)); + +export function AddProjectDialog() { + const draft = useStore((s) => s.locationDraft); + return ( + !open && useStore.getState().closeAddLocation()}> + {/* Anchored at the top, as New worktree is: Browse and the box's + states differ in height, and a centred dialog would move its title. */} + + {draft && } + + + ); +} + +export { AddProjectDialog as AddLocationDialog }; diff --git a/app/src/components/add-project/add-project-dialog.tsx b/app/src/components/add-project/add-project-dialog.tsx index 3fcd1a13..52fd8ae3 100644 --- a/app/src/components/add-project/add-project-dialog.tsx +++ b/app/src/components/add-project/add-project-dialog.tsx @@ -10,7 +10,7 @@ import { BoxStrip } from "@/components/add-project/box-strip"; import { Scene } from "@/components/art/scenes"; import { Button } from "@/components/ui/button"; import { StepHeader } from "@/components/step-header"; -import { Dialog, DialogFooter, DialogPanel, DialogPopup } from "@/components/ui/dialog"; +import { DialogFooter, DialogPanel } from "@/components/ui/dialog"; import { Kbd } from "@/components/ui/kbd"; import { Spinner } from "@/components/ui/spinner"; import { toastManager } from "@/components/ui/toast"; @@ -30,22 +30,11 @@ import { ErrorText } from "@/components/error-note"; // name) and the box says what Enter will do there. Folders on the box and // projects on the other boxes are offered below it, and the same repository // can be set up on more boxes at once, with its kit. -export function AddProjectDialog() { - const draft = useStore((s) => s.locationDraft); - return ( - !open && useStore.getState().closeAddLocation()}> - {/* Anchored at the top, as New worktree is: Browse and the box's - states differ in height, and a centred dialog would move its title. */} - - {draft && } - - - ); -} - type Log = { box: string; lines: string[] }; -function Body({ startBox }: { startBox?: string }) { +// AddProjectBody is the dialog's content; its frame (components/ +// add-location-dialog.tsx) loads it when it first opens. +export function AddProjectBody({ startBox }: { startBox?: string }) { const status = useStore((s) => s.status); const boxes = useMemo(() => status?.boxes ?? [], [status]); const firstOnline = boxes.find((b) => b.state === "online")?.name ?? ""; diff --git a/app/src/components/whats-new/spotlight.tsx b/app/src/components/whats-new/spotlight.tsx new file mode 100644 index 00000000..0a2c4899 --- /dev/null +++ b/app/src/components/whats-new/spotlight.tsx @@ -0,0 +1,185 @@ +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; +import { ArrowRightIcon, ArrowUpRightIcon, ChevronLeftIcon, XIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { WhatsNewArt } from "@/components/whats-new/art"; +import { type Shower, showerFor, usePreloadArt } from "@/components/whats-new/show"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogTitle } from "@/components/ui/dialog"; +import { Kbd } from "@/components/ui/kbd"; +import { useArt } from "@/lib/art/model"; +import { openDocs } from "@/lib/open-url"; +import { useStore } from "@/lib/store"; +import { cn } from "@/lib/utils"; +import { closeWhatsNew } from "@/lib/whats-new"; +import type { Release, WhatsNewItem } from "@/lib/whats-new-model"; +import { useWorkspaces } from "@/lib/workspaces"; + +// The What's new card (lib/whats-new.ts): the release's highlights one at +// a time, each with a picture of it in the app and a way there, listed +// down the side so the whole release reads at a glance. After an update a +// note at the foot of the sidebar opens it (WhatsNewNudge). + +// useShowers is each item's Show me as things stand; it follows the +// worktree in front and the artifacts known. +function useShowers(items: WhatsNewItem[], open: boolean): (Shower | undefined)[] { + usePreloadArt(open); + useArt((s) => s.byWt); + useWorkspaces((s) => s.current); + useStore((s) => s.boxes); + return items.map((i) => (i.show ? showerFor(i.show) : undefined)); +} + +function go(s: Shower) { + closeWhatsNew(); + // After the dialog has given the keyboard back, so what opens keeps it. + window.setTimeout(() => s.run(), 0); +} + +// Hint is what an item offers without a Show me: its keys and where. +// Beside a Show me, the keys alone. +function Hint({ item, where, className }: { item: WhatsNewItem; where: boolean; className?: string }) { + if (!item.keys) return null; + return ( + + {item.keys} + {where && item.where} + + ); +} + +// Also is the release's smaller things, a line each. +function Also({ release, className }: { release: Release; className?: string }) { + return ( +
+ Also in this release +
    + {release.also.map((a) => { + const to = a.show && showerFor(a.show); + return ( +
  • + {a.text}{" "} + {to && ( + + )} +
  • + ); + })} +
+
+ ); +} + +export function SpotlightCard({ open, release }: { open: boolean; release: Release }) { + const items = release.items; + const [at, setAt] = useState(0); + const popup = useRef(null); + useEffect(() => { + if (open) setAt(0); + }, [open]); + const showers = useShowers(items, open); + const item = items[at]; + const s = showers[at]; + const last = at === items.length - 1; + const step = (d: number) => setAt((n) => Math.min(items.length - 1, Math.max(0, n + d))); + return ( + !o && closeWhatsNew()}> + { + // ← and → page from anywhere in the card; ↑ and ↓ move in its list. + if (e.defaultPrevented || e.metaKey || e.altKey || e.ctrlKey) return; + if ((e.target as HTMLElement).closest("input, textarea")) return; + if (e.key === "ArrowRight" || e.key === "ArrowLeft") { + e.preventDefault(); + step(e.key === "ArrowRight" ? 1 : -1); + } else if ((e.key === "ArrowDown" || e.key === "ArrowUp") && e.target === popup.current) { + e.preventDefault(); + step(e.key === "ArrowDown" ? 1 : -1); + } + }} + > + setAt(Number(v))} orientation="vertical" className="flex min-h-0"> +
+ + What's new + Shipyard {release.version} + + + {items.map((it, i) => ( + + {it.title} + + ))} + + +
+
+ }> + + +
+ What's new + Shipyard {release.version} +
+ {items.map((it, i) => ( + +
+ +
+
+

{it.title}

+

{it.body}

+
+ {/* Narrow, the list is hidden: the smaller things follow the last. */} + {i === items.length - 1 && } +
+ ))} + +
+ {s && ( + + )} + + {!s && !item.keys && item.docs && ( + + )} +
+
+ + {at + 1} / {items.length} + + + {last ? ( + }>Done + ) : ( + + )} +
+
+
+
+
+
+ ); +} diff --git a/app/src/components/whats-new/whats-new-dialog.tsx b/app/src/components/whats-new/whats-new-dialog.tsx index c0d860e9..a00c1842 100644 --- a/app/src/components/whats-new/whats-new-dialog.tsx +++ b/app/src/components/whats-new/whats-new-dialog.tsx @@ -1,192 +1,21 @@ -import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; -import { ArrowRightIcon, ArrowUpRightIcon, ChevronLeftIcon, XIcon } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { XIcon } from "lucide-react"; +import { Suspense } from "react"; -import { WhatsNewArt } from "@/components/whats-new/art"; -import { type Shower, showerFor, usePreloadArt } from "@/components/whats-new/show"; import { Button } from "@/components/ui/button"; -import { Dialog, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPopup, DialogTitle } from "@/components/ui/dialog"; -import { Kbd } from "@/components/ui/kbd"; -import { useArt } from "@/lib/art/model"; -import { openDocs } from "@/lib/open-url"; -import { useStore } from "@/lib/store"; -import { cn } from "@/lib/utils"; -import { closeWhatsNew, dismissNudge, openWhatsNew, useWhatsNew } from "@/lib/whats-new"; -import type { Release, WhatsNewItem } from "@/lib/whats-new-model"; -import { useWorkspaces } from "@/lib/workspaces"; +import { lazyView } from "@/lib/lazy-view"; +import { dismissNudge, openWhatsNew, useWhatsNew } from "@/lib/whats-new"; -// The What's new card (lib/whats-new.ts): the release's highlights one at -// a time, each with a picture of it in the app and a way there, listed -// down the side so the whole release reads at a glance. After an update a -// note at the foot of the sidebar opens it (WhatsNewNudge). - -// useShowers is each item's Show me as things stand; it follows the -// worktree in front and the artifacts known. -function useShowers(items: WhatsNewItem[], open: boolean): (Shower | undefined)[] { - usePreloadArt(open); - useArt((s) => s.byWt); - useWorkspaces((s) => s.current); - useStore((s) => s.boxes); - return items.map((i) => (i.show ? showerFor(i.show) : undefined)); -} +// The What's new card itself (spotlight.tsx) loads after the app has +// started: most starts have no release to show. +const SpotlightCard = lazyView(() => import("@/components/whats-new/spotlight").then((m) => m.SpotlightCard)); export function WhatsNewDialog() { const { open, release } = useWhatsNew(); if (!release) return null; - return ; -} - -function go(s: Shower) { - closeWhatsNew(); - // After the dialog has given the keyboard back, so what opens keeps it. - window.setTimeout(() => s.run(), 0); -} - -// Hint is what an item offers without a Show me: its keys and where. -// Beside a Show me, the keys alone. -function Hint({ item, where, className }: { item: WhatsNewItem; where: boolean; className?: string }) { - if (!item.keys) return null; - return ( - - {item.keys} - {where && item.where} - - ); -} - -// Also is the release's smaller things, a line each. -function Also({ release, className }: { release: Release; className?: string }) { - return ( -
- Also in this release -
    - {release.also.map((a) => { - const to = a.show && showerFor(a.show); - return ( -
  • - {a.text}{" "} - {to && ( - - )} -
  • - ); - })} -
-
- ); -} - -function SpotlightCard({ open, release }: { open: boolean; release: Release }) { - const items = release.items; - const [at, setAt] = useState(0); - const popup = useRef(null); - useEffect(() => { - if (open) setAt(0); - }, [open]); - const showers = useShowers(items, open); - const item = items[at]; - const s = showers[at]; - const last = at === items.length - 1; - const step = (d: number) => setAt((n) => Math.min(items.length - 1, Math.max(0, n + d))); return ( - !o && closeWhatsNew()}> - { - // ← and → page from anywhere in the card; ↑ and ↓ move in its list. - if (e.defaultPrevented || e.metaKey || e.altKey || e.ctrlKey) return; - if ((e.target as HTMLElement).closest("input, textarea")) return; - if (e.key === "ArrowRight" || e.key === "ArrowLeft") { - e.preventDefault(); - step(e.key === "ArrowRight" ? 1 : -1); - } else if ((e.key === "ArrowDown" || e.key === "ArrowUp") && e.target === popup.current) { - e.preventDefault(); - step(e.key === "ArrowDown" ? 1 : -1); - } - }} - > - setAt(Number(v))} orientation="vertical" className="flex min-h-0"> -
- - What's new - Shipyard {release.version} - - - {items.map((it, i) => ( - - {it.title} - - ))} - - -
-
- }> - - -
- What's new - Shipyard {release.version} -
- {items.map((it, i) => ( - -
- -
-
-

{it.title}

-

{it.body}

-
- {/* Narrow, the list is hidden: the smaller things follow the last. */} - {i === items.length - 1 && } -
- ))} - -
- {s && ( - - )} - - {!s && !item.keys && item.docs && ( - - )} -
-
- - {at + 1} / {items.length} - - - {last ? ( - }>Done - ) : ( - - )} -
-
-
-
-
-
+ + + ); } diff --git a/app/src/views/onboarding/add-box-dialog.tsx b/app/src/views/onboarding/add-box-dialog.tsx index 2757a166..cc558e51 100644 --- a/app/src/views/onboarding/add-box-dialog.tsx +++ b/app/src/views/onboarding/add-box-dialog.tsx @@ -1,12 +1,16 @@ +import { Suspense } from "react"; import { create } from "zustand"; import { Dialog, DialogPopup } from "@/components/ui/dialog"; import { toastManager } from "@/components/ui/toast"; import { useStore } from "@/lib/store"; -import { AddBoxFlow } from "@/views/onboarding/add-box-flow"; +import { lazyView } from "@/lib/lazy-view"; import { prefetchTailnets } from "@/views/onboarding/tailnet"; import { openTeam, TeamBoxEntry } from "@/views/team/team-entry"; +// The flow (its install, SSH and tailnet steps) loads after the app starts. +const AddBoxFlow = lazyView(() => import("@/views/onboarding/add-box-flow").then((m) => m.AddBoxFlow)); + const useAddBox = create<{ open: boolean }>()(() => ({ open: false })); // openAddBox shows the add-a-box flow from anywhere: Settings, the sidebar, @@ -25,26 +29,28 @@ export function AddBoxDialog() { would move its title with each one. */} {open && ( - { - close(); - openTeam(undefined, "addbox"); - }} - /> - ) : undefined - } - onDone={(box) => { - close(); - toastManager.add({ title: `${box} is ready`, description: "Paired and online. Add a repo on it to start working there.", type: "success" }); - }} - /> + + { + close(); + openTeam(undefined, "addbox"); + }} + /> + ) : undefined + } + onDone={(box) => { + close(); + toastManager.add({ title: `${box} is ready`, description: "Paired and online. Add a repo on it to start working there.", type: "success" }); + }} + /> + )} diff --git a/app/src/views/pr-review/pr-review-sheet.tsx b/app/src/views/pr-review/pr-review-sheet.tsx new file mode 100644 index 00000000..7d1d427e --- /dev/null +++ b/app/src/views/pr-review/pr-review-sheet.tsx @@ -0,0 +1,21 @@ +import { Suspense, useRef } from "react"; + +import { Sheet, SheetPopup } from "@/components/ui/sheet"; +import { lazyView } from "@/lib/lazy-view"; +import { closeReviewSheet, usePrReview } from "@/lib/pr-review"; + +// PrReviewSheet's frame (review-sheet.tsx is what it shows): its content +// loads after the app has started. +const PrReviewBody = lazyView(() => import("@/views/pr-review/review-sheet").then((m) => m.PrReviewBody)); + +export function PrReviewSheet() { + const sheet = usePrReview((s) => s.sheet); + const popup = useRef(null); + return ( + !open && closeReviewSheet()}> + + {sheet && } + + + ); +} diff --git a/app/src/views/pr-review/review-sheet.tsx b/app/src/views/pr-review/review-sheet.tsx index 960d9d45..a2fa0b50 100644 --- a/app/src/views/pr-review/review-sheet.tsx +++ b/app/src/views/pr-review/review-sheet.tsx @@ -5,7 +5,7 @@ import { ErrorText } from "@/components/error-note"; import { SimpleSelect } from "@/components/simple-select"; import { Tip } from "@/components/tip"; import { Button } from "@/components/ui/button"; -import { Sheet, SheetDescription, SheetFooter, SheetHeader, SheetPanel, SheetPopup, SheetTitle } from "@/components/ui/sheet"; +import { SheetDescription, SheetFooter, SheetHeader, SheetPanel, SheetTitle } from "@/components/ui/sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { openBrowserAt } from "@/lib/actions"; import { ApiError, type Worktree } from "@/lib/api"; @@ -28,21 +28,11 @@ import { GitHubMark, StateIcon } from "@/views/team/team-parts"; // worktree at the head commit shown, set up by the team's kit, then opens // it with its dev server in a Browser tab and the Diff beside it. -export function PrReviewSheet() { - const sheet = usePrReview((s) => s.sheet); - const popup = useRef(null); - return ( - !open && closeReviewSheet()}> - - {sheet && } - - - ); -} - type Phase = { kind: "review" } | { kind: "running"; started: number; opened?: ReviewOpened; error?: string }; -function Body() { +// PrReviewBody is the sheet's content; its frame (pr-review-sheet.tsx) loads +// it when it first opens. +export function PrReviewBody() { const req = usePrReview((s) => s.sheet)!; const client = useStore((s) => s.client); const [plan, setPlan] = useState(); From 0adc6cf3f20745ac82881f0e9667b062613b9a83 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 23:05:00 +0100 Subject: [PATCH 39/44] test(app): a page-wide :has() rule fails the unit tests, and lastOf is covered css-has.test.ts fails on any stylesheet selector or Tailwind variant that asks body, html or :root :has(), the rule that restyled the whole page on every element added. lastOf moves to lib/last-of.ts with tests of its own. Co-Authored-By: Claude Opus 5.5 --- app/package.json | 2 +- app/src/hooks/use-agent-counts.ts | 13 +------- app/src/lib/css-has.test.ts | 38 ++++++++++++++++++++++ app/src/lib/last-of.test.ts | 45 +++++++++++++++++++++++++++ app/src/lib/last-of.ts | 13 ++++++++ app/src/views/home/widgets/agents.tsx | 3 +- 6 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 app/src/lib/css-has.test.ts create mode 100644 app/src/lib/last-of.test.ts create mode 100644 app/src/lib/last-of.ts diff --git a/app/package.json b/app/package.json index 3c57f969..4e02ec8e 100644 --- a/app/package.json +++ b/app/package.json @@ -21,7 +21,7 @@ "perf:guard": "node perf/guard.mjs", "perf:soak": "node perf/soak.mjs", "perf:terminals": "node perf/terminals.mjs", - "test": "node --experimental-strip-types --test src/lib/layout.test.ts src/lib/groups.test.ts src/lib/screen.test.ts src/lib/removing.test.ts src/lib/conversation-store.test.ts src/lib/wheel.test.ts src/lib/browser-url.test.ts src/lib/terminal.test.ts src/lib/predict.test.ts src/lib/browser-sandbox.test.ts src/lib/requirements.test.ts src/lib/diagnostics.test.ts src/lib/preview-frames.test.ts src/lib/home-layout.test.ts src/lib/file-match.test.ts src/lib/file-marks.test.ts src/lib/syntax-colors.test.ts src/lib/draft-text.test.ts src/lib/derive.test.ts src/lib/tree-model.test.ts src/lib/team-ref.test.ts src/lib/agent-messages.test.ts src/lib/page-loads.test.ts src/lib/devtools-model.test.ts src/lib/sidebar-width.test.ts src/lib/local-titles.test.ts src/lib/net.test.ts src/lib/share.test.ts src/lib/palette-shortcuts.test.ts src/lib/art/chart-spec.test.ts src/lib/art/gist.test.ts src/lib/art/vdiff.test.ts src/lib/term-output.test.ts src/lib/poll.test.ts src/lib/whats-new.test.ts src/lib/platform.test.ts src/lib/processes.test.ts src/lib/link.test.ts src/lib/worktree-tree.test.ts src/lib/review-link.test.ts src/lib/login-url.test.ts src/lib/team-suggest-model.test.ts ../site/review/review.test.mjs" + "test": "node --experimental-strip-types --test src/lib/layout.test.ts src/lib/groups.test.ts src/lib/screen.test.ts src/lib/removing.test.ts src/lib/conversation-store.test.ts src/lib/wheel.test.ts src/lib/browser-url.test.ts src/lib/terminal.test.ts src/lib/predict.test.ts src/lib/browser-sandbox.test.ts src/lib/requirements.test.ts src/lib/diagnostics.test.ts src/lib/preview-frames.test.ts src/lib/home-layout.test.ts src/lib/file-match.test.ts src/lib/file-marks.test.ts src/lib/syntax-colors.test.ts src/lib/draft-text.test.ts src/lib/derive.test.ts src/lib/tree-model.test.ts src/lib/team-ref.test.ts src/lib/agent-messages.test.ts src/lib/page-loads.test.ts src/lib/devtools-model.test.ts src/lib/sidebar-width.test.ts src/lib/local-titles.test.ts src/lib/net.test.ts src/lib/share.test.ts src/lib/palette-shortcuts.test.ts src/lib/art/chart-spec.test.ts src/lib/art/gist.test.ts src/lib/art/vdiff.test.ts src/lib/term-output.test.ts src/lib/poll.test.ts src/lib/whats-new.test.ts src/lib/platform.test.ts src/lib/processes.test.ts src/lib/link.test.ts src/lib/worktree-tree.test.ts src/lib/review-link.test.ts src/lib/login-url.test.ts src/lib/team-suggest-model.test.ts src/lib/last-of.test.ts src/lib/css-has.test.ts ../site/review/review.test.mjs" }, "dependencies": { "@base-ui/react": "^1.8.0", diff --git a/app/src/hooks/use-agent-counts.ts b/app/src/hooks/use-agent-counts.ts index 3da29cac..f1d8f24b 100644 --- a/app/src/hooks/use-agent-counts.ts +++ b/app/src/hooks/use-agent-counts.ts @@ -2,6 +2,7 @@ import { useMemo } from "react"; import type { Session, Status } from "@/lib/api"; import { type SessionState, sessionState } from "@/lib/derive"; +import { lastOf } from "@/lib/last-of"; import { type BoxData, useStore } from "@/lib/store"; export interface SessionEntry { @@ -26,18 +27,6 @@ const allSessions = lastOf((boxes: Record, status: Status | und .flatMap(([box, d]) => (d.sessions ?? []).map((session) => ({ box, session, state: sessionState(session, d.stats) }))); }); -// lastOf remembers fn's last answer, for the same arguments (by identity): -// a memo shared by every component that asks. -export function lastOf
(fn: (...args: A) => R): (...args: A) => R { - let last: { args: A; out: R } | undefined; - return (...args: A) => { - if (last && last.args.length === args.length && last.args.every((a, i) => Object.is(a, args[i]))) return last.out; - const out = fn(...args); - last = { args, out }; - return out; - }; -} - export function useAgentCounts() { const all = useAllSessions(); return useMemo( diff --git a/app/src/lib/css-has.test.ts b/app/src/lib/css-has.test.ts new file mode 100644 index 00000000..71644fc8 --- /dev/null +++ b/app/src/lib/css-has.test.ts @@ -0,0 +1,38 @@ +// node --experimental-strip-types --test src/lib/css-has.test.ts (pnpm test) +// +// A rule that asks the whole page :has() (body:has(…) .x, :root:has(…)) makes +// Chrome restyle every element whenever any element is added anywhere: a +// streamed reply's words, a menu opening, a key typed into a composer. In a +// long chat that was 9 ms a draft update, and 35 ms a key on Home with 300 +// worktrees. Say it with an attribute on the root instead (scenes.tsx: +// data-berth-dialog). +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; + +const src = join(import.meta.dirname, ".."); + +function files(dir: string, out: string[] = []): string[] { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) files(p, out); + else if (/\.(css|tsx?)$/.test(e.name) && !e.name.endsWith(".test.ts")) out.push(p); + } + return out; +} + +// In CSS, a selector; in components, a Tailwind arbitrary variant +// ("[body:has(…)_&]:…", "[:root:has(…)_&]:…"). +const PAGE_HAS = /(?:^|[\s,{}(>~+[])(?:html|body|:root)(?:\[[^\]]*\]|\.[\w-]+)*:has\(/m; + +test("no stylesheet or class asks the whole page :has()", () => { + const found: string[] = []; + for (const f of files(src)) { + let text = readFileSync(f, "utf8"); + if (f.endsWith(".css")) text = text.replace(/\/\*[\s\S]*?\*\//g, ""); + else text = [...text.matchAll(/\[[^\]\s"'`]*:has\([^\s"'`]*/g)].map((m) => m[0]).join("\n"); + for (const line of text.split("\n")) if (PAGE_HAS.test(line)) found.push(`${f.slice(src.length + 1)}: ${line.trim()}`); + } + assert.deepEqual(found, []); +}); diff --git a/app/src/lib/last-of.test.ts b/app/src/lib/last-of.test.ts new file mode 100644 index 00000000..a9801901 --- /dev/null +++ b/app/src/lib/last-of.test.ts @@ -0,0 +1,45 @@ +// node --experimental-strip-types --test src/lib/last-of.test.ts (pnpm test) +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { lastOf } from "./last-of.ts"; + +test("the same arguments give the same answer without working it out again", () => { + let runs = 0; + const rows = lastOf((all: number[], k: string) => { + runs++; + return all.map((n) => `${k}${n}`); + }); + const all = [1, 2, 3]; + const a = rows(all, "x"); + const b = rows(all, "x"); + assert.equal(a, b); + assert.equal(runs, 1); +}); + +test("an argument that is a new object, even an equal one, works it out again", () => { + let runs = 0; + const sum = lastOf((xs: number[]) => { + runs++; + return xs.reduce((s, x) => s + x, 0); + }); + assert.equal(sum([1, 2]), 3); + assert.equal(sum([1, 2]), 3); + assert.equal(runs, 2); + const xs = [4]; + sum(xs); + sum(xs); + assert.equal(runs, 3); +}); + +test("only the last answer is kept", () => { + let runs = 0; + const twice = lastOf((n: number) => { + runs++; + return n * 2; + }); + twice(1); + twice(2); + twice(1); + assert.equal(runs, 3); +}); diff --git a/app/src/lib/last-of.ts b/app/src/lib/last-of.ts new file mode 100644 index 00000000..b8ab3239 --- /dev/null +++ b/app/src/lib/last-of.ts @@ -0,0 +1,13 @@ +// lastOf remembers fn's last answer for the same arguments (compared by +// identity): a memo every component that asks shares, where each one's own +// useMemo would work the same thing out again (Home's dozen widgets over +// every session, lib/hooks use-agent-counts). +export function lastOf(fn: (...args: A) => R): (...args: A) => R { + let last: { args: A; out: R } | undefined; + return (...args: A) => { + if (last && last.args.length === args.length && last.args.every((a, i) => Object.is(a, args[i]))) return last.out; + const out = fn(...args); + last = { args, out }; + return out; + }; +} diff --git a/app/src/views/home/widgets/agents.tsx b/app/src/views/home/widgets/agents.tsx index 9177204c..c05baa83 100644 --- a/app/src/views/home/widgets/agents.tsx +++ b/app/src/views/home/widgets/agents.tsx @@ -4,7 +4,8 @@ import { useEffect, useMemo, useState } from "react"; import { AgentIcon, StateGlyph } from "@/components/agent-glyph"; import { toastError } from "@/components/error-note"; import { Button } from "@/components/ui/button"; -import { lastOf, type SessionEntry, useAllSessions } from "@/hooks/use-agent-counts"; +import { type SessionEntry, useAllSessions } from "@/hooks/use-agent-counts"; +import { lastOf } from "@/lib/last-of"; import { boxApi, type Session } from "@/lib/api"; import { agentLabel, agentOf, worktreeOf } from "@/lib/derive"; import { parseScreen } from "@/lib/screen-status"; From e5afd88c6c8c275ef3405360b787f5d62b69796c Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 23:06:15 +0100 Subject: [PATCH 40/44] perf(app): browser and preview panes load after the app has started BrowserPane and PreviewPane, with the devtools drawer and the sandbox and login helpers they bring, are fetched with the other lazy pages; a pane that shows before they arrive waits under a Suspense, as File and artifact panes already do. The tab strip's error badge moves to components/devtools-badge.tsx so it doesn't pull them back in. Initial JS: 1979 -> 1892 KB raw, 478 -> 459 KB brotli. Co-Authored-By: Claude Opus 5.5 --- app/src/components/browser-devtools.tsx | 26 +++----------------- app/src/components/devtools-badge.tsx | 28 ++++++++++++++++++++++ app/src/components/workspace/pane.tsx | 20 ++++++++++++---- app/src/components/workspace/tab-strip.tsx | 2 +- app/src/lib/lazy-view.tsx | 5 ++-- 5 files changed, 51 insertions(+), 30 deletions(-) create mode 100644 app/src/components/devtools-badge.tsx diff --git a/app/src/components/browser-devtools.tsx b/app/src/components/browser-devtools.tsx index d496e43b..73b0b866 100644 --- a/app/src/components/browser-devtools.tsx +++ b/app/src/components/browser-devtools.tsx @@ -2,13 +2,14 @@ import { invoke } from "@tauri-apps/api/core"; import { BanIcon, BugIcon, ChevronRightIcon, CircleXIcon, InfoIcon, SendIcon, SquareDashedMousePointerIcon, SquareTerminalIcon, TriangleAlertIcon, XIcon } from "lucide-react"; import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { ErrorBadge } from "@/components/devtools-badge"; import { Tip } from "@/components/tip"; import { Button } from "@/components/ui/button"; import { toastManager } from "@/components/ui/toast"; import type { BrowserContext } from "@/lib/browser-url"; import { agentOf } from "@/lib/derive"; -import { clearLog, type PaneLog, setDrawerTab, toggleDrawer, useDrawerOpen, useDrawerTab, useErrorCount, useErrorCountOf, useLog } from "@/lib/devtools"; -import { badgeText, type ConsoleEntry, consoleMessage, failed, formatMs, formatSize, isError, type NetEntry, requestMessage, shortAt, statusText } from "@/lib/devtools-model"; +import { clearLog, type PaneLog, setDrawerTab, toggleDrawer, useDrawerOpen, useDrawerTab, useErrorCount, useLog } from "@/lib/devtools"; +import { type ConsoleEntry, consoleMessage, failed, formatMs, formatSize, isError, type NetEntry, requestMessage, shortAt, statusText } from "@/lib/devtools-model"; import { send as sendPrompt } from "@/lib/orchestrate"; import { keysFor } from "@/lib/shortcuts"; import { useStore } from "@/lib/store"; @@ -68,27 +69,6 @@ export function DevtoolsToggle({ logKey, disabled }: { logKey: string; disabled? ); } -function ErrorBadge({ n, testId, className }: { n: number; testId?: string; className?: string }) { - return ( - - {badgeText(n)} - - ); -} - -// TabErrorBadge is the count on a Browser tab in the tab strip: its panes' -// page errors since they loaded. -export function TabErrorBadge({ paneIds }: { paneIds: string[] }) { - const n = useErrorCountOf(paneIds); - if (!n) return null; - // The tab is a tooltip's trigger already: the count says it all. - return ( - - - - ); -} - type Sending = { kind: "console"; entry: ConsoleEntry } | { kind: "request"; entry: NetEntry }; const HEIGHT_KEY = "berth.devtools.height"; diff --git a/app/src/components/devtools-badge.tsx b/app/src/components/devtools-badge.tsx new file mode 100644 index 00000000..44827c94 --- /dev/null +++ b/app/src/components/devtools-badge.tsx @@ -0,0 +1,28 @@ +import { useErrorCountOf } from "@/lib/devtools"; +import { badgeText } from "@/lib/devtools-model"; +import { cn } from "@/lib/utils"; + +// The page-error counts on a Browser tab and its devtools button: the tab +// strip's stays in the app while the browser pane and its drawer +// (browser-devtools.tsx) load after it has started. + +export function ErrorBadge({ n, testId, className }: { n: number; testId?: string; className?: string }) { + return ( + + {badgeText(n)} + + ); +} + +// TabErrorBadge is the count on a Browser tab in the tab strip: its panes' +// page errors since they loaded. +export function TabErrorBadge({ paneIds }: { paneIds: string[] }) { + const n = useErrorCountOf(paneIds); + if (!n) return null; + // The tab is a tooltip's trigger already: the count says it all. + return ( + + + + ); +} diff --git a/app/src/components/workspace/pane.tsx b/app/src/components/workspace/pane.tsx index 830b248b..16cea1b3 100644 --- a/app/src/components/workspace/pane.tsx +++ b/app/src/components/workspace/pane.tsx @@ -4,8 +4,6 @@ import { lazy, Suspense, useEffect, useMemo } from "react"; import { Tip } from "@/components/tip"; import { AgentIcon, StateGlyph } from "@/components/agent-glyph"; -import { BrowserPane } from "@/components/browser-pane"; -import { PreviewPane } from "@/components/preview-pane"; import { FileGlyph } from "@/components/files/file-bits"; import { EmptySide } from "@/components/workspace/compare-view"; import { CompareSideContext, type CompareSide, pageLoading } from "@/lib/compare-actions"; @@ -37,9 +35,15 @@ import { useStore } from "@/lib/store"; import { startRenaming } from "@/lib/session-title"; import { memory, memoryNote } from "@/lib/processes"; import { cn } from "@/lib/utils"; +import { lazyView } from "@/lib/lazy-view"; import { focusPane, paneBeside, paneToTab, setPaneContent, splitKey, useWorkspaces, useWorktreeRef } from "@/lib/workspaces"; import { platformKeys } from "@/lib/platform"; +// Browser and preview panes (and their devtools) load after the app has +// started, with the other pages (lib/lazy-view.tsx). +const BrowserPane = lazyView(() => import("@/components/browser-pane").then((m) => m.BrowserPane)); +const PreviewPane = lazyView(() => import("@/components/preview-pane").then((m) => m.PreviewPane)); + export { agentLabel }; // A File tab's pane and its editor load the first time one shows. @@ -139,8 +143,16 @@ export function Pane({ wsKey, tab, pane, visible, focused, split, mixed, compare void startSession(restartCommand(c.command) ?? c.agent ?? "", { kind: "replace", tab, pane: pane.id }, c.agent ? agentLabel(c.agent) : "Agent")} onShowTerminal={() => setPaneContent(wsKey, tab, pane.id, { ...c, view: "terminal" })} /> )} - {c.kind === "browser" && setPaneContent(wsKey, tab, pane.id, { kind: "browser", url })} onLoading={compare ? (l) => pageLoading(pane.id, l) : undefined} />} - {c.kind === "preview" && setPaneContent(wsKey, tab, pane.id, { kind: "preview", url })} />} + {c.kind === "browser" && ( + }> + setPaneContent(wsKey, tab, pane.id, { kind: "browser", url })} onLoading={compare ? (l) => pageLoading(pane.id, l) : undefined} /> + + )} + {c.kind === "preview" && ( + }> + setPaneContent(wsKey, tab, pane.id, { kind: "preview", url })} /> + + )} {c.kind === "file" && ( }> diff --git a/app/src/components/workspace/tab-strip.tsx b/app/src/components/workspace/tab-strip.tsx index 71b2f0b6..7523b34f 100644 --- a/app/src/components/workspace/tab-strip.tsx +++ b/app/src/components/workspace/tab-strip.tsx @@ -2,7 +2,7 @@ import { CloudOffIcon, GaugeIcon, PencilIcon, RowsIcon, SquareSplitHorizontalIco import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { StateGlyph } from "@/components/agent-glyph"; -import { TabErrorBadge } from "@/components/browser-devtools"; +import { TabErrorBadge } from "@/components/devtools-badge"; import { BoardButton } from "@/components/art/board-buttons"; import { DockButton } from "@/components/files/tree-dock"; import { Tip } from "@/components/tip"; diff --git a/app/src/lib/lazy-view.tsx b/app/src/lib/lazy-view.tsx index 7236f309..02a9738a 100644 --- a/app/src/lib/lazy-view.tsx +++ b/app/src/lib/lazy-view.tsx @@ -1,7 +1,8 @@ import { type ComponentType, lazy, useState } from "react"; -// lazyView is a page (Settings, Automations, Team setup…) that loads on -// first use rather than with the app, so they don't all weigh on its start. +// lazyView is a page (Settings, Automations, Team setup…), a dialog's +// content or a kind of pane that loads after the app has started rather +// than with it, so they don't all weigh on its start. // preloadViews fetches them once the app is up and idle, and a view whose // code is in by the time it shows draws at once, without suspending (no // blank frame on a first visit). Render it under a . From a96621f155124a533ea4e50e79f74eacb93f4a57 Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 23:18:54 +0100 Subject: [PATCH 41/44] perf(app): the folded rail redraws only the tile whose agent changed All of the rail's tiles (258 at 300 worktrees, each with its menu and hover card) drew again whenever any agent changed. A tile now draws again only when what it shows changed, and each worktree finds its sessions from a map instead of going through every session on its box. Co-Authored-By: Claude Opus 5.5 --- app/src/components/sidebar/rail.tsx | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/app/src/components/sidebar/rail.tsx b/app/src/components/sidebar/rail.tsx index 08cf6e6b..4062b86b 100644 --- a/app/src/components/sidebar/rail.tsx +++ b/app/src/components/sidebar/rail.tsx @@ -1,5 +1,5 @@ import { ChevronRightIcon, PencilIcon, ServerOffIcon, SquareArrowOutUpRightIcon } from "lucide-react"; -import { type KeyboardEvent, type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { type KeyboardEvent, memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { AgentIcon, StateGlyph } from "@/components/agent-glyph"; import { ContextRow } from "@/components/sidebar/actions"; @@ -24,6 +24,7 @@ import { renameWorktree, worktreeLabel } from "@/lib/worktree-names"; export type Lane = "waiting" | "running" | "finished" | "away"; export const LANES: Lane[] = ["waiting", "running", "finished", "away"]; +const NO_SESSIONS: Session[] = []; export interface RailAgent { id: string; @@ -61,10 +62,18 @@ export function useRailAgents(): RailAgent[] { for (const b of boxes) { const d = data[b.name]; const away = b.state === "online" ? undefined : BOX_WORDS[boxState(b, d)].lower; + // A box's sessions by where they run, so each worktree finds its own + // without going through them all. + const byDir = new Map(); + for (const s of d?.sessions ?? []) { + if (s.service || s.exited || !s.dir) continue; + const list = byDir.get(s.dir); + if (list) list.push(s); + else byDir.set(s.dir, [s]); + } for (const loc of d?.locations ?? []) { for (const wt of loc.worktrees ?? []) { - for (const s of d?.sessions ?? []) { - if (s.dir !== wt.path || s.service || s.exited) continue; + for (const s of byDir.get(wt.path) ?? NO_SESSIONS) { const agent = agentOf(s); if (!agent) continue; const state = sessionState(s, d?.stats); @@ -253,7 +262,12 @@ function tileActions(e: RailAgent) { ]; } -function AgentTile({ e }: { e: RailAgent }) { +// A tile draws again only when what it shows changed: the list is made afresh +// whenever any agent changes, and the others keep their objects (a session, +// a worktree) and their words. +const sameAgent = (a: RailAgent, b: RailAgent) => a === b || (Object.keys(a).length === Object.keys(b).length && (Object.keys(a) as (keyof RailAgent)[]).every((k) => a[k] === b[k])); + +const AgentTile = memo(function AgentTile({ e }: { e: RailAgent }) { return (
  • {/* On screen: a bar at the rail's edge, in its tab group's colour. */} @@ -292,7 +306,7 @@ function AgentTile({ e }: { e: RailAgent }) {
  • ); -} +}, (a, b) => sameAgent(a.e, b.e)); // LaneHead heads a lane with its glyph and count, so how many need you // stays in sight however far the rail scrolls. It folds the lane away. From 5add36706aef14b82fcdbdf5bc2f5bc75315ca5d Mon Sep 17 00:00:00 2001 From: sean Date: Fri, 9 Oct 2026 23:21:22 +0100 Subject: [PATCH 42/44] test: set the tmux trace before the sweep can read it Co-Authored-By: Claude Opus 5.5 --- internal/box/sessions_test.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/internal/box/sessions_test.go b/internal/box/sessions_test.go index 46ef7ed3..2b63245f 100644 --- a/internal/box/sessions_test.go +++ b/internal/box/sessions_test.go @@ -240,6 +240,10 @@ func TestScreenShowsTheSessionsOutput(t *testing.T) { func TestGetAndCaptureIsGetAndAScreen(t *testing.T) { s := testSessions(t) ctx := context.Background() + // Set before any list starts the background sweep, which reads it. + var mu sync.Mutex + asked := map[string]int{} + s.trace = func(args []string) { mu.Lock(); asked[args[0]]++; mu.Unlock() } if _, err := s.create(ctx, "both", "acme", t.TempDir(), "printf 'acme ready\\n'; sleep 30", "claude", nil, nil); err != nil { t.Fatal(err) } @@ -247,9 +251,6 @@ func TestGetAndCaptureIsGetAndAScreen(t *testing.T) { if err != nil { t.Fatal(err) } - var mu sync.Mutex - asked := 0 - s.trace = func([]string) { mu.Lock(); asked++; mu.Unlock() } deadline := time.Now().Add(5 * time.Second) for { got, raw, err := s.getAndCapture(ctx, "both", "-J", "-e") @@ -267,16 +268,14 @@ func TestGetAndCaptureIsGetAndAScreen(t *testing.T) { } time.Sleep(50 * time.Millisecond) } + // One read is one display-message, the capture riding along in it. mu.Lock() - if asked < 1 || asked > 100 { - t.Fatalf("asked tmux %d times", asked) - } - n := asked + clear(asked) mu.Unlock() s.getAndCapture(ctx, "both") mu.Lock() - if asked-n != 1 { - t.Fatalf("one read asked tmux %d times, want 1", asked-n) + if asked["display-message"] != 1 || asked["capture-pane"] != 0 { + t.Fatalf("one read asked tmux %v", asked) } mu.Unlock() if _, _, err := s.getAndCapture(ctx, "gone"); !errors.Is(err, ErrUnknownSession) { @@ -290,10 +289,7 @@ func TestGetAndCaptureIsGetAndAScreen(t *testing.T) { func TestEnvVarAsksTmuxOnce(t *testing.T) { s := testSessions(t) ctx := context.Background() - sess, err := s.Create(ctx, "envs", "acme", t.TempDir(), "sleep 30", []string{"ACME_HOME=/srv/acme"}) - if err != nil { - t.Fatal(err) - } + // Set before any list starts the background sweep, which reads it. var mu sync.Mutex asked := 0 s.trace = func(args []string) { @@ -303,6 +299,10 @@ func TestEnvVarAsksTmuxOnce(t *testing.T) { asked++ } } + sess, err := s.Create(ctx, "envs", "acme", t.TempDir(), "sleep 30", []string{"ACME_HOME=/srv/acme"}) + if err != nil { + t.Fatal(err) + } for range 3 { if v := s.EnvVar(ctx, sess, "ACME_HOME"); v != "/srv/acme" { t.Fatalf("ACME_HOME = %q", v) From 3a487fcfe47572766bfd0a98e89bb5df6e7a35c3 Mon Sep 17 00:00:00 2001 From: sean Date: Sat, 10 Oct 2026 00:12:27 +0100 Subject: [PATCH 43/44] docs: the overnight performance pass in the changelog Co-Authored-By: Claude Opus 5.5 --- docs/changelog.mdx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 9e5609b8..5c71b713 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -17,6 +17,25 @@ again. app.** Opening `http://localhost:3000` before its dev server started closed Shipyard half a second later, and again on every launch after, since the tab came back. +- **Shipyard opens faster.** It now loads four files before it first paints + instead of about three hundred, and settings, dialogs and the browser pane + load just after start. In our tests, the window drew in about a fifth of the + time. +- **Chats keep up while an agent writes.** A streamed reply costs about a + third of the work it did on the page, and typing in a chat, Home's composer + or the sidebar's menus stays quick with hundreds of worktrees. +- **The app asks the agent and your boxes less.** An idle chat makes about + half the requests it did, starting reads every box once, and a box's large + answers cross the link to your laptop about ten times smaller. +- **berthd uses less of a box.** Following a chat, a reply as it streams and + the agents' screens runs fewer tmux commands and less CPU; a chat's own + poll takes about a quarter of what it did. + Opening a long conversation uses a tenth of the memory, and a cache that grew + with every conversation now stays small. +- **The laptop agent is lighter per request.** Proxied pages, port forwards + and calls to your boxes allocate a fraction of what they did, and the kits + and team suggestions ask all your boxes at once. +- **The Mac app is 20% smaller.** ## v0.3.13 From e1dd8535b0dcae8c4cf3bb3c862854032bf91521 Mon Sep 17 00:00:00 2001 From: sean Date: Sat, 10 Oct 2026 00:20:02 +0100 Subject: [PATCH 44/44] revert(app): starting fetches every box at once again Waiting for the event stream before the first full read (565112c) made team setup's deep link decide its page before any box had been read, so ?team=done opened the setup instead of "You're set up" (e2e/team-setup.spec.ts:241). It saved two reads per box at start; not worth an ordering change on every launch. Co-Authored-By: Claude Opus 5.5 --- app/src/hooks/use-berth-connection.ts | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/app/src/hooks/use-berth-connection.ts b/app/src/hooks/use-berth-connection.ts index ce0ab969..5d608085 100644 --- a/app/src/hooks/use-berth-connection.ts +++ b/app/src/hooks/use-berth-connection.ts @@ -27,7 +27,6 @@ export function useBerthConnection() { const abort = new AbortController(); const { setClient, setConnection, refreshAll } = useStore.getState(); let poll = 0; - let first = 0; const connect = async (): Promise => { let delay = 1000; @@ -53,26 +52,14 @@ export function useBerthConnection() { void connect().then((client) => { if (!client || abort.signal.aborted) return; setClient(client); - // The first fetch of everything waits for the event stream, so that - // nothing can change unheard between the two, and is the only one: - // fetching before it as well read every box twice on each start. A - // stream slow to open doesn't hold the app back for long. - let synced = false; - first = window.setTimeout(() => !synced && void refreshAll(), 1500); + void refreshAll(); void loadPlugins(client); void loadProjects(); void loadNav(); void loadNotifications(); // Project menus show each project's kit status. void reloadKits(); - client.events( - handleEvent, - () => { - synced = true; - void refreshAll(); - }, - abort.signal, - ); + client.events(handleEvent, () => void refreshAll(), abort.signal); // The backstop: events say what changed, so this only catches what // one missed. Not while the window is hidden; coming back refreshes. poll = window.setInterval(() => !document.hidden && void refreshAll(), BACKSTOP); @@ -92,7 +79,6 @@ export function useBerthConnection() { return () => { abort.abort(); window.clearInterval(poll); - window.clearTimeout(first); window.removeEventListener("focus", onWake); window.removeEventListener("online", onWake); document.removeEventListener("visibilitychange", onVisible);