Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
9ab52be
perf(app): the mock fixtures load only in mock mode, not at every start
sean-brydon Oct 9, 2026
2b1f7b8
test: benchmarks for the box's hot paths
sean-brydon Oct 9, 2026
f62e024
perf(app): lucide's icon-by-name map loads with the first plugin icon
sean-brydon Oct 9, 2026
930fe0d
perf(transcript): opening a long conversation no longer copies its wh…
sean-brydon Oct 9, 2026
bfb31cd
perf(box): a chat's transcript poll runs one tmux command instead of …
sean-brydon Oct 9, 2026
7aaecb1
perf(box): large json answers cross the link to the laptop gzipped, a…
sean-brydon Oct 9, 2026
a0e31cd
perf(box): reading a screen the box just listed no longer lists the s…
sean-brydon Oct 9, 2026
090b56f
perf(box): the ledger skips rewriting agent-states.json when its stat…
sean-brydon Oct 9, 2026
3450dc5
test: benchmarks for the laptop agent's proxy, forwards, box streams …
sean-brydon Oct 9, 2026
5042307
perf(proxy): log each relayed request without copying the host's whol…
sean-brydon Oct 9, 2026
4536150
perf: reuse copy buffers for proxied requests, forwards and box streams
sean-brydon Oct 9, 2026
904f599
perf(transcript): opening a tool call's details reuses one line buffer
sean-brydon Oct 9, 2026
fe740cf
perf(app): pages other than the workspace load after the app has started
sean-brydon Oct 9, 2026
2270cd8
perf(transcript): a chat's poll with nothing new reads no file
sean-brydon Oct 9, 2026
52c63fa
perf(agent): look up routes without reading routes.json for every pro…
sean-brydon Oct 9, 2026
8490723
perf(agent): resolve worktree.location.localhost without building the…
sean-brydon Oct 9, 2026
9b71108
perf(app): adding anything to the page no longer restyles all of it
sean-brydon Oct 9, 2026
9929d29
perf(agent): the app's preflights are kept for ten minutes, so each c…
sean-brydon Oct 9, 2026
590a627
perf(app): a chat's controls take their signals from the transcript r…
sean-brydon Oct 9, 2026
514e036
perf(app): the dev-servers status count no longer reads every worktre…
sean-brydon Oct 9, 2026
53ce245
perf(app): an agent's event rereads only its own box's review inbox, …
sean-brydon Oct 9, 2026
565112c
perf(app): starting reads every box once, not twice
sean-brydon Oct 9, 2026
d66b970
perf(agent): ask every box for its kits and projects at once, not in …
sean-brydon Oct 9, 2026
8ef13f6
perf(trust): look peers up without reading and locking the store each…
sean-brydon Oct 9, 2026
9897ffc
perf(app): opening a worktree reads its artifacts once, not once per …
sean-brydon Oct 9, 2026
e9be8a9
perf(agent): relay the app's box requests without a fresh buffer and …
sean-brydon Oct 9, 2026
00eacac
perf(app): the app starts from two script files instead of ~300
sean-brydon Oct 9, 2026
b2ed6a6
test: benchmarks report cpu time per op, steadier than wall time on a…
sean-brydon Oct 9, 2026
027d486
perf(transcript): each line of a Claude Code record is decoded once, …
sean-brydon Oct 9, 2026
19e9526
fix(box): the touched-files cache keeps the last 64 conversations, no…
sean-brydon Oct 9, 2026
ed4c4aa
perf(box): a working chat's draft poll runs one tmux command instead …
sean-brydon Oct 9, 2026
330bab1
perf(app): an agent changing on one box redraws only its projects' ch…
sean-brydon Oct 9, 2026
f731953
perf(app): the closed command palette no longer lists everything on e…
sean-brydon Oct 9, 2026
e73be2c
perf(app): Home works out its agents once per update, not once per wi…
sean-brydon Oct 9, 2026
8ac0eb5
perf(app): a streaming reply redraws the chat, not the reply box and …
sean-brydon Oct 9, 2026
5c6d587
perf(app): the desktop app's binary is 20% smaller
sean-brydon Oct 9, 2026
446d30e
perf(box): stats name each agent's worktree with one git command a lo…
sean-brydon Oct 9, 2026
fcb87f8
perf(app): add-a-box, add-a-project, PR review and What's new load th…
sean-brydon Oct 9, 2026
0adc6cf
test(app): a page-wide :has() rule fails the unit tests, and lastOf i…
sean-brydon Oct 9, 2026
e5afd88
perf(app): browser and preview panes load after the app has started
sean-brydon Oct 9, 2026
4f46257
Merge branch 'perf/go-agent' into perf/overnight
sean-brydon Oct 9, 2026
5305653
Merge branch 'perf/data-flow' into perf/overnight
sean-brydon Oct 9, 2026
01c4670
Merge branch 'perf/bundle' into perf/overnight
sean-brydon Oct 9, 2026
a96621f
perf(app): the folded rail redraws only the tile whose agent changed
sean-brydon Oct 9, 2026
5add367
test: set the tmux trace before the sweep can read it
sean-brydon Oct 9, 2026
1c1df50
Merge branch 'perf/go-box' into perf/overnight
sean-brydon Oct 9, 2026
f728647
Merge branch 'perf/react-render' into perf/overnight
sean-brydon Oct 9, 2026
3a487fc
docs: the overnight performance pass in the changelog
sean-brydon Oct 9, 2026
e1dd853
revert(app): starting fetches every box at once again
sean-brydon Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
53 changes: 31 additions & 22 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { PrReviewSheet } from "@/views/pr-review/pr-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;

Expand All @@ -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);
Expand Down Expand Up @@ -129,7 +134,7 @@ export default function App() {
<div data-tauri-drag-region className="h-10 shrink-0" />
<main className="relative min-h-0 flex-1">
<ErrorBoundary scope="onboarding">
{view.kind === "team" ? <TeamSetupView org={view.org} from={view.from ?? "onboarding"} box={view.box} onBack={() => useStore.getState().setView({ kind: "workspace" })} /> : <OnboardingView />}
<Suspense>{view.kind === "team" ? <TeamSetupView org={view.org} from={view.from ?? "onboarding"} box={view.box} onBack={() => useStore.getState().setView({ kind: "workspace" })} /> : <OnboardingView />}</Suspense>
</ErrorBoundary>
</main>
</div>
Expand Down Expand Up @@ -237,7 +242,9 @@ function MainView() {
if (view.kind === "workspace" && onboarding) {
return (
<div className="absolute inset-0">
<OnboardingView />
<Suspense>
<OnboardingView />
</Suspense>
</div>
);
}
Expand All @@ -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.
<div className={cn("absolute inset-0 bg-background", zen && "mx-auto max-w-[1280px] min-[1300px]:border-x")}>
{view.kind === "dashboard" && <DashboardView />}
{view.kind === "automations" && <AutomationsView />}
{view.kind === "review" && <ReviewView />}
{view.kind === "kits" && <KitsView />}
{view.kind === "worktrees" && <WorktreesView />}
{view.kind === "project" && <ProjectView key={`${view.box}/${view.location}`} box={view.box} location={view.location} />}
{view.kind === "settings" && <SettingsView />}
{view.kind === "plugin" && <PluginScreenView screen={view.screen} />}
{view.kind === "team" && <TeamSetupView key={`${view.org ?? ""}${view.update ? ":update" : ""}`} org={view.org} from={view.from} box={view.box} update={view.update} />}
<Suspense>
{view.kind === "dashboard" && <DashboardView />}
{view.kind === "automations" && <AutomationsView />}
{view.kind === "review" && <ReviewView />}
{view.kind === "kits" && <KitsView />}
{view.kind === "worktrees" && <WorktreesView />}
{view.kind === "project" && <ProjectView key={`${view.box}/${view.location}`} box={view.box} location={view.location} />}
{view.kind === "settings" && <SettingsView />}
{view.kind === "plugin" && <PluginScreenView screen={view.screen} />}
{view.kind === "team" && <TeamSetupView key={`${view.org ?? ""}${view.update ? ":update" : ""}`} org={view.org} from={view.from} box={view.box} update={view.update} />}
</Suspense>
</div>
);
}
Expand Down
26 changes: 24 additions & 2 deletions app/src/components/add-location-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={!!draft} onOpenChange={(open) => !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. */}
<DialogPopup anchored className="sm:max-w-[38rem]" showCloseButton={false}>
<Suspense>{draft && <AddProjectBody key={draft.box ?? ""} startBox={draft.box} />}</Suspense>
</DialogPopup>
</Dialog>
);
}

export { AddProjectDialog as AddLocationDialog };
19 changes: 4 additions & 15 deletions app/src/components/add-project/add-project-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
<Dialog open={!!draft} onOpenChange={(open) => !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. */}
<DialogPopup anchored className="sm:max-w-[38rem]" showCloseButton={false}>
{draft && <Body key={draft.box ?? ""} startBox={draft.box} />}
</DialogPopup>
</Dialog>
);
}

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 ?? "";
Expand Down
7 changes: 5 additions & 2 deletions app/src/components/art/scenes.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
30 changes: 30 additions & 0 deletions app/src/components/art/scenes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) => ({ transformOrigin: `${x}px ${y}px`, ...extra }) as React.CSSProperties;
const delay = (s: number) => ({ "--ba-delay": `${s}s` }) as React.CSSProperties;
Expand Down Expand Up @@ -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];
Expand Down
26 changes: 3 additions & 23 deletions app/src/components/browser-devtools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -68,27 +69,6 @@ export function DevtoolsToggle({ logKey, disabled }: { logKey: string; disabled?
);
}

function ErrorBadge({ n, testId, className }: { n: number; testId?: string; className?: string }) {
return (
<span data-testid={testId} className={cn("inline-flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-destructive px-1 font-medium text-[9.5px] text-white tabular-nums leading-none", className)}>
{badgeText(n)}
</span>
);
}

// 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 (
<span role="img" aria-label={`${n} page error${n === 1 ? "" : "s"}`} className="inline-flex shrink-0">
<ErrorBadge n={n} testId="tab-devtools-badge" />
</span>
);
}

type Sending = { kind: "console"; entry: ConsoleEntry } | { kind: "request"; entry: NetEntry };

const HEIGHT_KEY = "berth.devtools.height";
Expand Down
8 changes: 7 additions & 1 deletion app/src/components/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -167,7 +167,12 @@ export function CommandPalette() {
setOpen(false);
};

// Closed, it keeps what it last listed (its closing frames show it)
// rather than listing every session and worktree again whenever a box
// reports in; it lists them afresh as it opens.
const shown = useRef<{ groups: Group[]; query: string }>(undefined);
const groups = useMemo<Group[]>(() => {
if (!open && shown.current && shown.current.query === query) return shown.current.groups;
const st = useStore.getState();
const go = (fn: () => void) => () => {
close(true);
Expand Down Expand Up @@ -396,6 +401,7 @@ export function CommandPalette() {
// close is stable enough: it only reads refs and store setters.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessions, boxes, status, themes, themeId, spaces, pluginCommands, query, nav.hidden, open, login]);
shown.current = { groups, query };

return (
<CommandDialog
Expand Down
Loading
Loading