Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
8 changes: 5 additions & 3 deletions .bb/skills/verify-bb/features/plugin-inline-vis.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ SKILL.md. Inspect nested `--help` before selecting flags and IDs.

| Feature | Drive | Observable success |
| --- | --- | --- |
| Render and open | Emit the directive with a relative .html/.htm path, open its header file action, and reload the thread. | The file renders inline and opens from the correct thread workspace. |
| Render and open | Emit the directive with a relative .html/.htm path, open the action that appears over the page on hover, and reload the thread. | The file renders inline with no card or border, follows the theme, and opens from the correct thread workspace. |
| Fenced block | Have the reply contain an `inline-vis` fenced code block whose body is an HTML page; watch it stream, then use the hover action. | A status line shows while the block is written; the page appears when the fence closes and is not reloaded as the reply continues; the action reveals the source; an unclosed block stays a code block. |
| Relative assets | Reference a fixture stylesheet, script, and image beside the HTML. | Assets resolve from the authorized workspace location and render without unrelated filesystem access. |
| Height | Try omitted height and values around the declared 120–1200 bounds. | Omitted height is 224px; whole numbers from 120 to 1200 are accepted. Invalid and out-of-range values show an explicit error instead of clamping. |
| Height | Try omitted height and values around the declared 80–2000 bounds. | With no height an HTML frame fits its page and a Markdown card previews 256px, expanding in place with "Show more"; whole numbers from 80 to 2000 are accepted (a cap for HTML, the preview height for Markdown). Invalid and out-of-range values show an explicit error instead of clamping. |
| Sandbox | Have the fixture attempt to read parent DOM, cookies, and origin storage; report only success/failure. | The sandbox blocks parent-origin access while allowing the intended visualization interaction. |
| Invalid artifacts | Try missing, non-HTML, invalid UTF-8, and greater-than-5MiB fixtures. | Clear artifact errors replace the frame; no silent blank success or unrestricted fallback occurs. |

Expand All @@ -39,4 +40,5 @@ External account changes use authorized disposable targets.

## Maintenance notes

- Omitted height uses 224px. Whole-number heights from 120 through 1200 are accepted; out-of-range or invalid values show an explicit height error rather than clamping. Source: `plugins/inline-vis/app.tsx:17`, `plugins/inline-vis/app.tsx:30`.
- An HTML frame fits its page (80–2000px) and `height` caps it; a Markdown card previews 256px and expands in place. Out-of-range or invalid values show an explicit height error rather than clamping. Source: `plugins/inline-vis/frame.ts` (`FRAME_MIN_HEIGHT`, `FRAME_MAX_HEIGHT`, `frameHeight`), `plugins/inline-vis/app.tsx` (`parseRequestedHeight`).
- The borderless frame, the 80–2000 bounds, the fenced-block recipe, and the expanding Markdown card date from 2026-10-12 and are not covered by the 2026-09-05 validation run.
10 changes: 10 additions & 0 deletions apps/app/src/components/commands/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { recordTelemetryEvent } from "@/components/onboarding/onboarding-telemetry";
import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport";
import { defineSplit } from "@/lib/define-split";
import { useCallback, useMemo, useRef, useState } from "react";
Expand Down Expand Up @@ -188,6 +189,15 @@ export function CommandPalette({ threadId, projectId }: CommandPaletteProps) {
(invocation) => {
const target = invocationTarget(invocation);
prepareOpen(target);
recordTelemetryEvent({
name: "feature_used",
properties: {
feature: "search",
feature_scope: "core",
interaction: "opened",
trigger: "unknown",
},
});
setSearchingThreads(true);
setOpen(true);
return true;
Expand Down
27 changes: 15 additions & 12 deletions apps/app/src/components/dialogs/ThreadArchiveDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,25 +35,28 @@ function renderDialog({
describe("ThreadArchiveDialog", () => {
it("announces the cascade with singular and plural child counts", () => {
const { view } = renderDialog({ childThreadCount: 1 });
expect(
screen.getByText(/1 child thread will be archived with this thread\./),
).toBeTruthy();
expect(screen.getByRole("dialog").textContent).toContain(
"Its 1 child thread will be archived too.",
);
expect(screen.getByText("1 child thread").className).toContain(
"font-semibold",
);

view.unmount();
renderDialog({ childThreadCount: 3 });
expect(
screen.getByText(/3 child threads will be archived with this thread\./),
).toBeTruthy();
expect(screen.getByRole("dialog").textContent).toContain(
"Its 3 child threads will be archived too.",
);
});

it("names the total number of threads in the title and action", () => {
it("names the single thread in the title and action", () => {
renderDialog({ childThreadCount: 3 });

expect(
screen.getByRole("heading", { name: "Archive 4 threads?" }),
screen.getByRole("heading", { name: "Archive thread?" }),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "Archive 4 threads" }),
screen.getByRole("button", { name: "Archive" }),
).toBeTruthy();
});

Expand All @@ -73,7 +76,7 @@ describe("ThreadArchiveDialog", () => {
it("focuses the archive action when opened for keyboard confirmation", () => {
renderDialog();
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "Archive 2 threads" }),
screen.getByRole("button", { name: "Archive" }),
);
});

Expand All @@ -86,7 +89,7 @@ describe("ThreadArchiveDialog", () => {

it("ignores confirmation while archiving is pending", () => {
const { onArchive } = renderDialog({ pending: true });
fireEvent.click(screen.getByRole("button", { name: "Archive 2 threads" }));
fireEvent.click(screen.getByRole("button", { name: "Archive" }));
expect(onArchive).not.toHaveBeenCalled();
});

Expand All @@ -99,7 +102,7 @@ describe("ThreadArchiveDialog", () => {
expect(onArchive).not.toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);

fireEvent.click(screen.getByRole("button", { name: "Archive 3 threads" }));
fireEvent.click(screen.getByRole("button", { name: "Archive" }));
expect(onArchive).toHaveBeenCalledWith({ thread, childThreadCount: 2 });
});
});
21 changes: 12 additions & 9 deletions apps/app/src/components/dialogs/ThreadArchiveDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from "@bb/shared-ui/dialog";
import { formatChildThreadCount } from "@/lib/child-thread-copy";

export interface ThreadArchiveDialogTarget {
thread: Thread;
Expand Down Expand Up @@ -68,16 +69,10 @@ export function ThreadArchiveDialogContent({
onArchive,
}: ThreadArchiveDialogContentProps) {
const { childThreadCount } = target;
const archivedThreadCount = childThreadCount + 1;
const active =
target.thread.status === "starting" ||
target.thread.status === "active" ||
target.thread.status === "stopping";
const sentences = [
active ? "This will stop current work." : null,
`${childThreadCount} child ${childThreadCount === 1 ? "thread" : "threads"} will be archived with this thread.`,
"Archived threads stay available and can be unarchived.",
].filter((sentence): sentence is string => sentence !== null);

return (
<form
Expand All @@ -90,8 +85,16 @@ export function ThreadArchiveDialogContent({
}}
>
<DialogHeader>
<DialogTitle>Archive {archivedThreadCount} threads?</DialogTitle>
<DialogDescription>{sentences.join(" ")}</DialogDescription>
<DialogTitle>Archive thread?</DialogTitle>
<DialogDescription>
{active ? "This will stop current work. " : null}
Its{" "}
<span className="font-semibold">
{formatChildThreadCount(childThreadCount)}
</span>{" "}
will be archived too. Archived threads stay available and can be
unarchived.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
Expand All @@ -103,7 +106,7 @@ export function ThreadArchiveDialogContent({
Cancel
</Button>
<Button type="submit" disabled={pending}>
Archive {archivedThreadCount} threads
Archive
</Button>
</DialogFooter>
</form>
Expand Down
20 changes: 15 additions & 5 deletions apps/app/src/components/dialogs/ThreadDeleteDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ConfirmDeleteDialog,
ConfirmDeleteDialogContent,
} from "@bb/shared-ui/confirm-delete-dialog";
import { formatChildThreadCount } from "@/lib/child-thread-copy";

export interface ThreadDeleteDialogTarget {
thread: Thread;
Expand Down Expand Up @@ -50,15 +51,24 @@ export function ThreadDeleteDialogContent({
onDelete,
}: ThreadDeleteDialogContentProps) {
const label = "thread";
const sentences = [
target.childThreadCount ? "Child threads will be deleted." : null,
"This action cannot be undone.",
].filter((part): part is string => part !== null);

return (
<ConfirmDeleteDialogContent
title={`Delete ${label}?`}
description={sentences.join(" ")}
description={
<>
{target.childThreadCount ? (
<>
Its{" "}
<span className="font-semibold">
{formatChildThreadCount(target.childThreadCount)}
</span>{" "}
will be deleted too.{" "}
</>
) : null}
This action cannot be undone.
</>
}
confirmLabel={`Delete ${label}`}
pending={pending}
onConfirm={() => onDelete(target)}
Expand Down
2 changes: 2 additions & 0 deletions apps/app/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ import { useMobileVisualViewportHeight } from "./useMobileVisualViewportHeight";
import { isIOSWebKit } from "@/lib/ios-webkit";
import { wsManager } from "@/lib/ws";
import { splitLayoutAtom } from "@/lib/split-layout/atoms";
import { useSplitViewTelemetry } from "@/lib/split-layout/useSplitViewTelemetry";
import { findPaneByThread } from "@/lib/split-layout";
import { applyThreadOpenToLayout } from "@/views/thread-detail/splitThreadNavigation";
import { useAppSettingsRouteMemory } from "@/hooks/useAppSettingsRouteMemory";
Expand Down Expand Up @@ -526,6 +527,7 @@ export function AppLayout({ children }: AppLayoutProps) {
useServerDaemonLogsCommand();
useDataDirectoryCommand();
useWindowReloadCommand();
useSplitViewTelemetry();
usePluginSafeModeCommands();
usePluginCachePruneCommand();
const archivedSectionId = isArchivedView
Expand Down
74 changes: 61 additions & 13 deletions apps/app/src/components/onboarding/OnboardingFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,11 +255,13 @@ interface SignInSession {

function AgentStepContainer({
chrome,
entry,
hostId,
hostName,
onContinue,
}: {
chrome: StepChrome;
entry: OnboardingEntry;
hostId: string | null;
hostName: string | null;
onContinue: () => void;
Expand All @@ -285,14 +287,21 @@ function AgentStepContainer({
const { mutate: closeTerminalMutate } = closeTerminal;
const { mutateAsync: createTerminalAsync } = createTerminal;

const endSignIn = useCallback(() => {
const current = signInRef.current;
if (current === null) return;
signInRef.current = null;
setSignIn(null);
closeTerminalMutate({ mode: "force", terminalId: current.session.id });
void refetchStates();
}, [closeTerminalMutate, refetchStates]);
const endSignIn = useCallback(
(outcome: "ready" | "exited" | "failed" | "cancelled" = "cancelled") => {
const current = signInRef.current;
if (current === null) return;
signInRef.current = null;
recordTelemetryEvent({
name: "onboarding_sign_in",
properties: { entry, outcome },
});
setSignIn(null);
closeTerminalMutate({ mode: "force", terminalId: current.session.id });
void refetchStates();
},
[closeTerminalMutate, entry, refetchStates],
);

const signInRequestRef = useRef(0);
useEffect(
Expand All @@ -309,7 +318,7 @@ function AgentStepContainer({
if (current !== null && exitCode !== 0) {
setSignInFailedProviderId(current.providerId);
}
endSignIn();
endSignIn(exitCode === 0 ? "exited" : "failed");
},
[endSignIn],
);
Expand All @@ -329,7 +338,7 @@ function AgentStepContainer({
states?.find((state) => state.providerId === signingInProviderId)
?.status === "ready";
useEffect(() => {
if (signingInProviderReady) endSignIn();
if (signingInProviderReady) endSignIn("ready");
}, [endSignIn, signingInProviderReady]);

const { runningJobKey } = installRunner;
Expand All @@ -351,6 +360,10 @@ function AgentStepContainer({
setSignInFailedProviderId(null);
signInRequestRef.current += 1;
const request = signInRequestRef.current;
recordTelemetryEvent({
name: "onboarding_sign_in",
properties: { entry, outcome: "started" },
});
const title = `Sign in to ${state.displayName}`;
await closeAbandonedSignInTerminals({ hostId, title });
let session: TerminalSession;
Expand All @@ -363,9 +376,17 @@ function AgentStepContainer({
target: { kind: "host_path", hostId, cwd: null },
});
} catch {
recordTelemetryEvent({
name: "onboarding_sign_in",
properties: { entry, outcome: "failed" },
});
return;
}
if (signInRequestRef.current !== request) {
recordTelemetryEvent({
name: "onboarding_sign_in",
properties: { entry, outcome: "cancelled" },
});
void sdk.terminals
.close({ mode: "force", terminalId: session.id })
.catch(() => null);
Expand Down Expand Up @@ -478,7 +499,7 @@ function AgentStepContainer({
onSignIn={(providerId) => void startSignIn(providerId)}
onInstall={startInstall}
onViewInstallLog={viewInstallLog}
onCancelSignIn={endSignIn}
onCancelSignIn={() => endSignIn()}
onRecheck={() => {
void refetchStates();
void cliStatusQuery.refetch();
Expand Down Expand Up @@ -912,11 +933,37 @@ export function OnboardingFlow({
const primaryHost = usePrimaryHost();
const hostId = primaryHost?.id ?? null;
useHostDiscoveredRepos(hostId);
const agentStates = useSystemProviderStates({
const agentStatesQuery = useSystemProviderStates({
enabled: hostId !== null,
poll: false,
...(hostId === null ? {} : { hostId }),
}).data?.providers;
});
const agentStates = agentStatesQuery.data?.providers;
const agentStateRef = useRef<string | null>(null);
useEffect(() => {
if (agentStates === undefined && !agentStatesQuery.isError) return;
const knownStates = agentStatesQuery.isError ? undefined : agentStates;
const hasUnknown =
knownStates?.some((state) => state.status === "unknown") ?? true;
const installed =
knownStates?.some(
(state) =>
state.status !== "not_installed" && state.status !== "unknown",
) ?? false;
const properties = {
entry,
agent_installed: installed ? true : hasUnknown ? null : false,
agent_ready: hasReadyAgent(knownStates)
? true
: hasUnknown
? null
: false,
};
const key = JSON.stringify(properties);
if (agentStateRef.current === key) return;
agentStateRef.current = key;
recordTelemetryEvent({ name: "onboarding_agent_state", properties });
}, [agentStates, agentStatesQuery.isError, entry]);
const startReportedRef = useRef(entry !== "first_run");
useEffect(() => {
if (startReportedRef.current || agentStates === undefined) return;
Expand Down Expand Up @@ -994,6 +1041,7 @@ export function OnboardingFlow({
return (
<AgentStepContainer
chrome={chrome}
entry={entry}
hostId={hostId}
hostName={primaryHost?.name ?? null}
onContinue={advance(
Expand Down
8 changes: 5 additions & 3 deletions apps/app/src/components/onboarding/onboarding-telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { sdk } from "@/lib/sdk";
export type OnboardingEntry = "first_run" | "replay";

export function recordTelemetryEvent(event: RecordTelemetryEventRequest): void {
void sdk.system
.experimental_recordTelemetryEvent(event)
.catch(() => undefined);
try {
void sdk.system
.experimental_recordTelemetryEvent(event)
.catch(() => undefined);
} catch {}
}
2 changes: 1 addition & 1 deletion apps/app/src/components/pickers/ParentThreadPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export function ParentThreadPicker({
No matching threads.
</div>
) : (
<CommandGroup heading="Assign parent thread">
<CommandGroup>
{filteredOptions.map((option) => (
<CommandItem
key={option.value}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,14 @@ vi.mock("@/hooks/queries/plugin-settings-queries", () => ({
}));

vi.mock("@/lib/sdk", () => ({
sdk: { projects: { attachments: { copy: mocks.copyAttachments } } },
sdk: {
projects: { attachments: { copy: mocks.copyAttachments } },
system: {
experimental_recordTelemetryEvent: vi
.fn()
.mockRejectedValue(new Error("Telemetry unavailable")),
},
},
}));

const PROJECT = makeProjectWithThreadsResponse({
Expand Down
Loading
Loading