diff --git a/apps/app/src/components/pickers/ManagerTemplatePicker.stories.tsx b/apps/app/src/components/pickers/ManagerTemplatePicker.stories.tsx deleted file mode 100644 index 00d1a51ca..000000000 --- a/apps/app/src/components/pickers/ManagerTemplatePicker.stories.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { ManagerTemplateSummary } from "@bb/server-contract"; -import { ManagerTemplatePicker } from "./ManagerTemplatePicker"; -import { StoryCard, StoryRow } from "../../../.ladle/story-card"; - -export default { - title: "pickers/Manager Template Picker", -}; - -const noop = () => {}; - -const multipleTemplates: readonly ManagerTemplateSummary[] = [ - { name: "default", isActive: true }, - { name: "sawyer-next", isActive: false }, -]; - -const nonDefaultActive: readonly ManagerTemplateSummary[] = [ - { name: "default", isActive: false }, - { name: "sawyer-next", isActive: true }, -]; - -const singleTemplate: readonly ManagerTemplateSummary[] = [ - { name: "default", isActive: true }, -]; - -export function Overview() { - return ( - - - - - - - - - - - - - - - ); -} diff --git a/apps/app/src/components/pickers/ManagerTemplatePicker.tsx b/apps/app/src/components/pickers/ManagerTemplatePicker.tsx deleted file mode 100644 index a3df9b76e..000000000 --- a/apps/app/src/components/pickers/ManagerTemplatePicker.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useMemo } from "react"; -import type { ManagerTemplateSummary } from "@bb/server-contract"; -import { OptionPicker, type PickerOption } from "./OptionPicker"; - -export interface ManagerTemplatePickerProps { - templates: readonly ManagerTemplateSummary[]; - value: string; - onChange: (templateName: string) => void; - /** Render with the menu open on mount. Story-only escape hatch. */ - defaultOpen?: boolean; - /** Whether the menu blocks page interaction. Defaults to Radix's true; pass false in stories. */ - modal?: boolean; -} - -export function ManagerTemplatePicker({ - templates, - value, - onChange, - defaultOpen, - modal, -}: ManagerTemplatePickerProps) { - const options = useMemo[]>( - () => - templates.map((template) => ({ - value: template.name, - label: template.name, - ...(template.isActive ? { description: "Active default" } : {}), - })), - [templates], - ); - - return ( - - ); -} diff --git a/apps/app/src/components/promptbox/NewThreadPromptBox.stories.tsx b/apps/app/src/components/promptbox/NewThreadPromptBox.stories.tsx index acfbebd60..f92123192 100644 --- a/apps/app/src/components/promptbox/NewThreadPromptBox.stories.tsx +++ b/apps/app/src/components/promptbox/NewThreadPromptBox.stories.tsx @@ -1,6 +1,5 @@ import { useMemo, useState } from "react"; import type { PermissionMode } from "@bb/domain"; -import type { ManagerTemplateSummary } from "@bb/server-contract"; import { NewThreadPromptBoxUI, type NewThreadBranchConfig, @@ -8,7 +7,6 @@ import { type NewThreadHostConfig, type NewThreadModeConfig, type NewThreadProjectConfig, - type NewThreadTemplateConfig, type NewThreadWorktreeConfig, type ThreadCreationMode, } from "@/components/promptbox/NewThreadPromptBox"; @@ -75,18 +73,6 @@ const baseProject: NewThreadProjectConfig = { onChange: noop, }; -const baseManagerTemplates: readonly ManagerTemplateSummary[] = [ - { name: "default", isActive: true }, - { name: "code-reviewer", isActive: false }, - { name: "release-captain", isActive: false }, -]; - -const baseTemplate: NewThreadTemplateConfig = { - templates: baseManagerTemplates, - value: "default", - onChange: noop, -}; - const baseHost: NewThreadHostConfig = { hosts: STORY_HOSTS, eligibleHosts: connectedStoryHosts, @@ -137,7 +123,6 @@ function useControlledMode( ? { mode: "manager", host: baseHost, - template: baseTemplate, } : { mode: "thread", @@ -261,7 +246,6 @@ function FullAccessRow() { ? { mode: "manager", host: baseHost, - template: baseTemplate, } : { mode: "thread", diff --git a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx index c4a3d8f96..7cc95c65e 100644 --- a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx +++ b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx @@ -1,6 +1,5 @@ import { memo, useMemo, useRef, type ReactNode } from "react"; import type { Host, ProjectSource } from "@bb/domain"; -import type { ManagerTemplateSummary } from "@bb/server-contract"; import { ExecutionControls, type ExecutionControlsProps, @@ -27,7 +26,6 @@ import { parseEnvironmentValue, } from "@/components/pickers/environment-picker-value"; import { HostPicker } from "@/components/pickers/HostPicker"; -import { ManagerTemplatePicker } from "@/components/pickers/ManagerTemplatePicker"; import { OPTION_BASE_CLASS_NAME, OPTION_CONTENT_CLASS_NAME, @@ -121,12 +119,6 @@ export interface NewThreadProjectConfig { createProject?: ProjectSelectorCreateProjectConfig; } -export interface NewThreadTemplateConfig { - templates: readonly ManagerTemplateSummary[]; - value: string; - onChange: (templateName: string) => void; -} - export interface NewThreadHostConfig { /** All known hosts — used by `HostPicker` to render the selected host's * label even when it falls outside the eligible set. */ @@ -144,9 +136,9 @@ export interface NewThreadHostConfig { /** * Mode-dependent block. Discriminated union — when mode is "thread" the * environment / branch / worktree / permission config is required and the - * reuse-pill header slot is available; when mode is "manager" only the - * manager-template picker is meaningful. Invalid combinations (e.g. - * "manager" + reuse env) are unrepresentable at the prop boundary. + * reuse-pill header slot is available; when mode is "manager" only the host + * picker is meaningful. Invalid combinations (e.g. "manager" + reuse env) + * are unrepresentable at the prop boundary. */ export type NewThreadModeConfig = | { @@ -168,9 +160,6 @@ export type NewThreadModeConfig = * host because the manager thread runs on it; thread mode picks one * via the env picker, which manager mode lacks. */ host: NewThreadHostConfig; - /** Manager-template picker shown beside the host picker. Omit (or - * pass `templates: []`) to hide. */ - template?: NewThreadTemplateConfig; }; export interface NewThreadPromptBoxUIProps { @@ -319,10 +308,7 @@ export const NewThreadPromptBoxUI = memo(function NewThreadPromptBoxUI({ /> ) : null} {modeConfig.mode === "manager" ? ( - + ) : ( = 2; - return ( - <> - - {showTemplatePicker && template ? ( - - ) : null} - > - ); -} - interface HostSlotProps { host: NewThreadHostConfig; } @@ -593,7 +554,6 @@ export type NewThreadConnectedModeConfig = | { mode: "manager"; host: NewThreadHostConfig; - template?: NewThreadTemplateConfig; }; export interface NewThreadPromptBoxProps extends Omit< @@ -627,7 +587,6 @@ export function NewThreadPromptBox({ modeConfig={{ mode: "manager", host: modeConfig.host, - template: modeConfig.template, }} /> ); diff --git a/apps/app/src/components/secondary-panel/BrowserTabContent.tsx b/apps/app/src/components/secondary-panel/BrowserTabContent.tsx index 79e7ffca8..88388079b 100644 --- a/apps/app/src/components/secondary-panel/BrowserTabContent.tsx +++ b/apps/app/src/components/secondary-panel/BrowserTabContent.tsx @@ -12,8 +12,12 @@ import type { BbDesktopBrowserState, BbDesktopBrowserViewportBounds, BbDesktopBrowserViewBounds, + BbDesktopBrowserViewLayoutDescriptor, +} from "@bb/server-contract"; +import { + bbDesktopBrowserViewLayoutDescriptorFromBounds, + clampBbDesktopBrowserViewBounds, } from "@bb/server-contract"; -import { clampBbDesktopBrowserViewBounds } from "@bb/server-contract"; import { Icon } from "@/components/ui/icon.js"; import { getDesktopBrowserApi } from "@/lib/bb-desktop"; import { @@ -70,10 +74,33 @@ interface NavButtonProps { onClick: () => void; } -interface BrowserViewBoundsFromElementArgs { +interface BrowserViewPlacementFromElementArgs { element: HTMLElement; } +/** + * A renderer-side placement measurement. Only `bounds` crosses the IPC + * boundary — the desktop main process derives its own resize-invariant layout + * descriptor from the rect against the window content bounds, the coordinate + * space it also reprojects in on native resize. `layout` here is computed + * against the renderer's layout viewport purely as a local dedupe key: it is + * invariant under native window resizes, so the ResizeObserver burst from a + * window edge-drag produces no renderer IPC (the main process owns that path). + */ +interface BrowserViewPlacement { + bounds: BbDesktopBrowserViewBounds; + layout: BbDesktopBrowserViewLayoutDescriptor; +} + +interface BrowserViewLayoutsEqualArgs { + a: BbDesktopBrowserViewLayoutDescriptor; + b: BbDesktopBrowserViewLayoutDescriptor; +} + +interface SyncBrowserViewPlacementArgs { + force: boolean; +} + const EMPTY_BROWSER_VIEW_BOUNDS: BbDesktopBrowserViewBounds = { x: 0, y: 0, @@ -97,13 +124,30 @@ function browserViewportBounds(): BbDesktopBrowserViewportBounds { }; } -function browserViewBoundsFromElement( - args: BrowserViewBoundsFromElementArgs, -): BbDesktopBrowserViewBounds { - return clampBbDesktopBrowserViewBounds({ +function browserViewPlacementFromElement( + args: BrowserViewPlacementFromElementArgs, +): BrowserViewPlacement { + const viewport = browserViewportBounds(); + const bounds = clampBbDesktopBrowserViewBounds({ bounds: roundedBoundsFromRect(args.element.getBoundingClientRect()), - viewport: browserViewportBounds(), + viewport, }); + return { + bounds, + layout: bbDesktopBrowserViewLayoutDescriptorFromBounds({ + bounds, + viewport, + }), + }; +} + +function browserViewLayoutsEqual(args: BrowserViewLayoutsEqualArgs): boolean { + return ( + args.a.left === args.b.left && + args.a.top === args.b.top && + args.a.rightInset === args.b.rightInset && + args.a.bottomInset === args.b.bottomInset + ); } function NavButton({ icon, label, disabled, onClick }: NavButtonProps) { @@ -256,59 +300,82 @@ export function BrowserTabContent({ isActiveRef.current = isActive; const hasPage = currentUrl.length > 0; - // Pending rAF handle for the coalesced bounds sync, so a burst of resize ticks - // collapses to a single native setBounds per frame. + // Pending rAF handle for layout-shape observation, so a burst of panel resize + // ticks collapses before the descriptor equality check. const boundsSyncFrameRef = useRef(null); + const lastSentLayoutRef = useRef( + null, + ); - const readBounds = useCallback(() => { + const readPlacement = useCallback(() => { const element = contentRef.current; if (element === null) { return null; } - return browserViewBoundsFromElement({ element }); + return browserViewPlacementFromElement({ element }); }, []); - const sendBounds = useCallback( - (bounds: BbDesktopBrowserViewBounds) => { + const sendPlacement = useCallback( + (placement: BrowserViewPlacement) => { if (desktopBrowser === null) { return; } + lastSentLayoutRef.current = placement.layout; desktopBrowser.setBounds({ tabId, - bounds, + bounds: placement.bounds, }); }, [desktopBrowser, tabId], ); - // Push the current content-rect to the native overlay immediately. The + // Push the current layout descriptor to the native overlay immediately. The // coordinator's show() calls this synchronously so bounds always land before // the view is made visible (never a stale/zero-bounds flash on activation). + const syncPlacement = useCallback( + ({ force }: SyncBrowserViewPlacementArgs) => { + const placement = readPlacement(); + if (placement === null) { + return; + } + const lastSentLayout = lastSentLayoutRef.current; + if ( + !force && + lastSentLayout !== null && + browserViewLayoutsEqual({ + a: lastSentLayout, + b: placement.layout, + }) + ) { + return; + } + sendPlacement(placement); + }, + [readPlacement, sendPlacement], + ); + const syncBounds = useCallback(() => { - const bounds = readBounds(); - if (bounds === null) { - return; - } - sendBounds(bounds); - }, [readBounds, sendBounds]); + syncPlacement({ force: true }); + }, [syncPlacement]); + // Initial bounds for attach. When the content element is not measurable yet + // the dedupe key stays null, so the first layout-shape observation always + // sends a real placement. const syncInitialBounds = useCallback(() => { - return readBounds() ?? EMPTY_BROWSER_VIEW_BOUNDS; - }, [readBounds]); + const placement = readPlacement(); + lastSentLayoutRef.current = placement?.layout ?? null; + return placement?.bounds ?? EMPTY_BROWSER_VIEW_BOUNDS; + }, [readPlacement]); - // rAF-coalesced bounds sync for live resize tracking. A drag-resize fires the - // ResizeObserver (and `window` resize) repeatedly; batching to one setBounds - // per frame lets the visible overlay follow the panel smoothly — far better - // than blanking the view for the whole drag, which flashed. const scheduleBoundsSync = useCallback(() => { if (boundsSyncFrameRef.current !== null) { return; } boundsSyncFrameRef.current = window.requestAnimationFrame(() => { boundsSyncFrameRef.current = null; - syncBounds(); + syncPlacement({ force: false }); }); - }, [syncBounds]); + }, [syncPlacement]); // Create (or re-attach to) the native view on mount and stream navigation // state back. Unmount is not ownership teardown: switching threads unmounts @@ -365,9 +432,9 @@ export function BrowserTabContent({ threadId, ]); - // Track the panel content rect so the native overlay stays aligned — including - // throughout a drag-resize, where the rect changes every frame. Coalesced via - // rAF so the overlay follows the live size instead of being hidden. + // Track true layout-shape changes. Native OS window resize is reprojected in + // the desktop main process from the cached descriptor; identical descriptors + // are ignored here so renderer IPC is not part of the window-edge drag path. useEffect(() => { const element = contentRef.current; if (element === null || desktopBrowser === null) { @@ -377,10 +444,8 @@ export function BrowserTabContent({ scheduleBoundsSync(); }); observer.observe(element); - window.addEventListener("resize", scheduleBoundsSync); return () => { observer.disconnect(); - window.removeEventListener("resize", scheduleBoundsSync); if (boundsSyncFrameRef.current !== null) { window.cancelAnimationFrame(boundsSyncFrameRef.current); boundsSyncFrameRef.current = null; diff --git a/apps/app/src/components/secondary-panel/BrowserTabDeck.test.tsx b/apps/app/src/components/secondary-panel/BrowserTabDeck.test.tsx index 6048b647c..194f70aab 100644 --- a/apps/app/src/components/secondary-panel/BrowserTabDeck.test.tsx +++ b/apps/app/src/components/secondary-panel/BrowserTabDeck.test.tsx @@ -50,7 +50,12 @@ function createRecordingBrowserApi(): RecordingBrowserApi { }); }, detach(tabId) { - calls.push({ method: "detach", bounds: null, tabId, visible: null }); + calls.push({ + method: "detach", + bounds: null, + tabId, + visible: null, + }); }, navigate(request) { calls.push({ @@ -498,6 +503,7 @@ describe("BrowserTabDeck", () => { it("resyncs bounds when the panel position changes without resizing", () => { const { api, calls } = createRecordingBrowserApi(); installDesktopBrowserApi(api); + const restoreViewport = installViewportSize({ width: 1000, height: 700 }); const rect: BrowserContentRect = { left: 320, top: 96, @@ -534,6 +540,7 @@ describe("BrowserTabDeck", () => { }); } finally { restoreRect(); + restoreViewport(); } }); @@ -574,6 +581,69 @@ describe("BrowserTabDeck", () => { } }); + it("does not stream bounds IPC from the renderer on window resize alone", () => { + const { api, calls } = createRecordingBrowserApi(); + installDesktopBrowserApi(api); + const restoreViewport = installViewportSize({ width: 1000, height: 700 }); + const restoreRect = installBrowserContentRect({ + left: 520, + top: 96, + width: 480, + height: 604, + }); + // Bounds syncs are deferred to rAF, so a re-added `window.resize` listener + // would only send IPC after a frame flush. Queue frames manually and flush + // them after the resize: with no listener nothing is scheduled and the + // flush is a no-op; a regressed listener gets its frame run and fails the + // assertion below. + const animationFrame = installQueuedAnimationFrame(); + + try { + render( + {}} + />, + ); + + calls.length = 0; + + // Grow the viewport while the content rect stays put, so the rect's + // layout shape genuinely changes relative to the viewport — a resync + // triggered by this resize cannot be swallowed by the send dedupe. + const restoreResizedViewport = installViewportSize({ + width: 1200, + height: 700, + }); + try { + act(() => { + window.dispatchEvent(new Event("resize")); + animationFrame.flushNext(); + animationFrame.flushNext(); + }); + + // Native window resizes are reprojected synchronously by the desktop + // main process from its cached descriptor; the renderer must stay + // silent on this path. + expect( + calls.some( + (call) => call.method === "setBounds" && call.tabId === TAB_A.id, + ), + ).toBe(false); + } finally { + restoreResizedViewport(); + } + } finally { + animationFrame.restore(); + restoreRect(); + restoreViewport(); + } + }); + it("hides the later view before showing the earlier one when switching B -> A", () => { const { api, calls } = createRecordingBrowserApi(); installDesktopBrowserApi(api); @@ -733,6 +803,14 @@ describe("BrowserTabDeck", () => { const { api, calls } = createRecordingBrowserApi(); installDesktopBrowserApi(api); const store = getDefaultStore(); + const restoreViewport = installViewportSize({ width: 1000, height: 700 }); + const rect: BrowserContentRect = { + left: 520, + top: 96, + width: 280, + height: 360, + }; + const restoreRect = installBrowserContentRect(rect); // Capture the callback the component subscribes its ResizeObserver with — // the drag-resize path runs through THIS (the panel handle shrinks/grows the @@ -750,51 +828,57 @@ describe("BrowserTabDeck", () => { vi.stubGlobal("ResizeObserver", CapturingResizeObserver); const animationFrame = installQueuedAnimationFrame(); - render( - {}} - />, - ); - expect(resizeCallbacks.length).toBeGreaterThan(0); - - const resizeStart = calls.length; - act(() => { - // Begin a drag-resize, then fire a ResizeObserver tick exactly as dragging - // the panel handle resizes the content element. - store.set(threadSecondaryPanelResizingAtom, true); - for (const fireResizeTick of resizeCallbacks) { - fireResizeTick(); - } - animationFrame.flushNext(); - animationFrame.flushNext(); - }); - const duringResize = calls.slice(resizeStart); - - // The overlay must NOT be blanked mid-resize — hiding it for the whole drag - // was the flash this fixes. - expect( - duringResize.some( - (call) => - call.method === "setVisible" && - call.tabId === TAB_A.id && - call.visible === false, - ), - ).toBe(false); - // Instead, the ResizeObserver tick syncs its bounds to the live panel size. - expect( - duringResize.some( - (call) => call.method === "setBounds" && call.tabId === TAB_A.id, - ), - ).toBe(true); - // Net effect: the active view stays visible throughout the resize. - expect(visibilityFor(calls, TAB_A.id)).toBe(true); + try { + render( + {}} + />, + ); + expect(resizeCallbacks.length).toBeGreaterThan(0); - animationFrame.restore(); - vi.unstubAllGlobals(); + const resizeStart = calls.length; + act(() => { + // Begin a drag-resize, then fire a ResizeObserver tick exactly as dragging + // the panel handle resizes the content element. + store.set(threadSecondaryPanelResizingAtom, true); + rect.width = 340; + for (const fireResizeTick of resizeCallbacks) { + fireResizeTick(); + } + animationFrame.flushNext(); + animationFrame.flushNext(); + }); + const duringResize = calls.slice(resizeStart); + + // The overlay must NOT be blanked mid-resize — hiding it for the whole drag + // was the flash this fixes. + expect( + duringResize.some( + (call) => + call.method === "setVisible" && + call.tabId === TAB_A.id && + call.visible === false, + ), + ).toBe(false); + // Instead, the ResizeObserver tick syncs its layout descriptor to the live + // panel size. + expect( + duringResize.some( + (call) => call.method === "setBounds" && call.tabId === TAB_A.id, + ), + ).toBe(true); + // Net effect: the active view stays visible throughout the resize. + expect(visibilityFor(calls, TAB_A.id)).toBe(true); + } finally { + animationFrame.restore(); + restoreRect(); + restoreViewport(); + vi.unstubAllGlobals(); + } }); }); diff --git a/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx b/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx index cbb14a04a..0fdaac4e0 100644 --- a/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx +++ b/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx @@ -43,12 +43,15 @@ export const CREATE_APP_PROMPT_TEMPLATE = `You are creating a new global bb app. Apps system reference — run \`bb guide app\` for full detail. Layout: - /apps//manifest.json — { manifestVersion: 1, id: applicationId, name?, icon | logo.svg, entry, capabilities: ["data"?, "message"?] } -- /apps//public/index.html — self-contained static HTML/CSS/JS/SVG served by bb; use inline/relative files, no web server, npm, or build step -- /apps//data/state.json — state if the app uses window.bb.data +- /apps//README.md — scaffold notes and build instructions +- /apps//public/index.html — prebuilt static web root served by bb; use flat relative asset refs +- /apps//data/state.json — empty seed state; app data can also use nested records such as todos/ +- /apps//skills/add-todos/SKILL.md — scaffold skill showing the Todo record shape +- /apps//source/ — editable Vite + React + TypeScript project; run \`pnpm install\` and \`pnpm build\` here after edits -In the page, use window.bb.data for live state (read / write / delete / list / onChange; onChange replays + streams) and window.bb.message(text) to send the thread a prompt. Guard with \`window.bb?.data?.…\` since capabilities are advisory. +In the page, use the injected window.bb SDK: window.bb.data.read({ path }), window.bb.data.write({ path, value }), window.bb.data.delete({ path }), window.bb.data.list({ prefix }), window.bb.data.onChange({ prefix, callback }) for live state, and window.bb.message.send({ payload }) to send the thread a prompt. -Scaffold with \`bb app new --name "Name"\` or \`bb app new --slug my-app\`; inside an app-capable runtime, inspect \`bb app current --json\` and write directly to \`BB_APP_ROOT\` / \`BB_APP_DATA_PATH\`. The application id is the lowercase slug folder name; display names are optional labels, not identifiers. +Scaffold with \`bb app new --name "Name"\` or \`bb app new --slug my-app\`; new apps open immediately from committed \`public/\`. Edit \`source/\`, rebuild to \`public/\`, and do not rely on a localhost dev server for the installed app. Inside an app-capable runtime, inspect \`bb app current --json\` and write directly to \`BB_APP_ROOT\` / \`BB_APP_DATA_PATH\`. The application id is the lowercase slug folder name; display names are optional labels, not identifiers. What I want: @@ -608,10 +611,15 @@ function RecentResultRow({ {name} - {label} + + {label} + {directory ? ( <> - + · diff --git a/apps/app/src/components/secondary-panel/NewTabPage.test.tsx b/apps/app/src/components/secondary-panel/NewTabPage.test.tsx index 36a8e67bc..de8887197 100644 --- a/apps/app/src/components/secondary-panel/NewTabPage.test.tsx +++ b/apps/app/src/components/secondary-panel/NewTabPage.test.tsx @@ -234,9 +234,9 @@ describe("NewTabPage", () => { ); expect(CREATE_APP_PROMPT_TEMPLATE).toContain("bb guide app"); expect(CREATE_APP_PROMPT_TEMPLATE).toContain("window.bb.data"); - expect(CREATE_APP_PROMPT_TEMPLATE).toContain( - "no web server, npm, or build step", - ); + expect(CREATE_APP_PROMPT_TEMPLATE).toContain("window.bb.message.send"); + expect(CREATE_APP_PROMPT_TEMPLATE).toContain("Vite + React + TypeScript"); + expect(CREATE_APP_PROMPT_TEMPLATE).toContain("pnpm build"); expect(CREATE_APP_PROMPT_TEMPLATE.endsWith("What I want:\n\n")).toBe(true); }); diff --git a/apps/app/src/components/thread/timeline/TimelineRowDetails.tsx b/apps/app/src/components/thread/timeline/TimelineRowDetails.tsx index 0c8339a0c..e4ac939fc 100644 --- a/apps/app/src/components/thread/timeline/TimelineRowDetails.tsx +++ b/apps/app/src/components/thread/timeline/TimelineRowDetails.tsx @@ -12,6 +12,7 @@ import { TimelineDetailScroll } from "./TimelineDetailScroll.js"; import { TimelineFileDiffBlock } from "./TimelineFileDiffBlock.js"; import { ToolCallDetailBlock } from "./ToolCallDetailBlock.js"; import { QuestionWorkRowBody } from "./QuestionWorkRowBody.js"; +import { WorkflowWorkRowBody } from "./WorkflowWorkRowBody.js"; import { buildThreadHostFileContentUrl } from "@/lib/file-content-urls"; import type { ThreadTimelineTheme } from "./types.js"; import type { ThreadTimelineImageViewSrcResolver } from "./types.js"; @@ -166,6 +167,8 @@ export function WorkRowBody({ return null; case "question": return ; + case "workflow": + return ; case "image-view": return ( + ); + case "failed": + return ( + + ); + case "skipped": + return ( + + ); + case "running": + return ( + + ); + case "queued": + case "interrupted": + return ( + + ); + } +} + +function formatCompactTokens(tokens: number): string { + if (tokens >= 1_000_000) { + return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`; + } + if (tokens >= 1_000) { + return `${(tokens / 1_000).toFixed(1).replace(/\.0$/, "")}k`; + } + return `${tokens}`; +} + +function formatCompactDuration(durationMs: number): string { + const totalSeconds = Math.max(0, Math.round(durationMs / 1_000)); + if (totalSeconds < 60) { + return `${totalSeconds}s`; + } + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}m${seconds.toString().padStart(2, "0")}s`; +} + +function shortModelName(model: string): string { + // "claude-haiku-4-5-20251001" → "haiku"; pass through aliases like "haiku". + const match = /^claude-([a-z]+)/.exec(model); + return match?.[1] ?? model; +} + +function buildAgentStats( + agent: WorkflowAgentSnapshot, + displayState: WorkflowAgentDisplayState, +): string { + const parts: string[] = []; + if (agent.agentType) { + parts.push(agent.agentType); + } + parts.push(shortModelName(agent.model)); + if (agent.tokens !== undefined && agent.tokens > 0) { + parts.push(`${formatCompactTokens(agent.tokens)} tok`); + } + if (agent.toolCalls !== undefined && agent.toolCalls > 0) { + parts.push( + `${agent.toolCalls} ${agent.toolCalls === 1 ? "tool" : "tools"}`, + ); + } + if (agent.durationMs !== undefined) { + parts.push(formatCompactDuration(agent.durationMs)); + } + if (agent.attempt > 1) { + parts.push(`attempt ${agent.attempt}`); + } + if (agent.cached) { + parts.push("cached"); + } + if (displayState === "queued") { + parts.push("queued"); + } + if (displayState === "interrupted") { + parts.push("stopped"); + } + return parts.join(" · "); +} + +function WorkflowAgentLine({ + agent, + workflowSettled, +}: { + agent: WorkflowAgentSnapshot; + workflowSettled: boolean; +}) { + const displayState = deriveAgentDisplayState(agent, workflowSettled); + return ( + + + + {agent.label} + + + {buildAgentStats(agent, displayState)} + + {displayState === "failed" && agent.error ? ( + + — {agent.error} + + ) : null} + + ); +} + +interface WorkflowPhaseGroup { + agents: WorkflowAgentSnapshot[]; + phase: WorkflowPhaseSnapshot | null; +} + +/** + * Groups agents under their phases in phase-index order, preserving declared + * phases that have not started yet and collecting phase-less agents into a + * trailing group. + */ +function groupAgentsByPhase( + phases: readonly WorkflowPhaseSnapshot[], + agents: readonly WorkflowAgentSnapshot[], +): WorkflowPhaseGroup[] { + const groups: WorkflowPhaseGroup[] = []; + const byIndex = new Map(); + for (const phase of phases) { + const group: WorkflowPhaseGroup = { phase, agents: [] }; + groups.push(group); + byIndex.set(phase.index, group); + } + const unphased: WorkflowAgentSnapshot[] = []; + for (const agent of agents) { + const group = + agent.phaseIndex !== undefined ? byIndex.get(agent.phaseIndex) : null; + if (group) { + group.agents.push(agent); + } else { + unphased.push(agent); + } + } + if (unphased.length > 0) { + groups.push({ phase: null, agents: unphased }); + } + return groups; +} + +function phaseProgressLabel(agents: readonly WorkflowAgentSnapshot[]): string { + if (agents.length === 0) { + return "not started"; + } + const settled = agents.filter((agent) => + isSettledWorkflowAgentState(agent.state), + ).length; + return `${settled}/${agents.length}`; +} + +export function WorkflowWorkRowBody({ + row, +}: { + row: TimelineViewWorkflowWorkRow; +}) { + const workflowSettled = row.status !== "pending"; + + if (!row.workflow) { + // Degraded body: no progress records — show the terminal summary or error. + if (!row.summary && !row.error) { + return null; + } + return ( + + {row.summary ?? row.error} + + ); + } + + const groups = groupAgentsByPhase(row.workflow.phases, row.workflow.agents); + // Sticky-bottom scroll keys off agent activity so live progress stays visible. + const contentKey = row.workflow.agents + .map((agent) => `${agent.index}:${agent.state}:${agent.lastProgressAt}`) + .join("|"); + + return ( + + + {groups.map((group) => ( + + {group.phase ? ( + + + {group.phase.title} + + + {phaseProgressLabel(group.agents)} + + + ) : null} + {group.agents.map((agent) => ( + + ))} + + ))} + {row.error ? ( + + {row.error} + + ) : null} + + + ); +} diff --git a/apps/app/src/components/thread/timeline/rows/Workflow.stories.tsx b/apps/app/src/components/thread/timeline/rows/Workflow.stories.tsx new file mode 100644 index 000000000..47a10e03a --- /dev/null +++ b/apps/app/src/components/thread/timeline/rows/Workflow.stories.tsx @@ -0,0 +1,277 @@ +import type { TimelineRow, TimelineWorkflowWorkRow } from "@bb/server-contract"; +import type { WorkflowProgressSnapshot } from "@bb/domain"; +import { ThreadTimelineRows } from "@/components/thread/timeline"; +import { StoryCard, StoryRow } from "../../../../../.ladle/story-card"; + +export default { + title: "thread/timeline/rows/Workflow", +}; + +function TimelineStage({ children }: { children: React.ReactNode }) { + return {children}; +} + +const baseProps = { + threadRuntimeDisplayStatus: "idle" as const, + workspaceRootPath: undefined, +}; + +// --------------------------------------------------------------------------- +// Real workflow run captured via the agent SDK (fixture-mini: two-phase +// workflow with three haiku agents — see +// packages/agent-runtime/src/__fixtures__/claude-code/sessions/workflow-mini.ndjson). +// The snapshots below are the folded workflow_progress state at three points +// in the run; the running/failed/interrupted variants synthesize the statuses +// the capture's happy path never hit. +// --------------------------------------------------------------------------- + +const runningSnapshot: WorkflowProgressSnapshot = { + phases: [ + { index: 1, title: "Scan" }, + { index: 2, title: "Summarize" }, + ], + agents: [ + { + index: 1, + label: "alpha", + state: "done", + model: "claude-haiku-4-5-20251001", + attempt: 1, + cached: false, + lastProgressAt: 1780540129098, + phaseIndex: 1, + phaseTitle: "Scan", + queuedAt: 1780540127739, + startedAt: 1780540127740, + promptPreview: "Reply with exactly the word alpha. Do not use any tools.", + tokens: 8886, + toolCalls: 0, + durationMs: 1358, + }, + { + index: 2, + label: "bravo", + state: "running", + model: "claude-haiku-4-5-20251001", + attempt: 1, + cached: false, + lastProgressAt: 1780540129378, + phaseIndex: 1, + phaseTitle: "Scan", + queuedAt: 1780540127739, + startedAt: 1780540127740, + promptPreview: "Reply with exactly the word bravo. Do not use any tools.", + tokens: 8887, + toolCalls: 0, + }, + { + index: 3, + label: "combine", + state: "queued", + model: "haiku", + attempt: 1, + cached: false, + lastProgressAt: 1780540129488, + phaseIndex: 2, + phaseTitle: "Summarize", + queuedAt: 1780540129488, + promptPreview: + 'Combine the words "alpha bravo" into one hyphenated token.', + }, + ], +}; + +const completedSnapshot: WorkflowProgressSnapshot = { + phases: runningSnapshot.phases, + agents: runningSnapshot.agents.map((agent) => ({ + ...agent, + state: "done" as const, + startedAt: agent.startedAt ?? agent.queuedAt, + tokens: agent.tokens ?? 8901, + toolCalls: 0, + durationMs: agent.durationMs ?? 1700, + })), +}; + +const failedSnapshot: WorkflowProgressSnapshot = { + phases: runningSnapshot.phases, + agents: [ + runningSnapshot.agents[0]!, + { + ...runningSnapshot.agents[1]!, + state: "failed", + error: "agent abandoned: user requested retry on all 3 attempts", + attempt: 3, + }, + { ...runningSnapshot.agents[2]!, state: "skipped" }, + ], +}; + +const workflowRowBase = { + threadId: "thr_fixture", + turnId: "turn-1", + sourceSeqStart: 2, + sourceSeqEnd: 9, + startedAt: 1780540127710, + createdAt: 1780540131011, + kind: "work" as const, + workKind: "workflow" as const, + itemId: "task:wu7ol9ras", + workflowName: "fixture-mini", + description: "Tiny fixture workflow for BB capture", + summary: null, + error: null, +}; + +const runningWorkflow: TimelineRow = { + ...workflowRowBase, + id: "thr_fixture:workflow:task:wu7ol9ras:running", + status: "pending", + taskStatus: "running", + workflow: runningSnapshot, + usage: { totalTokens: 17773, toolUses: 0, durationMs: 1772 }, + completedAt: null, +}; + +const completedWorkflow: TimelineRow = { + ...workflowRowBase, + id: "thr_fixture:workflow:task:wu7ol9ras:completed", + status: "completed", + taskStatus: "completed", + workflow: completedSnapshot, + usage: { totalTokens: 26674, toolUses: 0, durationMs: 3277 }, + summary: 'Dynamic workflow "Tiny fixture workflow for BB capture" completed', + completedAt: 1780540131011, +}; + +const failedWorkflow: TimelineRow = { + ...workflowRowBase, + id: "thr_fixture:workflow:task:wu7ol9ras:failed", + status: "error", + taskStatus: "failed", + workflow: failedSnapshot, + usage: { totalTokens: 21340, toolUses: 0, durationMs: 2810 }, + error: "agent abandoned: user requested retry on all 3 attempts", + completedAt: 1780540131011, +}; + +const interruptedWorkflow: TimelineRow = { + ...workflowRowBase, + id: "thr_fixture:workflow:task:wu7ol9ras:interrupted", + status: "interrupted", + taskStatus: "stopped", + // Agents 2 and 3 were still queued/running when the session died; the + // renderer derives their "stopped" display state from the settled row. + workflow: runningSnapshot, + usage: { totalTokens: 17773, toolUses: 0, durationMs: 1772 }, + completedAt: 1780540131011, +}; + +const degradedWorkflow: TimelineRow = { + ...workflowRowBase, + id: "thr_fixture:workflow:task:wu7ol9ras:degraded", + status: "completed", + taskStatus: "completed", + // No workflow_progress reported (older CLI): usage-only rendering. + workflow: null, + usage: { totalTokens: 26674, toolUses: 0, durationMs: 3277 }, + summary: 'Dynamic workflow "Tiny fixture workflow for BB capture" completed', + completedAt: 1780540131011, +}; + +const noPhasesWorkflow: TimelineRow = { + ...workflowRowBase, + id: "thr_fixture:workflow:task:wu7ol9ras:no-phases", + status: "pending", + taskStatus: "running", + workflow: { + phases: [], + agents: runningSnapshot.agents.map((agent) => { + const { phaseIndex, phaseTitle, ...rest } = agent; + return rest; + }), + }, + usage: { totalTokens: 17773, toolUses: 0, durationMs: 1772 }, + completedAt: null, +} satisfies TimelineWorkflowWorkRow; + +export function Overview() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/app/src/components/thread/timeline/timeline-auto-expand.test.ts b/apps/app/src/components/thread/timeline/timeline-auto-expand.test.ts index 0bffb64a3..b81c7d1fd 100644 --- a/apps/app/src/components/thread/timeline/timeline-auto-expand.test.ts +++ b/apps/app/src/components/thread/timeline/timeline-auto-expand.test.ts @@ -6,8 +6,32 @@ import { delegationRow, imageViewRow, systemRow, + workflowRow, } from "@/test/fixtures/thread-timeline-rows"; -import { collectTimelineAutoExpandedRowIds } from "./timeline-auto-expand"; +import { + collectTimelineAutoExpandedRowIds, + isWorkRowExpandable, +} from "./timeline-auto-expand"; + +describe("isWorkRowExpandable", () => { + it("marks an error-only degraded workflow row expandable so the error is reachable", () => { + // A workflow that fails before any workflow_progress arrives carries only + // an error: WorkflowWorkRowBody renders it, so the row must expand. + const row = workflowRow({ + error: "agent abandoned: user requested retry on all 3 attempts", + status: "error", + taskStatus: "failed", + }); + + expect(isWorkRowExpandable(row)).toBe(true); + }); + + it("keeps a degraded workflow row without workflow, summary, or error title-only", () => { + const row = workflowRow({ status: "pending", taskStatus: "running" }); + + expect(isWorkRowExpandable(row)).toBe(false); + }); +}); describe("collectTimelineAutoExpandedRowIds", () => { it("returns no auto-expanded ids when the scope is inactive", () => { diff --git a/apps/app/src/components/thread/timeline/timeline-auto-expand.ts b/apps/app/src/components/thread/timeline/timeline-auto-expand.ts index 9d208eba1..3f7090305 100644 --- a/apps/app/src/components/thread/timeline/timeline-auto-expand.ts +++ b/apps/app/src/components/thread/timeline/timeline-auto-expand.ts @@ -31,6 +31,13 @@ export function isWorkRowExpandable(row: TimelineViewWorkRow): boolean { return true; case "delegation": return row.childRows.length > 0 || row.output.trim().length > 0; + case "workflow": + // The phase/agent tree (or terminal summary/error) lives in the body; a + // degraded row with none of them stays title-only. Matches the + // body-collapse rule in WorkflowWorkRowBody. + return ( + row.workflow !== null || row.summary !== null || row.error !== null + ); default: return assertNever(row); } @@ -80,7 +87,12 @@ function shouldAutoExpandFrontierRow(row: ThreadTimelineViewRow): boolean { case "bundle-summary": return true; case "work": - return row.workKind === "delegation" || row.workKind === "image-view"; + return ( + row.workKind === "delegation" || + row.workKind === "image-view" || + // A running workflow auto-opens so live agent progress is visible. + (row.workKind === "workflow" && row.status === "pending") + ); case "conversation": case "step-summary": case "turn": diff --git a/apps/app/src/components/thread/timeline/timelineRowSignatures.ts b/apps/app/src/components/thread/timeline/timelineRowSignatures.ts index 0ed474e48..c90269989 100644 --- a/apps/app/src/components/thread/timeline/timelineRowSignatures.ts +++ b/apps/app/src/components/thread/timeline/timelineRowSignatures.ts @@ -161,6 +161,43 @@ function timelineWorkRowRenderSignature(row: TimelineViewWorkRow): string { row.completedAt, timelineRowsSignature(row.childRows), ]); + case "workflow": + return joinSignatureParts([ + ...baseParts, + row.itemId, + row.taskStatus, + row.workflowName, + row.description, + row.completedAt, + row.summary, + row.error, + row.usage?.totalTokens ?? null, + // Every progress-mutated agent field must break memo equality. + row.workflow + ? row.workflow.agents + .map((agent) => + joinSignatureParts([ + agent.index, + agent.label, + agent.state, + agent.attempt, + agent.tokens ?? null, + agent.toolCalls ?? null, + agent.durationMs ?? null, + agent.lastProgressAt, + agent.error ?? null, + ]), + ) + .join("\u001e") + : null, + row.workflow + ? row.workflow.phases + .map((phase) => + joinSignatureParts([phase.index, phase.title, phase.kind ?? null]), + ) + .join("\u001e") + : null, + ]); case "approval": return joinSignatureParts([ ...baseParts, diff --git a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts index 11039ecdb..19bb9d173 100644 --- a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts +++ b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts @@ -134,6 +134,8 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "allAppQueryKeyPrefix", "allAppsQueryKeyPrefix", "allSystemExecutionOptionsQueryKeyPrefix", + "appMarkdownPreviewQueryKeyPrefix", + "appQueryKey", "allThreadQueryKeyPrefix", "allThreadTerminalsQueryKeyPrefix", "environmentFilePreviewQueryKeyPrefix", diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts index 539986c36..ef94f1a93 100644 --- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts +++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts @@ -1,5 +1,6 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query"; import type { + AppChangeKind, EnvironmentChangeKind, HostChangeKind, ProjectChangeKind, @@ -26,6 +27,8 @@ import { allAppQueryKeyPrefix, allAppsQueryKeyPrefix, allSystemExecutionOptionsQueryKeyPrefix, + appMarkdownPreviewQueryKeyPrefix, + appQueryKey, allThreadQueryKeyPrefix, allThreadTerminalsQueryKeyPrefix, environmentFilePreviewQueryKeyPrefix, @@ -278,6 +281,15 @@ export const REALTIME_SYSTEM_CHANGE_REGISTRY = { }, } satisfies SystemChangeRegistry; +export const REALTIME_APP_CHANGE_REGISTRY = { + "apps-changed": { + dirty: [], // List-level invalidation rides system:apps-changed (the canonical path); handling it here too would double-invalidate. + }, + "content-changed": { + dirty: [dirtyAppContentQueries], // Served public/ files changed; reload just that app's open surfaces. + }, +} satisfies AppChangeRegistry; + export type ThreadChangeFlushPriority = "debounced" | "immediate"; export interface RealtimeDirtyContext { @@ -302,6 +314,10 @@ export interface ProjectRealtimeDirtyContext extends RealtimeDirtyContext { export type HostRealtimeDirtyContext = RealtimeDirtyContext; +export interface AppRealtimeDirtyContext extends RealtimeDirtyContext { + applicationId: string | undefined; +} + export type RealtimeDirtyHandler = ( context: Context, ) => readonly QueryKey[] | void; @@ -350,6 +366,12 @@ export interface SystemChangeRule { export type SystemChangeRegistry = Record; +export interface AppChangeRule { + dirty: readonly RealtimeDirtyHandler[]; +} + +export type AppChangeRegistry = Record; + export function executeRealtimeDirtyHandlers< Context extends RealtimeDirtyContext, >({ context, handlers }: ExecuteRealtimeDirtyHandlersArgs): void { @@ -613,3 +635,17 @@ function dirtyAppListQueries(): QueryKey[] { allAppMarkdownPreviewQueryKeyPrefix(), ]; } + +function dirtyAppContentQueries( + context: AppRealtimeDirtyContext, +): QueryKey[] { + if (context.applicationId === undefined) { + // Defensive: a content change without app identity falls back to the + // every-app scope so no open surface misses the reload. + return [allAppQueryKeyPrefix(), allAppMarkdownPreviewQueryKeyPrefix()]; + } + return [ + appQueryKey(context.applicationId), // Detail refetch bumps dataUpdatedAt, which busts the iframe reloadToken. + appMarkdownPreviewQueryKeyPrefix(context.applicationId), // Markdown entries re-render from the refetched content. + ]; +} diff --git a/apps/app/src/hooks/mutations/project-mutations.test.tsx b/apps/app/src/hooks/mutations/project-mutations.test.tsx index 59e20229d..d82e1860d 100644 --- a/apps/app/src/hooks/mutations/project-mutations.test.tsx +++ b/apps/app/src/hooks/mutations/project-mutations.test.tsx @@ -66,7 +66,6 @@ describe("project mutations", () => { serviceTier: "explicit", reasoningLevel: "explicit", }, - templateName: "default", environment: { type: "host", hostId: "host-1" }, input: [{ type: "text", text: "Start here" }], }); @@ -84,7 +83,6 @@ describe("project mutations", () => { serviceTier: "explicit", reasoningLevel: "explicit", }, - templateName: "default", environment: { type: "host", hostId: "host-1" }, input: [{ type: "text", text: "Start here" }], }); diff --git a/apps/app/src/hooks/mutations/project-mutations.ts b/apps/app/src/hooks/mutations/project-mutations.ts index 8cde5e12b..cd69c679f 100644 --- a/apps/app/src/hooks/mutations/project-mutations.ts +++ b/apps/app/src/hooks/mutations/project-mutations.ts @@ -54,7 +54,6 @@ export interface HireProjectManagerRequest { serviceTier?: ServiceTier; reasoningLevel?: ReasoningLevel; executionInputSources?: CreateManagerExecutionInputSources; - templateName?: string; environment: ManagerEnvironmentArgs; /** Optional user-provided first message; when empty the server uses * its welcome-message template instead. */ @@ -112,7 +111,6 @@ export function useHireProjectManager() { serviceTier, reasoningLevel, executionInputSources, - templateName, environment, input, }: HireProjectManagerRequest) => @@ -123,7 +121,6 @@ export function useHireProjectManager() { ...(serviceTier ? { serviceTier } : {}), ...(reasoningLevel ? { reasoningLevel } : {}), ...(executionInputSources ? { executionInputSources } : {}), - ...(templateName ? { templateName } : {}), environment, ...(input && input.length > 0 ? { input } : {}), }), diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index a186e7a94..e66c0a979 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -55,7 +55,6 @@ export const SYSTEM_CONFIG_QUERY_KEY = "systemConfig"; export const SYSTEM_EXECUTION_OPTIONS_QUERY_KEY = "systemExecutionOptions"; export const SYSTEM_VERSION_QUERY_KEY = "systemVersion"; export const LOCAL_PROVIDER_CLI_STATUS_QUERY_KEY = "localProviderCliStatus"; -export const MANAGER_TEMPLATES_QUERY_KEY = "managerTemplates"; export const LOCAL_PATH_EXISTENCE_QUERY_KEY = "localPathExistence"; export const REPLAY_CAPTURES_QUERY_KEY = "internalReplayCaptures"; export const CONVERSATION_MANAGER_TIMELINE_VIEW = @@ -242,7 +241,6 @@ export type AllAppsQueryKeyPrefix = readonly [typeof APPS_QUERY_KEY]; export type AppsQueryKey = readonly [typeof APPS_QUERY_KEY]; export type AllAppQueryKeyPrefix = readonly [typeof APP_QUERY_KEY]; export type AppQueryKey = readonly [typeof APP_QUERY_KEY, string]; -export type AppQueryKeyPrefix = readonly [typeof APP_QUERY_KEY]; export type AllAppMarkdownPreviewQueryKeyPrefix = readonly [ typeof APP_MARKDOWN_PREVIEW_QUERY_KEY, ]; @@ -253,6 +251,7 @@ export type AppMarkdownPreviewQueryKey = readonly [ ]; export type AppMarkdownPreviewQueryKeyPrefix = readonly [ typeof APP_MARKDOWN_PREVIEW_QUERY_KEY, + string, ]; export type ThreadHostFilePreviewQueryKey = readonly [ typeof THREAD_HOST_FILE_PREVIEW_QUERY_KEY, @@ -368,10 +367,6 @@ export type LocalProviderCliStatusQueryKey = readonly [ typeof LOCAL_PROVIDER_CLI_STATUS_QUERY_KEY, number | null, ]; -export type ManagerTemplatesQueryKey = readonly [ - typeof MANAGER_TEMPLATES_QUERY_KEY, - string | null, -]; export type SystemExecutionOptionsQueryKey = readonly [ typeof SYSTEM_EXECUTION_OPTIONS_QUERY_KEY, string | null, @@ -672,10 +667,6 @@ export function appQueryKey(applicationId: string): AppQueryKey { return [APP_QUERY_KEY, applicationId]; } -export function appQueryKeyPrefix(): AppQueryKeyPrefix { - return [APP_QUERY_KEY]; -} - export function allAppMarkdownPreviewQueryKeyPrefix(): AllAppMarkdownPreviewQueryKeyPrefix { return [APP_MARKDOWN_PREVIEW_QUERY_KEY]; } @@ -687,8 +678,10 @@ export function appMarkdownPreviewQueryKey( return [APP_MARKDOWN_PREVIEW_QUERY_KEY, applicationId, entryPath]; } -export function appMarkdownPreviewQueryKeyPrefix(): AppMarkdownPreviewQueryKeyPrefix { - return [APP_MARKDOWN_PREVIEW_QUERY_KEY]; +export function appMarkdownPreviewQueryKeyPrefix( + applicationId: string, +): AppMarkdownPreviewQueryKeyPrefix { + return [APP_MARKDOWN_PREVIEW_QUERY_KEY, applicationId]; } export function threadHostFilePreviewQueryKey( @@ -895,12 +888,6 @@ export function localProviderCliStatusQueryKey( return [LOCAL_PROVIDER_CLI_STATUS_QUERY_KEY, daemonPort]; } -export function managerTemplatesQueryKey( - hostId: string | null, -): ManagerTemplatesQueryKey { - return [MANAGER_TEMPLATES_QUERY_KEY, hostId]; -} - export interface SystemExecutionOptionsQueryKeyArgs { environmentId: string | null; providerId: string | null; diff --git a/apps/app/src/hooks/queries/system-queries.ts b/apps/app/src/hooks/queries/system-queries.ts index 12e476371..32fc81f6e 100644 --- a/apps/app/src/hooks/queries/system-queries.ts +++ b/apps/app/src/hooks/queries/system-queries.ts @@ -1,7 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import type { Host } from "@bb/domain"; import type { - ManagerTemplatesResponse, SystemConfigResponse, SystemExecutionOptionsResponse, SystemVersionResponse, @@ -14,7 +13,6 @@ import { hostQueryKey, hostsQueryKey, localProviderCliStatusQueryKey, - managerTemplatesQueryKey, systemConfigQueryKey, systemExecutionOptionsQueryKey, systemVersionQueryKey, @@ -95,21 +93,6 @@ export function useSystemConfig(options?: QueryOptions) { }); } -export interface UseManagerTemplatesArgs { - hostId?: string | null; - enabled?: boolean; -} - -export function useManagerTemplates(args: UseManagerTemplatesArgs = {}) { - const hostId = args.hostId ?? null; - return useQuery({ - queryKey: managerTemplatesQueryKey(hostId), - queryFn: () => api.listManagerTemplates(hostId ? { hostId } : {}), - enabled: args.enabled ?? true, - staleTime: 30_000, - }); -} - const SYSTEM_VERSION_STALE_TIME_MS = 60 * 60 * 1000; export function useSystemVersion(options?: QueryOptions) { diff --git a/apps/app/src/hooks/realtime-cache-effects.test.ts b/apps/app/src/hooks/realtime-cache-effects.test.ts index c9aea770b..a91ce20e5 100644 --- a/apps/app/src/hooks/realtime-cache-effects.test.ts +++ b/apps/app/src/hooks/realtime-cache-effects.test.ts @@ -10,6 +10,8 @@ import { import { createAppQueryClient } from "@/lib/query-client"; import { archivedThreadsListQueryKey, + appMarkdownPreviewQueryKey, + appQueryKey, appsQueryKey, environmentGitDiffQueryKey, environmentWorkStatusQueryKey, @@ -1019,6 +1021,171 @@ describe("createRealtimeCacheEffects", () => { effects.dispose(); }); + it("ignores app entity apps-changed — the SPA's app-list invalidation rides system:apps-changed", async () => { + const { effects, queryClient } = createRealtimeEffectsTestContext(); + const appsKey = appsQueryKey(); + queryClient.setQueryData(appsKey, []); + const appsQueryFn = vi.fn(async () => []); + const appsObserver = new QueryObserver(queryClient, { + queryKey: appsKey, + queryFn: appsQueryFn, + staleTime: Infinity, + }); + const unsubscribeApps = appsObserver.subscribe(() => {}); + appsQueryFn.mockClear(); + + effects.handleChanged({ + type: "changed", + entity: "app", + changes: ["apps-changed"], + }); + + await Promise.resolve(); + expect(appsQueryFn).not.toHaveBeenCalled(); + expect(queryClient.getQueryState(appsKey)?.isInvalidated).not.toBe(true); + + unsubscribeApps(); + effects.dispose(); + }); + + it("refetches only the changed app's detail and markdown preview queries for app content changes", async () => { + const { effects, queryClient } = createRealtimeEffectsTestContext(); + const appDetailKey = appQueryKey("my-app"); + const markdownPreviewKey = appMarkdownPreviewQueryKey("my-app", "index.md"); + const otherAppDetailKey = appQueryKey("other-app"); + const appsKey = appsQueryKey(); + queryClient.setQueryData(appDetailKey, {}); + queryClient.setQueryData(markdownPreviewKey, {}); + queryClient.setQueryData(otherAppDetailKey, {}); + queryClient.setQueryData(appsKey, []); + const appDetailQueryFn = vi.fn(async () => ({})); + const markdownPreviewQueryFn = vi.fn(async () => ({})); + const otherAppDetailQueryFn = vi.fn(async () => ({})); + const appsQueryFn = vi.fn(async () => []); + const appDetailObserver = new QueryObserver(queryClient, { + queryKey: appDetailKey, + queryFn: appDetailQueryFn, + staleTime: Infinity, + }); + const markdownPreviewObserver = new QueryObserver(queryClient, { + queryKey: markdownPreviewKey, + queryFn: markdownPreviewQueryFn, + staleTime: Infinity, + }); + const otherAppDetailObserver = new QueryObserver(queryClient, { + queryKey: otherAppDetailKey, + queryFn: otherAppDetailQueryFn, + staleTime: Infinity, + }); + const appsObserver = new QueryObserver(queryClient, { + queryKey: appsKey, + queryFn: appsQueryFn, + staleTime: Infinity, + }); + const unsubscribeAppDetail = appDetailObserver.subscribe(() => {}); + const unsubscribeMarkdownPreview = markdownPreviewObserver.subscribe( + () => {}, + ); + const unsubscribeOtherAppDetail = otherAppDetailObserver.subscribe( + () => {}, + ); + const unsubscribeApps = appsObserver.subscribe(() => {}); + appDetailQueryFn.mockClear(); + markdownPreviewQueryFn.mockClear(); + otherAppDetailQueryFn.mockClear(); + appsQueryFn.mockClear(); + + effects.handleChanged({ + type: "changed", + entity: "app", + id: "my-app", + changes: ["content-changed"], + }); + + await vi.waitFor(() => expect(appDetailQueryFn).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => + expect(markdownPreviewQueryFn).toHaveBeenCalledTimes(1), + ); + expect(otherAppDetailQueryFn).not.toHaveBeenCalled(); + expect( + queryClient.getQueryState(otherAppDetailKey)?.isInvalidated, + ).not.toBe(true); + expect(appsQueryFn).not.toHaveBeenCalled(); + expect(queryClient.getQueryState(appsKey)?.isInvalidated).not.toBe(true); + + unsubscribeAppDetail(); + unsubscribeMarkdownPreview(); + unsubscribeOtherAppDetail(); + unsubscribeApps(); + effects.dispose(); + }); + + it("falls back to refetching all mounted app detail queries for an app content change without id", async () => { + const { effects, queryClient } = createRealtimeEffectsTestContext(); + const appDetailKey = appQueryKey("my-app"); + const otherAppDetailKey = appQueryKey("other-app"); + queryClient.setQueryData(appDetailKey, {}); + queryClient.setQueryData(otherAppDetailKey, {}); + const appDetailQueryFn = vi.fn(async () => ({})); + const otherAppDetailQueryFn = vi.fn(async () => ({})); + const appDetailObserver = new QueryObserver(queryClient, { + queryKey: appDetailKey, + queryFn: appDetailQueryFn, + staleTime: Infinity, + }); + const otherAppDetailObserver = new QueryObserver(queryClient, { + queryKey: otherAppDetailKey, + queryFn: otherAppDetailQueryFn, + staleTime: Infinity, + }); + const unsubscribeAppDetail = appDetailObserver.subscribe(() => {}); + const unsubscribeOtherAppDetail = otherAppDetailObserver.subscribe( + () => {}, + ); + appDetailQueryFn.mockClear(); + otherAppDetailQueryFn.mockClear(); + + effects.handleChanged({ + type: "changed", + entity: "app", + changes: ["content-changed"], + }); + + await vi.waitFor(() => expect(appDetailQueryFn).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => + expect(otherAppDetailQueryFn).toHaveBeenCalledTimes(1), + ); + + unsubscribeAppDetail(); + unsubscribeOtherAppDetail(); + effects.dispose(); + }); + + it("refetches mounted app detail queries for system apps-changed — the HTML hot-reload chain depends on the detail refetch", async () => { + const { effects, queryClient } = createRealtimeEffectsTestContext(); + const appDetailKey = appQueryKey("my-app"); + queryClient.setQueryData(appDetailKey, {}); + const appDetailQueryFn = vi.fn(async () => ({})); + const appDetailObserver = new QueryObserver(queryClient, { + queryKey: appDetailKey, + queryFn: appDetailQueryFn, + staleTime: Infinity, + }); + const unsubscribeAppDetail = appDetailObserver.subscribe(() => {}); + appDetailQueryFn.mockClear(); + + effects.handleChanged({ + type: "changed", + entity: "system", + changes: ["apps-changed"], + }); + + await vi.waitFor(() => expect(appDetailQueryFn).toHaveBeenCalledTimes(1)); + + unsubscribeAppDetail(); + effects.dispose(); + }); + it("invalidates cached thread terminals for terminal changes", () => { vi.useFakeTimers(); const { effects, queryClient, terminalKey } = diff --git a/apps/app/src/hooks/realtime-cache-effects.ts b/apps/app/src/hooks/realtime-cache-effects.ts index 1a784fed7..2673110a5 100644 --- a/apps/app/src/hooks/realtime-cache-effects.ts +++ b/apps/app/src/hooks/realtime-cache-effects.ts @@ -16,6 +16,7 @@ import { createBufferedEnvironmentInvalidator } from "./buffered-environment-inv import { collectCachedThreadIdsForEnvironment, executeRealtimeDirtyHandlers, + REALTIME_APP_CHANGE_REGISTRY, REALTIME_ENVIRONMENT_CHANGE_REGISTRY, REALTIME_HOST_CHANGE_REGISTRY, REALTIME_PROJECT_CHANGE_REGISTRY, @@ -285,6 +286,17 @@ export function createRealtimeCacheEffects({ }); } break; + case "app": + for (const changeKind of message.changes) { + executeRealtimeDirtyHandlers({ + context: { + applicationId: message.id, + queryClient, + }, + handlers: REALTIME_APP_CHANGE_REGISTRY[changeKind].dirty, + }); + } + break; default: assertNever(message); } diff --git a/apps/app/src/hooks/useThreadCreationOptions.ts b/apps/app/src/hooks/useThreadCreationOptions.ts index 26890a704..be5d7954b 100644 --- a/apps/app/src/hooks/useThreadCreationOptions.ts +++ b/apps/app/src/hooks/useThreadCreationOptions.ts @@ -48,6 +48,7 @@ const REASONING_LABELS: Record = { medium: "Medium", high: "High", xhigh: "Extra High", + ultracode: "Ultracode", max: "Max", }; diff --git a/apps/app/src/hooks/useWebSocket.ts b/apps/app/src/hooks/useWebSocket.ts index d22d3f7cd..89afc78f4 100644 --- a/apps/app/src/hooks/useWebSocket.ts +++ b/apps/app/src/hooks/useWebSocket.ts @@ -30,6 +30,7 @@ export function useWebSocket(): void { wsManager.subscribe("environment"); wsManager.subscribe("host"); wsManager.subscribe("system"); + wsManager.subscribe("app"); return () => { cacheEffects.dispose(); @@ -40,6 +41,7 @@ export function useWebSocket(): void { wsManager.unsubscribe("environment"); wsManager.unsubscribe("host"); wsManager.unsubscribe("system"); + wsManager.unsubscribe("app"); }; // Route deletion handling is route-derived. Keep it behind a ref so // navigation cannot dispose cache effects and drop debounced invalidations. diff --git a/apps/app/src/lib/api.ts b/apps/app/src/lib/api.ts index 063ff3cf1..f08deb145 100644 --- a/apps/app/src/lib/api.ts +++ b/apps/app/src/lib/api.ts @@ -14,7 +14,6 @@ import type { import type { CreateManagerThreadRequest, CreateHostJoinResponse, - ManagerTemplatesResponse, CreateProjectSourceRequest, CreateProjectRequest, CreateQueuedMessageRequest, @@ -1395,20 +1394,6 @@ export async function listSystemProviders(): Promise { ); } -export interface ListManagerTemplatesArgs { - hostId?: string; -} - -export async function listManagerTemplates( - args: ListManagerTemplatesArgs = {}, -): Promise { - return request( - apiClient["manager-templates"].$get({ - query: args.hostId ? { hostId: args.hostId } : {}, - }), - ); -} - export async function getSystemVersion(): Promise { return request(apiClient.system.version.$get()); } diff --git a/apps/app/src/lib/ws.ts b/apps/app/src/lib/ws.ts index 4bfcfee15..89a898a7d 100644 --- a/apps/app/src/lib/ws.ts +++ b/apps/app/src/lib/ws.ts @@ -1,5 +1,8 @@ import ReconnectingWebSocket from "partysocket/ws"; -import { REALTIME_ENTITIES } from "@bb/server-contract"; +import { + changedMessageLenientSchema, + REALTIME_ENTITIES, +} from "@bb/server-contract"; import type { ClientMessage, ChangedMessage, @@ -64,11 +67,18 @@ export class WebSocketManager { this.socket.onmessage = (event: MessageEvent) => { if (typeof event.data !== "string") return; try { - const msg: unknown = JSON.parse(event.data); - if (isChangedMessage(msg)) { + // Lenient parse: tolerate a newer server (unknown fields stripped, + // unknown change kinds filtered) instead of dropping whole messages + // on additive contract changes. + const msg = changedMessageLenientSchema.safeParse( + JSON.parse(event.data), + ); + if (msg.success) { for (const cb of this.callbacks) { - cb(msg); + cb(msg.data); } + } else { + console.error("Ignored invalid realtime message", msg.error); } } catch { // Ignore malformed messages @@ -148,14 +158,6 @@ export class WebSocketManager { } } -function isChangedMessage(value: unknown): value is ChangedMessage { - if (typeof value !== "object" || value === null) return false; - if (!("type" in value) || !("entity" in value) || !("changes" in value)) - return false; - const record = value as Record; - return record.type === "changed"; -} - function subKey(entity: RealtimeEntity, id?: string): string { return id ? `${entity}:${id}` : entity; } diff --git a/apps/app/src/test/fixtures/thread-timeline-rows.ts b/apps/app/src/test/fixtures/thread-timeline-rows.ts index 609f18daa..045d74a00 100644 --- a/apps/app/src/test/fixtures/thread-timeline-rows.ts +++ b/apps/app/src/test/fixtures/thread-timeline-rows.ts @@ -23,6 +23,7 @@ import type { TimelineTurnRow, TimelineWebFetchWorkRow, TimelineWebSearchWorkRow, + TimelineWorkflowWorkRow, } from "@bb/server-contract"; import type { ThreadTurnInitiator } from "@bb/domain"; @@ -136,6 +137,24 @@ export interface ImageViewRowArgs { turnId?: string | null; } +export interface WorkflowRowArgs { + description?: string; + durationMs?: number | null; + error?: string | null; + id?: string; + itemId?: string; + seq?: number; + sourceSeqEnd?: number; + sourceSeqStart?: number; + status?: TimelineRowStatus; + summary?: string | null; + taskStatus?: TimelineWorkflowWorkRow["taskStatus"]; + turnId?: string | null; + usage?: TimelineWorkflowWorkRow["usage"]; + workflow?: TimelineWorkflowWorkRow["workflow"]; + workflowName?: string | null; +} + export interface ApprovalRowArgs { approvalKind?: TimelineApprovalWorkRow["approvalKind"]; id?: string; @@ -263,6 +282,7 @@ const DEFAULT_TURN_ROW_ID = "turn-summary-1"; const DEFAULT_WEB_FETCH_ID = "web-fetch-1"; const DEFAULT_WEB_SEARCH_ID = "web-search-1"; const DEFAULT_IMAGE_VIEW_ID = "image-view-1"; +const DEFAULT_WORKFLOW_ID = "workflow-1"; function rowSequence({ seq, sourceSeqStart }: RowSequenceArgs): number { return seq ?? sourceSeqStart ?? 1; @@ -628,6 +648,41 @@ export function imageViewRow({ }; } +export function workflowRow({ + description = "Fixture workflow", + durationMs = null, + error = null, + id = DEFAULT_WORKFLOW_ID, + itemId, + seq, + sourceSeqEnd, + sourceSeqStart, + status = "completed", + summary = null, + taskStatus = "completed", + turnId, + usage = null, + workflow = null, + workflowName = "fixture-workflow", +}: WorkflowRowArgs = {}): TimelineWorkflowWorkRow { + const base = baseRow({ id, seq, sourceSeqEnd, sourceSeqStart, turnId }); + return { + ...base, + kind: "work", + workKind: "workflow", + status, + itemId: itemId ?? id, + workflowName, + description, + taskStatus, + workflow, + usage, + summary, + error, + completedAt: completedAtFromDuration(base.startedAt, durationMs), + }; +} + export function approvalRow({ approvalKind = "permission-grant", id = "approval-1", diff --git a/apps/app/src/views/RootComposeView.test.tsx b/apps/app/src/views/RootComposeView.test.tsx index 9c6c8f3cb..b1f818eae 100644 --- a/apps/app/src/views/RootComposeView.test.tsx +++ b/apps/app/src/views/RootComposeView.test.tsx @@ -16,7 +16,6 @@ import { type ThreadWithRuntime, } from "@bb/domain"; import type { - ManagerTemplatesResponse, ProjectWithThreadsResponse, SidebarBootstrapResponse, SystemConfigResponse, @@ -101,11 +100,6 @@ const systemExecutionOptions = { modelLoadError: null, } satisfies SystemExecutionOptionsResponse; -const managerTemplates = { - templates: [], - activeName: "default", -} satisfies ManagerTemplatesResponse; - const systemConfig = { featureFlags: { placeholder: false }, hostDaemonPort: null, @@ -232,10 +226,6 @@ function installRootComposeFetchRoutes( pathname: "/api/v1/hosts", handler: () => jsonResponse([localHost]), }, - { - pathname: "/api/v1/manager-templates", - handler: () => jsonResponse(managerTemplates), - }, { pathname: "/api/v1/system/execution-options", handler: () => jsonResponse(systemExecutionOptions), diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index 8dfa771f2..014222daa 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -33,7 +33,6 @@ import { stripProjectThreads, } from "@/hooks/queries/project-queries"; import { useEffectiveHosts } from "@/hooks/queries/effective-hosts"; -import { useManagerTemplates } from "@/hooks/queries/system-queries"; import { useThreads } from "@/hooks/queries/thread-queries"; import { useHostDaemon } from "@/hooks/useHostDaemon"; import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage"; @@ -214,13 +213,6 @@ export function RootComposeView() { const { isLocalHost, localHostId } = useHostDaemon(); const hostsQuery = useEffectiveHosts(); const hosts = useMemo(() => hostsQuery.data ?? [], [hostsQuery.data]); - const managerTemplatesQuery = useManagerTemplates(); - const managerTemplates = useMemo( - () => managerTemplatesQuery.data?.templates ?? [], - [managerTemplatesQuery.data?.templates], - ); - const managerTemplateActiveName = - managerTemplatesQuery.data?.activeName ?? null; const uploadPromptAttachment = useUploadPromptAttachment(); const promptDraft = usePromptDraftStorage({ projectId, threadId: null }); const { data: projectPromptHistory = [] } = @@ -234,13 +226,10 @@ export function RootComposeView() { }, ); const [attachmentError, setAttachmentError] = useState(null); - // Manager-mode selections. Held as raw user choices; the effective values - // resolved against the loaded hosts / templates are computed below so a - // stale selection (host disconnects, template removed) falls back to a - // safe default without an effect. + // Manager-mode host selection. Held as a raw user choice; the effective + // value resolved against the loaded hosts is computed below so a stale + // selection (host disconnects) falls back to a safe default without an effect. const [managerHostSelection, setManagerHostSelection] = useState(""); - const [managerTemplateSelection, setManagerTemplateSelection] = - useState(""); const prompt = promptDraft.text; const promptInput = useMemo( () => @@ -406,26 +395,6 @@ export function RootComposeView() { ); }, [eligibleProjectlessThreadHosts, isLocalHost, localHostId]); - const defaultManagerTemplateName = useMemo(() => { - if ( - managerTemplateActiveName !== null && - managerTemplates.some( - (template) => template.name === managerTemplateActiveName, - ) - ) { - return managerTemplateActiveName; - } - return managerTemplates[0]?.name ?? ""; - }, [managerTemplateActiveName, managerTemplates]); - const effectiveManagerTemplateName = useMemo(() => { - const isKnown = managerTemplates.some( - (template) => template.name === managerTemplateSelection, - ); - return managerTemplateSelection && isKnown - ? managerTemplateSelection - : defaultManagerTemplateName; - }, [defaultManagerTemplateName, managerTemplateSelection, managerTemplates]); - // Projectless threads choose a host directly, not an environment mode. Keep // the underlying persisted value host-shaped for the create-thread contract, // but discard reuse/worktree mode when resolving the effective value. @@ -663,9 +632,7 @@ export function RootComposeView() { if (mode === "manager") { // Managers don't require a prompt — submitting with empty text just // falls back to the server's welcome-message template. Host comes - // from the manager-mode host picker; template comes from the - // template picker (only sent when non-empty so the server keeps its - // own default). + // from the manager-mode host picker. if ( hireProjectManager.isPending || managerDefaultExecutionOptionsQuery.isLoading || @@ -682,9 +649,6 @@ export function RootComposeView() { reasoningLevel, executionInputSources: managerExecutionInputSources, environment: { type: "host", hostId: effectiveManagerHostId }, - ...(effectiveManagerTemplateName - ? { templateName: effectiveManagerTemplateName } - : {}), ...(submittedInput.length > 0 ? { input: submittedInput } : {}), }); promptDraft.clearIfCurrentMatches(submittedDraft); @@ -733,7 +697,6 @@ export function RootComposeView() { }, [ createThread, effectiveManagerHostId, - effectiveManagerTemplateName, hireProjectManager, managerExecutionInputSources, managerDefaultExecutionOptionsQuery.isLoading, @@ -1065,15 +1028,6 @@ export function RootComposeView() { onChange: setManagerHostSelection, isLocalHost, }, - ...(managerTemplates.length > 0 - ? { - template: { - templates: managerTemplates, - value: effectiveManagerTemplateName, - onChange: setManagerTemplateSelection, - }, - } - : {}), } : { mode: "thread", diff --git a/apps/app/src/views/thread-detail/ThreadDetailHeader.test.tsx b/apps/app/src/views/thread-detail/ThreadDetailHeader.test.tsx index d6f378dcd..c5e379aa7 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailHeader.test.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailHeader.test.tsx @@ -1,7 +1,11 @@ // @vitest-environment jsdom +import type { ReactNode } from "react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { BbDesktopApi, BbDesktopInfo } from "@bb/server-contract"; +import { MACOS_WINDOW_NO_DRAG_CLASS } from "@/lib/bb-desktop"; +import { createNoopDesktopBrowserApi } from "@/test/bb-desktop-test-utils"; import { ThreadDetailHeader } from "./ThreadDetailHeader"; vi.mock("@/components/ui/hooks/use-compact-viewport.js", () => ({ @@ -14,6 +18,7 @@ vi.mock("@/components/ui/sidebar.js", () => ({ })); interface RenderHeaderOverrides { + actionsMenu?: ReactNode; isSecondaryPanelOpen?: boolean; onToggleSecondaryPanel?: () => void; } @@ -21,7 +26,7 @@ interface RenderHeaderOverrides { function renderHeader(overrides: RenderHeaderOverrides = {}) { const noop = () => {}; const props = { - actionsMenu: null, + actionsMenu: overrides.actionsMenu ?? null, activeTerminalCount: 0, isManagedThread: false, isManagerThread: false, @@ -37,8 +42,66 @@ function renderHeader(overrides: RenderHeaderOverrides = {}) { return render(); } +function installMacosDesktopChrome(): void { + const info: BbDesktopInfo = { + lastCheckedAt: null, + latestVersion: null, + pendingVersion: null, + platform: "macos", + updateAvailable: false, + updateDownloaded: false, + version: "0.0.1", + }; + const desktop: BbDesktopApi = { + ...info, + browser: createNoopDesktopBrowserApi(), + async checkForUpdates() { + return info; + }, + async getInfo() { + return info; + }, + async installUpdate() { + return undefined; + }, + onChange() { + return () => undefined; + }, + setTheme() {}, + }; + window.bbDesktop = desktop; +} + afterEach(() => { cleanup(); + delete window.bbDesktop; +}); + +describe("ThreadDetailHeader actions menu drag region", () => { + // The header center slot is a macOS title-bar drag region; without a no-drag + // exemption the actions-menu trigger's clicks are swallowed as window drags. + it("exempts the actions menu from window dragging under desktop chrome", () => { + installMacosDesktopChrome(); + renderHeader({ + actionsMenu: Thread actions, + }); + + const wrapper = screen.getByTestId("thread-detail-header-actions-menu"); + expect(wrapper.className).toContain(MACOS_WINDOW_NO_DRAG_CLASS); + expect( + screen.getByRole("button", { name: "Thread actions" }).parentElement, + ).toBe(wrapper); + }); + + it("keeps the desktop-only no-drag classes off the web build", () => { + renderHeader({ + actionsMenu: Thread actions, + }); + + const wrapper = screen.getByTestId("thread-detail-header-actions-menu"); + expect(wrapper.className).not.toContain("app-region"); + expect(wrapper.className).not.toContain("z-50"); + }); }); describe("ThreadDetailHeader panel toggle", () => { diff --git a/apps/app/src/views/thread-detail/ThreadDetailHeader.tsx b/apps/app/src/views/thread-detail/ThreadDetailHeader.tsx index 95289bd4c..7bb818731 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailHeader.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailHeader.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { Button } from "@/components/ui/button.js"; import { COARSE_POINTER_TOOLBAR_ACTION_BUTTON_CLASS } from "@/components/ui/coarse-pointer-sizing.js"; import { Icon } from "@/components/ui/icon.js"; @@ -11,6 +11,12 @@ import { } from "@/components/layout/AppPageHeader"; import { resolveShowPanelControl } from "@/components/secondary-panel/panelToggleControlState"; import type { ThreadGitActionDialogTarget } from "@/components/dialogs/ThreadGitActionDialog"; +import { + getBbDesktopInfo, + MACOS_WINDOW_NO_DRAG_CLASS, + shouldUseMacosDesktopChrome, +} from "@/lib/bb-desktop"; +import { cn } from "@/lib/utils"; const THREAD_HEADER_ACTION_BUTTON_CLASS = COARSE_POINTER_TOOLBAR_ACTION_BUTTON_CLASS; @@ -53,6 +59,8 @@ export function ThreadDetailHeader({ }: ThreadDetailHeaderProps) { const [primaryAction, ...secondaryActions] = threadHeaderGitActions; const renderAsDrawer = useIsCompactViewport(); + const [desktopInfo] = useState(getBbDesktopInfo); + const usesDesktopChrome = shouldUseMacosDesktopChrome(desktopInfo); // On a wide viewport the conversation header only owns the panel-CLOSED // affordance: a button that opens the secondary panel (read as "open the @@ -69,7 +77,25 @@ export function ThreadDetailHeader({ {!isManagerThread && isManagedThread ? ( managed ) : null} - {actionsMenu} + {/* + The header's center slot sits inside the macOS title-bar drag region + (AppPageHeader only exempts the actions slot), so the interactive + actions menu must opt out of dragging or its clicks are swallowed as + window drags. Gated on desktop chrome like every other no-drag site — + the class also carries `relative z-50`, which must not leak into the + web build. + */} + {actionsMenu == null ? null : ( + + {actionsMenu} + + )} > ); diff --git a/apps/cli/package.json b/apps/cli/package.json index 24dd64e84..c115b4246 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -17,7 +17,7 @@ "@bb/config": "workspace:*", "@bb/core-ui": "workspace:*", "@bb/domain": "workspace:*", - "@bb/host-daemon-contract": "workspace:*", + "@bb/sdk": "workspace:*", "@bb/server-contract": "workspace:*", "@bb/templates": "workspace:*", "@bb/thread-view": "workspace:*", diff --git a/apps/cli/src/__tests__/command-output.test.ts b/apps/cli/src/__tests__/command-output.test.ts index 9c43449c1..4197265c5 100644 --- a/apps/cli/src/__tests__/command-output.test.ts +++ b/apps/cli/src/__tests__/command-output.test.ts @@ -10,26 +10,61 @@ import { type Thread, type ThreadGitDiffResponse, } from "@bb/domain"; -import type { - EnvironmentDiffResponse, - ThreadTimelineResponse, - TimelineRow, - TimelineRowBase, - TimelineUserConversationRow, +import { + createApiClient, + type ApiClient, + type EnvironmentDiffResponse, + type ThreadTimelineResponse, + type TimelineRow, + type TimelineRowBase, + type TimelineUserConversationRow, } from "@bb/server-contract"; +import type { BbSdkContext } from "@bb/sdk"; const readlineState = vi.hoisted(() => ({ question: vi.fn(), close: vi.fn(), })); -vi.mock("../client.js", () => { - return { - createClient: vi.fn(), - unwrap: vi.fn(async (responsePromise: Promise) => { - return responsePromise; - }), - }; +// Tests stub the server at the hono-client level: each test registers a +// partial `api` tree whose methods resolve to parsed response bodies (or real +// `Response` objects for raw routes). +const serverClientState = vi.hoisted(() => ({ + createClient: vi.fn(), +})); + +vi.mock("../client.js", async () => { + const { createBbSdk } = + await vi.importActual("@bb/sdk/core"); + const { createHttpTransport } = + await vi.importActual("@bb/sdk/node"); + // Stubbed api methods may resolve to parsed bodies directly; wrap those in + // real 200 Responses so every read runs through the production transport + // semantics (error mapping included) instead of a test re-implementation. + const toResponse = (resolved: MockTransportResolved): Response => + resolved instanceof Response + ? resolved + : new Response(JSON.stringify(resolved), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + const createCliBbSdk = vi.fn( + (baseUrl: string, options: MockCliBbSdkOptions = {}) => { + const realTransport = createHttpTransport({ baseUrl, runtime: "node" }); + return createBbSdk({ + context: options.context, + transport: { + ...realTransport, + api: serverClientState.createClient(baseUrl)?.api ?? {}, + readJson: (responsePromise: MockTransportPromise) => + realTransport.readJson(responsePromise.then(toResponse)), + readVoid: (responsePromise: MockTransportPromise) => + realTransport.readVoid(responsePromise.then(toResponse)), + }, + }); + }, + ); + return { createCliBbSdk }; }); vi.mock("node:readline/promises", () => ({ @@ -43,7 +78,6 @@ vi.mock("../daemon.js", () => ({ fetchLocalHostId: vi.fn(async () => "host-test-001"), })); -import { createClient, unwrap } from "../client.js"; import { fetchLocalHostId } from "../daemon.js"; import { registerAppCommands } from "../commands/app.js"; import { registerEnvironmentCommands } from "../commands/environment.js"; @@ -55,7 +89,25 @@ import { registerProviderCommands } from "../commands/provider.js"; import { registerStatusCommand } from "../commands/status.js"; import { registerThreadCommands } from "../commands/thread/index.js"; -type ServerClient = ReturnType; +type ServerClient = ApiClient; +type MockTransportResolved = + | Response + | object + | string + | number + | boolean + | null + | undefined; +type MockTransportPromise = Promise; +type ConsoleLogArgs = Parameters; + +interface ServerClientOverride { + api: object; +} + +interface MockCliBbSdkOptions { + context?: BbSdkContext; +} interface TimelineBaseArgs { id: string; @@ -310,16 +362,16 @@ function makePermissionGrantApprovalPayload( }; } -function asServerClient(value: unknown): ServerClient { - return value as ServerClient; +function asServerClient(value: ServerClientOverride): ServerClient { + return Object.assign(createApiClient("http://server"), value); } function collectLogLines(logSpy: ReturnType): string[] { - return logSpy.mock.calls.map((args: unknown[]) => args.join(" ")); + return logSpy.mock.calls.map((args: ConsoleLogArgs) => args.join(" ")); } function collectLogPayloads(logSpy: ReturnType): string[] { - return logSpy.mock.calls.map((args: unknown[]) => String(args[0] ?? "")); + return logSpy.mock.calls.map((args: ConsoleLogArgs) => String(args[0] ?? "")); } async function runCommand( @@ -356,8 +408,7 @@ async function getHelpOutput( } describe("CLI command output contracts", () => { - const createClientMock = vi.mocked(createClient); - const unwrapMock = vi.mocked(unwrap); + const createClientMock = serverClientState.createClient; const fetchLocalHostIdMock = vi.mocked(fetchLocalHostId); beforeEach(() => { @@ -377,10 +428,6 @@ describe("CLI command output contracts", () => { ); createClientMock.mockReset(); - unwrapMock.mockReset(); - unwrapMock.mockImplementation(async (responsePromise: Promise) => { - return responsePromise; - }); fetchLocalHostIdMock.mockClear(); fetchLocalHostIdMock.mockResolvedValue("host-test-001"); Object.defineProperty(process.stdin, "isTTY", { @@ -424,21 +471,6 @@ describe("CLI command output contracts", () => { expect(output).toContain("No more than 20 schedules."); }); - it("bb guide manager-templates prints the manager template chapter", async () => { - await runCommand(["guide", "manager-templates"], registerGuideCommand); - - const output = collectLogPayloads(vi.mocked(console.log)).join("\n"); - expect(output.trim().length).toBeGreaterThan(0); - expect(output).toContain("Manager templates"); - expect(output).toContain("/manager-templates/"); - expect(output).toContain("$BB_DATA_DIR/manager-templates/"); - expect(output).toContain('DATA_DIR="${BB_DATA_DIR:-$HOME/.bb}"'); - expect(output).not.toContain("~/.bb/manager-templates"); - expect(output).not.toContain("~/.bb-dev/manager-templates"); - expect(output).toContain("bb manager hire --template sawyer-next"); - expect(output).toContain("recursively copies every regular file"); - }); - it("bb guide app prints the app chapter", async () => { await runCommand(["guide", "app"], registerGuideCommand); @@ -447,8 +479,11 @@ describe("CLI command output contracts", () => { expect(output).toContain("Apps"); expect(output).toContain("/apps//"); expect(output).toContain("window.bb.data"); + expect(output).toContain("window.bb.message.send"); expect(output).toContain("bb app current --json"); - expect(output).toContain("Do not start a web server"); + expect(output).toContain("Vite + React + TypeScript Todo app"); + expect(output).toContain("pnpm build"); + expect(output).toContain("skills/add-todos/SKILL.md"); }); it("bb guide unknown chapter lists styling in available chapters", async () => { @@ -459,7 +494,7 @@ describe("CLI command output contracts", () => { const errorOutput = collectLogLines(vi.mocked(console.error)).join("\n"); expect(errorOutput).toContain("Unknown guide chapter 'missing'"); expect(errorOutput).toContain( - "Available: threads, environments, managers, manager-templates, app, providers, projects, hosts, styling, async.", + "Available: threads, environments, managers, app, providers, projects, hosts, styling, async.", ); }); @@ -725,8 +760,6 @@ describe("CLI command output contracts", () => { "claude-code", "--model", "claude-opus-4-7", - "--template", - "minimal", "--service-tier", "fast", "--reasoning-level", @@ -745,7 +778,6 @@ describe("CLI command output contracts", () => { providerId: "claude-code", reasoningLevel: "high", serviceTier: "fast", - templateName: "minimal", }, }); expect(collectLogLines(vi.mocked(console.log))).toContain( @@ -998,7 +1030,6 @@ describe("CLI command output contracts", () => { expect(helpOutput).not.toContain("--permission-mode "); expect(helpOutput).toContain("--service-tier "); - expect(helpOutput).toContain("--template "); expect(helpOutput).toMatch( /remembered manager defaults or the server\s+manager policy/, ); @@ -1294,6 +1325,55 @@ describe("CLI command output contracts", () => { ]); }); + it("bb app data read reports a missing data path for an existing app", async () => { + vi.mocked(globalThis.fetch).mockImplementation( + async () => + new Response( + JSON.stringify({ + code: "ENOENT", + message: "App data not found: state.json", + }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + + await expect( + runCommand(["app", "data", "read", "status", "state.json"], (program) => + registerAppCommands(program, () => "http://server"), + ), + ).rejects.toThrow("process.exit:1"); + + expect(collectLogLines(vi.mocked(console.error))).toContain( + "Error: App data path not found: state.json", + ); + }); + + it("bb app data read surfaces the server error for a missing app", async () => { + vi.mocked(globalThis.fetch).mockImplementation( + async () => + new Response( + JSON.stringify({ code: "app_missing", message: "App not found" }), + { + status: 404, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + + await expect( + runCommand(["app", "data", "read", "ghost", "state.json"], (program) => + registerAppCommands(program, () => "http://server"), + ), + ).rejects.toThrow("process.exit:1"); + + expect(collectLogLines(vi.mocked(console.error))).toContain( + "Error: HTTP 404: App not found", + ); + }); + it("bb manager status includes managed child threads", async () => { const managerThread: Thread = makeThread({ id: "thread-manager-1", @@ -3273,8 +3353,7 @@ describe("CLI command output contracts", () => { }); describe("CLI JSON output contracts", () => { - const createClientMock = vi.mocked(createClient); - const unwrapMock = vi.mocked(unwrap); + const createClientMock = serverClientState.createClient; beforeEach(() => { vi.spyOn(console, "log").mockImplementation(() => {}); @@ -3286,10 +3365,6 @@ describe("CLI JSON output contracts", () => { ); createClientMock.mockReset(); - unwrapMock.mockReset(); - unwrapMock.mockImplementation(async (responsePromise: Promise) => { - return responsePromise; - }); vi.stubEnv("BB_PROJECT_ID", undefined); vi.stubEnv("BB_THREAD_ID", undefined); diff --git a/apps/cli/src/__tests__/context-env.test.ts b/apps/cli/src/__tests__/context-env.test.ts index d02933d68..d612dba55 100644 --- a/apps/cli/src/__tests__/context-env.test.ts +++ b/apps/cli/src/__tests__/context-env.test.ts @@ -5,7 +5,6 @@ import { requireThreadId, requireThreadIdWithLabelOrSelf, resolveContextSnapshot, - resolveHostDaemonUrl, resolveProjectId, resolveServerUrl, resolveThreadId, @@ -72,7 +71,6 @@ describe("context-env", () => { }); expect(resolveServerUrl(context)).toBe("http://server.test"); - expect(resolveHostDaemonUrl(context)).toBe("http://127.0.0.1:4567"); expect(resolveContextSnapshot(context).serverUrl).toBe( "http://server.test", ); diff --git a/apps/cli/src/client.ts b/apps/cli/src/client.ts index bf8f44ade..d97d627c8 100644 --- a/apps/cli/src/client.ts +++ b/apps/cli/src/client.ts @@ -1,297 +1,16 @@ -import { createApiClient, type ApiClient } from "@bb/server-contract"; -import { extractErrorMessage } from "@bb/core-ui"; +import { + createNodeBbSdk, + type BbSdk, + type BbSdkContext, +} from "@bb/sdk/node"; -// Total timeout from request start through response body consumption. Keep this -// above the server's 60s long-poll cap so server timeouts win that race. -export const DEFAULT_CLI_REQUEST_TIMEOUT_MS = 75_000; - -export type FetchImplementation = typeof fetch; - -export interface CliRequestTimeoutFetchOptions { - timeoutMs: number; -} - -interface CliRequestTimeoutContext { - requestSignal: AbortSignal; - timeoutSignal: AbortSignal; - timeoutMs: number; -} - -type ResponseBodyReader = () => Promise; - -interface ReadResponseBodyWithTimeoutMappingArgs { - context: CliRequestTimeoutContext; - read: ResponseBodyReader; -} - -interface WrapCliRequestTimeoutResponseArgs { - context: CliRequestTimeoutContext; - response: Response; -} - -interface WrapCliRequestTimeoutBodyArgs { - context: CliRequestTimeoutContext; - stream: ReadableStream; -} - -const RESPONSE_BODY_READER_METHODS = new Set([ - "arrayBuffer", - "blob", - "bytes", - "formData", - "json", - "text", -]); - -function formatCliRequestTimeoutDuration(timeoutMs: number): string { - const seconds = timeoutMs / 1000; - if (!Number.isInteger(seconds)) { - return `${timeoutMs} ms`; - } - return seconds === 1 ? "1 second" : `${seconds} seconds`; -} - -class CliRequestTimeoutError extends Error { - constructor(timeoutMs: number) { - super( - `BB request timed out after ${formatCliRequestTimeoutDuration( - timeoutMs, - )}.`, - ); - this.name = "CliRequestTimeoutError"; - } -} - -export function createClient(baseUrl: string): ApiClient { - return createApiClient(baseUrl, { - fetch: createCliRequestTimeoutFetch({ - timeoutMs: DEFAULT_CLI_REQUEST_TIMEOUT_MS, - }), - }); -} - -export type Client = ReturnType; - -export function createCliRequestTimeoutFetch( - options: CliRequestTimeoutFetchOptions, -): FetchImplementation { - validateCliRequestTimeoutMs(options.timeoutMs); - - return async (input, init) => { - const timeoutSignal = AbortSignal.timeout(options.timeoutMs); - const requestSignal = init?.signal - ? AbortSignal.any([init.signal, timeoutSignal]) - : timeoutSignal; - const context: CliRequestTimeoutContext = { - requestSignal, - timeoutSignal, - timeoutMs: options.timeoutMs, - }; - - try { - const response = await fetch(input, { ...init, signal: requestSignal }); - return wrapCliRequestTimeoutResponse({ context, response }); - } catch (err) { - if (isCliRequestTimeoutError(context, err)) { - throw new CliRequestTimeoutError(options.timeoutMs); - } - throw err; - } - }; -} - -async function readResponseBodyWithTimeoutMapping( - args: ReadResponseBodyWithTimeoutMappingArgs, -): Promise { - try { - return await args.read(); - } catch (err) { - if (isCliRequestTimeoutError(args.context, err)) { - throw new CliRequestTimeoutError(args.context.timeoutMs); - } - throw err; - } -} - -function wrapCliRequestTimeoutResponse( - args: WrapCliRequestTimeoutResponseArgs, -): Response { - const { context, response } = args; - let body: ReadableStream | null | undefined; - - return new Proxy(response, { - get(target, property) { - if (RESPONSE_BODY_READER_METHODS.has(property)) { - const read = Reflect.get(target, property, target); - if (typeof read === "function") { - return () => - readResponseBodyWithTimeoutMapping({ - context, - read: read.bind(target), - }); - } - } - - switch (property) { - case "body": - if (target.body === null) { - return null; - } - body ??= wrapCliRequestTimeoutBody({ - context, - stream: target.body, - }); - return body; - case "clone": - return () => - wrapCliRequestTimeoutResponse({ - context, - response: target.clone(), - }); - default: { - const value = Reflect.get(target, property, target); - return typeof value === "function" ? value.bind(target) : value; - } - } - }, - }); -} - -function wrapCliRequestTimeoutBody( - args: WrapCliRequestTimeoutBodyArgs, -): ReadableStream { - let reader: ReadableStreamDefaultReader | null = null; - const getReader = () => { - reader ??= args.stream.getReader(); - return reader; - }; - - return new ReadableStream({ - async pull(controller) { - try { - const result = await getReader().read(); - if (result.done) { - controller.close(); - return; - } - controller.enqueue(result.value); - } catch (err) { - if (isCliRequestTimeoutError(args.context, err)) { - controller.error(new CliRequestTimeoutError(args.context.timeoutMs)); - return; - } - controller.error(err); - } - }, - cancel(reason) { - return getReader().cancel(reason); - }, - }); -} - -function isCliRequestTimeoutError( - context: CliRequestTimeoutContext, - err: unknown, -): boolean { - // Some paths reject with the timeout reason directly; others wrap it as a - // platform AbortError/TimeoutError while preserving the composed reason. - if (context.timeoutSignal.aborted && err === context.timeoutSignal.reason) { - return true; - } - - return ( - context.timeoutSignal.aborted && - context.requestSignal.reason === context.timeoutSignal.reason && - err instanceof Error && - (err.name === "AbortError" || err.name === "TimeoutError") - ); -} - -function validateCliRequestTimeoutMs(timeoutMs: number): void { - // timeoutMs=0 is an effectively immediate abort knob for tests and callers. - if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { - throw new RangeError( - "CLI request timeout must be a non-negative finite number.", - ); - } -} - -function isTypeErrorWithCauseCode(err: unknown, expectedCode: string): boolean { - if (!(err instanceof TypeError)) { - return false; - } - const { cause } = err as Error & { cause?: unknown }; - if (!cause || typeof cause !== "object") { - return false; - } - return "code" in cause && cause.code === expectedCode; -} - -const ERROR_EXTRACT_OPTS = { legacyKeys: ["detail"] as const }; - -async function readHttpErrorMessage(res: Response): Promise { - let rawBody: string; - try { - rawBody = await res.text(); - } catch (err) { - if (err instanceof CliRequestTimeoutError) { - throw err; - } - rawBody = ""; - } - const normalized = rawBody.replace(/\s+/g, " ").trim(); - if (normalized.length === 0) { - return res.statusText; - } - - const contentType = res.headers.get("content-type"); - const shouldParseJson = - (contentType?.includes("application/json") ?? false) || - normalized.startsWith("{") || - normalized.startsWith("["); - if (!shouldParseJson) { - return normalized; - } - - try { - const parsed = JSON.parse(normalized) as unknown; - return extractErrorMessage(parsed, ERROR_EXTRACT_OPTS) ?? normalized; - } catch { - return normalized; - } -} - -export async function unwrap( - responsePromise: Promise, -): Promise { - const res = await resolveResponse(responsePromise); - const text = await res.text(); - return JSON.parse(text) as T; -} - -export async function unwrapVoid( - responsePromise: Promise, -): Promise { - await resolveResponse(responsePromise); +export interface CreateCliBbSdkOptions { + context?: BbSdkContext; } -async function resolveResponse( - responsePromise: Promise, -): Promise { - let res: Response; - try { - res = await responsePromise; - } catch (err) { - if (isTypeErrorWithCauseCode(err, "ECONNREFUSED")) { - throw new Error( - "Cannot connect to BB server. Ensure it is running and BB_SERVER_URL is correct.", - ); - } - throw err; - } - if (!res.ok) { - const message = await readHttpErrorMessage(res); - throw new Error(`HTTP ${res.status}: ${message}`); - } - return res; +export function createCliBbSdk( + baseUrl: string, + options: CreateCliBbSdkOptions = {}, +): BbSdk { + return createNodeBbSdk({ baseUrl, context: options.context }); } diff --git a/apps/cli/src/commands/app.ts b/apps/cli/src/commands/app.ts index 76d6d9916..15aa612ff 100644 --- a/apps/cli/src/commands/app.ts +++ b/apps/cli/src/commands/app.ts @@ -2,21 +2,22 @@ import { Buffer } from "node:buffer"; import { readFile } from "node:fs/promises"; import { Command } from "commander"; import { + appDataPathSchema, applicationIdSchema, deriveApplicationIdFromName, jsonValueSchema, } from "@bb/domain"; -import type { ApplicationId, JsonValue } from "@bb/domain"; +import type { AppDataPath, ApplicationId, JsonValue } from "@bb/domain"; import type { AppDataEntry, - AppDataListResponse, AppDetail, AppIcon, AppSummary, CreateAppRequest, } from "@bb/server-contract"; +import type { CurrentAppRuntimeContext } from "@bb/sdk"; import { action } from "../action.js"; -import { createClient, unwrap } from "../client.js"; +import { createCliBbSdk } from "../client.js"; import { renderBorderlessTable } from "../table.js"; import { confirmDestructiveAction, outputJson } from "./helpers.js"; @@ -48,13 +49,6 @@ interface AppMessageCommandOptions { targetThread: string; } -interface CurrentAppRuntimeContext { - applicationId: ApplicationId; - appRootPath: string; - appDataPath: string; - appsRootPath: string; -} - function parseApplicationId(value: string): ApplicationId { const parsed = applicationIdSchema.safeParse(value); if (parsed.success) { @@ -65,6 +59,14 @@ function parseApplicationId(value: string): ApplicationId { ); } +function parseAppDataPath(value: string): AppDataPath { + const parsed = appDataPathSchema.safeParse(value); + if (parsed.success) { + return parsed.data; + } + throw new Error("Invalid app data path."); +} + function deriveApplicationIdFromNameForCli(name: string): ApplicationId { try { return deriveApplicationIdFromName(name); @@ -100,20 +102,6 @@ function buildCreateAppRequest(opts: AppNewCommandOptions): CreateAppRequest { return request; } -function encodePathSegments(value: string): string { - return value.split("/").map(encodeURIComponent).join("/"); -} - -function appDataUrl( - baseUrl: string, - applicationId: ApplicationId, - dataPath: string, -): string { - return `${baseUrl.replace(/\/$/u, "")}/api/v1/apps/${encodeURIComponent( - applicationId, - )}/data/${encodePathSegments(dataPath)}`; -} - function formatIcon(icon: AppIcon): string { return icon.kind === "builtin" ? icon.name : "logo"; } @@ -228,8 +216,8 @@ export function registerAppCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (opts: AppJsonOptions) => { - const client = createClient(getUrl()); - const apps = await unwrap(client.api.v1.apps.$get()); + const sdk = createCliBbSdk(getUrl()); + const apps = await sdk.apps.list(); if (outputJson(opts, apps)) return; printAppsTable(apps); }), @@ -247,12 +235,8 @@ export function registerAppCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (opts: AppNewCommandOptions) => { - const client = createClient(getUrl()); - const created = await unwrap( - client.api.v1.apps.$post({ - json: buildCreateAppRequest(opts), - }), - ); + const sdk = createCliBbSdk(getUrl()); + const created = await sdk.apps.create(buildCreateAppRequest(opts)); if (outputJson(opts, created)) return; printAppDetail(created); }), @@ -264,7 +248,9 @@ export function registerAppCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (opts: AppJsonOptions) => { - const current = readCurrentAppRuntimeContext(); + const runtimeContext = readCurrentAppRuntimeContext(); + const sdk = createCliBbSdk(getUrl(), { context: runtimeContext }); + const current = await sdk.apps.current(); if (outputJson(opts, current)) return; console.log(`Application ID: ${current.applicationId}`); console.log(` App root: ${current.appRootPath}`); @@ -280,12 +266,8 @@ export function registerAppCommands( .action( action(async (rawApplicationId: string, opts: AppJsonOptions) => { const applicationId = parseApplicationId(rawApplicationId); - const client = createClient(getUrl()); - const detail = await unwrap( - client.api.v1.apps[":applicationId"].$get({ - param: { applicationId }, - }), - ); + const sdk = createCliBbSdk(getUrl()); + const detail = await sdk.apps.get({ applicationId }); if (outputJson(opts, detail)) return; printAppDetail(detail); }), @@ -300,12 +282,8 @@ export function registerAppCommands( action( async (rawApplicationId: string, opts: AppDeleteCommandOptions) => { const applicationId = parseApplicationId(rawApplicationId); - const client = createClient(getUrl()); - const appDetail = await unwrap( - client.api.v1.apps[":applicationId"].$get({ - param: { applicationId }, - }), - ); + const sdk = createCliBbSdk(getUrl()); + const appDetail = await sdk.apps.get({ applicationId }); if (!opts.yes) { const confirmed = await confirmDestructiveAction( `Delete app "${appDetail.name}" (${applicationId})? This cannot be undone.`, @@ -315,11 +293,7 @@ export function registerAppCommands( return; } } - await unwrap<{ ok: true }>( - client.api.v1.apps[":applicationId"].$delete({ - param: { applicationId }, - }), - ); + await sdk.apps.delete({ applicationId }); const payload = { ok: true, applicationId }; if (outputJson(opts, payload)) return; console.log(`App ${applicationId} deleted`); @@ -341,13 +315,12 @@ export function registerAppCommands( opts: AppDataListCommandOptions, ) => { const applicationId = parseApplicationId(rawApplicationId); - const client = createClient(getUrl()); - const response = await unwrap( - client.api.v1.apps[":applicationId"].data.$get({ - param: { applicationId }, - query: prefix ? { prefix } : {}, - }), - ); + const dataPrefix = prefix === undefined ? "" : parseAppDataPath(prefix); + const sdk = createCliBbSdk(getUrl()); + const response = await sdk.apps.data.list({ + applicationId, + prefix: dataPrefix, + }); if (outputJson(opts, response.entries)) return; printDataEntries(response.entries); }, @@ -360,12 +333,15 @@ export function registerAppCommands( .action( action(async (rawApplicationId: string, dataPath: string) => { const applicationId = parseApplicationId(rawApplicationId); - const response = await unwrap( - fetch(appDataUrl(getUrl(), applicationId, dataPath), { - method: "GET", - headers: { Accept: "application/json" }, - }), - ); + const path = parseAppDataPath(dataPath); + const sdk = createCliBbSdk(getUrl()); + const response = await sdk.apps.data.read({ + applicationId, + path, + }); + if (!response) { + throw new Error(`App data path not found: ${path}`); + } console.log(JSON.stringify(response.value, null, 2)); }), ); @@ -383,17 +359,14 @@ export function registerAppCommands( opts: AppDataWriteCommandOptions, ) => { const applicationId = parseApplicationId(rawApplicationId); + const path = parseAppDataPath(dataPath); const value = await readWriteValue(opts); - await unwrap( - fetch(appDataUrl(getUrl(), applicationId, dataPath), { - method: "PUT", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ value }), - }), - ); + const sdk = createCliBbSdk(getUrl()); + await sdk.apps.data.write({ + applicationId, + path, + value, + }); console.log(`Wrote ${dataPath}`); }, ), @@ -405,12 +378,9 @@ export function registerAppCommands( .action( action(async (rawApplicationId: string, dataPath: string) => { const applicationId = parseApplicationId(rawApplicationId); - await unwrap<{ ok: true }>( - fetch(appDataUrl(getUrl(), applicationId, dataPath), { - method: "DELETE", - headers: { Accept: "application/json" }, - }), - ); + const path = parseAppDataPath(dataPath); + const sdk = createCliBbSdk(getUrl()); + await sdk.apps.data.delete({ applicationId, path }); console.log(`Deleted ${dataPath}`); }), ); @@ -425,16 +395,12 @@ export function registerAppCommands( async (rawApplicationId: string, opts: AppMessageCommandOptions) => { const applicationId = parseApplicationId(rawApplicationId); const payload = parseJsonValueInput(opts.json); - const client = createClient(getUrl()); - await unwrap( - client.api.v1.apps[":applicationId"].message.$post({ - param: { applicationId }, - json: { - payload, - targetThreadId: opts.targetThread, - }, - }), - ); + const sdk = createCliBbSdk(getUrl()); + await sdk.apps.message({ + applicationId, + payload, + targetThreadId: opts.targetThread, + }); console.log(`Message sent to ${opts.targetThread}`); }, ), diff --git a/apps/cli/src/commands/environment-helpers.ts b/apps/cli/src/commands/environment-helpers.ts index cffe16605..a33430ffd 100644 --- a/apps/cli/src/commands/environment-helpers.ts +++ b/apps/cli/src/commands/environment-helpers.ts @@ -1,9 +1,9 @@ -import type { Environment, Host } from "@bb/domain"; +import type { Host } from "@bb/domain"; import { type EnvironmentDisplayInfo, formatEnvironmentDisplay, } from "@bb/core-ui"; -import { type Client, unwrap } from "../client.js"; +import type { BbSdk } from "@bb/sdk"; import { fetchLocalHostId } from "../daemon.js"; export interface ThreadEnvironmentInfo { @@ -14,36 +14,28 @@ export interface ThreadEnvironmentInfo { } async function fetchHost(args: { - client: Client; hostId: string; + sdk: BbSdk; }): Promise { try { - return await unwrap( - args.client.api.v1.hosts[":id"].$get({ - param: { id: args.hostId }, - }), - ); + return await args.sdk.hosts.get({ hostId: args.hostId }); } catch { return null; } } export async function fetchEnvironmentInfo(args: { - client: Client; environmentId: string; + sdk: BbSdk; }): Promise { try { const [env, localHostId] = await Promise.all([ - unwrap( - args.client.api.v1.environments[":id"].$get({ - param: { id: args.environmentId }, - }), - ), + args.sdk.environments.get({ environmentId: args.environmentId }), fetchLocalHostId(), ]); const host = await fetchHost({ - client: args.client, hostId: env.hostId, + sdk: args.sdk, }); const isLocal = env.hostId === localHostId; return { diff --git a/apps/cli/src/commands/environment.ts b/apps/cli/src/commands/environment.ts index 38783f177..2259f5544 100644 --- a/apps/cli/src/commands/environment.ts +++ b/apps/cli/src/commands/environment.ts @@ -1,11 +1,10 @@ import { Command } from "commander"; -import type { Environment } from "@bb/domain"; import type { CommitActionResponse, SquashMergeActionResponse, } from "@bb/server-contract"; import { action } from "../action.js"; -import { createClient, unwrap } from "../client.js"; +import { createCliBbSdk } from "../client.js"; import { outputJson, prependErrorContext, @@ -16,6 +15,10 @@ interface EnvironmentCommitCommandOptions { json?: boolean; } +interface EnvironmentShowCommandOptions { + json?: boolean; +} + interface EnvironmentUpdateCommandOptions { clearMergeBaseBranch?: boolean; json?: boolean; @@ -40,13 +43,9 @@ export function registerEnvironmentCommands( .description("Show environment details") .option("--json", "Print machine-readable JSON output") .action( - action(async (id: string, opts: { json?: boolean }) => { - const client = createClient(getUrl()); - const env = await unwrap( - client.api.v1.environments[":id"].$get({ - param: { id }, - }), - ); + action(async (id: string, opts: EnvironmentShowCommandOptions) => { + const sdk = createCliBbSdk(getUrl()); + const env = await sdk.environments.get({ environmentId: id }); if (outputJson(opts, env)) return; console.log(`Environment: ${env.id}`); console.log(` Project: ${env.projectId}`); @@ -84,7 +83,6 @@ export function registerEnvironmentCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (id: string, opts: EnvironmentUpdateCommandOptions) => { - const client = createClient(getUrl()); if (opts.mergeBaseBranch && opts.clearMergeBaseBranch) { throw new Error( "Cannot combine --merge-base-branch with --clear-merge-base-branch.", @@ -96,16 +94,13 @@ export function registerEnvironmentCommands( ); } - const environment = await unwrap( - client.api.v1.environments[":id"].$patch({ - param: { id }, - json: { - mergeBaseBranch: opts.clearMergeBaseBranch - ? null - : (opts.mergeBaseBranch ?? null), - }, - }), - ); + const sdk = createCliBbSdk(getUrl()); + const environment = await sdk.environments.update({ + environmentId: id, + mergeBaseBranch: opts.clearMergeBaseBranch + ? null + : (opts.mergeBaseBranch ?? null), + }); if (outputJson(opts, environment)) return; console.log(`Environment ${environment.id} updated`); @@ -123,17 +118,10 @@ export function registerEnvironmentCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (id: string, opts: EnvironmentCommitCommandOptions) => { - const client = createClient(getUrl()); + const sdk = createCliBbSdk(getUrl()); let result: CommitActionResponse; try { - result = await unwrap( - client.api.v1.environments[":id"].actions.$post({ - param: { id }, - json: { - action: "commit", - }, - }), - ); + result = await sdk.environments.commit({ environmentId: id }); } catch (err: unknown) { throw prependErrorContext( `Failed to commit in environment ${id}`, @@ -152,18 +140,12 @@ export function registerEnvironmentCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (id: string, opts: EnvironmentSquashMergeCommandOptions) => { - const client = createClient(getUrl()); - const result = await unwrap( - client.api.v1.environments[":id"].actions.$post({ - param: { id }, - json: { - action: "squash_merge", - options: { - mergeBaseBranch: opts.mergeBaseBranch, - }, - }, - }), - ); + const sdk = createCliBbSdk(getUrl()); + const result: SquashMergeActionResponse = + await sdk.environments.squashMerge({ + environmentId: id, + mergeBaseBranch: opts.mergeBaseBranch, + }); if (outputJson(opts, result)) return; printEnvironmentGitOperationResult(result); }), diff --git a/apps/cli/src/commands/guide.ts b/apps/cli/src/commands/guide.ts index ec9a97cf5..08136efd7 100644 --- a/apps/cli/src/commands/guide.ts +++ b/apps/cli/src/commands/guide.ts @@ -1,21 +1,11 @@ import { Command } from "commander"; -import { renderTemplate } from "@bb/templates"; -import type { TemplateId } from "@bb/templates"; +import { createGuideArea } from "@bb/sdk/node"; import { action } from "../action.js"; import { outputJson } from "./helpers.js"; -const guideChapters: Record = { - threads: "bbGuideThreads", - environments: "bbGuideEnvironments", - managers: "bbGuideManagers", - "manager-templates": "bbGuideManagerTemplates", - app: "bbGuideApp", - providers: "bbGuideProviders", - projects: "bbGuideProjects", - hosts: "bbGuideHosts", - styling: "bbGuideApp", - async: "bbGuideAsync", -}; +interface GuideCommandOptions { + json?: boolean; +} export function registerGuideCommand(program: Command): void { program @@ -23,24 +13,18 @@ export function registerGuideCommand(program: Command): void { .description("Show the BB system overview and CLI guide") .option("--json", "Print machine-readable JSON output") .action( - action(async (chapter: string | undefined, opts: { json?: boolean }) => { + action(async (chapter: string | undefined, opts: GuideCommandOptions) => { + // The guide renders local templates only; it must keep working in + // environments where no BB server is configured. + const rendered = createGuideArea().render({ chapter }); if (chapter) { - const templateId = guideChapters[chapter]; - if (!templateId) { - const available = Object.keys(guideChapters).join(", "); - throw new Error( - `Unknown guide chapter '${chapter}'. Available: ${available}.`, - ); - } - const content = renderTemplate(templateId, {}); - if (outputJson(opts, { chapter, content })) return; - console.log(content); + if (outputJson(opts, rendered)) return; + console.log(rendered.content); return; } - const overview = renderTemplate("bbGuideOverview", {}); - if (outputJson(opts, { overview })) return; - console.log(overview); + if (outputJson(opts, { overview: rendered.content })) return; + console.log(rendered.content); }), ); } diff --git a/apps/cli/src/commands/host.ts b/apps/cli/src/commands/host.ts index 6a724ab86..e11780f52 100644 --- a/apps/cli/src/commands/host.ts +++ b/apps/cli/src/commands/host.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import type { Host } from "@bb/domain"; import { action } from "../action.js"; -import { createClient, unwrap } from "../client.js"; +import { createCliBbSdk } from "../client.js"; import { renderBorderlessTable } from "../table.js"; import { outputJson } from "./helpers.js"; @@ -21,8 +21,8 @@ export function registerHostCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (opts: HostListCommandOptions) => { - const client = createClient(getUrl()); - const hosts = await unwrap(client.api.v1.hosts.$get()); + const sdk = createCliBbSdk(getUrl()); + const hosts = await sdk.hosts.list(); if (outputJson(opts, hosts)) return; if (hosts.length === 0) { console.log("No hosts found"); diff --git a/apps/cli/src/commands/manager.ts b/apps/cli/src/commands/manager.ts index 9b79d6e0a..805874bfd 100644 --- a/apps/cli/src/commands/manager.ts +++ b/apps/cli/src/commands/manager.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import { PERSONAL_PROJECT_ID, type Thread } from "@bb/domain"; import { action } from "../action.js"; -import { createClient, unwrap } from "../client.js"; +import { createCliBbSdk } from "../client.js"; import { fetchLocalHostId } from "../daemon.js"; import { renderBorderlessTable } from "../table.js"; import { resolveProjectIdWithLabel } from "../context-env.js"; @@ -21,7 +21,6 @@ interface ManagerHireCommandOptions { provider?: string; model?: string; serviceTier?: string; - template?: string; reasoningLevel?: string; } @@ -40,15 +39,6 @@ interface ManagerDeleteCommandOptions { json?: boolean; } -interface ManagerListQueryArgs { - projectId?: string; -} - -interface ManagerListQuery { - projectId?: string; - type: "manager"; -} - interface PrintThreadsTableArgs { includeProject: boolean; threads: Thread[]; @@ -75,10 +65,6 @@ export function registerManagerCommands( "--model ", "Model ID for the manager. Omit to use the remembered or server default for the resolved provider", ) - .option( - "--template ", - "Manager template set name from manager-templates/", - ) .option("--service-tier ", "Service tier: fast or default") .option( "--reasoning-level ", @@ -92,7 +78,7 @@ export function registerManagerCommands( projectIdArg: string | undefined, opts: ManagerHireCommandOptions, ) => { - const client = createClient(getUrl()); + const sdk = createCliBbSdk(getUrl()); const resolvedProject = resolveProjectIdWithLabel( projectIdArg ?? opts.project, ); @@ -116,21 +102,16 @@ export function registerManagerCommands( ); } } - const thread = await unwrap( - client.api.v1.projects[":id"].managers.$post({ - param: { id: projectId }, - json: { - origin: "cli", - ...(opts.name ? { name: opts.name } : {}), - ...(opts.provider ? { providerId: opts.provider } : {}), - ...(opts.model ? { model: opts.model } : {}), - ...(serviceTier ? { serviceTier } : {}), - ...(opts.template ? { templateName: opts.template } : {}), - environment: { type: "host", hostId }, - ...(reasoningLevel ? { reasoningLevel } : {}), - }, - }), - ); + const thread = await sdk.managers.hire({ + projectId, + origin: "cli", + ...(opts.name ? { name: opts.name } : {}), + ...(opts.provider ? { providerId: opts.provider } : {}), + ...(opts.model ? { model: opts.model } : {}), + ...(serviceTier ? { serviceTier } : {}), + environment: { type: "host", hostId }, + ...(reasoningLevel ? { reasoningLevel } : {}), + }); if (outputJson(opts, thread)) return; console.log(`Manager hired: ${thread.id}`); printManagerThread(thread); @@ -154,7 +135,7 @@ export function registerManagerCommands( projectIdArg: string | undefined, opts: ManagerListCommandOptions, ) => { - const client = createClient(getUrl()); + const sdk = createCliBbSdk(getUrl()); const resolvedProject = resolveProjectIdWithLabel( projectIdArg ?? opts.project, ); @@ -166,13 +147,8 @@ export function registerManagerCommands( opts, ); } - const query = buildManagerListQuery({ - projectId: resolvedProject?.id, - }); - const managers = await unwrap( - client.api.v1.threads.$get({ - query, - }), + const managers = await sdk.managers.list( + resolvedProject ? { projectId: resolvedProject.id } : {}, ); if (outputJson(opts, managers)) return; if (managers.length === 0) { @@ -193,21 +169,11 @@ export function registerManagerCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (id: string, opts: ManagerStatusCommandOptions) => { - const client = createClient(getUrl()); - const managerThreadId = id; - const managerThread = await getManagerThreadById( - client, - managerThreadId, - ); - const managedThreads = await listManagedThreads( - client, - managerThread.projectId, - managerThreadId, - ); - if (outputJson(opts, { manager: managerThread, managedThreads })) - return; - printManagerThread(managerThread); - printManagedThreadTable(managedThreads); + const sdk = createCliBbSdk(getUrl()); + const result = await sdk.managers.status({ managerId: id }); + if (outputJson(opts, result)) return; + printManagerThread(result.manager); + printManagedThreadTable(result.managedThreads); }), ); @@ -222,12 +188,12 @@ export function registerManagerCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (id: string, opts: ManagerDeleteCommandOptions) => { - const client = createClient(getUrl()); + const sdk = createCliBbSdk(getUrl()); const managerThreadId = id; - const managerThread = await getManagerThreadById( - client, - managerThreadId, - ); + const managerThread = await sdk.threads.get({ threadId: managerThreadId }); + if (managerThread.type !== "manager") { + throw new Error(`Thread ${managerThreadId} is not a manager`); + } if (!opts.yes) { const confirmed = await confirmDestructiveAction( `Delete manager "${managerThread.title ?? managerThread.id}" permanently? This cannot be undone.`, @@ -237,55 +203,17 @@ export function registerManagerCommands( return; } } - await unwrap<{ ok: boolean }>( - client.api.v1.threads[":id"].$delete({ - param: { id: managerThreadId }, - json: { - managerChildThreadsConfirmed: - opts.confirmAssignedChildThreads === true, - }, - }), - ); + await sdk.managers.delete({ + managerId: managerThreadId, + managerChildThreadsConfirmed: + opts.confirmAssignedChildThreads === true, + }); if (outputJson(opts, { ok: true, managerId: managerThreadId })) return; console.log(`Manager ${managerThreadId} deleted`); }), ); } -async function getThreadById( - client: ReturnType, - threadId: string, -): Promise { - return unwrap( - client.api.v1.threads[":id"].$get({ - param: { id: threadId }, - }), - ); -} - -async function getManagerThreadById( - client: ReturnType, - threadId: string, -): Promise { - const thread = await getThreadById(client, threadId); - if (thread.type !== "manager") { - throw new Error(`Thread ${threadId} is not a manager`); - } - return thread; -} - -async function listManagedThreads( - client: ReturnType, - projectId: string, - managerThreadId: string, -): Promise { - return unwrap( - client.api.v1.threads.$get({ - query: { projectId, parentThreadId: managerThreadId }, - }), - ); -} - function printManagerThread(thread: Thread): void { console.log(""); console.log(` ID: ${thread.id}`); @@ -298,13 +226,6 @@ function printManagerThread(thread: Thread): void { console.log(""); } -function buildManagerListQuery(args: ManagerListQueryArgs): ManagerListQuery { - return { - ...(args.projectId ? { projectId: args.projectId } : {}), - type: "manager", - }; -} - function formatProjectLabel(projectId: string): string { return projectId === PERSONAL_PROJECT_ID ? "-" : projectId; } diff --git a/apps/cli/src/commands/project.ts b/apps/cli/src/commands/project.ts index 0416e21de..8bee167ef 100644 --- a/apps/cli/src/commands/project.ts +++ b/apps/cli/src/commands/project.ts @@ -6,7 +6,7 @@ import type { UpdateProjectSourceRequest, } from "@bb/server-contract"; import { action } from "../action.js"; -import { createClient, unwrap } from "../client.js"; +import { createCliBbSdk } from "../client.js"; import { fetchLocalHostId } from "../daemon.js"; import { renderBorderlessTable } from "../table.js"; import { confirmDestructiveAction, outputJson } from "./helpers.js"; @@ -59,10 +59,6 @@ interface ProjectSourceInputOptions { path?: string; } -interface ProjectUpdateBody { - name?: string; -} - type ProjectSource = ProjectResponse["sources"][number]; async function requireHostId(hostId: string | undefined): Promise { @@ -153,10 +149,8 @@ export function registerProjectCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (opts: ProjectListCommandOptions) => { - const client = createClient(getUrl()); - const projects = await unwrap( - client.api.v1.projects.$get(), - ); + const sdk = createCliBbSdk(getUrl()); + const projects = await sdk.projects.list(); if (outputJson(opts, projects)) return; if (projects.length === 0) { console.log("No projects found"); @@ -179,19 +173,15 @@ export function registerProjectCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (opts: ProjectCreateCommandOptions) => { - const client = createClient(getUrl()); + const sdk = createCliBbSdk(getUrl()); const source = await buildProjectSourceFromOptions({ host: opts.host, path: opts.root, }); - const created = await unwrap( - client.api.v1.projects.$post({ - json: { - name: opts.name, - source, - }, - }), - ); + const created = await sdk.projects.create({ + name: opts.name, + source, + }); if (outputJson(opts, created)) return; console.log(`Project created: ${created.id}`); const localHostId = await fetchLocalHostId(); @@ -205,12 +195,8 @@ export function registerProjectCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (id: string, opts: ProjectShowCommandOptions) => { - const client = createClient(getUrl()); - const found = await unwrap( - client.api.v1.projects[":id"].$get({ - param: { id }, - }), - ); + const sdk = createCliBbSdk(getUrl()); + const found = await sdk.projects.get({ projectId: id }); if (outputJson(opts, found)) return; const localHostId = await fetchLocalHostId(); printProject(found, localHostId); @@ -224,17 +210,14 @@ export function registerProjectCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (id: string, opts: ProjectUpdateCommandOptions) => { - const client = createClient(getUrl()); if (!opts.name) { throw new Error("No changes requested. Provide --name."); } - const body: ProjectUpdateBody = { name: opts.name }; - const updated = await unwrap( - client.api.v1.projects[":id"].$patch({ - param: { id }, - json: body, - }), - ); + const sdk = createCliBbSdk(getUrl()); + const updated = await sdk.projects.update({ + projectId: id, + name: opts.name, + }); if (outputJson(opts, updated)) return; console.log(`Project ${updated.id} updated`); const localHostId = await fetchLocalHostId(); @@ -249,7 +232,6 @@ export function registerProjectCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (id: string, opts: ProjectDeleteCommandOptions) => { - const client = createClient(getUrl()); if (!opts.yes) { const confirmed = await confirmDestructiveAction( `Delete project ${id} and all its threads?`, @@ -259,11 +241,8 @@ export function registerProjectCommands( return; } } - await unwrap<{ ok: boolean }>( - client.api.v1.projects[":id"].$delete({ - param: { id }, - }), - ); + const sdk = createCliBbSdk(getUrl()); + await sdk.projects.delete({ projectId: id }); if (outputJson(opts, { ok: true, id })) return; console.log(`Project ${id} deleted`); }), @@ -282,25 +261,22 @@ export function registerProjectCommands( .action( action( async (projectId: string, opts: ProjectSourceAddCommandOptions) => { - const client = createClient(getUrl()); + const sdk = createCliBbSdk(getUrl()); const createPayload = await buildProjectSourceFromOptions({ host: opts.host, path: opts.path, }); - const created = await unwrap( - client.api.v1.projects[":id"].sources.$post({ - param: { id: projectId }, - json: createPayload, - }), - ); + const created = await sdk.projects.sources.add({ + projectId, + ...createPayload, + }); const sourceResponse = opts.default - ? await unwrap( - client.api.v1.projects[":id"].sources[":sourceId"].$patch({ - param: { id: projectId, sourceId: created.id }, - json: buildDefaultProjectSourceUpdateRequest(created), - }), - ) + ? await sdk.projects.sources.update({ + projectId, + sourceId: created.id, + ...buildDefaultProjectSourceUpdateRequest(created), + }) : created; if (outputJson(opts, sourceResponse)) return; @@ -324,23 +300,18 @@ export function registerProjectCommands( sourceId: string, opts: ProjectSourceUpdateCommandOptions, ) => { - const client = createClient(getUrl()); - const project = await unwrap( - client.api.v1.projects[":id"].$get({ - param: { id: projectId }, - }), - ); + const sdk = createCliBbSdk(getUrl()); + const project = await sdk.projects.get({ projectId }); const existingSource = requireProjectSource(project, sourceId); const updatePayload = buildProjectSourceUpdateRequest( existingSource, opts, ); - const updated = await unwrap( - client.api.v1.projects[":id"].sources[":sourceId"].$patch({ - param: { id: projectId, sourceId }, - json: updatePayload, - }), - ); + const updated = await sdk.projects.sources.update({ + projectId, + sourceId, + ...updatePayload, + }); if (outputJson(opts, updated)) return; console.log(`Project source updated: ${updated.id}`); @@ -362,7 +333,6 @@ export function registerProjectCommands( sourceId: string, opts: ProjectSourceDeleteCommandOptions, ) => { - const client = createClient(getUrl()); if (!opts.yes) { const confirmed = await confirmDestructiveAction( `Delete project source ${sourceId} from project ${projectId}?`, @@ -373,11 +343,8 @@ export function registerProjectCommands( } } - await unwrap<{ ok: boolean }>( - client.api.v1.projects[":id"].sources[":sourceId"].$delete({ - param: { id: projectId, sourceId }, - }), - ); + const sdk = createCliBbSdk(getUrl()); + await sdk.projects.sources.delete({ projectId, sourceId }); const result = { ok: true, projectId, sourceId }; if (outputJson(opts, result)) return; console.log(`Project source ${sourceId} deleted`); diff --git a/apps/cli/src/commands/provider.ts b/apps/cli/src/commands/provider.ts index 4ecebf74e..2adfc2cef 100644 --- a/apps/cli/src/commands/provider.ts +++ b/apps/cli/src/commands/provider.ts @@ -1,11 +1,8 @@ import { Command } from "commander"; import type { AvailableModel } from "@bb/domain"; -import type { - SystemExecutionOptionsResponse, - SystemProviderInfo, -} from "@bb/server-contract"; +import type { SystemProviderInfo } from "@bb/server-contract"; import { action } from "../action.js"; -import { createClient, unwrap } from "../client.js"; +import { createCliBbSdk } from "../client.js"; import { renderBorderlessTable } from "../table.js"; import { outputJson } from "./helpers.js"; @@ -38,10 +35,8 @@ export function registerProviderCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (opts: ProviderListCommandOptions) => { - const client = createClient(getUrl()); - const providers = await unwrap( - client.api.v1.system.providers.$get({ query: {} }), - ); + const sdk = createCliBbSdk(getUrl()); + const providers = await sdk.providers.list(); if (outputJson(opts, providers)) return; if (providers.length === 0) { console.log("No providers available"); @@ -65,14 +60,10 @@ export function registerProviderCommands( providerId: string | undefined, opts: ProviderModelsCommandOptions, ) => { - const client = createClient(getUrl()); - const executionOptions = await unwrap( - client.api.v1.system["execution-options"].$get({ - query: { - ...(providerId ? { providerId } : {}), - }, - }), - ); + const sdk = createCliBbSdk(getUrl()); + const executionOptions = await sdk.providers.models({ + ...(providerId ? { providerId } : {}), + }); const models = includeSelectedOnlyModel({ models: executionOptions.models, selectedOnlyModels: executionOptions.selectedOnlyModels, diff --git a/apps/cli/src/commands/replay.ts b/apps/cli/src/commands/replay.ts index cc08a3571..bc66a5288 100644 --- a/apps/cli/src/commands/replay.ts +++ b/apps/cli/src/commands/replay.ts @@ -2,12 +2,11 @@ import { Command } from "commander"; import { replaySpeedSchema, type ReplayCaptureHostSummary, - type ReplayCaptureListResponse, type ReplayRunResponse, type ReplayRunSpeed, } from "@bb/server-contract"; import { action } from "../action.js"; -import { createClient, unwrap } from "../client.js"; +import { createCliBbSdk } from "../client.js"; import { renderBorderlessTable } from "../table.js"; import { outputJson } from "./helpers.js"; @@ -101,10 +100,8 @@ export function registerReplayCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (opts: ReplayListOptions) => { - const client = createClient(getUrl()); - const result = await unwrap( - client.api.v1["development-only"].replay.captures.$get(), - ); + const sdk = createCliBbSdk(getUrl()); + const result = await sdk.replay.list(); if (outputJson(opts, result)) return; printCaptureTable(result.captures); }), @@ -121,14 +118,9 @@ export function registerReplayCommands( ) .action( action(async (captureId: string, opts: ReplayRunOptions) => { - const client = createClient(getUrl()); + const sdk = createCliBbSdk(getUrl()); const speed = parseSpeed(opts.speed); - const result = await unwrap( - client.api.v1["development-only"].replay.captures[":id"].runs.$post({ - param: { id: captureId }, - json: { speed }, - }), - ); + const result = await sdk.replay.run({ captureId, speed }); if (outputJson(opts, result)) return; printReplayRun(result); }), @@ -140,12 +132,8 @@ export function registerReplayCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (captureId: string, opts: ReplayListOptions) => { - const client = createClient(getUrl()); - const result = await unwrap<{ ok: true }>( - client.api.v1["development-only"].replay.captures[":id"].$delete({ - param: { id: captureId }, - }), - ); + const sdk = createCliBbSdk(getUrl()); + const result = await sdk.replay.delete({ captureId }); if (outputJson(opts, result)) return; console.log(`Deleted replay capture ${captureId}`); }), diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 59b3184b4..fcb3fbd31 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -1,22 +1,18 @@ import { Command } from "commander"; -import type { Thread, ThreadTimelinePendingTodos } from "@bb/domain"; -import type { ProjectResponse } from "@bb/server-contract"; +import type { ThreadTimelinePendingTodos } from "@bb/domain"; import { action } from "../action.js"; import { resolveContextSnapshot, type ContextSnapshot, } from "../context-env.js"; -import { type Client, createClient, unwrap } from "../client.js"; +import { createCliBbSdk } from "../client.js"; import { outputJson } from "./helpers.js"; import { type ThreadEnvironmentInfo, fetchEnvironmentInfo, printEnvironmentInfo, } from "./environment-helpers.js"; -import { - fetchThreadPendingTodos, - printPendingTodos, -} from "./thread/pending-todos.js"; +import { printPendingTodos } from "./thread/pending-todos.js"; interface StatusPayload { project: { id: string; name: string } | null; @@ -44,57 +40,6 @@ interface StatusCommandOptions { type ResolveServerUrl = () => string; type ResolveStatusContext = () => ContextSnapshot; -async function fetchSilent(fn: () => Promise): Promise { - try { - return await fn(); - } catch { - return null; - } -} - -function fetchProject(args: { - client: Client; - projectId: string; -}): Promise { - return fetchSilent(() => - unwrap( - args.client.api.v1.projects[":id"].$get({ - param: { id: args.projectId }, - }), - ), - ); -} - -function fetchThread(args: { - client: Client; - threadId: string; -}): Promise { - return fetchSilent(() => - unwrap( - args.client.api.v1.threads[":id"].$get({ - param: { id: args.threadId }, - }), - ), - ); -} - -function fetchManagedThreads(args: { - client: Client; - projectId: string; - parentThreadId: string; -}): Promise { - return fetchSilent(() => - unwrap( - args.client.api.v1.threads.$get({ - query: { - projectId: args.projectId, - parentThreadId: args.parentThreadId, - }, - }), - ), - ); -} - export function registerStatusCommand( program: Command, getUrl: ResolveServerUrl, @@ -119,64 +64,47 @@ export function registerStatusCommand( // Try to fetch enriched data from the server if (context.projectId || context.threadId) { - const client = createClient(getUrl()); - - const [projectResult, threadResult] = await Promise.all([ - context.projectId - ? fetchProject({ client, projectId: context.projectId }) - : Promise.resolve(null), - context.threadId - ? fetchThread({ client, threadId: context.threadId }) - : Promise.resolve(null), - ]); - - if (projectResult) { + const sdk = createCliBbSdk(getUrl()); + const status = await sdk.status.get({ + projectId: context.projectId, + threadId: context.threadId, + }); + + if (status.project) { payload.project = { - id: projectResult.id, - name: projectResult.name, + id: status.project.id, + name: status.project.name, }; serverAvailable = true; } - if (threadResult) { + if (status.thread) { let environmentInfo: ThreadEnvironmentInfo | null = null; - if (threadResult.environmentId) { + if (status.thread.environmentId) { environmentInfo = await fetchEnvironmentInfo({ - client, - environmentId: threadResult.environmentId, + environmentId: status.thread.environmentId, + sdk, }); } - payload.pendingTodos = await fetchThreadPendingTodos({ - client, - threadId: threadResult.id, - }); - + payload.pendingTodos = status.pendingTodos; payload.thread = { - id: threadResult.id, - type: threadResult.type, - status: threadResult.status, - title: threadResult.title ?? null, - pinnedAt: threadResult.pinnedAt, - parentThreadId: threadResult.parentThreadId ?? null, + id: status.thread.id, + type: status.thread.type, + status: status.thread.status, + title: status.thread.title ?? null, + pinnedAt: status.thread.pinnedAt, + parentThreadId: status.thread.parentThreadId ?? null, environment: environmentInfo, }; serverAvailable = true; - // If the thread is a manager, fetch managed (child) threads - if (threadResult.type === "manager") { - const managed = await fetchManagedThreads({ - client, - projectId: threadResult.projectId, - parentThreadId: threadResult.id, - }); - if (managed) { - payload.managedThreads = managed.map((t) => ({ - id: t.id, - status: t.status, - title: t.title ?? null, - })); - } + if (status.managedThreads) { + payload.managedThreads = status.managedThreads.map((thread) => ({ + id: thread.id, + status: thread.status, + title: thread.title ?? null, + })); } } } diff --git a/apps/cli/src/commands/thread/actions.ts b/apps/cli/src/commands/thread/actions.ts index e76009e42..501371b57 100644 --- a/apps/cli/src/commands/thread/actions.ts +++ b/apps/cli/src/commands/thread/actions.ts @@ -3,10 +3,9 @@ import { type PermissionMode, type ReasoningLevel, type ServiceTier, - type Thread, } from "@bb/domain"; import { action } from "../../action.js"; -import { createClient, unwrap } from "../../client.js"; +import { createCliBbSdk } from "../../client.js"; import { confirmDestructiveAction, outputJson, @@ -108,7 +107,6 @@ export function registerActionsCommands( .action( action( async (id: string | undefined, opts: ThreadUpdateCommandOptions) => { - const client = createClient(getUrl()); if (opts.parentThread && opts.clearParentThread) { throw new Error( "Cannot combine --parent-thread with --clear-parent-thread.", @@ -144,12 +142,8 @@ export function registerActionsCommands( body.reasoningLevel = reasoningLevel; } - const thread = await unwrap( - client.api.v1.threads[":id"].$patch({ - param: { id: threadId }, - json: body, - }), - ); + const sdk = createCliBbSdk(getUrl()); + const thread = await sdk.threads.update({ threadId, ...body }); if (outputJson(opts, thread)) return; console.log(`Thread ${thread.id} updated`); if (opts.title) { @@ -181,13 +175,9 @@ export function registerActionsCommands( action( async (id: string | undefined, opts: ThreadArchiveCommandOptions) => { const threadId = requireThreadIdOrSelf(id, opts); - const client = createClient(getUrl()); + const sdk = createCliBbSdk(getUrl()); try { - await unwrap<{ ok: boolean }>( - client.api.v1.threads[":id"].archive.$post({ - param: { id: threadId }, - }), - ); + await sdk.threads.archive({ threadId }); } catch (err: unknown) { throw prependErrorContext( `Failed to archive thread ${threadId}`, @@ -208,13 +198,9 @@ export function registerActionsCommands( .action( action( async (id: string | undefined, opts: ThreadUnarchiveCommandOptions) => { - const client = createClient(getUrl()); const threadId = requireThreadIdOrSelf(id, opts); - await unwrap<{ ok: boolean }>( - client.api.v1.threads[":id"].unarchive.$post({ - param: { id: threadId }, - }), - ); + const sdk = createCliBbSdk(getUrl()); + await sdk.threads.unarchive({ threadId }); if (outputJson(opts, { ok: true, threadId })) return; console.log(`Thread ${threadId} unarchived`); }, @@ -228,13 +214,9 @@ export function registerActionsCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (id: string | undefined, opts: ThreadPinCommandOptions) => { - const client = createClient(getUrl()); const threadId = requireThreadIdOrSelf(id, opts); - const thread = await unwrap( - client.api.v1.threads[":id"].pin.$post({ - param: { id: threadId }, - }), - ); + const sdk = createCliBbSdk(getUrl()); + const thread = await sdk.threads.pin({ threadId }); if (outputJson(opts, thread)) return; console.log(`Thread ${thread.id} pinned`); }), @@ -247,13 +229,9 @@ export function registerActionsCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (id: string | undefined, opts: ThreadPinCommandOptions) => { - const client = createClient(getUrl()); const threadId = requireThreadIdOrSelf(id, opts); - const thread = await unwrap( - client.api.v1.threads[":id"].unpin.$post({ - param: { id: threadId }, - }), - ); + const sdk = createCliBbSdk(getUrl()); + const thread = await sdk.threads.unpin({ threadId }); if (outputJson(opts, thread)) return; console.log(`Thread ${thread.id} unpinned`); }), @@ -270,11 +248,9 @@ export function registerActionsCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (id: string, opts: ThreadDeleteCommandOptions) => { - const client = createClient(getUrl()); + const sdk = createCliBbSdk(getUrl()); try { - const thread = await unwrap( - client.api.v1.threads[":id"].$get({ param: { id } }), - ); + const thread = await sdk.threads.get({ threadId: id }); if (!opts.yes) { const confirmed = await confirmDestructiveAction( @@ -286,15 +262,11 @@ export function registerActionsCommands( } } - await unwrap<{ ok: boolean }>( - client.api.v1.threads[":id"].$delete({ - param: { id }, - json: { - managerChildThreadsConfirmed: - opts.confirmAssignedChildThreads === true, - }, - }), - ); + await sdk.threads.delete({ + threadId: id, + managerChildThreadsConfirmed: + opts.confirmAssignedChildThreads === true, + }); } catch (err: unknown) { throw prependErrorContext(`Failed to delete thread ${id}`, err); } @@ -346,11 +318,9 @@ export function registerActionsCommands( .option("--json", "Print machine-readable JSON output") .action( action(async (id: string | undefined, opts: ThreadStopCommandOptions) => { - const client = createClient(getUrl()); const threadId = requireThreadIdOrSelf(id, opts); - await unwrap<{ ok: boolean }>( - client.api.v1.threads[":id"].stop.$post({ param: { id: threadId } }), - ); + const sdk = createCliBbSdk(getUrl()); + await sdk.threads.stop({ threadId }); if (outputJson(opts, { ok: true, threadId })) return; console.log(`Thread ${threadId} stopped`); }), @@ -360,24 +330,22 @@ export function registerActionsCommands( async function postThreadMessage( args: PostThreadMessageArgs, ): Promise<{ ok: boolean; mode?: "steer" }> { - const client = createClient(args.getUrl()); - const response = await unwrap<{ ok: boolean }>( - client.api.v1.threads[":id"].send.$post({ - param: { id: args.threadId }, - json: { - input: [{ type: "text", text: args.message }], - mode: args.mode, - ...(args.model ? { model: args.model } : {}), - ...(args.permissionMode ? { permissionMode: args.permissionMode } : {}), - ...(args.reasoningLevel ? { reasoningLevel: args.reasoningLevel } : {}), - ...(args.serviceTier ? { serviceTier: args.serviceTier } : {}), - ...(args.senderThreadId ? { senderThreadId: args.senderThreadId } : {}), - }, - }), - ); + const sdk = createCliBbSdk(args.getUrl()); + const response = await sdk.threads.send({ + threadId: args.threadId, + input: [{ type: "text", text: args.message }], + mode: args.mode, + ...(args.model ? { model: args.model } : {}), + ...(args.permissionMode ? { permissionMode: args.permissionMode } : {}), + ...(args.reasoningLevel ? { reasoningLevel: args.reasoningLevel } : {}), + ...(args.serviceTier ? { serviceTier: args.serviceTier } : {}), + ...(args.senderThreadId ? { senderThreadId: args.senderThreadId } : {}), + }); + if (args.mode === "steer") { + return { ...response, mode: "steer" }; + } return { ...response, - ...(args.mode === "steer" ? { mode: "steer" as const } : {}), }; } diff --git a/apps/cli/src/commands/thread/interactions.ts b/apps/cli/src/commands/thread/interactions.ts index c95ef7225..c288e3d1a 100644 --- a/apps/cli/src/commands/thread/interactions.ts +++ b/apps/cli/src/commands/thread/interactions.ts @@ -25,7 +25,7 @@ import { type UserQuestionPendingInteractionResolution, } from "@bb/domain"; import { action } from "../../action.js"; -import { createClient, unwrap } from "../../client.js"; +import { createCliBbSdk } from "../../client.js"; import { renderBorderlessTable } from "../../table.js"; import { outputJson, @@ -306,15 +306,11 @@ function printInteraction(interaction: PendingInteraction): void { async function fetchInteraction( args: FetchInteractionArgs, ): Promise { - const client = createClient(args.getUrl()); - return unwrap( - client.api.v1.threads[":id"].interactions[":interactionId"].$get({ - param: { - id: args.threadId, - interactionId: args.interactionId, - }, - }), - ); + const sdk = createCliBbSdk(args.getUrl()); + return sdk.threads.interactions.get({ + interactionId: args.interactionId, + threadId: args.threadId, + }); } function appendRepeatableOption( @@ -540,16 +536,12 @@ async function resolveInteraction(args: ResolveInteractionArgs): Promise { threadId: args.threadId, }); const resolution = args.buildResolution(interaction); - const client = createClient(args.getUrl()); - const updated = await unwrap( - client.api.v1.threads[":id"].interactions[":interactionId"].resolve.$post({ - param: { - id: args.threadId, - interactionId: args.interactionId, - }, - json: resolution, - }), - ).catch((error: unknown) => { + const sdk = createCliBbSdk(args.getUrl()); + const updated = await sdk.threads.interactions.resolve({ + interactionId: args.interactionId, + resolution, + threadId: args.threadId, + }).catch((error: unknown) => { throw prependErrorContext( `Failed to ${args.failureAction} interaction ${args.interactionId}`, error, @@ -682,14 +674,12 @@ export function registerInteractionCommands( opts: ThreadInteractionTargetOptions, ) => { const resolved = requireThreadIdWithLabelOrSelf(id, opts); - const client = createClient(getUrl()); + const sdk = createCliBbSdk(getUrl()); printContextLabel(resolved, "Thread", "BB_THREAD_ID", opts); - const items = await unwrap( - client.api.v1.threads[":id"].interactions.$get({ - param: { id: resolved.id }, - }), - ); + const items = await sdk.threads.interactions.list({ + threadId: resolved.id, + }); if (outputJson(opts, items)) { return; diff --git a/apps/cli/src/commands/thread/list.ts b/apps/cli/src/commands/thread/list.ts index 316a1e397..6faa1ee9d 100644 --- a/apps/cli/src/commands/thread/list.ts +++ b/apps/cli/src/commands/thread/list.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import { PERSONAL_PROJECT_ID, type Thread } from "@bb/domain"; import { action } from "../../action.js"; -import { createClient, unwrap } from "../../client.js"; +import { createCliBbSdk } from "../../client.js"; import { resolveProjectIdWithLabel, resolveThreadId, @@ -33,17 +33,14 @@ export function registerListCommand( .option("--json", "Print machine-readable JSON output") .action( action(async (opts: ThreadListCommandOptions) => { - const client = createClient(getUrl()); + const sdk = createCliBbSdk(getUrl()); const resolvedProject = resolveProjectIdWithLabel(opts.project); const parentThreadId = resolveThreadId(opts.parentThread); - const query = { + const threads = await sdk.threads.list({ ...(resolvedProject ? { projectId: resolvedProject.id } : {}), ...(parentThreadId ? { parentThreadId } : {}), - ...(opts.archived ? { archived: "true" as const } : {}), - }; - const threads = await unwrap( - client.api.v1.threads.$get({ query }), - ); + ...(opts.archived ? { archived: true } : {}), + }); if (outputJson(opts, threads)) return; if (threads.length === 0) { console.log("No threads found"); diff --git a/apps/cli/src/commands/thread/pending-todos.test.ts b/apps/cli/src/commands/thread/pending-todos.test.ts index 29dcae65e..53ede0c73 100644 --- a/apps/cli/src/commands/thread/pending-todos.test.ts +++ b/apps/cli/src/commands/thread/pending-todos.test.ts @@ -1,15 +1,13 @@ import { describe, expect, it, vi } from "vitest"; import type { ThreadTimelinePendingTodos } from "@bb/domain"; - -vi.mock("../../client.js", () => ({ - unwrap: vi.fn(async (responsePromise: Promise) => responsePromise), -})); - +import type { ThreadTimelineResponse } from "@bb/server-contract"; import { - fetchThreadPendingTodos, - printPendingTodos, -} from "./pending-todos.js"; -import type { Client } from "../../client.js"; + createNodeBbSdk, + type BbSdk, + type FetchImplementation, +} from "@bb/sdk/node"; + +import { fetchThreadPendingTodos, printPendingTodos } from "./pending-todos.js"; function captureLogLines(fn: () => void): { lines: string[] } { const spy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -103,46 +101,79 @@ describe("printPendingTodos", () => { }); describe("fetchThreadPendingTodos", () => { - function makeClientWithTimeline( - handler: () => Promise, - ): Client { + function makeTimelineResponse( + pendingTodos: ThreadTimelinePendingTodos | null, + ): ThreadTimelineResponse { return { - api: { - v1: { - threads: { - ":id": { - timeline: { - $get: vi.fn(handler), - }, - }, - }, - }, + activeThinking: null, + pendingTodos, + rows: [], + timelinePage: { + kind: "latest", + segmentLimit: 20, + returnedSegmentCount: 0, + hasOlderRows: false, + olderCursor: null, }, - } as unknown as Client; + }; + } + + interface SdkOverHttpBoundary { + requestUrls: string[]; + sdk: BbSdk; + } + + function makeSdkOverHttpBoundary( + fetchImpl: (url: string) => Promise, + ): SdkOverHttpBoundary { + const requestUrls: string[] = []; + const fetchMock: FetchImplementation = async (input) => { + const url = String(input); + requestUrls.push(url); + return fetchImpl(url); + }; + return { + requestUrls, + sdk: createNodeBbSdk({ baseUrl: "http://bb.test", fetch: fetchMock }), + }; } - it("returns the pendingTodos field from a successful timeline response", async () => { + it("requests a summary-only timeline and returns its pendingTodos field", async () => { const snapshot: ThreadTimelinePendingTodos = { sourceSeq: 7, updatedAt: 7, items: [{ id: "a", text: "Work", status: "in_progress" }], }; - const client = makeClientWithTimeline(async () => ({ - pendingTodos: snapshot, - })); + const { requestUrls, sdk } = makeSdkOverHttpBoundary(async () => + Response.json(makeTimelineResponse(snapshot)), + ); const result = await fetchThreadPendingTodos({ - client, + sdk, threadId: "thread-1", }); expect(result).toEqual(snapshot); + expect(requestUrls).toEqual([ + "http://bb.test/api/v1/threads/thread-1/timeline?summaryOnly=true", + ]); }); - it("returns null when the timeline call rejects (best-effort contract)", async () => { - const client = makeClientWithTimeline(async () => { + it("returns null when the timeline request fails at the network level (best-effort contract)", async () => { + const { sdk } = makeSdkOverHttpBoundary(async () => { throw new Error("network down"); }); const result = await fetchThreadPendingTodos({ - client, + sdk, + threadId: "thread-1", + }); + expect(result).toBeNull(); + }); + + it("returns null when the server responds with an HTTP error (best-effort contract)", async () => { + const { sdk } = makeSdkOverHttpBoundary(async () => + Response.json({ message: "Thread not found" }, { status: 404 }), + ); + const result = await fetchThreadPendingTodos({ + sdk, threadId: "thread-1", }); expect(result).toBeNull(); diff --git a/apps/cli/src/commands/thread/pending-todos.ts b/apps/cli/src/commands/thread/pending-todos.ts index 2a65f9295..eec87dfbd 100644 --- a/apps/cli/src/commands/thread/pending-todos.ts +++ b/apps/cli/src/commands/thread/pending-todos.ts @@ -3,8 +3,12 @@ import type { ThreadTimelinePendingTodoItem, ThreadTimelinePendingTodoItemStatus, } from "@bb/domain"; -import type { ThreadTimelineResponse } from "@bb/server-contract"; -import { unwrap, type Client } from "../../client.js"; +import type { BbSdk } from "@bb/sdk"; + +export interface FetchThreadPendingTodosArgs { + sdk: Pick; + threadId: string; +} /** * Best-effort fetch — returns null if the timeline endpoint is unreachable or @@ -14,17 +18,14 @@ import { unwrap, type Client } from "../../client.js"; * Uses `summaryOnly=true` so the server skips row generation/serialization; * the CLI only consumes `pendingTodos`, not the full timeline. */ -export async function fetchThreadPendingTodos(args: { - client: Client; - threadId: string; -}): Promise { +export async function fetchThreadPendingTodos( + args: FetchThreadPendingTodosArgs, +): Promise { try { - const response = await unwrap( - args.client.api.v1.threads[":id"].timeline.$get({ - param: { id: args.threadId }, - query: { summaryOnly: "true" }, - }), - ); + const response = await args.sdk.threads.timeline({ + threadId: args.threadId, + summaryOnly: "true", + }); return response.pendingTodos; } catch { return null; diff --git a/apps/cli/src/commands/thread/show.ts b/apps/cli/src/commands/thread/show.ts index 3fe095668..3774e39e7 100644 --- a/apps/cli/src/commands/thread/show.ts +++ b/apps/cli/src/commands/thread/show.ts @@ -7,19 +7,17 @@ import { resolveEnvironmentMergeBaseBranch, type Environment, type Thread, - type ThreadEventRow, type ThreadGitDiffResponse, type ThreadTimelinePendingTodos, type WorkspaceStatus, } from "@bb/domain"; +import type { BbSdk } from "@bb/sdk"; import type { EnvironmentDiffQuery, - EnvironmentDiffResponse, - EnvironmentStatusResponse, ThreadTimelineResponse, } from "@bb/server-contract"; import { action } from "../../action.js"; -import { createClient, type Client, unwrap } from "../../client.js"; +import { createCliBbSdk } from "../../client.js"; import { outputJson, printContextLabel, @@ -76,17 +74,21 @@ type FetchedGitDiff = | { available: true; diff: ThreadGitDiffResponse } | { available: false; message: string }; +type CliEnvironmentDiffQuery = + | { target: "uncommitted" } + | { mergeBaseBranch?: string; target: "branch_committed" } + | { mergeBaseBranch?: string; target: "all" } + | { sha: string; target: "commit" }; + async function fetchWorkStatus(args: { - client: Client; environmentId: string; mergeBaseBranch: string; + sdk: BbSdk; }): Promise { - const environmentStatus = await unwrap( - args.client.api.v1.environments[":id"].status.$get({ - param: { id: args.environmentId }, - query: { mergeBaseBranch: args.mergeBaseBranch }, - }), - ); + const environmentStatus = await args.sdk.environments.status({ + environmentId: args.environmentId, + mergeBaseBranch: args.mergeBaseBranch, + }); if (environmentStatus.outcome === "available") { return { available: true, status: environmentStatus.workspace }; } @@ -97,16 +99,14 @@ async function fetchWorkStatus(args: { } async function fetchGitDiff(args: { - client: Client; environmentId: string; query: EnvironmentDiffQuery; + sdk: BbSdk; }): Promise { - const environmentDiff = await unwrap( - args.client.api.v1.environments[":id"].diff.$get({ - param: { id: args.environmentId }, - query: args.query, - }), - ); + const environmentDiff = await args.sdk.environments.diff({ + environmentId: args.environmentId, + ...args.query, + }); if (environmentDiff.outcome === "available") { return { available: true, diff: environmentDiff.diff }; } @@ -144,12 +144,10 @@ export function registerShowCommand( .action( action(async (id: string | undefined, opts: ThreadShowCommandOptions) => { const resolved = requireThreadIdWithLabelOrSelf(id, opts); - const client = createClient(getUrl()); + const sdk = createCliBbSdk(getUrl()); const threadId = resolved.id; printContextLabel(resolved, "Thread", "BB_THREAD_ID", opts); - const thread = await unwrap( - client.api.v1.threads[":id"].$get({ param: { id: threadId } }), - ); + const thread = await sdk.threads.get({ threadId }); const statusPayload: ThreadStatusPayload = { thread }; let environment: Environment | null | undefined; @@ -160,11 +158,9 @@ export function registerShowCommand( if (environment !== undefined) { return environment; } - environment = await unwrap( - client.api.v1.environments[":id"].$get({ - param: { id: thread.environmentId }, - }), - ); + environment = await sdk.environments.get({ + environmentId: thread.environmentId, + }); return environment; }; const requireMergeBaseBranch = async (override?: string) => { @@ -183,27 +179,27 @@ export function registerShowCommand( if (opts.workStatus && thread.environmentId) { const mergeBaseBranch = await requireMergeBaseBranch(); fetchedWorkStatus = await fetchWorkStatus({ - client, environmentId: thread.environmentId, mergeBaseBranch, + sdk, }); } let fetchedGitDiff: FetchedGitDiff | undefined; if (opts.gitDiff && thread.environmentId) { const diffTarget = (opts.diffTarget ?? "all").trim(); - const query = (() => { + const query: CliEnvironmentDiffQuery = (() => { switch (diffTarget) { case "uncommitted": - return { target: "uncommitted" as const }; + return { target: "uncommitted" }; case "branch_committed": return { - target: "branch_committed" as const, + target: "branch_committed", mergeBaseBranch: opts.diffMergeBase, }; case "all": return { - target: "all" as const, + target: "all", mergeBaseBranch: opts.diffMergeBase, }; case "commit": @@ -213,7 +209,7 @@ export function registerShowCommand( ); } return { - target: "commit" as const, + target: "commit", sha: opts.diffSha, }; default: @@ -222,41 +218,39 @@ export function registerShowCommand( ); } })(); - const resolvedQuery = + const resolvedQuery: EnvironmentDiffQuery = query.target === "branch_committed" || query.target === "all" ? { - ...query, + target: query.target, mergeBaseBranch: await requireMergeBaseBranch( query.mergeBaseBranch, ), } : query; fetchedGitDiff = await fetchGitDiff({ - client, environmentId: thread.environmentId, query: resolvedQuery, + sdk, }); } let mergeBaseBranches: string[] | undefined; if (opts.mergeBaseBranches && thread.environmentId) { - mergeBaseBranches = await unwrap( - client.api.v1.environments[":id"].diff.branches.$get({ - param: { id: thread.environmentId }, - query: {}, - }), - ); + const branchResponse = await sdk.environments.diffBranches({ + environmentId: thread.environmentId, + }); + mergeBaseBranches = branchResponse.branches; } const environmentInfo = thread.environmentId ? await fetchEnvironmentInfo({ - client, environmentId: thread.environmentId, + sdk, }) : null; const pendingTodos = await fetchThreadPendingTodos({ - client, + sdk, threadId, }); @@ -372,7 +366,7 @@ export function registerShowCommand( .action( action(async (id: string | undefined, opts: ThreadLogCommandOptions) => { const resolved = requireThreadIdWithLabelOrSelf(id, opts); - const client = createClient(getUrl()); + const sdk = createCliBbSdk(getUrl()); const threadId = resolved.id; printContextLabel(resolved, "Thread", "BB_THREAD_ID", opts); const format = resolveThreadTimelineTextFormat(opts); @@ -384,25 +378,19 @@ export function registerShowCommand( } if (format === "json") { - const events = await unwrap( - client.api.v1.threads[":id"].events.$get({ - param: { id: threadId }, - query: { - limit: String(opts.limit ?? 100), - ...(opts.afterSeq ? { afterSeq: opts.afterSeq } : {}), - }, - }), - ); + const events = await sdk.threads.events.list({ + threadId, + limit: String(opts.limit ?? 100), + ...(opts.afterSeq ? { afterSeq: opts.afterSeq } : {}), + }); console.log(JSON.stringify(events, null, 2)); return; } - const timeline = await unwrap( - client.api.v1.threads[":id"].timeline.$get({ - param: { id: threadId }, - query: format === "verbose" ? { includeNestedRows: "true" } : {}, - }), - ); + const timeline: ThreadTimelineResponse = await sdk.threads.timeline({ + threadId, + ...(format === "verbose" ? { includeNestedRows: "true" } : {}), + }); const color = process.stdout.isTTY === true && !process.env.NO_COLOR; const text = formatThreadTimelineText(timeline.rows, { verbose: format === "verbose", @@ -418,12 +406,8 @@ export function registerShowCommand( .option("--json", "Print machine-readable JSON output") .action( action(async (id: string, opts: ThreadOutputCommandOptions) => { - const client = createClient(getUrl()); - const result = await unwrap<{ output: string | null }>( - client.api.v1.threads[":id"].output.$get({ - param: { id }, - }), - ); + const sdk = createCliBbSdk(getUrl()); + const result = await sdk.threads.output({ threadId: id }); if (outputJson(opts, result)) return; if (result.output) { console.log(result.output); diff --git a/apps/cli/src/commands/thread/spawn.ts b/apps/cli/src/commands/thread/spawn.ts index 182702c83..80c641dc8 100644 --- a/apps/cli/src/commands/thread/spawn.ts +++ b/apps/cli/src/commands/thread/spawn.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import { PERSONAL_PROJECT_ID, type Thread } from "@bb/domain"; import type { BaseBranchSpec, EnvironmentArgs } from "@bb/server-contract"; import { action } from "../../action.js"; -import { createClient, unwrap } from "../../client.js"; +import { createCliBbSdk } from "../../client.js"; import { resolveProjectId, resolveEnvironmentId, @@ -159,7 +159,6 @@ export function registerSpawnCommand( ) .action( action(async (opts: ThreadSpawnCommandOptions) => { - const client = createClient(getUrl()); if (opts.parentThread && opts.contextParentThread === false) { throw new Error( "Cannot combine --parent-thread with --no-context-parent-thread.", @@ -196,23 +195,20 @@ export function registerSpawnCommand( let thread: Thread; try { - thread = await unwrap( - client.api.v1.threads.$post({ - json: { - origin: "cli", - projectId, - ...(opts.provider ? { providerId: opts.provider } : {}), - ...(opts.model ? { model: opts.model } : {}), - input: [{ type: "text", text: opts.prompt }], - ...(reasoningLevel ? { reasoningLevel } : {}), - ...(opts.title ? { title: opts.title } : {}), - ...(serviceTier ? { serviceTier } : {}), - ...(permissionMode ? { permissionMode } : {}), - environment, - ...(parentThreadId ? { parentThreadId } : {}), - }, - }), - ); + const sdk = createCliBbSdk(getUrl()); + thread = await sdk.threads.spawn({ + origin: "cli", + projectId, + ...(opts.provider ? { providerId: opts.provider } : {}), + ...(opts.model ? { model: opts.model } : {}), + input: [{ type: "text", text: opts.prompt }], + ...(reasoningLevel ? { reasoningLevel } : {}), + ...(opts.title ? { title: opts.title } : {}), + ...(serviceTier ? { serviceTier } : {}), + ...(permissionMode ? { permissionMode } : {}), + environment, + ...(parentThreadId ? { parentThreadId } : {}), + }); } catch (err: unknown) { throw prependErrorContext("Failed to create thread", err); } diff --git a/apps/cli/src/commands/thread/wait.ts b/apps/cli/src/commands/thread/wait.ts index a83426177..b49790834 100644 --- a/apps/cli/src/commands/thread/wait.ts +++ b/apps/cli/src/commands/thread/wait.ts @@ -1,15 +1,12 @@ import { Command } from "commander"; import { - parseThreadEventRow, - type Thread, type ThreadStatus, threadStatusSchema, threadStatusValues, } from "@bb/domain"; import { assertNever } from "@bb/core-ui"; -import type { ThreadEventWaitQuery } from "@bb/server-contract"; import { action, CliExitError } from "../../action.js"; -import { createClient, unwrap } from "../../client.js"; +import { createCliBbSdk } from "../../client.js"; import { outputJson, printContextLabel, @@ -57,7 +54,7 @@ export function registerWaitCommand( .option("--json", "Print machine-readable JSON output") .action( action(async (id: string | undefined, opts: ThreadWaitCommandOptions) => { - const client = createClient(getUrl()); + const sdk = createCliBbSdk(getUrl()); const resolved = requireThreadIdWithLabel(id); const threadId = resolved.id; printContextLabel(resolved, "Thread", "BB_THREAD_ID", opts); @@ -68,9 +65,7 @@ export function registerWaitCommand( while (true) { if (target.kind === "status") { - const thread = await unwrap( - client.api.v1.threads[":id"].$get({ param: { id: threadId } }), - ); + const thread = await sdk.threads.get({ threadId }); if (thread.status === target.status) { if (outputJson(opts, { threadId, matched: true, target })) return; console.log( @@ -102,22 +97,13 @@ export function registerWaitCommand( const remainingMs = Math.max(0, deadline - Date.now()); const waitMs = Math.floor(Math.min(remainingMs, 30_000)); - const waitQuery: ThreadEventWaitQuery = { + const matched = await sdk.threads.events.wait({ + threadId, type: target.eventType, waitMs: String(waitMs), - }; - - const response = await client.api.v1.threads[ - ":id" - ].events.wait.$get({ - param: { id: threadId }, - query: waitQuery, }); - // Server returns 204 (no content) on timeout — the typed contract - // only declares the 200 shape, so widen the status to number. - const statusCode: number = response.status; - if (statusCode === 204) { + if (matched === null) { if (Date.now() >= deadline) { throw new CliExitError( `Timed out waiting for thread ${threadId} event ${target.eventType}.`, @@ -126,14 +112,8 @@ export function registerWaitCommand( } await sleep(pollIntervalMs); continue; - } else if (!response.ok) { - const body = await response.text(); - throw new Error( - `Wait request failed with ${statusCode}: ${body}`, - ); } - const matched = parseThreadEventRow(await response.json()); if (outputJson(opts, { threadId, matched: true, target })) return; console.log( `Thread ${threadId} observed event ${target.eventType} at seq ${matched.seq}.`, diff --git a/apps/cli/src/context-env.ts b/apps/cli/src/context-env.ts index 879d5afbf..eaa8071e4 100644 --- a/apps/cli/src/context-env.ts +++ b/apps/cli/src/context-env.ts @@ -1,5 +1,4 @@ import { loadCliConfig, type CliConfig } from "@bb/config/cli"; -import { DEFAULT_HOST_DAEMON_LOCAL_BIND_HOST } from "@bb/host-daemon-contract"; const VALID_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; @@ -40,12 +39,6 @@ export function resolveServerUrl( return context.cliConfig.BB_SERVER_URL; } -export function resolveHostDaemonUrl( - context: CliRuntimeContext = createCliRuntimeContext(), -): string { - return `http://${DEFAULT_HOST_DAEMON_LOCAL_BIND_HOST}:${context.cliConfig.BB_HOST_DAEMON_PORT}`; -} - export function resolveProjectId(flagValue?: string): string | undefined { const fromFlag = trimToUndefined(flagValue); if (fromFlag) return validateId(fromFlag, "--project flag"); diff --git a/apps/cli/src/daemon.ts b/apps/cli/src/daemon.ts index 2a3a39fed..121d40414 100644 --- a/apps/cli/src/daemon.ts +++ b/apps/cli/src/daemon.ts @@ -1,27 +1,11 @@ -import { createHostDaemonLocalClient } from "@bb/host-daemon-contract"; -import { resolveHostDaemonUrl } from "./context-env.js"; +import { fetchLocalHostId as fetchSdkLocalHostId } from "@bb/sdk/node"; let cachedHostId: string | null | undefined; -/** - * Fetch the local host ID from the host daemon. - * Returns null if the daemon is unreachable. - * Caches the result for the lifetime of the process. - */ export async function fetchLocalHostId(): Promise { - if (cachedHostId !== undefined) return cachedHostId; - try { - const client = createHostDaemonLocalClient(resolveHostDaemonUrl()); - const res = await client.status.$get(); - if (!res.ok) { - cachedHostId = null; - return null; - } - const body = (await res.json()) as { hostId: string }; - cachedHostId = body.hostId; + if (cachedHostId !== undefined) { return cachedHostId; - } catch { - cachedHostId = null; - return null; } + cachedHostId = await fetchSdkLocalHostId(); + return cachedHostId; } diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 3a7d285d0..96cc2a946 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -63,9 +63,14 @@ pnpm exec turbo run desktop:build --filter=@bb/desktop ``` Artifacts are written under `apps/desktop/release/`. The desktop build is -macOS-only and Apple Silicon arm64-only. Without signing secrets, local and CI -builds remain unsigned and macOS shows the normal Gatekeeper warning on first -launch. +macOS-only and Apple Silicon arm64-only. Without signing secrets, local builds +sign with a code-signing identity auto-discovered from the keychain and skip +notarization. A valid signature matters even for local builds: macOS +provenance-tracks unsigned apps, forcing syspolicyd to evaluate every exec in +the app's process tree, which can stall process launches system-wide. On +machines with no keychain identity (or with `CSC_IDENTITY_AUTO_DISCOVERY=false`, +as CI sets for workflow-artifact-only builds), artifacts remain unsigned and +macOS shows the normal Gatekeeper warning on first launch. ## Releasing @@ -93,8 +98,9 @@ immutable releases and `desktop-latest` for the moving pointer. ## macOS signing + notarization The desktop package is ready for Developer ID signing and Apple notarization. -Unsigned local builds continue to work with no secrets. To activate signed and -notarized release artifacts, add these GitHub Actions secrets: +Local builds with no secrets sign via keychain auto-discovery and skip +notarization. To activate signed and notarized release artifacts, add these +GitHub Actions secrets: | Secret | Value | | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/apps/desktop/electron-builder.config.json b/apps/desktop/electron-builder.config.json index 484813334..7d3daeb91 100644 --- a/apps/desktop/electron-builder.config.json +++ b/apps/desktop/electron-builder.config.json @@ -13,6 +13,11 @@ "assets/**", "dist/**", "node_modules/**", + { + "from": "node_modules/bb-app/server/dist/app-scaffold-template", + "to": "node_modules/bb-app/server/dist/app-scaffold-template", + "filter": ["**/*"] + }, "package.json", "!**/*.map" ], diff --git a/apps/desktop/scripts/run-electron-builder.mjs b/apps/desktop/scripts/run-electron-builder.mjs index e460939a2..8ed6be2ad 100644 --- a/apps/desktop/scripts/run-electron-builder.mjs +++ b/apps/desktop/scripts/run-electron-builder.mjs @@ -67,7 +67,7 @@ function logWarning(message) { } function logSigningPlan(signingPlan) { - if (signingPlan.codeSigningEnabled) { + if (signingPlan.mode === "environment") { if (signingPlan.identityName) { console.log( `macOS code signing enabled with CSC_NAME identity "${signingPlan.identityName}".`, @@ -77,9 +77,13 @@ function logSigningPlan(signingPlan) { "macOS code signing enabled; electron-builder will derive the identity from CSC_LINK.", ); } + } else if (signingPlan.mode === "keychain") { + console.log( + "macOS code signing via keychain auto-discovery; artifacts stay unsigned if no identity is installed. Notarization skipped.", + ); } else { logWarning( - "macOS signing/notarization skipped: no required signing secrets found. Local artifacts will be unsigned.", + "macOS signing skipped: CSC_IDENTITY_AUTO_DISCOVERY=false and no signing secrets found. Artifacts will be unsigned.", ); } @@ -88,6 +92,28 @@ function logSigningPlan(signingPlan) { } } +function autoDiscoveryExplicitlyDisabled(env) { + return ( + envValueIsSet(env.CSC_IDENTITY_AUTO_DISCOVERY) && + env.CSC_IDENTITY_AUTO_DISCOVERY.trim() === "false" + ); +} + +/** + * Resolves one of three signing modes: + * + * - "environment": all CI signing/notarization secrets are set — sign with the + * provided certificate and notarize (the published-release path). + * - "keychain": no secrets — sign with an auto-discovered keychain identity and + * skip notarization. Locally built apps never get the quarantine xattr, so + * notarization is unnecessary, but a valid signature is not optional: an + * unsigned bundle is provenance-tracked by macOS, which forces syspolicyd to + * evaluate every exec in the app's process tree and can stall execs + * system-wide. Machines without a signing identity fall back to unsigned + * artifacts inside electron-builder. + * - "disabled": no secrets and CSC_IDENTITY_AUTO_DISCOVERY=false — explicitly + * unsigned (the CI path for workflow-artifact-only builds). + */ function createSigningPlan(env) { const presentSigningKeys = presentEnvironmentKeys( requiredSigningEnvironmentKeys, @@ -106,19 +132,24 @@ function createSigningPlan(env) { presentSigningKeys, )}. Missing: ${formatEnvironmentKeyList( missingSigningKeys, - )}. Set all required keys or unset all of them for an unsigned local build.`, + )}. Set all required keys or unset all of them for a keychain-signed local build.`, ); } - const codeSigningEnabled = hasAllSigningKeys; - const identityName = envValueIsSet(env.CSC_NAME) - ? env.CSC_NAME.trim() - : undefined; + if (hasAllSigningKeys) { + return { + mode: "environment", + identityName: envValueIsSet(env.CSC_NAME) + ? env.CSC_NAME.trim() + : undefined, + notarizationEnabled: true, + }; + } return { - codeSigningEnabled, - identityName, - notarizationEnabled: codeSigningEnabled, + mode: autoDiscoveryExplicitlyDisabled(env) ? "disabled" : "keychain", + identityName: undefined, + notarizationEnabled: false, }; } @@ -130,14 +161,13 @@ export function resolveElectronBuilderConfig(baseConfig, env) { notarize: signingPlan.notarizationEnabled, }; - if (signingPlan.codeSigningEnabled) { - if (signingPlan.identityName) { - mac.identity = signingPlan.identityName; - } else { - delete mac.identity; - } - } else { + if (signingPlan.mode === "disabled") { mac.identity = null; + } else if (signingPlan.identityName) { + mac.identity = signingPlan.identityName; + } else { + // Let electron-builder resolve the identity (CSC_LINK or keychain). + delete mac.identity; } config.mac = mac; @@ -154,7 +184,7 @@ function createElectronBuilderEnv(signingPlan) { }; childEnv.CSC_IDENTITY_AUTO_DISCOVERY = - signingPlan.codeSigningEnabled && !signingPlan.identityName + signingPlan.mode !== "disabled" && !signingPlan.identityName ? "true" : "false"; diff --git a/apps/desktop/src/desktop-browser-view.ts b/apps/desktop/src/desktop-browser-view.ts index 303fdf0a0..9a0f8e6aa 100644 --- a/apps/desktop/src/desktop-browser-view.ts +++ b/apps/desktop/src/desktop-browser-view.ts @@ -1,19 +1,22 @@ import { WebContentsView, session, - type BrowserWindow, type Session, } from "electron"; import { BB_DESKTOP_BROWSER_MAX_TITLE_LENGTH, BB_DESKTOP_BROWSER_MAX_URL_LENGTH, - clampBbDesktopBrowserViewBounds, + bbDesktopBrowserViewBoundsFromLayoutDescriptor, + bbDesktopBrowserViewLayoutDescriptorFromBounds, type BbDesktopBrowserAttachRequest, type BbDesktopBrowserNavigateRequest, + type BbDesktopBrowserOpenTabRequest, type BbDesktopBrowserSetBoundsRequest, type BbDesktopBrowserSetVisibleRequest, type BbDesktopBrowserState, + type BbDesktopBrowserViewportBounds, type BbDesktopBrowserViewBounds, + type BbDesktopBrowserViewLayoutDescriptor, } from "@bb/server-contract"; import { BB_DESKTOP_BROWSER_OPEN_TAB_CHANNEL, @@ -50,7 +53,41 @@ const ERR_ABORTED = -3; interface BrowserViewEntry { view: WebContentsView; lastErrorText: string | null; + /** + * Resize-invariant placement derived main-side from the last + * renderer-measured rect (see {@link layoutFromRendererBounds}), cached so + * native window resizes can reproject synchronously without renderer IPC. + */ + layout: BbDesktopBrowserViewLayoutDescriptor; popupTimestamps: number[]; + visible: boolean; +} + +export type DesktopBrowserHostWebContentsPayload = + | BbDesktopBrowserState + | BbDesktopBrowserOpenTabRequest; + +export interface DesktopBrowserHostContentBounds { + height: number; + width: number; +} + +export interface DesktopBrowserHostContentView { + addChildView(view: WebContentsView): void; + removeChildView(view: WebContentsView): void; +} + +export interface DesktopBrowserHostWebContents { + id: number; + isDestroyed(): boolean; + send(channel: string, payload: DesktopBrowserHostWebContentsPayload): void; +} + +export interface DesktopBrowserHostWindow { + contentView: DesktopBrowserHostContentView; + getContentBounds(): DesktopBrowserHostContentBounds; + isDestroyed(): boolean; + webContents: DesktopBrowserHostWebContents; } interface CreateDesktopBrowserViewManagerArgs { @@ -58,26 +95,38 @@ interface CreateDesktopBrowserViewManagerArgs { } interface HostScopedRequestArgs { - hostWindow: BrowserWindow; + hostWindow: DesktopBrowserHostWindow; request: TRequest; } interface HostScopedTabArgs { - hostWindow: BrowserWindow; + hostWindow: DesktopBrowserHostWindow; tabId: string; } -interface ClampBoundsToHostWindowArgs { - bounds: BbDesktopBrowserViewBounds; - hostWindow: BrowserWindow; +interface CreateEntryArgs { + hostWindow: DesktopBrowserHostWindow; + layout: BbDesktopBrowserViewLayoutDescriptor; + tabId: string; +} + +interface HostWindowViewportBoundsArgs { + hostWindow: DesktopBrowserHostWindow; } -interface SetEntryBoundsArgs { +interface LayoutFromRendererBoundsArgs { bounds: BbDesktopBrowserViewBounds; + hostWindow: DesktopBrowserHostWindow; +} + +interface ApplyEntryLayoutArgs { entry: BrowserViewEntry; - hostWindow: BrowserWindow; + hostWindow: DesktopBrowserHostWindow; + layout: BbDesktopBrowserViewLayoutDescriptor; } +interface SetEntryLayoutArgs extends ApplyEntryLayoutArgs {} + export interface DesktopBrowserViewManager { attach(args: HostScopedRequestArgs): void; detach(args: HostScopedTabArgs): void; @@ -92,6 +141,12 @@ export interface DesktopBrowserViewManager { setVisible( args: HostScopedRequestArgs, ): void; + /** + * Reproject cached layout descriptors against the live BrowserWindow content + * size. Called from native resize events so visible WebContentsViews stay + * pinned without waiting for renderer layout or IPC. + */ + syncVisibleBoundsForWindow(hostWindow: DesktopBrowserHostWindow): void; /** * Drop every view owned by a closed host window. Keyed by the host * `webContents.id` because the host `BrowserWindow` (and its child views) are @@ -101,39 +156,66 @@ export interface DesktopBrowserViewManager { destroyAll(): void; } -function browserViewKey(hostWindow: BrowserWindow, tabId: string): string { +function browserViewKey( + hostWindow: DesktopBrowserHostWindow, + tabId: string, +): string { return `${hostWindow.webContents.id}:${tabId}`; } -function send(hostWindow: BrowserWindow, channel: string, payload: unknown): void { +function send( + hostWindow: DesktopBrowserHostWindow, + channel: string, + payload: DesktopBrowserHostWebContentsPayload, +): void { if (hostWindow.isDestroyed() || hostWindow.webContents.isDestroyed()) { return; } hostWindow.webContents.send(channel, payload); } -function clampBoundsToHostWindow( - args: ClampBoundsToHostWindowArgs, -): BbDesktopBrowserViewBounds { +function hostWindowViewportBounds( + args: HostWindowViewportBoundsArgs, +): BbDesktopBrowserViewportBounds { const contentBounds = args.hostWindow.getContentBounds(); - return clampBbDesktopBrowserViewBounds({ + return { + width: contentBounds.width, + height: contentBounds.height, + }; +} + +/** + * Derive the cached layout descriptor from a renderer-measured absolute rect. + * Derivation happens HERE, against the same `getContentBounds()` space the + * native-resize reprojection uses — never in the renderer, whose layout + * viewport (`window.innerWidth/innerHeight`) diverges from the window content + * area when DevTools is docked, which would misproject the view over the + * DevTools pane. + */ +function layoutFromRendererBounds( + args: LayoutFromRendererBoundsArgs, +): BbDesktopBrowserViewLayoutDescriptor { + return bbDesktopBrowserViewLayoutDescriptorFromBounds({ bounds: args.bounds, - viewport: { - width: contentBounds.width, - height: contentBounds.height, - }, + viewport: hostWindowViewportBounds({ hostWindow: args.hostWindow }), }); } -function setEntryBounds(args: SetEntryBoundsArgs): void { +function applyEntryLayout(args: ApplyEntryLayoutArgs): void { + const viewport = hostWindowViewportBounds({ hostWindow: args.hostWindow }); args.entry.view.setBounds( - clampBoundsToHostWindow({ - bounds: args.bounds, - hostWindow: args.hostWindow, + bbDesktopBrowserViewBoundsFromLayoutDescriptor({ + layout: args.layout, + viewport, }), ); } +function setEntryLayout(args: SetEntryLayoutArgs): void { + args.entry.layout = args.layout; + applyEntryLayout(args); +} + function buildBrowserState( tabId: string, entry: BrowserViewEntry, @@ -191,7 +273,10 @@ export function createDesktopBrowserViewManager( return browserSession; } - function pushState(hostWindow: BrowserWindow, tabId: string): void { + function pushState( + hostWindow: DesktopBrowserHostWindow, + tabId: string, + ): void { const entry = entries.get(browserViewKey(hostWindow, tabId)); if (!entry || entry.view.webContents.isDestroyed()) { return; @@ -204,7 +289,7 @@ export function createDesktopBrowserViewManager( } function wireWebContents( - hostWindow: BrowserWindow, + hostWindow: DesktopBrowserHostWindow, tabId: string, entry: BrowserViewEntry, ): void { @@ -269,10 +354,7 @@ export function createDesktopBrowserViewManager( ); } - function createEntry( - hostWindow: BrowserWindow, - tabId: string, - ): BrowserViewEntry { + function createEntry(args: CreateEntryArgs): BrowserViewEntry { ensureHardenedSession(); const view = new WebContentsView({ webPreferences: { @@ -289,11 +371,13 @@ export function createDesktopBrowserViewManager( const entry: BrowserViewEntry = { view, lastErrorText: null, + layout: args.layout, popupTimestamps: [], + visible: false, }; - wireWebContents(hostWindow, tabId, entry); - hostWindow.contentView.addChildView(view); - entries.set(browserViewKey(hostWindow, tabId), entry); + wireWebContents(args.hostWindow, args.tabId, entry); + args.hostWindow.contentView.addChildView(view); + entries.set(browserViewKey(args.hostWindow, args.tabId), entry); return entry; } @@ -310,7 +394,10 @@ export function createDesktopBrowserViewManager( }); } - function destroyEntry(hostWindow: BrowserWindow, key: string): void { + function destroyEntry( + hostWindow: DesktopBrowserHostWindow, + key: string, + ): void { const entry = entries.get(key); if (!entry) { return; @@ -338,9 +425,16 @@ export function createDesktopBrowserViewManager( return { attach({ hostWindow, request }) { const key = browserViewKey(hostWindow, request.tabId); - const entry = entries.get(key) ?? createEntry(hostWindow, request.tabId); - setEntryBounds({ hostWindow, entry, bounds: request.bounds }); - entry.view.setVisible(request.visible); + const layout = layoutFromRendererBounds({ + bounds: request.bounds, + hostWindow, + }); + const entry = + entries.get(key) ?? + createEntry({ hostWindow, layout, tabId: request.tabId }); + setEntryLayout({ entry, hostWindow, layout }); + entry.visible = request.visible; + entry.view.setVisible(entry.visible); loadIfNeeded(entry, request.url); pushState(hostWindow, request.tabId); }, @@ -378,14 +472,35 @@ export function createDesktopBrowserViewManager( }, setBounds({ hostWindow, request }) { withEntry({ hostWindow, tabId: request.tabId }, (entry) => { - setEntryBounds({ hostWindow, entry, bounds: request.bounds }); + setEntryLayout({ + entry, + hostWindow, + layout: layoutFromRendererBounds({ + bounds: request.bounds, + hostWindow, + }), + }); }); }, setVisible({ hostWindow, request }) { withEntry({ hostWindow, tabId: request.tabId }, (entry) => { - entry.view.setVisible(request.visible); + entry.visible = request.visible; + entry.view.setVisible(entry.visible); }); }, + syncVisibleBoundsForWindow(hostWindow) { + const prefix = `${hostWindow.webContents.id}:`; + for (const [key, entry] of entries.entries()) { + if ( + !key.startsWith(prefix) || + !entry.visible || + entry.view.webContents.isDestroyed() + ) { + continue; + } + applyEntryLayout({ hostWindow, entry, layout: entry.layout }); + } + }, releaseWindow(hostWebContentsId) { const prefix = `${hostWebContentsId}:`; for (const [key, entry] of [...entries.entries()]) { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f67d1e448..066a2ba7c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -731,6 +731,30 @@ interface DesktopBrowserTabCommandArgs { tabId: string; } +interface DesktopBrowserWindowLifecycleArgs { + browserWindow: BrowserWindow; + manager: DesktopBrowserViewManager; +} + +function registerDesktopBrowserWindowLifecycle({ + browserWindow, + manager, +}: DesktopBrowserWindowLifecycleArgs): void { + const hostWebContentsId = browserWindow.webContents.id; + const syncVisibleBounds = () => { + manager.syncVisibleBoundsForWindow(browserWindow); + }; + // `resize` fires per tick during an interactive resize, after the bounds + // change, so reprojecting here is the synchronous lockstep path. `will-resize` + // is intentionally NOT registered: it fires before the bounds change, so + // `getContentBounds()` still reports the old size and reprojection would be + // an inert duplicate of the work this listener does one event later. + browserWindow.on("resize", syncVisibleBounds); + browserWindow.once("closed", () => { + manager.releaseWindow(hostWebContentsId); + }); +} + function registerDesktopBrowserIpc(manager: DesktopBrowserViewManager): void { // Every browser command is renderer → main fire-and-forget; navigation state // flows back over `BB_DESKTOP_BROWSER_STATE_CHANNEL`. Each handler resolves @@ -999,9 +1023,12 @@ async function runDesktopApp(): Promise { void desktopAutoUpdateService?.checkAfterActive(); }); app.on("browser-window-created", (_event, browserWindow) => { - const hostWebContentsId = browserWindow.webContents.id; - browserWindow.once("closed", () => { - desktopBrowserViewManager?.releaseWindow(hostWebContentsId); + if (desktopBrowserViewManager === null) { + return; + } + registerDesktopBrowserWindowLifecycle({ + browserWindow, + manager: desktopBrowserViewManager, }); }); registerDesktopShutdownSignalHandlers({ diff --git a/apps/desktop/test/desktop-browser-bounds.test.ts b/apps/desktop/test/desktop-browser-bounds.test.ts index 3109aff61..af5281468 100644 --- a/apps/desktop/test/desktop-browser-bounds.test.ts +++ b/apps/desktop/test/desktop-browser-bounds.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; import { + bbDesktopBrowserViewBoundsFromLayoutDescriptor, + bbDesktopBrowserViewLayoutDescriptorFromBounds, clampBbDesktopBrowserViewBounds, type BbDesktopBrowserViewBounds, + type BbDesktopBrowserViewLayoutDescriptor, type BbDesktopBrowserViewportBounds, } from "@bb/server-contract"; @@ -12,6 +15,13 @@ interface BrowserBoundsClampTestCase { viewport: BbDesktopBrowserViewportBounds; } +interface BrowserLayoutProjectionTestCase { + expected: BbDesktopBrowserViewBounds; + label: string; + layout: BbDesktopBrowserViewLayoutDescriptor; + viewport: BbDesktopBrowserViewportBounds; +} + const browserBoundsClampTestCases: BrowserBoundsClampTestCase[] = [ { label: "anchors the left edge and trims overflow at the right and bottom", @@ -33,6 +43,21 @@ const browserBoundsClampTestCases: BrowserBoundsClampTestCase[] = [ }, ]; +const browserLayoutProjectionTestCases: BrowserLayoutProjectionTestCase[] = [ + { + label: "projects right and bottom edges from cached insets", + layout: { left: 240, top: 72, rightInset: 0, bottomInset: 0 }, + viewport: { width: 900, height: 640 }, + expected: { x: 240, y: 72, width: 660, height: 568 }, + }, + { + label: "collapses when insets exceed the live content size", + layout: { left: 240, top: 72, rightInset: 700, bottomInset: 500 }, + viewport: { width: 500, height: 360 }, + expected: { x: 240, y: 72, width: 0, height: 0 }, + }, +]; + describe("desktop browser bounds containment", () => { it.each(browserBoundsClampTestCases)("$label", (testCase) => { expect( @@ -42,4 +67,43 @@ describe("desktop browser bounds containment", () => { }), ).toEqual(testCase.expected); }); + + it.each(browserLayoutProjectionTestCases)("$label", (testCase) => { + expect( + bbDesktopBrowserViewBoundsFromLayoutDescriptor({ + layout: testCase.layout, + viewport: testCase.viewport, + }), + ).toEqual(testCase.expected); + }); + + it("round-trips a clamped absolute rect into resize-invariant insets", () => { + const bounds: BbDesktopBrowserViewBounds = { + x: 180, + y: 48, + width: 400, + height: 420, + }; + const viewport: BbDesktopBrowserViewportBounds = { width: 500, height: 360 }; + const layout = bbDesktopBrowserViewLayoutDescriptorFromBounds({ + bounds, + viewport, + }); + + expect(layout).toEqual({ + left: 180, + top: 48, + rightInset: 0, + bottomInset: 0, + }); + expect( + bbDesktopBrowserViewLayoutDescriptorFromBounds({ + bounds: bbDesktopBrowserViewBoundsFromLayoutDescriptor({ + layout, + viewport, + }), + viewport, + }), + ).toEqual(layout); + }); }); diff --git a/apps/desktop/test/desktop-browser-policy.test.ts b/apps/desktop/test/desktop-browser-policy.test.ts index b374eda8c..2592dd64c 100644 --- a/apps/desktop/test/desktop-browser-policy.test.ts +++ b/apps/desktop/test/desktop-browser-policy.test.ts @@ -47,6 +47,9 @@ describe("resolveWindowOpenAction", () => { }); describe("browser IPC payload schemas", () => { + // The desktop shell hosts whatever SPA the probed bb server serves (no + // version handshake), so these request shapes are wire-frozen: they must + // keep accepting exactly the historical bounds-only payloads. it("accepts a well-formed attach request and rejects bad shapes", () => { expect( bbDesktopBrowserAttachRequestSchema.safeParse({ @@ -81,6 +84,17 @@ describe("browser IPC payload schemas", () => { extra: true, }).success, ).toBe(false); + // A layout descriptor never crosses the IPC boundary; older shells' + // strict parsers would drop the whole request if a renderer sent one. + expect( + bbDesktopBrowserAttachRequestSchema.safeParse({ + tabId: "browser:abc", + url: "", + bounds: { x: 0, y: 0, width: 800, height: 600 }, + layout: { left: 0, top: 0, rightInset: 0, bottomInset: 0 }, + visible: false, + }).success, + ).toBe(false); }); it("accepts a well-formed state push and rejects non-integer bounds", () => { diff --git a/apps/desktop/test/desktop-browser-view-manager.test.ts b/apps/desktop/test/desktop-browser-view-manager.test.ts new file mode 100644 index 000000000..20513ccb0 --- /dev/null +++ b/apps/desktop/test/desktop-browser-view-manager.test.ts @@ -0,0 +1,351 @@ +import type { WebContentsView } from "electron"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { BbDesktopBrowserViewBounds } from "@bb/server-contract"; +import { + createDesktopBrowserViewManager, + type DesktopBrowserHostContentBounds, + type DesktopBrowserHostContentView, + type DesktopBrowserHostWebContents, + type DesktopBrowserHostWebContentsPayload, + type DesktopBrowserHostWindow, +} from "../src/desktop-browser-view.js"; + +type FakeWebContentsListener = (...args: never[]) => void; + +interface FakeWindowOpenDetails { + url: string; +} + +interface FakeWindowOpenDecision { + action: "deny"; +} + +type FakeWindowOpenHandler = ( + details: FakeWindowOpenDetails, +) => FakeWindowOpenDecision; + +const electronMock = vi.hoisted(() => { + class FakeWebContents { + public readonly navigationHistory = { + canGoBack() { + return false; + }, + canGoForward() { + return false; + }, + goBack() {}, + goForward() {}, + }; + + close(): void {} + + getTitle(): string { + return ""; + } + + getURL(): string { + return ""; + } + + isDestroyed(): boolean { + return false; + } + + isLoadingMainFrame(): boolean { + return false; + } + + loadURL(_url: string): Promise { + return Promise.resolve(); + } + + on(_eventName: string, _listener: FakeWebContentsListener): void {} + + reload(): void {} + + setWindowOpenHandler(_handler: FakeWindowOpenHandler): void {} + + stop(): void {} + } + + class FakeWebContentsView { + public readonly boundsCalls: BbDesktopBrowserViewBounds[] = []; + public readonly webContents = new FakeWebContents(); + public visible = false; + + setBounds(bounds: BbDesktopBrowserViewBounds): void { + this.boundsCalls.push(bounds); + } + + setVisible(visible: boolean): void { + this.visible = visible; + } + } + + const fakeViews: FakeWebContentsView[] = []; + + return { + fakeViews, + FakeWebContentsView: class extends FakeWebContentsView { + constructor() { + super(); + fakeViews.push(this); + } + }, + session: { + fromPartition() { + return { + on() {}, + setPermissionCheckHandler() {}, + setPermissionRequestHandler() {}, + webRequest: { + onBeforeRequest() {}, + }, + }; + }, + }, + }; +}); + +vi.mock("electron", () => ({ + WebContentsView: electronMock.FakeWebContentsView, + session: electronMock.session, +})); + +interface FakeHostWindowArgs { + contentBounds: DesktopBrowserHostContentBounds; + webContentsId: number; +} + +class FakeHostWebContents implements DesktopBrowserHostWebContents { + public destroyed = false; + public readonly sentPayloads: DesktopBrowserHostWebContentsPayload[] = []; + public readonly id: number; + + constructor(id: number) { + this.id = id; + } + + isDestroyed(): boolean { + return this.destroyed; + } + + send(_channel: string, payload: DesktopBrowserHostWebContentsPayload): void { + this.sentPayloads.push(payload); + } +} + +class FakeContentView implements DesktopBrowserHostContentView { + public readonly addedViews: WebContentsView[] = []; + public readonly removedViews: WebContentsView[] = []; + + addChildView(view: WebContentsView): void { + this.addedViews.push(view); + } + + removeChildView(view: WebContentsView): void { + this.removedViews.push(view); + } +} + +class FakeHostWindow implements DesktopBrowserHostWindow { + public contentBounds: DesktopBrowserHostContentBounds; + public destroyed = false; + public readonly contentView = new FakeContentView(); + public readonly webContents: FakeHostWebContents; + + constructor({ contentBounds, webContentsId }: FakeHostWindowArgs) { + this.contentBounds = contentBounds; + this.webContents = new FakeHostWebContents(webContentsId); + } + + getContentBounds(): DesktopBrowserHostContentBounds { + return this.contentBounds; + } + + isDestroyed(): boolean { + return this.destroyed; + } +} + +beforeEach(() => { + electronMock.fakeViews.length = 0; +}); + +describe("DesktopBrowserViewManager", () => { + it("reprojects visible view bounds from the cached layout descriptor on host resize", () => { + const manager = createDesktopBrowserViewManager({ partition: "persist:test" }); + const hostWindow = new FakeHostWindow({ + contentBounds: { width: 700, height: 450 }, + webContentsId: 41, + }); + + manager.attach({ + hostWindow, + request: { + tabId: "browser:a", + url: "", + bounds: { x: 100, y: 50, width: 500, height: 350 }, + visible: true, + }, + }); + + const view = electronMock.fakeViews[0]; + expect(view).toBeDefined(); + if (view === undefined) { + throw new Error("Expected the browser view to be created."); + } + expect(view.boundsCalls[0]).toEqual({ + x: 100, + y: 50, + width: 500, + height: 350, + }); + + hostWindow.contentBounds = { width: 80, height: 40 }; + manager.syncVisibleBoundsForWindow(hostWindow); + + expect(view.boundsCalls[1]).toEqual({ + x: 80, + y: 40, + width: 0, + height: 0, + }); + + hostWindow.contentBounds = { width: 900, height: 640 }; + manager.syncVisibleBoundsForWindow(hostWindow); + + expect(view.boundsCalls[2]).toEqual({ + x: 100, + y: 50, + width: 700, + height: 540, + }); + }); + + it("derives insets from the window content bounds, not the renderer viewport (docked DevTools)", () => { + const manager = createDesktopBrowserViewManager({ partition: "persist:test" }); + // Host window content is 1000x900 but the renderer's page viewport is only + // 1000x500 (DevTools docked bottom): the renderer-measured rect ends at + // y=500 even though the window content area is 900 tall. + const hostWindow = new FakeHostWindow({ + contentBounds: { width: 1000, height: 900 }, + webContentsId: 43, + }); + + manager.attach({ + hostWindow, + request: { + tabId: "browser:a", + url: "", + bounds: { x: 600, y: 100, width: 400, height: 400 }, + visible: true, + }, + }); + + const view = electronMock.fakeViews[0]; + expect(view).toBeDefined(); + if (view === undefined) { + throw new Error("Expected the browser view to be created."); + } + // Steady state: the absolute renderer rect is authoritative. A descriptor + // measured against the renderer's 500px-tall page viewport (bottomInset 0) + // would project to the full 900px content height, stretching the view over + // the DevTools pane. + expect(view.boundsCalls[0]).toEqual({ + x: 600, + y: 100, + width: 400, + height: 400, + }); + + // Native window resize: the insets were derived against the content edge, + // so the view tracks the growing page region (the DevTools pane keeps its + // size during a window resize) instead of jumping coordinate spaces. + hostWindow.contentBounds = { width: 1000, height: 1000 }; + manager.syncVisibleBoundsForWindow(hostWindow); + expect(view.boundsCalls[1]).toEqual({ + x: 600, + y: 100, + width: 400, + height: 500, + }); + }); + + it("rederives the cached layout from renderer bounds on setBounds", () => { + const manager = createDesktopBrowserViewManager({ partition: "persist:test" }); + const hostWindow = new FakeHostWindow({ + contentBounds: { width: 700, height: 450 }, + webContentsId: 44, + }); + + manager.attach({ + hostWindow, + request: { + tabId: "browser:a", + url: "", + bounds: { x: 100, y: 50, width: 500, height: 350 }, + visible: true, + }, + }); + manager.setBounds({ + hostWindow, + request: { + tabId: "browser:a", + bounds: { x: 200, y: 90, width: 400, height: 300 }, + }, + }); + + const view = electronMock.fakeViews[0]; + expect(view).toBeDefined(); + if (view === undefined) { + throw new Error("Expected the browser view to be created."); + } + expect(view.boundsCalls[1]).toEqual({ + x: 200, + y: 90, + width: 400, + height: 300, + }); + + // The reprojection cache must follow the latest renderer rect: insets are + // now 100/60, so a host resize projects from those, not the attach-time ones. + hostWindow.contentBounds = { width: 900, height: 640 }; + manager.syncVisibleBoundsForWindow(hostWindow); + expect(view.boundsCalls[2]).toEqual({ + x: 200, + y: 90, + width: 600, + height: 490, + }); + }); + + it("does not resize hidden views from the native host resize path", () => { + const manager = createDesktopBrowserViewManager({ partition: "persist:test" }); + const hostWindow = new FakeHostWindow({ + contentBounds: { width: 700, height: 450 }, + webContentsId: 42, + }); + + manager.attach({ + hostWindow, + request: { + tabId: "browser:a", + url: "", + bounds: { x: 100, y: 50, width: 500, height: 350 }, + visible: false, + }, + }); + + const view = electronMock.fakeViews[0]; + expect(view).toBeDefined(); + if (view === undefined) { + throw new Error("Expected the browser view to be created."); + } + + hostWindow.contentBounds = { width: 900, height: 640 }; + manager.syncVisibleBoundsForWindow(hostWindow); + + expect(view.boundsCalls).toHaveLength(1); + }); +}); diff --git a/apps/desktop/test/electron-builder-config.test.ts b/apps/desktop/test/electron-builder-config.test.ts index 4862dd7e5..ea28bd06c 100644 --- a/apps/desktop/test/electron-builder-config.test.ts +++ b/apps/desktop/test/electron-builder-config.test.ts @@ -28,6 +28,19 @@ const macConfigSchema = z }) .passthrough(); +const electronBuilderFileSetSchema = z + .object({ + filter: z.array(z.string().min(1)), + from: z.string().min(1), + to: z.string().min(1), + }) + .passthrough(); + +const electronBuilderFilePatternSchema = z.union([ + z.string().min(1), + electronBuilderFileSetSchema, +]); + const electronBuilderConfigSchema = z .object({ afterPack: z.string().min(1), @@ -37,7 +50,7 @@ const electronBuilderConfigSchema = z sign: z.boolean(), }) .passthrough(), - files: z.array(z.string().min(1)), + files: z.array(electronBuilderFilePatternSchema), mac: macConfigSchema, npmRebuild: z.literal(false), publish: z.tuple([ @@ -240,6 +253,23 @@ describe("electron-builder signing config", () => { expect(config.files).toContain("!**/*.map"); }); + it("copies the app scaffold template as a dedicated file set", async () => { + const configText = await readFile( + resolve(desktopPackageRoot, "electron-builder.config.json"), + "utf8", + ); + const config = electronBuilderConfigSchema.parse(JSON.parse(configText)); + + // electron-builder prunes *.d.ts while collecting node_modules. The + // scaffold source is user-editable template content, so copy that subtree + // separately without relaxing dependency pruning for the rest of node_modules. + expect(config.files).toContainEqual({ + filter: ["**/*"], + from: "node_modules/bb-app/server/dist/app-scaffold-template", + to: "node_modules/bb-app/server/dist/app-scaffold-template", + }); + }); + it("patches packaged node-pty helper path handling", async () => { const appOutDir = await mkdtemp( resolve(tmpdir(), "bb-desktop-native-modules-"), @@ -327,14 +357,26 @@ describe("electron-builder signing config", () => { expect(config.publish[0]).toMatchObject(DESKTOP_AUTO_UPDATE_FEED_CONFIG); }); - it("keeps local builds unsigned when signing secrets are absent", async () => { + it("signs local builds via keychain auto-discovery when signing secrets are absent", async () => { + // An unsigned bundle is provenance-tracked by macOS, which makes syspolicyd + // evaluate every exec in the app's process tree — local builds must sign + // with a keychain identity when one is available. const { config } = await readResolvedConfig({}); - expect(config.mac.identity).toBeNull(); + expect(config.mac).not.toHaveProperty("identity"); expect(config.mac.notarize).toBe(false); expect(config.dmg.sign).toBe(false); }); + it("keeps builds unsigned when keychain auto-discovery is explicitly disabled", async () => { + const { config } = await readResolvedConfig({ + CSC_IDENTITY_AUTO_DISCOVERY: "false", + }); + + expect(config.mac.identity).toBeNull(); + expect(config.mac.notarize).toBe(false); + }); + it("rejects partial signing secret sets", async () => { const partialAppleCredentials = await runConfigScript({ APPLE_ID: "sawyer@example.com", diff --git a/apps/host-daemon/src/app.ts b/apps/host-daemon/src/app.ts index 68aebeb95..8b6bd8820 100644 --- a/apps/host-daemon/src/app.ts +++ b/apps/host-daemon/src/app.ts @@ -576,6 +576,12 @@ export async function createHostDaemonApp( onApplicationDataResync: (change) => { void appDataChangeReporter.requestResync(change); }, + onApplicationContentChanged: ({ applicationId }) => { + sendServerMessage({ + type: "application-content-changed", + applicationId, + }); + }, onInjectedSkillsChanged: (change) => { options.logger.debug( { diff --git a/apps/host-daemon/src/command-dispatch-support.ts b/apps/host-daemon/src/command-dispatch-support.ts index 960aac613..294ed7fa7 100644 --- a/apps/host-daemon/src/command-dispatch-support.ts +++ b/apps/host-daemon/src/command-dispatch-support.ts @@ -198,6 +198,11 @@ export async function requireWorkspaceEnvironment( dataDir?: string; environmentId: string; injectedSkillSources?: readonly HostDaemonInjectedSkillSource[]; + /** + * Set by thread commands that resolve with injectedSkillSources, so a + * busy runtime is reused instead of conflicting; see EnsureEnvironmentArgs. + */ + targetThreadId?: string; workspaceContext: WorkspaceContext; }, runtimeManager: RuntimeManager, @@ -218,6 +223,9 @@ export async function requireWorkspaceEnvironment( ...(args.injectedSkillSources !== undefined ? { injectedSkillSources: args.injectedSkillSources } : {}), + ...(args.targetThreadId !== undefined + ? { targetThreadId: args.targetThreadId } + : {}), ...(args.dataDir ? { personalWorkspaceRoot: getPersonalWorkspaceRoot(args.dataDir) } : {}), diff --git a/apps/host-daemon/src/command-dispatch.test.ts b/apps/host-daemon/src/command-dispatch.test.ts index 5090828b4..424efa8c8 100644 --- a/apps/host-daemon/src/command-dispatch.test.ts +++ b/apps/host-daemon/src/command-dispatch.test.ts @@ -1,16 +1,118 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import type { AgentRuntime } from "@bb/agent-runtime"; +import type { HostDaemonInjectedSkillSource } from "@bb/host-daemon-contract"; import type { HostWorkspace } from "@bb/host-workspace"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi, type Mock } from "vitest"; import { dispatchCommand } from "./command-dispatch.js"; import type { CommandOf } from "./command-dispatch-support.js"; import { RuntimeManager } from "./runtime-manager.js"; +const WORKSPACE_PATH = "/tmp/bb-command-dispatch-test"; + interface Deferred { promise: Promise; resolve: (value: TValue | PromiseLike) => void; reject: (reason?: Error) => void; } +interface WriteInjectedSkillSourceArgs { + dataDir: string; + token: string; +} + +interface BusySkillCatalogFixture { + createRuntimeSpy: Mock<() => AgentRuntime>; + dataDir: string; + manager: RuntimeManager; + originalCatalogHash: string | null; + runtime: AgentRuntime; + source: HostDaemonInjectedSkillSource; +} + +const tempDirs: string[] = []; + +async function makeTempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); +}); + +async function writeInjectedSkillSource( + args: WriteInjectedSkillSourceArgs, +): Promise { + const sourceRootPath = path.join(args.dataDir, "skills", "release-notes"); + await fs.mkdir(sourceRootPath, { recursive: true }); + await fs.writeFile( + path.join(sourceRootPath, "SKILL.md"), + [ + "---", + "name: release-notes", + "description: Use release-notes when command dispatch tests run.", + "---", + "", + args.token, + "", + ].join("\n"), + "utf8", + ); + return { + sourceType: "data-dir", + applicationId: null, + name: "release-notes", + description: "Use release-notes when command dispatch tests run.", + sourceRootPath, + skillFilePath: path.join(sourceRootPath, "SKILL.md"), + }; +} + +/** + * Builds the thread-brick scenario the catalog-deferral fix targets: an + * environment whose runtime was created with an injected skill catalog, made + * busy by an active thread, after which the skill source content changes so + * the next staged catalog hash no longer matches the loaded runtime's. + */ +async function setupBusySkillCatalogEnvironment(args: { + activeThreadId: string; +}): Promise { + const dataDir = await makeTempDir("bb-command-dispatch-skills-"); + const source = await writeInjectedSkillSource({ + dataDir, + token: "first-token", + }); + const runtime = createRuntime(); + const createRuntimeSpy = vi.fn(() => runtime); + const manager = new RuntimeManager({ + dataDir, + createRuntime: createRuntimeSpy, + provisionWorkspace: async () => createWorkspace(), + }); + const entry = await manager.ensureEnvironment({ + environmentId: "env-1", + injectedSkillSources: [source], + workspacePath: WORKSPACE_PATH, + }); + manager.markThreadActive("env-1", args.activeThreadId, "provider-thread-1"); + await writeInjectedSkillSource({ dataDir, token: "second-token" }); + return { + createRuntimeSpy, + dataDir, + manager, + originalCatalogHash: entry.skillCatalogHash, + runtime, + source, + }; +} + function createDeferred(): Deferred { let resolve!: Deferred["resolve"]; let reject!: Deferred["reject"]; @@ -27,7 +129,7 @@ async function unexpectedWorkspaceCall(): Promise { function createWorkspace(): HostWorkspace { return { - path: "/tmp/bb-command-dispatch-test", + path: WORKSPACE_PATH, managed: false, isGitRepo: false, isWorktree: false, @@ -182,4 +284,125 @@ describe("dispatchCommand", () => { expect(result).toEqual({}); expect(runtime.renameThread).not.toHaveBeenCalled(); }); + + // Regression: a thread.start whose freshly staged skill catalog differed + // from the busy runtime's catalog used to fail the command (and brick the + // thread) instead of reusing the runtime. This drives the real plumbing — + // the handler's targetThreadId carried through workspace resolution into + // RuntimeManager.ensureEnvironment. + it("reuses a busy runtime when thread.start carries a changed skill catalog", async () => { + const fixture = await setupBusySkillCatalogEnvironment({ + activeThreadId: "sibling-thread", + }); + const command: CommandOf<"thread.start"> = { + type: "thread.start", + environmentId: "env-1", + threadId: "thread-1", + workspaceContext: { + workspacePath: WORKSPACE_PATH, + workspaceProvisionType: "unmanaged", + }, + projectId: "proj_1", + providerId: "codex", + requestId: "creq_2345678923", + input: [{ type: "text", text: "hello" }], + options: { + model: "gpt-5", + serviceTier: "default", + reasoningLevel: "medium", + workflowsEnabled: false, + permissionMode: "full", + permissionEscalation: null, + }, + instructions: "Be concise.", + dynamicTools: [], + injectedSkillSources: [fixture.source], + instructionMode: "append", + }; + + const result = await dispatchCommand(command, { + dataDir: fixture.dataDir, + eventSink: { + emit: vi.fn(), + flush: vi.fn(async () => undefined), + }, + fetchProjectAttachment: async () => { + throw new Error("Unexpected project attachment fetch"); + }, + runtimeManager: fixture.manager, + threadStorageRootPath: "/tmp/bb-thread-storage", + }); + + expect(result.providerThreadId).toBe("provider-thread-1"); + expect(fixture.runtime.startThread).toHaveBeenCalledTimes(1); + expect(fixture.createRuntimeSpy).toHaveBeenCalledTimes(1); + expect(fixture.runtime.shutdown).not.toHaveBeenCalled(); + // The stale catalog stays bound; the refresh is deferred until idle. + expect(fixture.manager.get("env-1")?.skillCatalogHash).toBe( + fixture.originalCatalogHash, + ); + }); + + // Regression: the self-brick case — an agent installs a skill mid-turn, so + // the next turn.submit for its own (active) thread stages a different + // catalog hash. The command must reuse the busy runtime instead of failing + // and dropping the message. + it("reuses a busy runtime when turn.submit carries a changed skill catalog", async () => { + const fixture = await setupBusySkillCatalogEnvironment({ + activeThreadId: "thread-1", + }); + const command: CommandOf<"turn.submit"> = { + type: "turn.submit", + environmentId: "env-1", + threadId: "thread-1", + requestId: "creq_2345678923", + input: [{ type: "text", text: "follow up" }], + options: { + model: "gpt-5", + serviceTier: "default", + reasoningLevel: "medium", + workflowsEnabled: false, + permissionMode: "full", + permissionEscalation: null, + }, + resumeContext: { + workspaceContext: { + workspacePath: WORKSPACE_PATH, + workspaceProvisionType: "unmanaged", + }, + projectId: "proj_1", + providerId: "codex", + providerThreadId: "provider-thread-1", + instructions: "Be concise.", + dynamicTools: [], + injectedSkillSources: [fixture.source], + instructionMode: "append", + }, + target: { mode: "start" }, + }; + + const result = await dispatchCommand(command, { + dataDir: fixture.dataDir, + eventSink: { + emit: vi.fn(), + flush: vi.fn(async () => undefined), + }, + fetchProjectAttachment: async () => { + throw new Error("Unexpected project attachment fetch"); + }, + runtimeManager: fixture.manager, + threadStorageRootPath: "/tmp/bb-thread-storage", + }); + + expect(result).toEqual({ appliedAs: "new-turn" }); + expect(fixture.runtime.runTurn).toHaveBeenCalledTimes(1); + // The runtime already hosts the thread, so no resume round-trip happens. + expect(fixture.runtime.resumeThread).not.toHaveBeenCalled(); + expect(fixture.createRuntimeSpy).toHaveBeenCalledTimes(1); + expect(fixture.runtime.shutdown).not.toHaveBeenCalled(); + // The stale catalog stays bound; the refresh is deferred until idle. + expect(fixture.manager.get("env-1")?.skillCatalogHash).toBe( + fixture.originalCatalogHash, + ); + }); }); diff --git a/apps/host-daemon/src/command-dispatch.ts b/apps/host-daemon/src/command-dispatch.ts index bc3412e1b..61a51a3bc 100644 --- a/apps/host-daemon/src/command-dispatch.ts +++ b/apps/host-daemon/src/command-dispatch.ts @@ -42,7 +42,6 @@ import { completeCodexInference, transcribeCodexVoice, } from "./codex-chatgpt-client.js"; -import { listManagerTemplatesCommand } from "./command-handlers/manager-templates.js"; import { ensureThreadRuntime, handleThreadDeleted, @@ -353,8 +352,6 @@ const onlineRpcHandlers: OnlineRpcHandlerMap = { "host.list_files": listHostFiles, "host.list_paths": listHostPaths, "host.list_branches": listHostBranches, - "host.list_manager_templates": async (command, options) => - listManagerTemplatesCommand(command, { dataDir: options.dataDir }), "host.file_metadata": readHostFileMetadata, "host.read_file": readHostFile, "host.read_file_relative": readHostRelativeFile, diff --git a/apps/host-daemon/src/command-handlers/manager-templates.test.ts b/apps/host-daemon/src/command-handlers/manager-templates.test.ts deleted file mode 100644 index 150babca1..000000000 --- a/apps/host-daemon/src/command-handlers/manager-templates.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { listManagerTemplates } from "./manager-templates.js"; - -const tempDirs: string[] = []; - -async function makeTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -async function writeTemplate(args: { - dataDir: string; - name: string; - files: Record; -}): Promise { - const templateDir = path.join(args.dataDir, "manager-templates", args.name); - await fs.mkdir(templateDir, { recursive: true }); - for (const [fileName, content] of Object.entries(args.files)) { - await fs.writeFile(path.join(templateDir, fileName), content, "utf8"); - } -} - -async function writeActiveFile(dataDir: string, name: string): Promise { - const root = path.join(dataDir, "manager-templates"); - await fs.mkdir(root, { recursive: true }); - await fs.writeFile(path.join(root, "active"), `${name}\n`, "utf8"); -} - -afterEach(async () => { - await Promise.all( - tempDirs - .splice(0) - .map((dir) => fs.rm(dir, { recursive: true, force: true })), - ); -}); - -describe("listManagerTemplates", () => { - it("returns an empty list and default active when the templates root is missing", async () => { - const dataDir = await makeTempDir("bb-mt-empty-"); - expect(await listManagerTemplates({ dataDir })).toEqual({ - templates: [], - activeName: "default", - }); - }); - - it("includes empty template directories so the picker matches seeding semantics", async () => { - const dataDir = await makeTempDir("bb-mt-empty-dir-"); - await writeTemplate({ - dataDir, - name: "default", - files: { "PREFERENCES.md": "ok" }, - }); - await fs.mkdir(path.join(dataDir, "manager-templates", "empty-set"), { - recursive: true, - }); - expect(await listManagerTemplates({ dataDir })).toEqual({ - templates: [{ name: "default" }, { name: "empty-set" }], - activeName: "default", - }); - }); - - it("skips entries that are files or symlinks even when their name would parse", async () => { - const dataDir = await makeTempDir("bb-mt-nondir-"); - await writeTemplate({ - dataDir, - name: "default", - files: { "PREFERENCES.md": "ok" }, - }); - const root = path.join(dataDir, "manager-templates"); - await fs.writeFile(path.join(root, "stray-file"), "ignored", "utf8"); - const elsewhere = await makeTempDir("bb-mt-symlink-target-"); - await fs.symlink(elsewhere, path.join(root, "linked")); - expect(await listManagerTemplates({ dataDir })).toEqual({ - templates: [{ name: "default" }], - activeName: "default", - }); - }); - - it("sorts templates alphabetically and resolves a non-default active pointer", async () => { - const dataDir = await makeTempDir("bb-mt-sorted-"); - await writeTemplate({ - dataDir, - name: "default", - files: { "PREFERENCES.md": "ok" }, - }); - await writeTemplate({ - dataDir, - name: "sawyer-next", - files: { "PREFERENCES.md": "ok" }, - }); - await writeActiveFile(dataDir, "sawyer-next"); - expect(await listManagerTemplates({ dataDir })).toEqual({ - templates: [{ name: "default" }, { name: "sawyer-next" }], - activeName: "sawyer-next", - }); - }); - - it("falls back to default when active is empty or contains an invalid name", async () => { - const dataDir = await makeTempDir("bb-mt-active-fallback-"); - await writeTemplate({ - dataDir, - name: "default", - files: { "PREFERENCES.md": "ok" }, - }); - await fs.writeFile( - path.join(dataDir, "manager-templates", "active"), - "../escape\n", - "utf8", - ); - expect(await listManagerTemplates({ dataDir })).toEqual({ - templates: [{ name: "default" }], - activeName: "default", - }); - }); - - it("normalizes active to default when it points at a valid name with no matching directory", async () => { - const dataDir = await makeTempDir("bb-mt-active-orphan-"); - await writeTemplate({ - dataDir, - name: "default", - files: { "PREFERENCES.md": "ok" }, - }); - await writeActiveFile(dataDir, "ghost-template"); - expect(await listManagerTemplates({ dataDir })).toEqual({ - templates: [{ name: "default" }], - activeName: "default", - }); - }); -}); diff --git a/apps/host-daemon/src/command-handlers/manager-templates.ts b/apps/host-daemon/src/command-handlers/manager-templates.ts deleted file mode 100644 index 6be3529b3..000000000 --- a/apps/host-daemon/src/command-handlers/manager-templates.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { readFile, readdir } from "node:fs/promises"; -import path from "node:path"; -import { managerTemplateNameSchema } from "@bb/domain"; -import type { - HostDaemonOnlineRpcResult, - ManagerTemplateSummary, -} from "@bb/host-daemon-contract"; -import type { CommandOf } from "../command-dispatch-support.js"; -import { isFsErrorWithCode } from "../fs-errors.js"; - -const MANAGER_TEMPLATE_DIR_NAME = "manager-templates"; -const ACTIVE_MANAGER_TEMPLATE_FILE_NAME = "active"; -const DEFAULT_MANAGER_TEMPLATE_NAME = "default"; - -interface ListManagerTemplatesArgs { - dataDir: string; -} - -async function readActiveTemplateNameRaw(rootPath: string): Promise { - let activeContent: string; - try { - activeContent = await readFile( - path.join(rootPath, ACTIVE_MANAGER_TEMPLATE_FILE_NAME), - "utf8", - ); - } catch (error) { - if (isFsErrorWithCode(error, "ENOENT")) { - return DEFAULT_MANAGER_TEMPLATE_NAME; - } - throw error; - } - const firstLine = activeContent.split(/\r?\n/u)[0]?.trim() ?? ""; - if (firstLine.length === 0) { - return DEFAULT_MANAGER_TEMPLATE_NAME; - } - const parsed = managerTemplateNameSchema.safeParse(firstLine); - if (!parsed.success) { - return DEFAULT_MANAGER_TEMPLATE_NAME; - } - return parsed.data; -} - -async function listTemplateDirectoryNames(rootPath: string): Promise { - let entries; - try { - entries = await readdir(rootPath, { withFileTypes: true }); - } catch (error) { - if (isFsErrorWithCode(error, "ENOENT")) { - return []; - } - throw error; - } - - const names: string[] = []; - for (const entry of entries) { - // Match the seeding code's notion of "a template": a top-level real - // directory under manager-templates/ with a name that satisfies the - // schema. An empty directory is still a valid template — it suppresses - // the bundled fallback during seeding, so it must surface in the picker. - // Symlinks are excluded because `Dirent.isDirectory()` is false for them. - if (!entry.isDirectory()) { - continue; - } - const parsed = managerTemplateNameSchema.safeParse(entry.name); - if (!parsed.success) { - continue; - } - names.push(parsed.data); - } - return names.sort((a, b) => a.localeCompare(b)); -} - -export async function listManagerTemplates( - args: ListManagerTemplatesArgs, -): Promise> { - const rootPath = path.join(args.dataDir, MANAGER_TEMPLATE_DIR_NAME); - const [names, rawActiveName] = await Promise.all([ - listTemplateDirectoryNames(rootPath), - readActiveTemplateNameRaw(rootPath), - ]); - const templates: ManagerTemplateSummary[] = names.map((name) => ({ name })); - // Active normalization keeps the contract self-consistent: if the pointer - // names a syntactically valid template that isn't on disk, fall back to - // "default" — the same fallback used for a missing/empty/invalid pointer. - const activeName = names.includes(rawActiveName) - ? rawActiveName - : DEFAULT_MANAGER_TEMPLATE_NAME; - return { templates, activeName }; -} - -export async function listManagerTemplatesCommand( - _command: CommandOf<"host.list_manager_templates">, - options: { dataDir: string }, -): Promise> { - return listManagerTemplates({ dataDir: options.dataDir }); -} diff --git a/apps/host-daemon/src/command-handlers/thread.ts b/apps/host-daemon/src/command-handlers/thread.ts index af7d1f3cb..f500f4529 100644 --- a/apps/host-daemon/src/command-handlers/thread.ts +++ b/apps/host-daemon/src/command-handlers/thread.ts @@ -72,6 +72,7 @@ export async function startThread( environmentId: command.environmentId, injectedSkillSources: command.injectedSkillSources, runtimeManager: options.runtimeManager, + targetThreadId: command.threadId, workspaceContext: command.workspaceContext, }); const result = await entry.runtime.startThread({ @@ -110,6 +111,7 @@ export async function ensureThreadRuntime( environmentId: command.environmentId, injectedSkillSources: resumeContext.injectedSkillSources, runtimeManager: options.runtimeManager, + targetThreadId: command.threadId, workspaceContext: resumeContext.workspaceContext, }); diff --git a/apps/host-daemon/src/runtime-manager.test.ts b/apps/host-daemon/src/runtime-manager.test.ts index 4b8a55c8a..8e2f22c5b 100644 --- a/apps/host-daemon/src/runtime-manager.test.ts +++ b/apps/host-daemon/src/runtime-manager.test.ts @@ -22,7 +22,10 @@ import { } from "@bb/host-workspace"; import { makeWorkspaceMergeBase, makeWorkspaceStatus } from "@bb/test-helpers"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { RuntimeManager } from "./runtime-manager.js"; +import { + RuntimeManager, + SkillCatalogConflictError, +} from "./runtime-manager.js"; type GetCurrentBranchArgs = Parameters; type GetStatusResult = Awaited>; @@ -425,6 +428,230 @@ describe("RuntimeManager", () => { expect(firstEntry.runtime.shutdown).toHaveBeenCalledTimes(1); }); + it("reuses a busy runtime with a stale skill catalog and refreshes it once idle", async () => { + const dataDir = await makeTempDir("bb-runtime-manager-skills-defer-"); + const source = await writeInjectedSkillSource({ + dataDir, + name: "release-notes", + token: "first-token", + }); + const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1"); + const createRuntime = vi.fn(() => createFakeRuntime()); + const manager = new RuntimeManager({ + dataDir, + provisionWorkspace, + createRuntime, + }); + + const firstEntry = await manager.ensureEnvironment({ + environmentId: "env-skills", + injectedSkillSources: [source], + workspacePath: "/tmp/env-1", + }); + const firstCatalogHash = firstEntry.skillCatalogHash; + manager.markThreadActive("env-skills", "thread-1", "provider-1"); + await writeInjectedSkillSource({ + dataDir, + name: "release-notes", + token: "second-token", + }); + + const busyEntry = await manager.ensureEnvironment({ + environmentId: "env-skills", + injectedSkillSources: [source], + targetThreadId: "thread-1", + workspacePath: "/tmp/env-1", + }); + + expect(busyEntry).toBe(firstEntry); + expect(busyEntry.skillCatalogHash).toBe(firstCatalogHash); + expect(createRuntime).toHaveBeenCalledTimes(1); + expect(firstEntry.runtime.shutdown).not.toHaveBeenCalled(); + + manager.markThreadInactive("env-skills", "thread-1"); + const idleEntry = await manager.ensureEnvironment({ + environmentId: "env-skills", + injectedSkillSources: [source], + targetThreadId: "thread-1", + workspacePath: "/tmp/env-1", + }); + + expect(idleEntry).not.toBe(firstEntry); + expect(idleEntry.skillCatalogHash).toMatch(/^[a-f0-9]{64}$/u); + expect(idleEntry.skillCatalogHash).not.toBe(firstCatalogHash); + expect(createRuntime).toHaveBeenCalledTimes(2); + expect(firstEntry.runtime.shutdown).toHaveBeenCalledTimes(1); + }); + + it("replaces an idle runtime that hosts the target thread and keeps the new staged catalog", async () => { + const dataDir = await makeTempDir("bb-runtime-manager-skills-idle-host-"); + const source = await writeInjectedSkillSource({ + dataDir, + name: "release-notes", + token: "first-token", + }); + const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1"); + const createRuntime = vi.fn(() => createFakeRuntime()); + const manager = new RuntimeManager({ + dataDir, + provisionWorkspace, + createRuntime, + }); + + const firstEntry = await manager.ensureEnvironment({ + environmentId: "env-skills", + injectedSkillSources: [source], + workspacePath: "/tmp/env-1", + }); + manager.markThreadActive("env-skills", "thread-1", "provider-1"); + manager.markThreadInactive("env-skills", "thread-1"); + await writeInjectedSkillSource({ + dataDir, + name: "release-notes", + token: "second-token", + }); + + const secondEntry = await manager.ensureEnvironment({ + environmentId: "env-skills", + injectedSkillSources: [source], + targetThreadId: "thread-1", + workspacePath: "/tmp/env-1", + }); + + expect(secondEntry).not.toBe(firstEntry); + expect(firstEntry.skillCatalogHash).toMatch(/^[a-f0-9]{64}$/u); + expect(secondEntry.skillCatalogHash).toMatch(/^[a-f0-9]{64}$/u); + expect(secondEntry.skillCatalogHash).not.toBe(firstEntry.skillCatalogHash); + expect(createRuntime).toHaveBeenCalledTimes(2); + expect(firstEntry.runtime.shutdown).toHaveBeenCalledTimes(1); + + // The replacement's staging cleanup must keep the about-to-be-active + // catalog (the new runtime's skill roots point into it) and drop the + // replaced one. The hash-shape assertions above keep the `?? ""` fallback + // from silently pointing these stats at the staging root itself. + const stagingRoot = path.join(dataDir, "runtime", "global-skills"); + const newCatalogStat = await fs.stat( + path.join(stagingRoot, secondEntry.skillCatalogHash ?? ""), + ); + expect(newCatalogStat.isDirectory()).toBe(true); + await expect( + fs.stat(path.join(stagingRoot, firstEntry.skillCatalogHash ?? "")), + ).rejects.toThrow(); + }); + + it("reuses a busy runtime for a target thread it does not host yet", async () => { + const dataDir = await makeTempDir("bb-runtime-manager-skills-unhosted-"); + const source = await writeInjectedSkillSource({ + dataDir, + name: "release-notes", + token: "first-token", + }); + const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1"); + const createRuntime = vi.fn(() => createFakeRuntime()); + const manager = new RuntimeManager({ + dataDir, + provisionWorkspace, + createRuntime, + }); + + const firstEntry = await manager.ensureEnvironment({ + environmentId: "env-skills", + injectedSkillSources: [source], + workspacePath: "/tmp/env-1", + }); + manager.markThreadActive("env-skills", "other-thread", "provider-1"); + await writeInjectedSkillSource({ + dataDir, + name: "release-notes", + token: "second-token", + }); + + const secondEntry = await manager.ensureEnvironment({ + environmentId: "env-skills", + injectedSkillSources: [source], + targetThreadId: "thread-1", + workspacePath: "/tmp/env-1", + }); + + expect(secondEntry).toBe(firstEntry); + expect(createRuntime).toHaveBeenCalledTimes(1); + expect(firstEntry.runtime.shutdown).not.toHaveBeenCalled(); + }); + + it("reuses a runtime pinned busy by a terminal when a thread brings skill sources", async () => { + const dataDir = await makeTempDir("bb-runtime-manager-skills-terminal-"); + const source = await writeInjectedSkillSource({ + dataDir, + name: "release-notes", + token: "first-token", + }); + const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1"); + const createRuntime = vi.fn(() => createFakeRuntime()); + const manager = new RuntimeManager({ + dataDir, + provisionWorkspace, + createRuntime, + }); + + // Terminal-first entry: created without skill sources, so the runtime has + // no catalog (hash null) and the open terminal keeps it busy. + const terminalEntry = await manager.ensureEnvironment({ + environmentId: "env-skills", + workspacePath: "/tmp/env-1", + }); + manager.markTerminalActive("env-skills", "terminal-1"); + + const threadEntry = await manager.ensureEnvironment({ + environmentId: "env-skills", + injectedSkillSources: [source], + targetThreadId: "thread-1", + workspacePath: "/tmp/env-1", + }); + + expect(threadEntry).toBe(terminalEntry); + expect(threadEntry.skillCatalogHash).toBeNull(); + expect(createRuntime).toHaveBeenCalledTimes(1); + expect(terminalEntry.runtime.shutdown).not.toHaveBeenCalled(); + }); + + it("rejects a stale skill catalog on a busy runtime when no thread targets it", async () => { + const dataDir = await makeTempDir("bb-runtime-manager-skills-conflict-"); + const source = await writeInjectedSkillSource({ + dataDir, + name: "release-notes", + token: "first-token", + }); + const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1"); + const createRuntime = vi.fn(() => createFakeRuntime()); + const manager = new RuntimeManager({ + dataDir, + provisionWorkspace, + createRuntime, + }); + + const firstEntry = await manager.ensureEnvironment({ + environmentId: "env-skills", + injectedSkillSources: [source], + workspacePath: "/tmp/env-1", + }); + manager.markThreadActive("env-skills", "thread-1", "provider-1"); + await writeInjectedSkillSource({ + dataDir, + name: "release-notes", + token: "second-token", + }); + + await expect( + manager.ensureEnvironment({ + environmentId: "env-skills", + injectedSkillSources: [source], + workspacePath: "/tmp/env-1", + }), + ).rejects.toBeInstanceOf(SkillCatalogConflictError); + expect(createRuntime).toHaveBeenCalledTimes(1); + expect(firstEntry.runtime.shutdown).not.toHaveBeenCalled(); + }); + it("applies unmanaged checkout provisioning to existing runtime entries", async () => { const provisionWorkspace = createProvisionWorkspaceMock("/tmp/env-1"); const createRuntime = vi.fn(() => createFakeRuntime()); @@ -1297,6 +1524,7 @@ describe("RuntimeManager", () => { const onApplicationStorageTargetsChanged = vi.fn(); const onApplicationDataChanged = vi.fn(); const onApplicationDataResync = vi.fn(); + const onApplicationContentChanged = vi.fn(); const manager = new RuntimeManager({ appsRootPath: "/tmp/bb-data/apps", hostWatcher, @@ -1305,6 +1533,7 @@ describe("RuntimeManager", () => { onApplicationStorageTargetsChanged, onApplicationDataChanged, onApplicationDataResync, + onApplicationContentChanged, }); manager.replaceTrackedApplicationDataTargets([ @@ -1340,6 +1569,10 @@ describe("RuntimeManager", () => { kind: "application-data-resync", applicationId: "status", }); + watchApplicationStorageRootArgs?.onChange({ + kind: "application-content-changed", + applicationId: "status", + }); expect(onApplicationStorageTargetsChanged).toHaveBeenCalledTimes(1); expect(onApplicationDataChanged).toHaveBeenCalledWith({ @@ -1350,6 +1583,10 @@ describe("RuntimeManager", () => { expect(onApplicationDataResync).toHaveBeenCalledWith({ applicationId: "status", }); + expect(onApplicationContentChanged).toHaveBeenCalledTimes(1); + expect(onApplicationContentChanged).toHaveBeenCalledWith({ + applicationId: "status", + }); await manager.shutdownAll(); diff --git a/apps/host-daemon/src/runtime-manager.ts b/apps/host-daemon/src/runtime-manager.ts index 1e31d1480..b66b9ae4d 100644 --- a/apps/host-daemon/src/runtime-manager.ts +++ b/apps/host-daemon/src/runtime-manager.ts @@ -93,7 +93,11 @@ interface RuntimeSkillConfig { skillRoots: readonly AgentRuntimeSkillRoot[]; } -interface CreateEntryArgs extends EnsureEnvironmentArgs { +interface CreateEntryArgs + extends Omit< + EnsureEnvironmentArgs, + "injectedSkillSources" | "targetThreadId" + > { skillConfig: RuntimeSkillConfig | null; } @@ -105,6 +109,7 @@ interface ApplyExistingEnvironmentProvisionArgs { interface EnsureCompatibleEntryArgs { entry: RuntimeEntry; skillConfig: RuntimeSkillConfig | null; + targetThreadId?: string; } interface ReplaceEntryForSkillCatalogArgs { @@ -112,6 +117,30 @@ interface ReplaceEntryForSkillCatalogArgs { skillConfig: RuntimeSkillConfig; } +interface SkillCatalogConflictErrorArgs { + environmentId: string; + activeCatalogHash: string | null; + requestedCatalogHash: string; +} + +/** + * Internal invariant guard: thrown when an environment's runtime must be + * replaced to pick up a changed injected skill catalog while it has active + * work (active threads or open terminals) and the requesting command targets + * no thread. No production caller can reach this — only thread commands + * (thread.start, turn.submit) resolve with injected skill sources, and they + * always pass a targetThreadId, which reuses the busy runtime and defers the + * refresh instead. Reaching this error indicates a daemon bug. + */ +export class SkillCatalogConflictError extends Error { + constructor(args: SkillCatalogConflictErrorArgs) { + super( + `Daemon bug: a command targeting no thread carried injected skill sources into busy environment ${args.environmentId} (active catalog ${args.activeCatalogHash ?? "none"}, requested ${args.requestedCatalogHash})`, + ); + this.name = "SkillCatalogConflictError"; + } +} + function lazyProvisionOpts( environmentId: string, workspacePath: string, @@ -192,6 +221,14 @@ export interface RuntimeEntry { environmentId: string; runtime: AgentRuntime; skillCatalogHash: string | null; + /** + * Log-throttle state only: the last stale requested catalog hash this entry + * warned about, so the deferral warn fires once per requested catalog + * instead of on every command while the runtime stays busy. It never drives + * the deferred refresh — every thread command re-stages and re-compares the + * catalog. + */ + lastWarnedStaleSkillCatalogHash: string | null; stopWatchingStatus: StopWatching; workspace: HostWorkspace; path: string; @@ -209,6 +246,10 @@ export interface ApplicationDataResyncNotification { applicationId: ApplicationId; } +export interface ApplicationContentChangedNotification { + applicationId: ApplicationId; +} + export interface InjectedSkillsChangedNotification { applicationId: ApplicationId | null; changedPaths: string[]; @@ -219,6 +260,15 @@ export interface EnsureEnvironmentArgs { environmentId: string; injectedSkillSources?: readonly HostDaemonInjectedSkillSource[]; personalWorkspaceRoot?: string; + /** + * The thread the requesting command targets; set by thread commands that + * resolve with injected skill sources (thread.start, turn.submit). When + * set, a busy runtime is reused even when its injected skill catalog is + * stale, instead of failing the command and dropping the thread's message; + * the catalog refresh is deferred to the next launch on an idle + * environment. + */ + targetThreadId?: string; workspacePath?: string; workspaceProvisionType?: WorkspaceProvisionType; provision?: ProvisionWorkspaceArgs; @@ -247,6 +297,9 @@ export interface RuntimeManagerOptions { onApplicationStorageTargetsChanged?: () => void; onApplicationDataChanged?: (args: ApplicationDataChangedNotification) => void; onApplicationDataResync?: (args: ApplicationDataResyncNotification) => void; + onApplicationContentChanged?: ( + args: ApplicationContentChangedNotification, + ) => void; onApplicationStorageWatchError?: (args: { error: ApplicationStorageWatchError; }) => void; @@ -597,16 +650,27 @@ export class RuntimeManager { return false; } - private async cleanupUnusedInjectedSkillStagingDirs(): Promise { + /** + * Removes staged skill catalog directories no loaded entry references. + * `pendingCatalogHashes` names catalogs that are about to become active but + * are not yet registered in `entries` — e.g. the replacement catalog during + * a runtime swap — so the cleanup does not delete a just-staged directory. + */ + private async cleanupUnusedInjectedSkillStagingDirs( + pendingCatalogHashes: readonly string[], + ): Promise { if (!this.options.dataDir) { return; } try { await cleanupInjectedSkillStagingDirs({ dataDir: this.options.dataDir, - keepCatalogHashes: [...this.entries.values()].flatMap((entry) => - entry.skillCatalogHash === null ? [] : [entry.skillCatalogHash], - ), + keepCatalogHashes: [ + ...pendingCatalogHashes, + ...[...this.entries.values()].flatMap((entry) => + entry.skillCatalogHash === null ? [] : [entry.skillCatalogHash], + ), + ], logger: this.getInjectedSkillsLogger(), }); } catch (error) { @@ -626,9 +690,11 @@ export class RuntimeManager { args: ReplaceEntryForSkillCatalogArgs, ): Promise { if (this.entryHasActiveRuntimeWork(args.entry)) { - throw new Error( - `Environment ${args.entry.environmentId} already has an active runtime with injected skill catalog ${args.entry.skillCatalogHash ?? "none"}; requested ${args.skillConfig.catalogHash}`, - ); + throw new SkillCatalogConflictError({ + environmentId: args.entry.environmentId, + activeCatalogHash: args.entry.skillCatalogHash, + requestedCatalogHash: args.skillConfig.catalogHash, + }); } this.entries.delete(args.entry.environmentId); @@ -638,7 +704,9 @@ export class RuntimeManager { this.stopWatchingThreadStorageIfNoTrackedThreads(); await this.stopWatchingStatus(args.entry); await args.entry.runtime.shutdown(); - await this.cleanupUnusedInjectedSkillStagingDirs(); + await this.cleanupUnusedInjectedSkillStagingDirs([ + args.skillConfig.catalogHash, + ]); } private async ensureCompatibleEntry( @@ -653,6 +721,36 @@ export class RuntimeManager { return args.entry; } + // A thread command must not force a catalog swap while the runtime is + // busy: replacement would kill in-flight work, and failing the command + // would drop the thread's message — an agent can trigger this against its + // own thread by installing a skill mid-turn, and an open terminal would + // otherwise pin every thread in the environment into the failure. Reuse + // the busy runtime with its stale catalog and defer the refresh to the + // next launch on an idle environment. + if ( + args.targetThreadId !== undefined && + this.entryHasActiveRuntimeWork(args.entry) + ) { + if ( + args.entry.lastWarnedStaleSkillCatalogHash !== + args.skillConfig.catalogHash + ) { + args.entry.lastWarnedStaleSkillCatalogHash = + args.skillConfig.catalogHash; + this.options.logger?.warn( + { + environmentId: args.entry.environmentId, + threadId: args.targetThreadId, + activeCatalogHash: args.entry.skillCatalogHash, + requestedCatalogHash: args.skillConfig.catalogHash, + }, + "Deferring injected skill catalog refresh for busy runtime", + ); + } + return args.entry; + } + await this.replaceEntryForSkillCatalog({ entry: args.entry, skillConfig: args.skillConfig, @@ -736,6 +834,9 @@ export class RuntimeManager { const compatible = await this.ensureCompatibleEntry({ entry: existing, skillConfig, + ...(args.targetThreadId !== undefined + ? { targetThreadId: args.targetThreadId } + : {}), }); if (compatible) { return compatible; @@ -748,6 +849,9 @@ export class RuntimeManager { const compatible = await this.ensureCompatibleEntry({ entry, skillConfig, + ...(args.targetThreadId !== undefined + ? { targetThreadId: args.targetThreadId } + : {}), }); if (compatible) { return compatible; @@ -807,7 +911,7 @@ export class RuntimeManager { this.stopWatchingThreadStorageIfNoTrackedThreads(); await entry.runtime.shutdown(); await entry.workspace.destroy(); - await this.cleanupUnusedInjectedSkillStagingDirs(); + await this.cleanupUnusedInjectedSkillStagingDirs([]); } async forgetEnvironment(environmentId: string): Promise { @@ -832,7 +936,7 @@ export class RuntimeManager { this.entries.delete(environmentId); await this.stopWatchingStatus(entry); await entry.runtime.shutdown(); - await this.cleanupUnusedInjectedSkillStagingDirs(); + await this.cleanupUnusedInjectedSkillStagingDirs([]); } async evictIdleEnvironments(): Promise { @@ -868,7 +972,7 @@ export class RuntimeManager { throw firstRejected.reason; } - await this.cleanupUnusedInjectedSkillStagingDirs(); + await this.cleanupUnusedInjectedSkillStagingDirs([]); return shutdownResults.flatMap((result) => result.status === "fulfilled" ? [result.value] : [], ); @@ -911,7 +1015,7 @@ export class RuntimeManager { this.stopWatchingApplicationStorageRoot = STOP_WATCHING; await this.stopWatchingDataDirSkillsRoot(); this.stopWatchingDataDirSkillsRoot = STOP_WATCHING; - await this.cleanupUnusedInjectedSkillStagingDirs(); + await this.cleanupUnusedInjectedSkillStagingDirs([]); } private buildUnexpectedProviderExitEvents( @@ -1132,6 +1236,7 @@ export class RuntimeManager { environmentId: args.environmentId, runtime, skillCatalogHash: args.skillConfig?.catalogHash ?? null, + lastWarnedStaleSkillCatalogHash: null, stopWatchingStatus, terminals: new Set(), workspace, @@ -1214,6 +1319,11 @@ export class RuntimeManager { applicationId: event.applicationId, }); } + if (event.kind === "application-content-changed") { + this.options.onApplicationContentChanged?.({ + applicationId: event.applicationId, + }); + } if (event.kind === "injected-skills-changed") { this.options.onInjectedSkillsChanged?.({ applicationId: event.applicationId, diff --git a/apps/host-daemon/src/server-connection.ts b/apps/host-daemon/src/server-connection.ts index 6fb1541b2..4d21b733d 100644 --- a/apps/host-daemon/src/server-connection.ts +++ b/apps/host-daemon/src/server-connection.ts @@ -46,19 +46,27 @@ interface ServerMessagePayloadSummary { const SERVER_MESSAGE_PAYLOAD_PREVIEW_CHARS = 512; -type HostDaemonEnvironmentChangeMessage = Extract< - HostDaemonDaemonWsMessage, - { type: "environment-change" } ->; - -const APPLICATION_STORAGE_CHANGED_MESSAGE = { - type: "application-storage-changed", -} satisfies HostDaemonDaemonWsMessage; - -function environmentChangeMessageKey( - message: HostDaemonEnvironmentChangeMessage, -): string { - return `${message.environmentId}\u0000${message.change}`; +/** + * Returns the dedup key for messages that survive a disconnect, or null for + * message kinds that are dropped when the websocket is down. Buffered + * messages coalesce per key to the latest value and replay in insertion + * order after reconnect. To make a new message kind recoverable, add a case + * here — buffering, success-clearing, shutdown clearing, and flushing all + * key off this function. + */ +function recoverableMessageKey( + message: HostDaemonDaemonWsMessage, +): string | null { + switch (message.type) { + case "environment-change": + return `environment-change\u0000${message.environmentId}\u0000${message.change}`; + case "application-storage-changed": + return "application-storage-changed"; + case "application-content-changed": + return `application-content-changed\u0000${message.applicationId}`; + default: + return null; + } } function summarizeServerMessagePayload( @@ -96,11 +104,10 @@ export class ServerConnection { private stopped = false; private sessionCloseHandler: ServerConnectionOptions["onSessionClose"]; private fatalConnectError: ServerResponseError | null = null; - private readonly pendingEnvironmentChanges = new Map< + private readonly pendingRecoverableMessages = new Map< string, - HostDaemonEnvironmentChangeMessage + HostDaemonDaemonWsMessage >(); - private pendingApplicationStorageChanged = false; constructor(private readonly options: ServerConnectionOptions) { this.sessionCloseHandler = options.onSessionClose; @@ -137,8 +144,7 @@ export class ServerConnection { async shutdown(): Promise { this.stopped = true; - this.pendingEnvironmentChanges.clear(); - this.pendingApplicationStorageChanged = false; + this.pendingRecoverableMessages.clear(); this.stopPollingFallback(); this.clearHeartbeat(); this.clearSession(); @@ -155,16 +161,16 @@ export class ServerConnection { sendMessage(message: HostDaemonDaemonWsMessage): boolean { const payload = hostDaemonDaemonWsMessageSchema.parse(message); + const recoverableKey = recoverableMessageKey(payload); if (!this.websocket || this.websocket.readyState !== OPEN_READY_STATE) { - this.bufferMessageIfRecoverable(payload); + if (recoverableKey !== null) { + this.pendingRecoverableMessages.set(recoverableKey, payload); + } return false; } this.websocket.send(JSON.stringify(payload)); - if (payload.type === "environment-change") { - this.pendingEnvironmentChanges.delete(environmentChangeMessageKey(payload)); - } - if (payload.type === "application-storage-changed") { - this.pendingApplicationStorageChanged = false; + if (recoverableKey !== null) { + this.pendingRecoverableMessages.delete(recoverableKey); } return true; } @@ -350,29 +356,16 @@ export class ServerConnection { }); } - private bufferMessageIfRecoverable( - message: HostDaemonDaemonWsMessage, - ): void { - if (message.type === "environment-change") { - this.pendingEnvironmentChanges.set( - environmentChangeMessageKey(message), - message, - ); - } - if (message.type === "application-storage-changed") { - this.pendingApplicationStorageChanged = true; - } - } - private flushPendingRecoverableMessages(): void { - for (const message of Array.from(this.pendingEnvironmentChanges.values())) { + // Snapshot before sending: each send mutates the map (delete on + // success, re-set on failure), so don't iterate it live. + for (const message of Array.from( + this.pendingRecoverableMessages.values(), + )) { if (!this.sendMessage(message)) { return; } } - if (this.pendingApplicationStorageChanged) { - this.sendMessage(APPLICATION_STORAGE_CHANGED_MESSAGE); - } } private handleWebSocketMessage(data: unknown): void { diff --git a/apps/host-daemon/src/workspace-resolution.ts b/apps/host-daemon/src/workspace-resolution.ts index 40f52c950..8a2a3a5f6 100644 --- a/apps/host-daemon/src/workspace-resolution.ts +++ b/apps/host-daemon/src/workspace-resolution.ts @@ -34,6 +34,11 @@ interface ResolveWorkspaceForCommandArgs { requireGit?: boolean; requireManagedWorktree?: boolean; runtimeManager: RuntimeManager; + /** + * Set by thread commands that resolve with injectedSkillSources, so a busy + * runtime is reused instead of conflicting; see EnsureEnvironmentArgs. + */ + targetThreadId?: string; workspaceContext: WorkspaceContext; } @@ -131,6 +136,9 @@ export async function resolveWorkspaceForCommand( ...(args.injectedSkillSources !== undefined ? { injectedSkillSources: args.injectedSkillSources } : {}), + ...(args.targetThreadId !== undefined + ? { targetThreadId: args.targetThreadId } + : {}), workspaceContext: args.workspaceContext, }, args.runtimeManager, diff --git a/apps/host-daemon/test/command/command-router.test.ts b/apps/host-daemon/test/command/command-router.test.ts index 5ef387cf7..1b507b95a 100644 --- a/apps/host-daemon/test/command/command-router.test.ts +++ b/apps/host-daemon/test/command/command-router.test.ts @@ -261,6 +261,7 @@ function createStandardRuntimeCommandContext(args: { model: "gpt-5", serviceTier: "default" as const, reasoningLevel: "medium" as const, + workflowsEnabled: false, permissionMode: "full" as const, permissionEscalation: null, }, @@ -1432,6 +1433,7 @@ describe("CommandRouter", () => { model: "gpt-5", serviceTier: "default" as const, reasoningLevel: "medium" as const, + workflowsEnabled: false, permissionMode: "full" as const, permissionEscalation: null, }, @@ -1587,6 +1589,7 @@ describe("CommandRouter", () => { model: "gpt-5", serviceTier: "default" as const, reasoningLevel: "medium" as const, + workflowsEnabled: false, permissionMode: "full" as const, permissionEscalation: null, }, @@ -1620,6 +1623,7 @@ describe("CommandRouter", () => { model: "gpt-5", serviceTier: "default" as const, reasoningLevel: "medium" as const, + workflowsEnabled: false, permissionMode: "full" as const, permissionEscalation: null, }, diff --git a/apps/host-daemon/test/command/thread-dispatch.test.ts b/apps/host-daemon/test/command/thread-dispatch.test.ts index 9bbf7b6c4..2e2ca1228 100644 --- a/apps/host-daemon/test/command/thread-dispatch.test.ts +++ b/apps/host-daemon/test/command/thread-dispatch.test.ts @@ -62,6 +62,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -113,6 +114,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -180,6 +182,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -281,6 +284,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -355,6 +359,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -413,6 +418,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -472,6 +478,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -540,6 +547,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -598,6 +606,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -649,6 +658,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -705,6 +715,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -834,6 +845,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -877,6 +889,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -959,6 +972,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -990,6 +1004,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -1067,6 +1082,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -1101,6 +1117,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -1152,6 +1169,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -1210,6 +1228,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -1262,6 +1281,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -1308,6 +1328,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -1349,6 +1370,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -1421,6 +1443,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -1582,6 +1605,7 @@ describe("thread command dispatch", () => { model: "claude-opus-4-7", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -1637,6 +1661,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -1673,6 +1698,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -1783,6 +1809,7 @@ describe("thread command dispatch", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, diff --git a/apps/host-daemon/test/connection/server-connection.test.ts b/apps/host-daemon/test/connection/server-connection.test.ts index e1e7f55c1..218297801 100644 --- a/apps/host-daemon/test/connection/server-connection.test.ts +++ b/apps/host-daemon/test/connection/server-connection.test.ts @@ -161,6 +161,7 @@ describe("ServerConnection", () => { model: "gpt-5", serviceTier: "default", reasoningLevel: "medium", + workflowsEnabled: false, permissionMode: "full", permissionEscalation: null, }, @@ -711,6 +712,152 @@ describe("ServerConnection", () => { await connection.shutdown(); }); + it("sends application content change hints over the daemon websocket", async () => { + testServer = await createTestServer(); + const server = testServer; + const { connection } = createConnection(server); + + await connection.start(); + expect( + connection.sendMessage({ + type: "application-content-changed", + applicationId: "status", + }), + ).toBe(true); + + await waitFor(() => + server.heartbeats.some( + (entry) => entry.message.type === "application-content-changed", + ), + ); + expect(server.heartbeats).toContainEqual({ + sessionId: "session-1", + message: { + type: "application-content-changed", + applicationId: "status", + }, + }); + + await connection.shutdown(); + }); + + it("buffers application content change hints per app while disconnected and flushes after reconnect", async () => { + testServer = await createTestServer(); + const server = testServer; + const { connection } = createConnection(server); + + expect( + connection.sendMessage({ + type: "application-content-changed", + applicationId: "status", + }), + ).toBe(false); + expect( + connection.sendMessage({ + type: "application-content-changed", + applicationId: "status", + }), + ).toBe(false); + expect( + connection.sendMessage({ + type: "application-content-changed", + applicationId: "tasks", + }), + ).toBe(false); + expect(server.heartbeats).toEqual([]); + + await connection.start(); + await waitFor( + () => + server.heartbeats.filter( + (entry) => entry.message.type === "application-content-changed", + ).length === 2, + ); + expect( + server.heartbeats.filter( + (entry) => entry.message.type === "application-content-changed", + ), + ).toEqual([ + { + sessionId: "session-1", + message: { + type: "application-content-changed", + applicationId: "status", + }, + }, + { + sessionId: "session-1", + message: { + type: "application-content-changed", + applicationId: "tasks", + }, + }, + ]); + + await connection.shutdown(); + }); + + it("buffers mixed recoverable hint kinds without collisions and flushes them in arrival order", async () => { + testServer = await createTestServer(); + const server = testServer; + const { connection } = createConnection(server); + + expect( + connection.sendMessage({ + type: "application-content-changed", + applicationId: "status", + }), + ).toBe(false); + expect( + connection.sendMessage({ + type: "application-storage-changed", + }), + ).toBe(false); + expect( + connection.sendMessage({ + type: "environment-change", + environmentId: "env-1", + change: "work-status-changed", + }), + ).toBe(false); + expect(server.heartbeats).toEqual([]); + + await connection.start(); + await waitFor( + () => + server.heartbeats.filter( + (entry) => entry.message.type !== "heartbeat", + ).length === 3, + ); + expect( + server.heartbeats.filter((entry) => entry.message.type !== "heartbeat"), + ).toEqual([ + { + sessionId: "session-1", + message: { + type: "application-content-changed", + applicationId: "status", + }, + }, + { + sessionId: "session-1", + message: { + type: "application-storage-changed", + }, + }, + { + sessionId: "session-1", + message: { + type: "environment-change", + environmentId: "env-1", + change: "work-status-changed", + }, + }, + ]); + + await connection.shutdown(); + }); + it("includes active threads when opening the session", async () => { testServer = await createTestServer(); const activeThreads: HostDaemonActiveThread[] = [ diff --git a/apps/host-daemon/test/integration/daemon.integration.test.ts b/apps/host-daemon/test/integration/daemon.integration.test.ts index 49efd7e72..f74e2fa1d 100644 --- a/apps/host-daemon/test/integration/daemon.integration.test.ts +++ b/apps/host-daemon/test/integration/daemon.integration.test.ts @@ -491,6 +491,7 @@ function createStandardThreadStartCommand(args: { model: "gpt-5", serviceTier: "default" as const, reasoningLevel: "medium" as const, + workflowsEnabled: false, permissionMode: "full" as const, permissionEscalation: null, }, @@ -521,6 +522,7 @@ function createTurnSubmitCommand(args: { model: "gpt-5", serviceTier: "default" as const, reasoningLevel: "medium" as const, + workflowsEnabled: false, permissionMode: "full" as const, permissionEscalation: null, }, diff --git a/apps/server/package.json b/apps/server/package.json index 83e6eb8d5..41f1602d7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -4,8 +4,9 @@ "type": "module", "private": true, "scripts": { - "build": "node ../../scripts/build-node-entry.mjs src/index.ts dist/index.js --clean-dist --templates --external ./start-server.js --copy-dir ../../packages/db/drizzle dist/drizzle && node ../../scripts/build-node-entry.mjs src/start-server.ts dist/start-server.js", + "build": "node ../../scripts/build-node-entry.mjs src/index.ts dist/index.js --clean-dist --templates --external ./start-server.js --copy-dir ../../packages/db/drizzle dist/drizzle && node --import tsx scripts/copy-app-scaffold-template.ts && node ../../scripts/build-node-entry.mjs src/start-server.ts dist/start-server.js", "bench": "vitest bench --config vitest.config.ts", + "build:app-scaffold-template": "node ./scripts/build-app-scaffold-template.mjs", "start": "node dist/index.js", "start:prod": "cross-env NODE_ENV=production node dist/index.js", "dev": "node --conditions=source --import tsx scripts/dev-supervisor.mjs", @@ -23,6 +24,7 @@ "@bb/logger": "workspace:*", "@bb/process-utils": "workspace:*", "@bb/replay-capture": "workspace:*", + "@bb/sdk": "workspace:*", "@bb/secret-storage": "workspace:*", "@bb/server-contract": "workspace:*", "@bb/templates": "workspace:*", diff --git a/apps/server/scripts/app-scaffold-template-digest.d.mts b/apps/server/scripts/app-scaffold-template-digest.d.mts new file mode 100644 index 000000000..6ab84e8f4 --- /dev/null +++ b/apps/server/scripts/app-scaffold-template-digest.d.mts @@ -0,0 +1,16 @@ +export declare const appScaffoldTemplatePath: string; +export declare const appScaffoldTemplateSourcePath: string; +export declare const appScaffoldTemplateDigestPath: string; + +export interface AppScaffoldTemplateDigest { + public: Record; + source: Record; +} + +/** + * Hashes the app scaffold template's editable source/ tree (the vite build + * inputs, including the generated bb-sdk.d.ts) and the committed prebuilt + * public/ tree it produces. The recorded digest pins both sides so neither + * can change without rerunning the rebuild script. + */ +export declare function computeAppScaffoldTemplateDigest(): AppScaffoldTemplateDigest; diff --git a/apps/server/scripts/app-scaffold-template-digest.mjs b/apps/server/scripts/app-scaffold-template-digest.mjs new file mode 100644 index 000000000..c30f104e0 --- /dev/null +++ b/apps/server/scripts/app-scaffold-template-digest.mjs @@ -0,0 +1,81 @@ +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const serverRoot = path.resolve(scriptDir, ".."); + +export const appScaffoldTemplatePath = path.resolve( + serverRoot, + "src", + "services", + "threads", + "app-scaffold-template", +); +export const appScaffoldTemplateSourcePath = path.join( + appScaffoldTemplatePath, + "source", +); +export const appScaffoldTemplateDigestPath = path.resolve( + serverRoot, + "test", + "public", + "app-scaffold-template.digest.json", +); + +// Local dev workflows (pnpm install, the template dev server, browser test +// runs) leave regenerable output behind inside source/; none of it feeds the +// vite build. +const EXCLUDED_SOURCE_ENTRY_NAMES = new Set([ + "node_modules", + "screenshots", + "playwright-report", + "test-results", +]); + +function collectFileHashes({ rootPath, excludedEntryNames }) { + const hashes = {}; + const stack = [""]; + while (stack.length > 0) { + const relativeDir = stack.pop(); + const absoluteDir = path.join(rootPath, relativeDir); + for (const entry of readdirSync(absoluteDir, { withFileTypes: true })) { + if (excludedEntryNames?.has(entry.name)) { + continue; + } + const relativePath = path.posix.join(relativeDir, entry.name); + if (entry.isDirectory()) { + stack.push(relativePath); + continue; + } + if (!entry.isFile()) { + continue; + } + hashes[relativePath] = createHash("sha256") + .update(readFileSync(path.join(rootPath, relativePath))) + .digest("hex"); + } + } + return Object.fromEntries( + Object.entries(hashes).sort(([left], [right]) => left.localeCompare(right)), + ); +} + +/** + * Hashes the app scaffold template's editable source/ tree (the vite build + * inputs, including the generated bb-sdk.d.ts) and the committed prebuilt + * public/ tree it produces. The recorded digest pins both sides so neither + * can change without rerunning the rebuild script. + */ +export function computeAppScaffoldTemplateDigest() { + return { + public: collectFileHashes({ + rootPath: path.join(appScaffoldTemplatePath, "public"), + }), + source: collectFileHashes({ + rootPath: appScaffoldTemplateSourcePath, + excludedEntryNames: EXCLUDED_SOURCE_ENTRY_NAMES, + }), + }; +} diff --git a/apps/server/scripts/build-app-scaffold-template.mjs b/apps/server/scripts/build-app-scaffold-template.mjs new file mode 100644 index 000000000..5a894314b --- /dev/null +++ b/apps/server/scripts/build-app-scaffold-template.mjs @@ -0,0 +1,33 @@ +import { execFileSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import { + appScaffoldTemplateDigestPath, + appScaffoldTemplateSourcePath, + computeAppScaffoldTemplateDigest, +} from "./app-scaffold-template-digest.mjs"; + +// Rebuilds the app scaffold template's committed public/ tree from source/ +// (the template build typechecks via tsc --noEmit before vite builds), then +// records the source/public digest that the drift test +// (test/public/app-scaffold-template-drift.test.ts) verifies. +function runInTemplateSource(args) { + execFileSync("pnpm", ["--ignore-workspace", ...args], { + cwd: appScaffoldTemplateSourcePath, + stdio: "inherit", + }); +} + +runInTemplateSource(["install", "--frozen-lockfile"]); +runInTemplateSource(["run", "build"]); + +writeFileSync( + appScaffoldTemplateDigestPath, + `${JSON.stringify(computeAppScaffoldTemplateDigest(), null, 2)}\n`, +); +console.log( + `Rebuilt app scaffold template public/ and recorded ${path.relative( + process.cwd(), + appScaffoldTemplateDigestPath, + )}`, +); diff --git a/apps/server/scripts/copy-app-scaffold-template.ts b/apps/server/scripts/copy-app-scaffold-template.ts new file mode 100644 index 000000000..82578da4b --- /dev/null +++ b/apps/server/scripts/copy-app-scaffold-template.ts @@ -0,0 +1,22 @@ +import { rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + APP_SCAFFOLD_TEMPLATE_DIRECTORY_NAME, + copyApplicationScaffoldTemplate, + resolveApplicationScaffoldTemplatePath, +} from "../src/services/threads/app-scaffold-template-copy.js"; + +// Build step: copies the app scaffold template into dist with the same +// dev-artifact exclusions the runtime copy applies, so source/node_modules +// and test output never ship in dist (and downstream bb-app/desktop bundles). +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const targetPath = path.resolve( + scriptDir, + "../dist", + APP_SCAFFOLD_TEMPLATE_DIRECTORY_NAME, +); + +const templatePath = resolveApplicationScaffoldTemplatePath(); +await rm(targetPath, { force: true, recursive: true }); +await copyApplicationScaffoldTemplate({ targetPath, templatePath }); diff --git a/apps/server/src/internal/events.ts b/apps/server/src/internal/events.ts index cb5417da1..76e70b95b 100644 --- a/apps/server/src/internal/events.ts +++ b/apps/server/src/internal/events.ts @@ -40,7 +40,7 @@ import type { LoggedPendingInteractionWorkSessionDeps, } from "../types.js"; import { - isAgePrunableThreadEventType, + isActivePruneTriggerThreadEventType, maybePruneActiveThreadEventHistory, } from "../services/system/event-pruning.js"; import { @@ -189,6 +189,8 @@ function resolveProviderIdentifiers(event: HostDaemonEventEnvelope["event"]): { case "turn/input/accepted": case "item/started": case "item/completed": + case "item/backgroundTask/progress": + case "item/backgroundTask/completed": case "item/agentMessage/delta": case "item/commandExecution/outputDelta": case "item/fileChange/outputDelta": @@ -601,7 +603,7 @@ function resolveActivePruneCandidates( if (!insertedEventIndexLookup.has(index)) { continue; } - if (!isAgePrunableThreadEventType(entry.event.type)) { + if (!isActivePruneTriggerThreadEventType(entry.event.type)) { continue; } const acceptedEvent = args.acceptedEvents[index]; diff --git a/apps/server/src/internal/session-owner-side-effects.ts b/apps/server/src/internal/session-owner-side-effects.ts index a3d5d5492..6218dd2aa 100644 --- a/apps/server/src/internal/session-owner-side-effects.ts +++ b/apps/server/src/internal/session-owner-side-effects.ts @@ -15,6 +15,7 @@ import type { LoggedPendingInteractionWorkSessionDeps, } from "../types.js"; import { reconcileDaemonReportedThreads } from "../services/threads/thread-lifecycle.js"; +import { settleDanglingBackgroundTasks } from "../services/threads/background-task-reconciliation.js"; const DAEMON_RESTARTED_PENDING_INTERACTION_REASON = "Host daemon restarted while awaiting user interaction; retry the thread to continue"; @@ -30,13 +31,19 @@ type DaemonSocketClosedDeps = Pick< >; type ExpiredHostSessionLeaseDeps = Pick< AppDeps, - "db" | "hub" | "pendingInteractions" + "db" | "hub" | "logger" | "pendingInteractions" >; export interface HandleHostSessionOpenedArgs { activeThreads: HostDaemonActiveThread[]; hostId: string; openedSession: HostDaemonSessionRow; + /** + * The host's most recent session before this open, regardless of status — + * a daemon crash closes its session immediately on socket close, so the + * restarted-daemon reconciliation below must not require the previous + * session to still be active. + */ previousSession: HostDaemonSessionRow | null; } @@ -70,25 +77,37 @@ export async function handleHostSessionOpened( args.previousSession && args.previousSession.id !== args.openedSession.id ) { - deps.hub.closeDaemonSession(args.previousSession.id, "replaced"); + if (args.previousSession.status === "active") { + deps.hub.closeDaemonSession(args.previousSession.id, "replaced"); + } if (args.previousSession.instanceId !== args.openedSession.instanceId) { - // A different instanceId is a new daemon process: the prior process is - // gone and cannot report the commands it had fetched. Return them to the - // queue now so the restarted daemon re-fetches them, instead of waiting - // out the delivery lease (up to 20min for provision). Same-instance - // reconnects keep the lease as a safety window, since a blipped-but-alive - // daemon may still report the original attempt. - const requeued = requeueFetchedCommandsForSession(deps.db, { - sessionId: args.previousSession.id, - }); - if (requeued.requeued > 0) { - deps.hub.notifyCommand(args.hostId); + if (args.previousSession.status === "active") { + // A different instanceId is a new daemon process: the prior process is + // gone and cannot report the commands it had fetched. Return them to + // the queue now so the restarted daemon re-fetches them, instead of + // waiting out the delivery lease (up to 20min for provision). + // Same-instance reconnects keep the lease as a safety window, since a + // blipped-but-alive daemon may still report the original attempt. + // Already-closed previous sessions are excluded: their threads are + // interrupted by the disconnect/lease reconciliation, and requeueing + // would replay stale work on top of the interruption. + const requeued = requeueFetchedCommandsForSession(deps.db, { + sessionId: args.previousSession.id, + }); + if (requeued.requeued > 0) { + deps.hub.notifyCommand(args.hostId); + } } interruptPendingInteractionsForHostThreads(deps, { hostId: args.hostId, reason: DAEMON_RESTARTED_PENDING_INTERACTION_REASON, }); + // The restarted daemon lost its in-memory background-task state and the + // CLI processes died with it — settle the persisted open items. This + // also covers restarts inside the disconnect grace window, where the + // grace callback sees the new active session and skips its settle. + settleDanglingBackgroundTasks(deps, { hostId: args.hostId }); } } @@ -151,6 +170,10 @@ export function handleExpiredHostSessionLeases( hostId, reason: DAEMON_SESSION_EXPIRED_PENDING_INTERACTION_REASON, }); + // A host that never reconnects has no re-register to settle its open + // background tasks; mirror the pending-interaction reconciliation here + // so lost workflows do not dangle as running forever. + settleDanglingBackgroundTasks(deps, { hostId }); } notifyHostThreadRuntimeStatusChanged(deps, hostId); } @@ -168,6 +191,11 @@ function completeDaemonDisconnectGrace( hostId: args.hostId, reason: DAEMON_DISCONNECTED_PENDING_INTERACTION_REASON, }); + // Same policy as pending interactions: after the grace window the daemon is + // treated as gone, so its background tasks are settled. If the daemon was + // alive-but-partitioned, its later real progress/completed events supersede + // the settle row (latest state row per item wins). + settleDanglingBackgroundTasks(deps, { hostId: args.hostId }); notifyHostThreadRuntimeStatusChanged(deps, args.hostId); } diff --git a/apps/server/src/internal/session.ts b/apps/server/src/internal/session.ts index f8f6f9dc7..95670ba08 100644 --- a/apps/server/src/internal/session.ts +++ b/apps/server/src/internal/session.ts @@ -1,5 +1,5 @@ import { - getActiveSession, + getLatestSessionForHost, listRetiredLoadedEnvironmentIdsOnHost, listTrackedThreadStorageTargetsOnHost, openSession, @@ -57,7 +57,13 @@ export function registerInternalSessionRoutes(app: Hono, deps: AppDeps): void { ); } - const existingSession = getActiveSession(deps.db, daemon.hostId); + // The latest session regardless of status/lease: a crashed daemon's + // session is closed the moment its socket drops, so requiring an active + // previous session would skip the restarted-daemon reconciliation in + // exactly the case it exists for. + const previousSession = getLatestSessionForHost(deps.db, { + hostId: daemon.hostId, + }); upsertHost(deps.db, deps.hub, { id: daemon.hostId, name: payload.hostName, @@ -83,7 +89,7 @@ export function registerInternalSessionRoutes(app: Hono, deps: AppDeps): void { activeThreads: payload.activeThreads, hostId: daemon.hostId, openedSession: session, - previousSession: existingSession, + previousSession, }); const trackedThreadTargets = listTrackedThreadStorageTargetsOnHost( @@ -134,7 +140,11 @@ export function registerInternalSessionRoutes(app: Hono, deps: AppDeps): void { // Attachment paths are project-scoped upload tokens, so cross-check // projectId before reading bytes even though threadId identifies a thread. if (thread.projectId !== query.projectId) { - throw new ApiError(403, "forbidden", "Thread does not belong to project"); + throw new ApiError( + 403, + "forbidden", + "Thread does not belong to project", + ); } if (environment.hostId !== session.hostId) { throw new ApiError( diff --git a/apps/server/src/routes/apps.ts b/apps/server/src/routes/apps.ts index cd13a1b69..2ae18ba3f 100644 --- a/apps/server/src/routes/apps.ts +++ b/apps/server/src/routes/apps.ts @@ -35,7 +35,6 @@ import { appMessageRequestSchema, createAppRequestSchema, typedRoutes, - type AppCapability, type AppDataEntry, type AppDetail, type AppEntry, @@ -48,10 +47,13 @@ import { import type { AppDeps, LoggedWorkSessionDeps } from "../types.js"; import { ApiError } from "../errors.js"; import { requirePublicThread } from "../services/lib/entity-lookup.js"; +import { writeInitialApplicationFiles } from "../services/threads/app-scaffold.js"; import { requireThreadCommandEnvironment } from "../services/threads/thread-command-environment.js"; import { sendThreadMessage } from "../services/threads/thread-send.js"; -import { injectAppClientScript } from "../services/threads/app-client-script.js"; -import { buildBlankAppIndexHtml } from "../services/threads/blank-app-scaffold.js"; +import { + appRuntimeScriptAsset, + injectAppClientScript, +} from "../services/threads/app-client-script.js"; import { extractRoutePath, parseSafeRelativeRoutePath, @@ -109,7 +111,6 @@ interface WriteApplicationDataEntryArgs extends ReadApplicationDataEntryArgs { interface CreateInjectedAppHtmlResponseArgs { appSessionToken: AppSessionToken | null; - capabilities: AppCapability[]; html: string; applicationId: ApplicationId; requestUrl: string; @@ -149,6 +150,9 @@ interface PublishApplicationTempRootArgs { tempRootPath: string; } +type PublishApplicationTempRootResult = "published" | "collision"; +type DeleteGlobalApplicationResult = "deleted" | "partial"; + interface AppSession { applicationId: ApplicationId; projectId: string; @@ -184,9 +188,10 @@ type LogoExtension = "svg" | "png" | "jpg" | "jpeg"; const DATA_DIRECTORY_NAME = "data"; const MANIFEST_FILE_NAME = "manifest.json"; -const PUBLIC_DIRECTORY_NAME = "public"; const HTML_CONTENT_TYPE = "text/html; charset=utf-8"; +const JAVASCRIPT_CONTENT_TYPE = "text/javascript; charset=utf-8"; const NO_STORE_CACHE_CONTROL = "no-store"; +const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"; const CONTENT_TYPE_OPTIONS = "nosniff"; const HTML_ENTRY_MAX_BYTES = 5 * 1024 * 1024; const LOGO_MAX_BYTES = 1024 * 1024; @@ -194,6 +199,7 @@ const LOGO_EXTENSIONS: readonly LogoExtension[] = ["svg", "png", "jpg", "jpeg"]; const APP_SESSION_TOKEN_PREFIX = "appsess_"; const INVALID_APP_MANIFEST_MESSAGE = "App manifest failed validation. Inspect manifest.json or rebuild the app."; + class InvalidAppManifestError extends ApiError { readonly applicationId: ApplicationId; readonly manifestPath: string; @@ -212,7 +218,7 @@ function sha256(bytes: Buffer): string { return createHash("sha256").update(bytes).digest("hex"); } -function canonicalizeJson(value: JsonValue | AppManifest): string { +function canonicalizeJson(value: JsonValue): string { return `${JSON.stringify(value, null, 2)}\n`; } @@ -572,19 +578,6 @@ async function buildApplicationDetail( }; } -function assertAppCapability( - manifest: AppManifest, - capability: AppCapability, -): void { - if (!manifest.capabilities.includes(capability)) { - throw new ApiError( - 403, - "invalid_request", - `App does not have the ${capability} capability`, - ); - } -} - function isIgnoredApplicationStorageEntry(entryName: string): boolean { return entryName.startsWith(".tmp-") || entryName.startsWith(".delete-"); } @@ -659,6 +652,32 @@ export async function notifyGlobalAppsChanged( deps: GlobalAppListDeps, ): Promise { deps.hub.notifySystem(["apps-changed"]); + deps.hub.notifyAppsChanged(); +} + +const APP_RUNTIME_SCRIPT_BODY = Buffer.from( + appRuntimeScriptAsset.contents, + "utf8", +); + +/** + * Serves the shared window.bb runtime bundle referenced by every injected app + * HTML response. The URL is keyed by the bundle's content hash, so the body + * can be cached forever; unknown hashes 404 because served HTML is no-store + * and always references the current hash. + */ +function serveAppRuntimeScript(rawFileName: string): Response { + if (rawFileName !== appRuntimeScriptAsset.fileName) { + throw new ApiError(404, "ENOENT", "App runtime script not found"); + } + return new Response(new Uint8Array(APP_RUNTIME_SCRIPT_BODY), { + status: 200, + headers: { + "cache-control": IMMUTABLE_CACHE_CONTROL, + "content-type": JAVASCRIPT_CONTENT_TYPE, + "x-content-type-options": CONTENT_TYPE_OPTIONS, + }, + }); } function createHtmlResponse(html: string): Response { @@ -693,15 +712,9 @@ function createInjectedAppHtmlResponse( ): Response { return createHtmlResponse( injectAppClientScript(args.html, { - appId: args.applicationId, applicationId: args.applicationId, appSessionToken: args.appSessionToken, - capabilities: args.capabilities, targetThreadId: args.targetThreadId, - dataUrl: `/api/v1/apps/${encodeURIComponent(args.applicationId)}/data`, - messageUrl: `/api/v1/apps/${encodeURIComponent( - args.applicationId, - )}/message`, wsUrl: buildAppWebSocketUrl(deps, args.requestUrl), }), ); @@ -795,7 +808,6 @@ async function serveApplicationEntry( targetThreadId: rawTargetThreadId ?? null, appSessionToken: token, html: result.toString("utf8"), - capabilities: manifest.capabilities, }); } @@ -1102,49 +1114,9 @@ async function createApplicationTempRoot( return tempRootPath; } -async function writeInitialApplicationFiles( - tempRootPath: string, - applicationId: ApplicationId, - name: string, -): Promise { - const manifest: AppManifest = { - manifestVersion: 1, - id: applicationId, - name, - entry: "index.html", - capabilities: ["data", "message"], - }; - await mkdir(path.join(tempRootPath, PUBLIC_DIRECTORY_NAME), { - recursive: true, - }); - await mkdir(path.join(tempRootPath, DATA_DIRECTORY_NAME), { - recursive: true, - }); - await writeFile( - path.join(tempRootPath, MANIFEST_FILE_NAME), - canonicalizeJson(manifest), - "utf8", - ); - await writeFile( - path.join(tempRootPath, PUBLIC_DIRECTORY_NAME, "index.html"), - buildBlankAppIndexHtml({ name }), - "utf8", - ); - await writeFile( - path.join(tempRootPath, DATA_DIRECTORY_NAME, "state.json"), - canonicalizeJson({}), - "utf8", - ); - appManifestSchema.parse( - JSON.parse( - await readFile(path.join(tempRootPath, MANIFEST_FILE_NAME), "utf8"), - ), - ); -} - async function publishApplicationTempRoot( args: PublishApplicationTempRootArgs, -): Promise<"published" | "collision"> { +): Promise { try { await stat(args.applicationPath); return "collision"; @@ -1184,11 +1156,11 @@ async function createGlobalApplication( appsRootPath, applicationId: args.applicationId, }); - await writeInitialApplicationFiles( + await writeInitialApplicationFiles({ tempRootPath, - args.applicationId, - args.name, - ); + applicationId: args.applicationId, + name: args.name, + }); const result = await publishApplicationTempRoot({ applicationPath, tempRootPath, @@ -1223,7 +1195,7 @@ async function createGlobalApplication( async function deleteGlobalApplication( dataDir: string, applicationId: ApplicationId, -): Promise<"deleted" | "partial"> { +): Promise { const applicationPath = resolveApplicationPath(dataDir, applicationId); try { await stat(applicationPath); @@ -1362,11 +1334,10 @@ export function registerGlobalAppRoutes(app: Hono, deps: AppDeps): void { const applicationId = parseApplicationId( context.req.param("applicationId"), ); - const manifest = await readApplicationManifestForRequest(deps, { + await readApplicationManifestForRequest(deps, { dataDir: deps.config.dataDir, applicationId, }); - assertAppCapability(manifest, "data"); const dataPath = parseOptionalAppDataPrefix(query.prefix); return context.json({ entries: await listApplicationDataEntries({ @@ -1385,11 +1356,10 @@ export function registerGlobalAppRoutes(app: Hono, deps: AppDeps): void { const applicationId = parseApplicationId( context.req.param("applicationId"), ); - const manifest = await readApplicationManifestForRequest(deps, { + await readApplicationManifestForRequest(deps, { dataDir: deps.config.dataDir, applicationId, }); - assertAppCapability(manifest, "message"); const target = resolveAppMessageTarget(deps, { applicationId, payload, @@ -1417,6 +1387,10 @@ export function registerGlobalAppRoutes(app: Hono, deps: AppDeps): void { }, ); + app.get("/app-runtime/:fileName", (context) => + serveAppRuntimeScript(context.req.param("fileName")), + ); + app.get("/apps/:applicationId/", async (context) => serveApplicationEntry( deps, @@ -1435,11 +1409,10 @@ export function registerGlobalAppRoutes(app: Hono, deps: AppDeps): void { const applicationId = parseApplicationId( context.req.param("applicationId"), ); - const manifest = await readApplicationManifestForRequest(deps, { + await readApplicationManifestForRequest(deps, { dataDir: deps.config.dataDir, applicationId, }); - assertAppCapability(manifest, "data"); const dataPath = parseAppDataRoutePath( extractRoutePath({ requestUrl: context.req.url, @@ -1464,11 +1437,10 @@ export function registerGlobalAppRoutes(app: Hono, deps: AppDeps): void { const applicationId = parseApplicationId( context.req.param("applicationId"), ); - const manifest = await readApplicationManifestForRequest(deps, { + await readApplicationManifestForRequest(deps, { dataDir: deps.config.dataDir, applicationId, }); - assertAppCapability(manifest, "data"); const dataPath = parseAppDataRoutePath( extractRoutePath({ requestUrl: context.req.url, @@ -1492,11 +1464,10 @@ export function registerGlobalAppRoutes(app: Hono, deps: AppDeps): void { const applicationId = parseApplicationId( context.req.param("applicationId"), ); - const manifest = await readApplicationManifestForRequest(deps, { + await readApplicationManifestForRequest(deps, { dataDir: deps.config.dataDir, applicationId, }); - assertAppCapability(manifest, "data"); const dataPath = parseAppDataRoutePath( extractRoutePath({ requestUrl: context.req.url, diff --git a/apps/server/src/routes/manager-templates.ts b/apps/server/src/routes/manager-templates.ts deleted file mode 100644 index aa3652763..000000000 --- a/apps/server/src/routes/manager-templates.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - managerTemplatesQuerySchema, - typedRoutes, - type PublicApiSchema, -} from "@bb/server-contract"; -import type { Hono } from "hono"; -import type { ServerAppDeps } from "../types.js"; -import { COMMAND_TIMEOUT_MS } from "../constants.js"; -import { ApiError } from "../errors.js"; -import { callHostRetryableOnlineRpc } from "../services/hosts/online-rpc.js"; -import { resolveSystemLookupHostId } from "../services/system/host-lookup.js"; - -export function registerManagerTemplateRoutes( - app: Hono, - deps: ServerAppDeps, -): void { - const { get } = typedRoutes(app, { - onValidationError: (msg) => new ApiError(400, "invalid_request", msg), - }); - - get( - "/manager-templates", - managerTemplatesQuerySchema, - async (context, query) => { - const hostId = resolveSystemLookupHostId(deps, query); - const result = await callHostRetryableOnlineRpc(deps, { - hostId, - timeoutMs: COMMAND_TIMEOUT_MS, - command: { type: "host.list_manager_templates" }, - }); - return context.json({ - templates: result.templates.map((template) => ({ - name: template.name, - isActive: template.name === result.activeName, - })), - activeName: result.activeName, - }); - }, - ); -} diff --git a/apps/server/src/routes/projects.ts b/apps/server/src/routes/projects.ts index 060153e74..758a8ba87 100644 --- a/apps/server/src/routes/projects.ts +++ b/apps/server/src/routes/projects.ts @@ -724,24 +724,6 @@ export function registerProjectRoutes(app: Hono, deps: AppDeps): void { }; } - if (payload.templateName !== undefined) { - const templatesResult = await callHostRetryableOnlineRpc(deps, { - hostId, - timeoutMs: COMMAND_TIMEOUT_MS, - command: { type: "host.list_manager_templates" }, - }); - const knownTemplateNames = new Set( - templatesResult.templates.map((template) => template.name), - ); - if (!knownTemplateNames.has(payload.templateName)) { - throw new ApiError( - 400, - "invalid_request", - `Unknown manager template "${payload.templateName}"`, - ); - } - } - let title: string; if (payload.name) { title = payload.name; @@ -788,7 +770,6 @@ export function registerProjectRoutes(app: Hono, deps: AppDeps): void { const thread = await createThreadFromRequest(deps, { automationId: null, - managerTemplateName: payload.templateName ?? null, origin: payload.origin, projectId, providerId: payload.providerId, diff --git a/apps/server/src/routes/threads/base.ts b/apps/server/src/routes/threads/base.ts index 218c466dc..ede6617b0 100644 --- a/apps/server/src/routes/threads/base.ts +++ b/apps/server/src/routes/threads/base.ts @@ -140,7 +140,6 @@ export function registerThreadBaseRoutes(app: Hono, deps: AppDeps): void { const thread = await createThreadFromRequest(deps, { ...payload, automationId: null, - managerTemplateName: null, origin: payload.origin, type: "standard", }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index aa602b651..ac20b1028 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -15,7 +15,6 @@ import { registerGlobalAppRoutes } from "./routes/apps.js"; import { registerEnvironmentRoutes } from "./routes/environments.js"; import { registerFileRoutes } from "./routes/files.js"; import { registerHostRoutes } from "./routes/hosts.js"; -import { registerManagerTemplateRoutes } from "./routes/manager-templates.js"; import { registerProjectRoutes } from "./routes/projects.js"; import { registerSystemRoutes } from "./routes/system.js"; import { registerDevelopmentOnlyReplayRoutes } from "./routes/internal-replay.js"; @@ -253,7 +252,6 @@ export function createApp( registerEnvironmentRoutes(publicApi, deps); registerThreadRoutes(publicApi, deps); registerSystemRoutes(publicApi, deps); - registerManagerTemplateRoutes(publicApi, deps); registerDevelopmentOnlyReplayRoutes(publicApi, deps); app.route("/api/v1", publicApi); app.use("/api/v1/*", () => { diff --git a/apps/server/src/services/scheduling/automation-sweep.ts b/apps/server/src/services/scheduling/automation-sweep.ts index f3865cc2c..e7ea9b078 100644 --- a/apps/server/src/services/scheduling/automation-sweep.ts +++ b/apps/server/src/services/scheduling/automation-sweep.ts @@ -130,7 +130,6 @@ async function runAutomation( await createThreadFromRequest(deps, { ...executionContext.action.threadRequest, automationId: automation.id, - managerTemplateName: null, origin: null, projectId: automation.projectId, type: "standard", diff --git a/apps/server/src/services/scheduling/nudge-sweep-runner.ts b/apps/server/src/services/scheduling/nudge-sweep-runner.ts index 0fdb78898..30b1233b0 100644 --- a/apps/server/src/services/scheduling/nudge-sweep-runner.ts +++ b/apps/server/src/services/scheduling/nudge-sweep-runner.ts @@ -452,7 +452,6 @@ async function prepareDueNudge( { hostId: environment.hostId, input, - mode: "change-detection", thread, }, ); diff --git a/apps/server/src/services/system/bb-app-managed-config.ts b/apps/server/src/services/system/bb-app-managed-config.ts index c5f5a23dc..8636cd3af 100644 --- a/apps/server/src/services/system/bb-app-managed-config.ts +++ b/apps/server/src/services/system/bb-app-managed-config.ts @@ -99,6 +99,9 @@ export function applyBbAppManagedConfig( const managedConfig = args.managedConfig.config ?? {}; const managedEnv = args.managedEnvFile.env ?? {}; + // providerId validity is enforced by customProviderModelSchema at parse time. + args.targetConfig.customModels = + args.managedConfig.customModels ?? args.baseConfig.customModels; args.targetConfig.inferenceModel = managedConfig.BB_INFERENCE !== undefined ? validateInferenceModel(managedConfig.BB_INFERENCE) diff --git a/apps/server/src/services/system/event-pruning.ts b/apps/server/src/services/system/event-pruning.ts index 1ecfb8498..de386668f 100644 --- a/apps/server/src/services/system/event-pruning.ts +++ b/apps/server/src/services/system/event-pruning.ts @@ -2,6 +2,7 @@ import { performance } from "node:perf_hooks"; import { getThread, getLatestThreadSequence, + pruneBackgroundTaskProgressEvents, pruneContextWindowUsageEventsBeforeSequence, pruneResolvedItemDeltas, pruneTokenUsageEventsBeforeSequence, @@ -21,6 +22,7 @@ export interface PruneThreadEventHistoryArgs { export interface ThreadEventPruningResult { latestSequence: number; removedAgePrunableEvents: number; + removedBackgroundTaskProgressEvents: number; removedResolvedItemDeltas: number; sequenceCutoff: number; totalRemoved: number; @@ -38,6 +40,7 @@ interface ActiveThreadPruneState { type ThreadEventPruningStep = | "get_latest_thread_sequence" + | "prune_background_task_progress" | "prune_context_window_usage" | "prune_generic_age_prunable_events" | "prune_resolved_item_deltas" @@ -66,6 +69,18 @@ export const AGE_PRUNABLE_THREAD_EVENT_TYPES: readonly ThreadEventType[] = [ "turn/diff/updated", ] as const; +/** + * Event types whose ingestion may trigger an opportunistic prune of the + * thread's event history. Covers the age-prunable stream types plus + * backgroundTask progress snapshots: workflows keep streaming progress after + * their spawning turn completed, so without this trigger nothing would bound + * the superseded snapshots until the next turn completes. + */ +const ACTIVE_PRUNE_TRIGGER_THREAD_EVENT_TYPES: readonly ThreadEventType[] = [ + ...AGE_PRUNABLE_THREAD_EVENT_TYPES, + "item/backgroundTask/progress", +] as const; + const GENERIC_AGE_PRUNABLE_THREAD_EVENT_TYPES: readonly ThreadEventType[] = [ "turn/diff/updated", ] as const; @@ -76,8 +91,8 @@ const KEEP_RECENT_BY_MODE: Record = { archived: ARCHIVED_THREAD_EVENT_KEEP_RECENT, }; -const agePrunableThreadEventTypeSet = new Set( - AGE_PRUNABLE_THREAD_EVENT_TYPES, +const activePruneTriggerThreadEventTypeSet = new Set( + ACTIVE_PRUNE_TRIGGER_THREAD_EVENT_TYPES, ); const activeThreadPruneStateByThreadId = new Map< string, @@ -104,10 +119,10 @@ function runThreadEventPruningStep( } } -export function isAgePrunableThreadEventType( +export function isActivePruneTriggerThreadEventType( eventType: ThreadEventType, ): boolean { - return agePrunableThreadEventTypeSet.has(eventType); + return activePruneTriggerThreadEventTypeSet.has(eventType); } export function pruneThreadEventHistory( @@ -150,13 +165,24 @@ export function pruneThreadEventHistory( threadId: args.threadId, }), ); + const removedBackgroundTaskProgressEvents = runThreadEventPruningStep( + "prune_background_task_progress", + () => + pruneBackgroundTaskProgressEvents(deps.db, { + threadId: args.threadId, + }), + ); return { latestSequence, removedAgePrunableEvents, + removedBackgroundTaskProgressEvents, removedResolvedItemDeltas, sequenceCutoff, - totalRemoved: removedAgePrunableEvents + removedResolvedItemDeltas, + totalRemoved: + removedAgePrunableEvents + + removedBackgroundTaskProgressEvents + + removedResolvedItemDeltas, }; } @@ -201,7 +227,14 @@ export function maybePruneActiveThreadEventHistory( args: MaybePruneActiveThreadEventHistoryArgs, ): ThreadEventPruningResult | null { const thread = getThread(deps.db, args.threadId); - if (!thread || thread.status !== "active" || thread.archivedAt !== null) { + // Idle threads still ingest prunable streams: a backgrounded workflow keeps + // emitting thread-scoped progress snapshots after its spawning turn + // completed, which is exactly when nothing else would prune them. + if ( + !thread || + (thread.status !== "active" && thread.status !== "idle") || + thread.archivedAt !== null + ) { return null; } @@ -226,7 +259,7 @@ export function maybePruneActiveThreadEventHistory( }); return pruneThreadEventHistoryBestEffort(deps, { - mode: "active", + mode: thread.status === "active" ? "active" : "idle", threadId: args.threadId, }); } diff --git a/apps/server/src/services/system/execution-options.ts b/apps/server/src/services/system/execution-options.ts index c8254e018..f05d0117b 100644 --- a/apps/server/src/services/system/execution-options.ts +++ b/apps/server/src/services/system/execution-options.ts @@ -3,11 +3,17 @@ import type { SystemExecutionOptionsModelLoadError, SystemExecutionOptionsResponse, } from "@bb/server-contract"; -import type { ProviderInfo } from "@bb/domain"; +import type { CustomProviderModel } from "@bb/config/bb-app-managed-config"; +import { + reasoningEffortsForLevels, + type AvailableModel, + type ProviderInfo, +} from "@bb/domain"; import type { AppDeps } from "../../types.js"; import { COMMAND_TIMEOUT_MS } from "../../constants.js"; import { ApiError } from "../../errors.js"; import { callHostRetryableOnlineRpc } from "../hosts/online-rpc.js"; +import { getSupportedReasoningLevelsForProvider } from "../threads/thread-reasoning-policy.js"; import { resolveSystemLookupHostId } from "./host-lookup.js"; export interface SystemExecutionOptionsRequest { @@ -26,6 +32,88 @@ type ModelListResult = Pick< "modelLoadError" | "models" | "selectedOnlyModels" >; +interface AppendCustomModelsArgs { + customModels: CustomProviderModel[]; + models: AvailableModel[]; + providerId: string; + selectedOnlyModels: AvailableModel[]; +} + +type AppendCustomModelsResult = Pick< + SystemExecutionOptionsResponse, + "models" | "selectedOnlyModels" +>; + +function buildCustomModel(customModel: CustomProviderModel): AvailableModel { + return { + id: customModel.model, + model: customModel.model, + displayName: customModel.displayName ?? customModel.model, + description: "Custom model from config.json", + // Custom models advertise the provider's full reasoning ladder: per-model + // support is unknowable server-side and the picker reconciles the user's + // choice per model (see reconcileReasoningLevel in @bb/domain). The + // ladder comes from the same per-provider policy table that validates + // reasoning overrides, so the picker and validation cannot drift apart. + supportedReasoningEfforts: reasoningEffortsForLevels( + getSupportedReasoningLevelsForProvider(customModel.providerId), + ), + defaultReasoningEffort: "medium", + isDefault: false, + }; +} + +// Appends the user's configured custom models for the provider to the +// provider-reported catalog. Catalog metadata wins on model-id collision so +// the picker never shows duplicate or conflicting rows: active entries are +// kept as-is, and selected-only entries (retired/pinned models the catalog +// describes accurately but no longer offers) are promoted into the active +// list instead of being shadowed by a synthesized entry. This also runs when +// the provider model list failed to load so custom models stay selectable. +export function appendCustomModels({ + customModels, + models, + providerId, + selectedOnlyModels, +}: AppendCustomModelsArgs): AppendCustomModelsResult { + const providerCustomModels = customModels.filter( + (customModel) => customModel.providerId === providerId, + ); + if (providerCustomModels.length === 0) { + return { models, selectedOnlyModels }; + } + + const seenModelIds = new Set(models.map((model) => model.model)); + const promotedModelIds = new Set(); + const appendedModels: AvailableModel[] = []; + + for (const customModel of providerCustomModels) { + if (seenModelIds.has(customModel.model)) { + continue; + } + seenModelIds.add(customModel.model); + const selectedOnlyMatch = selectedOnlyModels.find( + (model) => model.model === customModel.model, + ); + if (selectedOnlyMatch !== undefined) { + promotedModelIds.add(selectedOnlyMatch.model); + appendedModels.push(selectedOnlyMatch); + continue; + } + appendedModels.push(buildCustomModel(customModel)); + } + + return { + models: [...models, ...appendedModels], + selectedOnlyModels: + promotedModelIds.size === 0 + ? selectedOnlyModels + : selectedOnlyModels.filter( + (model) => !promotedModelIds.has(model.model), + ), + }; +} + export async function resolveSystemExecutionOptions( deps: AppDeps, query: SystemExecutionOptionsRequest, @@ -90,10 +178,17 @@ export async function resolveSystemExecutionOptions( }; } - return { - providers, + const { models, selectedOnlyModels } = appendCustomModels({ + customModels: deps.config.customModels, models: modelResult.models, + providerId: modelsProvider.id, selectedOnlyModels: modelResult.selectedOnlyModels, + }); + + return { + providers, + models, + selectedOnlyModels, modelLoadError: modelResult.modelLoadError, }; } diff --git a/apps/server/src/services/threads/app-client-script.ts b/apps/server/src/services/threads/app-client-script.ts index 90e315d63..4d4567f66 100644 --- a/apps/server/src/services/threads/app-client-script.ts +++ b/apps/server/src/services/threads/app-client-script.ts @@ -1,32 +1,47 @@ -import type { ApplicationId } from "@bb/domain"; -import type { AppCapability } from "@bb/server-contract"; +import { + appRuntimeBrowserBundle, + createAppRuntimeBootstrapScript, + type AppRuntimeBootstrap, +} from "@bb/sdk/app-runtime"; -export interface AppClientBootstrap { - appId: ApplicationId; - applicationId: ApplicationId; - appSessionToken: string | null; - capabilities: AppCapability[]; - dataUrl: string; - messageUrl: string; - targetThreadId: string | null; - wsUrl: string; -} +export type AppClientBootstrap = AppRuntimeBootstrap; interface CreateAppClientScriptArgs { bootstrap: AppClientBootstrap; } +interface AppRuntimeScriptAsset { + /** Exact JavaScript text served at {@link AppRuntimeScriptAsset.url}. */ + contents: string; + /** Content-hashed file name (`.js`) the runtime route validates. */ + fileName: string; + /** Root-relative URL injected app HTML references. */ + url: string; +} + const APP_CLIENT_SCRIPT_MARKER = "data-bb-app-client"; +/** + * The window.bb runtime is served once as an immutable, content-hashed asset + * instead of being inlined into every app HTML response. Only the small + * bootstrap assignment stays inline, because it carries per-response values + * (the app session token). + */ +export const appRuntimeScriptAsset: AppRuntimeScriptAsset = { + contents: appRuntimeBrowserBundle.contents, + fileName: `${appRuntimeBrowserBundle.sha256}.js`, + url: `/api/v1/app-runtime/${appRuntimeBrowserBundle.sha256}.js`, +}; + function escapedJsonForInlineScript(value: AppClientBootstrap): string { return JSON.stringify(value).replace(/${buildAppClientJavascript( - bootstrapJson, - )}`; + const bootstrapScript = createAppRuntimeBootstrapScript({ + bootstrapJson: escapedJsonForInlineScript(args.bootstrap), + }); + return ``; } export function injectAppClientScript( @@ -59,440 +74,3 @@ export function injectAppClientScript( return `${script}${html}`; } - -function buildAppClientJavascript(bootstrapJson: string): string { - return ` -(function () { - var bootstrap = ${bootstrapJson}; - var capabilities = Object.create(null); - bootstrap.capabilities.forEach(function (capability) { - capabilities[capability] = true; - }); - - function hasCapability(name) { - return capabilities[name] === true; - } - - function cloneValue(value) { - if (value === undefined) return undefined; - return JSON.parse(JSON.stringify(value)); - } - - function isJsonValue(value) { - if (value === null) return true; - var type = typeof value; - if (type === "string" || type === "boolean") return true; - if (type === "number") return Number.isFinite(value); - if (Array.isArray(value)) return value.every(isJsonValue); - if (type !== "object") return false; - var proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return false; - return Object.keys(value).every(function (key) { - return isJsonValue(value[key]); - }); - } - - function validatePath(path, allowEmpty) { - // Keep these app-data path limits in sync with packages/domain/src/apps.ts. - if (allowEmpty && path === "") return; - if (typeof path !== "string") { - throw new Error("App data path must be a string"); - } - if ( - path.length === 0 || - path.length > 512 || - path.indexOf("\\\\") !== -1 || - path.indexOf(String.fromCharCode(0)) !== -1 || - path.charAt(0) === "/" || - path.charAt(path.length - 1) === "/" - ) { - throw new Error("Invalid app data path: " + String(path)); - } - var segments = path.split("/"); - if (segments.length > 8) { - throw new Error("App data path is too deep: " + path); - } - for (var index = 0; index < segments.length; index += 1) { - var segment = segments[index]; - if ( - segment === "." || - segment === ".." || - segment.charAt(0) === "." || - !/^[A-Za-z0-9._-]{1,80}$/.test(segment) - ) { - throw new Error("Invalid app data path segment: " + String(segment)); - } - } - } - - function dataPathUrl(path) { - validatePath(path, false); - return bootstrap.dataUrl + "/" + path.split("/").map(encodeURIComponent).join("/"); - } - - function buildError(response, text) { - var message = response.statusText || ("HTTP " + response.status); - var code = null; - if (text) { - try { - var body = JSON.parse(text); - if (body && typeof body === "object" && typeof body.message === "string") { - message = body.message; - if (typeof body.code === "string") code = body.code; - } else { - message = text; - } - } catch (error) { - message = text; - } - } - var appError = new Error(message); - appError.status = response.status; - if (code) appError.code = code; - return appError; - } - - function rejectResponse(response) { - return response.text().then(function (text) { - throw buildError(response, text); - }); - } - - function read(path) { - return fetch(dataPathUrl(path), { - method: "GET", - credentials: "same-origin", - headers: { "Accept": "application/json" } - }).then(function (response) { - if (response.status === 404) return undefined; - if (!response.ok) return rejectResponse(response); - return response.json().then(function (entry) { - return cloneValue(entry.value); - }); - }); - } - - function write(path, value) { - validatePath(path, false); - if (value === undefined || !isJsonValue(value)) { - return Promise.reject(new Error("bb.data.write requires a JSON value")); - } - return fetch(dataPathUrl(path), { - method: "PUT", - credentials: "same-origin", - headers: { - "Accept": "application/json", - "Content-Type": "application/json" - }, - body: JSON.stringify({ value: value }) - }).then(function (response) { - if (response.ok) return undefined; - return rejectResponse(response); - }); - } - - function deletePath(path) { - return fetch(dataPathUrl(path), { - method: "DELETE", - credentials: "same-origin", - headers: { "Accept": "application/json" } - }).then(function (response) { - if (response.ok) return undefined; - return rejectResponse(response); - }); - } - - function listEntries(prefix) { - var effectivePrefix = prefix === undefined ? "" : prefix; - validatePath(effectivePrefix, true); - var url = bootstrap.dataUrl; - if (effectivePrefix !== "") { - url += "?prefix=" + encodeURIComponent(effectivePrefix); - } - return fetch(url, { - method: "GET", - credentials: "same-origin", - headers: { "Accept": "application/json" } - }).then(function (response) { - if (!response.ok) return rejectResponse(response); - return response.json().then(function (body) { - return body.entries.map(function (entry) { - return { - path: entry.path, - value: cloneValue(entry.value), - version: entry.version - }; - }); - }); - }); - } - - function list(prefix) { - return listEntries(prefix).then(function (entries) { - return entries.map(function (entry) { - return { path: entry.path, value: cloneValue(entry.value) }; - }); - }); - } - - var socket = null; - var reconnectTimer = null; - var reconnectDelay = 1000; - var listeners = []; - var socketHasOpened = false; - var socketSubscribed = false; - var subscriptionReady = null; - var resolveSubscriptionReady = null; - var subscriptionEntityId = bootstrap.applicationId + ":data"; - - function pathMatchesPrefix(path, prefix) { - return prefix === "" || path === prefix || path.indexOf(prefix + "/") === 0; - } - - function callListener(listener, event) { - if (!listener.active) return; - if (!pathMatchesPrefix(event.path, listener.prefix)) return; - try { - listener.callback({ - path: event.path, - value: event.deleted ? undefined : cloneValue(event.value), - deleted: event.deleted - }); - } catch (error) { - console.error("window.bb.data.onChange callback failed", error); - } - } - - function deliverOrBufferListener(listener, event) { - if (!listener.active) return; - if (!pathMatchesPrefix(event.path, listener.prefix)) return; - if (listener.replaying) { - listener.bufferedEvents.push(event); - return; - } - callListener(listener, event); - } - - function handleBroadcast(message) { - if ( - !message || - message.applicationId !== bootstrap.applicationId - ) { - return; - } - if (message.type === "app-data.resync") { - listeners.slice().forEach(function (listener) { - replayExisting(listener); - }); - return; - } - if (message.type !== "app-data.changed") { - return; - } - var event = { - path: message.path, - value: message.deleted ? undefined : cloneValue(message.value), - deleted: message.deleted, - version: message.version - }; - listeners.slice().forEach(function (listener) { - deliverOrBufferListener(listener, event); - }); - } - - function sendSubscriptionMessage(type) { - if (!socket || socket.readyState !== WebSocket.OPEN) return; - socket.send(JSON.stringify({ - type: type, - entity: "thread", - id: subscriptionEntityId - })); - } - - function resetSubscriptionReady() { - subscriptionReady = new Promise(function (resolve) { - resolveSubscriptionReady = resolve; - }); - } - - function closeSocketIfIdle() { - if (listeners.length > 0) return; - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - if (!socket) return; - if (resolveSubscriptionReady) { - resolveSubscriptionReady(); - resolveSubscriptionReady = null; - } - if (socket.readyState === WebSocket.OPEN && socketSubscribed) { - sendSubscriptionMessage("unsubscribe"); - socketSubscribed = false; - } - if ( - socket.readyState === WebSocket.OPEN || - socket.readyState === WebSocket.CONNECTING - ) { - socketHasOpened = false; - socket.close(); - } - } - - function connectSocket() { - if (!subscriptionReady) resetSubscriptionReady(); - if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return subscriptionReady; - socket = new WebSocket(bootstrap.wsUrl); - socket.onopen = function () { - var reconnected = socketHasOpened; - socketHasOpened = true; - reconnectDelay = 1000; - if (listeners.length > 0) { - sendSubscriptionMessage("subscribe"); - socketSubscribed = true; - } - if (resolveSubscriptionReady) { - resolveSubscriptionReady(); - resolveSubscriptionReady = null; - } - closeSocketIfIdle(); - if (reconnected) { - listeners.slice().forEach(function (listener) { - replayExisting(listener); - }); - } - }; - socket.onmessage = function (event) { - if (typeof event.data !== "string") return; - try { - handleBroadcast(JSON.parse(event.data)); - } catch (error) { - console.error("window.bb ignored invalid realtime message", error); - } - }; - socket.onclose = function () { - socketSubscribed = false; - resetSubscriptionReady(); - if (listeners.length === 0) return; - if (reconnectTimer) return; - reconnectTimer = setTimeout(function () { - reconnectTimer = null; - reconnectDelay = Math.min(reconnectDelay * 1.5, 30000); - connectSocket(); - }, reconnectDelay); - }; - socket.onerror = function () { - if (socket) socket.close(); - }; - return subscriptionReady; - } - - function replayExisting(listener) { - if (!listener.active) return Promise.resolve(); - listener.replayPromise = (listener.replayPromise || Promise.resolve()).then(function () { - if (!listener.active) return; - listener.replaying = true; - listener.bufferedEvents = []; - return connectSocket().then(function () { - if (!listener.active) return null; - return listEntries(listener.prefix); - }).then(function (entries) { - if (!listener.active || !entries) return; - var replayedVersions = Object.create(null); - entries.forEach(function (entry) { - replayedVersions[entry.path] = entry.version; - callListener(listener, { - path: entry.path, - value: entry.value, - deleted: false - }); - }); - listener.bufferedEvents.forEach(function (event) { - if (!event.deleted && replayedVersions[event.path] === event.version) return; - callListener(listener, event); - }); - }).finally(function () { - listener.replaying = false; - listener.bufferedEvents = []; - }); - }).catch(function (error) { - if (!listener.active) return; - console.error("window.bb.data.onChange replay failed", error); - }); - return listener.replayPromise; - } - - function onChange(prefix, callback) { - var effectivePrefix = prefix === undefined ? "" : prefix; - validatePath(effectivePrefix, true); - if (typeof callback !== "function") { - throw new Error("bb.data.onChange requires a callback"); - } - var listener = { - prefix: effectivePrefix, - callback: callback, - active: true, - replaying: false, - bufferedEvents: [], - replayPromise: null - }; - listeners.push(listener); - replayExisting(listener); - return function () { - if (!listener.active) return; - listener.active = false; - listener.bufferedEvents = []; - listeners = listeners.filter(function (candidate) { - return candidate !== listener; - }); - closeSocketIfIdle(); - }; - } - - function message(text) { - if (text === undefined || !isJsonValue(text)) { - throw new TypeError("window.bb.message(payload) requires a JSON value"); - } - return fetch(bootstrap.messageUrl, { - method: "POST", - credentials: "same-origin", - headers: { - "Accept": "application/json", - "Content-Type": "application/json" - }, - body: JSON.stringify({ - payload: text, - appSessionToken: bootstrap.appSessionToken || undefined - }) - }).then(function (response) { - if (response.ok) return undefined; - return rejectResponse(response); - }); - } - - var bb = { appId: bootstrap.appId, applicationId: bootstrap.applicationId }; - if (hasCapability("data")) { - bb.data = { - read: read, - write: write, - delete: deletePath, - list: list, - onChange: onChange - }; - } - if (hasCapability("message")) { - bb.message = message; - } - - try { - Object.defineProperty(window, "bb", { - value: bb, - configurable: true, - writable: false - }); - } catch (error) { - window.bb = bb; - } -})(); -`; -} diff --git a/apps/server/src/services/threads/app-scaffold-template-copy.ts b/apps/server/src/services/threads/app-scaffold-template-copy.ts new file mode 100644 index 000000000..846075d71 --- /dev/null +++ b/apps/server/src/services/threads/app-scaffold-template-copy.ts @@ -0,0 +1,123 @@ +import { cp } from "node:fs/promises"; +import { constants as fsConstants, existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Locates and copies the app scaffold template that ships beside this module. + * + * This module is shared between the server runtime (app provisioning in + * ./app-scaffold.ts) and the build step that copies the template into dist + * (scripts/copy-app-scaffold-template.ts, loaded with tsx before workspace + * packages are built). Keep it free of workspace and third-party imports. + */ + +interface CopyApplicationScaffoldTemplateArgs { + targetPath: string; + templatePath: string; +} + +interface ResolveApplicationScaffoldTemplatePathArgs { + moduleDir: string; +} + +interface ShouldCopyApplicationScaffoldTemplatePathArgs { + sourcePath: string; + templatePath: string; +} + +export const APP_SCAFFOLD_TEMPLATE_DIRECTORY_NAME = "app-scaffold-template"; +export const APP_SCAFFOLD_MANIFEST_FILE_NAME = "manifest.json"; +export const APP_SCAFFOLD_README_FILE_NAME = "README.md"; +const APP_SCAFFOLD_SOURCE_DIRECTORY_NAME = "source"; +const APP_SCAFFOLD_SOURCE_DEV_OUTPUT_DIRECTORY_NAMES = new Set([ + "node_modules", + "playwright-report", + "screenshots", + "test-results", +]); +// Structural essentials the server itself depends on. Template content +// (skills, source files) may change without breaking template detection. +const APP_SCAFFOLD_TEMPLATE_SENTINEL_PATHS = [ + APP_SCAFFOLD_MANIFEST_FILE_NAME, + APP_SCAFFOLD_README_FILE_NAME, + path.join("public", "index.html"), +]; +const APP_SCAFFOLD_COPY_MODE = fsConstants.COPYFILE_FICLONE; +const scaffoldModuleDir = path.dirname(fileURLToPath(import.meta.url)); + +function shouldCopyApplicationScaffoldTemplatePath( + args: ShouldCopyApplicationScaffoldTemplatePathArgs, +): boolean { + const relativePath = path + .relative(args.templatePath, args.sourcePath) + .split(path.sep) + .join("/"); + if (relativePath === "") { + return true; + } + if ( + relativePath === ".." || + relativePath.startsWith("../") || + path.isAbsolute(relativePath) + ) { + return false; + } + const pathSegments = relativePath.split("/"); + if (pathSegments[0] !== APP_SCAFFOLD_SOURCE_DIRECTORY_NAME) { + return true; + } + return !pathSegments.some((segment) => + APP_SCAFFOLD_SOURCE_DEV_OUTPUT_DIRECTORY_NAMES.has(segment), + ); +} + +function hasApplicationScaffoldTemplate(templatePath: string): boolean { + return APP_SCAFFOLD_TEMPLATE_SENTINEL_PATHS.every((sentinelPath) => + existsSync(path.join(templatePath, sentinelPath)), + ); +} + +/** + * The template directory sits beside this module in both layouts: + * src/services/threads/ in the source tree, and dist/ in the bundled server + * (the build copies the template to dist/app-scaffold-template and esbuild + * bundles this module into the dist entry points). + */ +export function resolveApplicationScaffoldTemplatePathForModuleDir( + args: ResolveApplicationScaffoldTemplatePathArgs, +): string { + const templatePath = path.resolve( + args.moduleDir, + APP_SCAFFOLD_TEMPLATE_DIRECTORY_NAME, + ); + if (!hasApplicationScaffoldTemplate(templatePath)) { + throw new Error(`Missing app scaffold template at ${templatePath}`); + } + return templatePath; +} + +export function resolveApplicationScaffoldTemplatePath(): string { + return resolveApplicationScaffoldTemplatePathForModuleDir({ + moduleDir: scaffoldModuleDir, + }); +} + +/** + * Copies the template tree, excluding dev artifacts under source/ + * (node_modules, playwright-report, screenshots, test-results) at every depth. + */ +export async function copyApplicationScaffoldTemplate( + args: CopyApplicationScaffoldTemplateArgs, +): Promise { + await cp(args.templatePath, args.targetPath, { + filter: (sourcePath) => + shouldCopyApplicationScaffoldTemplatePath({ + sourcePath, + templatePath: args.templatePath, + }), + force: false, + mode: APP_SCAFFOLD_COPY_MODE, + recursive: true, + }); +} diff --git a/apps/server/src/services/threads/app-scaffold-template/README.md b/apps/server/src/services/threads/app-scaffold-template/README.md new file mode 100644 index 000000000..b1038b07c --- /dev/null +++ b/apps/server/src/services/threads/app-scaffold-template/README.md @@ -0,0 +1,75 @@ +# BB_APP_NAME_PLACEHOLDER + +A Vite, React, and TypeScript Todo app scaffold for bb apps. It stores each +todo as its own app data record, subscribes to live data changes, and can send a +message back to the manager thread that opened it. + +## Layout + +```text +manifest.json +README.md +public/ # Served by bb as the app web root +data/state.json # Empty seed state +skills/add-todos/ # Agent skill for out-of-band todo writes +source/ # Editable Vite + React + TypeScript app +``` + +Only `public/` is served to the browser. The committed `public/` build lets a +new app render immediately after `bb app new`. + +## Install And Build + +From this app directory: + +```bash +cd source +pnpm install +pnpm build +``` + +`pnpm build` typechecks with `tsc --noEmit`, then runs Vite and emits the +browser build to `../public` with a relative base (`"./"`), +`build.outDir: "../public"`, and `build.assetsDir: ""`. That keeps asset +references flat and relative so bb can serve them from the app route. + +## Development + +Use `source/` for edits: + +```bash +cd source +pnpm dev +``` + +The dev server is only for local editing. bb serves the prebuilt files in +`public/`, so rebuild after editing `source/`. + +## App Data + +Todos are per-item records: + +```text +todos/ +``` + +```json +{ + "id": "todo_example", + "title": "Write the first todo", + "done": false, + "createdAt": "2026-06-03T20:00:00.000Z", + "updatedAt": "2026-06-03T20:00:00.000Z" +} +``` + +The app subscribes with `window.bb.data.onChange({ prefix: "todos", callback })` +— the SDK replays existing records to every new subscriber, so the +subscription also hydrates initial state — and resets its state on +`window.bb.on({ event: "app-data:resync", callback })` before the SDK +re-replays records. It writes with `window.bb.data.write({ path, value })`, +deletes with `window.bb.data.delete({ path })`, and sends manager updates with +`window.bb.message.send({ payload })`. + +The vendored SDK declaration at `source/src/bb-sdk.d.ts` mirrors the current +`@bb/sdk` injected app runtime. Keep it in sync when the SDK contract changes. diff --git a/apps/server/src/services/threads/app-scaffold-template/data/state.json b/apps/server/src/services/threads/app-scaffold-template/data/state.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/apps/server/src/services/threads/app-scaffold-template/data/state.json @@ -0,0 +1 @@ +{} diff --git a/apps/server/src/services/threads/app-scaffold-template/manifest.json b/apps/server/src/services/threads/app-scaffold-template/manifest.json new file mode 100644 index 000000000..7e60129ce --- /dev/null +++ b/apps/server/src/services/threads/app-scaffold-template/manifest.json @@ -0,0 +1,7 @@ +{ + "manifestVersion": 1, + "id": "todo-starter", + "name": "Todo Starter", + "entry": "index.html", + "capabilities": ["data", "message"] +} diff --git a/apps/server/src/services/threads/app-scaffold-template/public/index-BAq8GMwy.js b/apps/server/src/services/threads/app-scaffold-template/public/index-BAq8GMwy.js new file mode 100644 index 000000000..8ebfe6507 --- /dev/null +++ b/apps/server/src/services/threads/app-scaffold-template/public/index-BAq8GMwy.js @@ -0,0 +1,99 @@ +(function(){const E=document.createElement("link").relList;if(E&&E.supports&&E.supports("modulepreload"))return;for(const C of document.querySelectorAll('link[rel="modulepreload"]'))y(C);new MutationObserver(C=>{for(const p of C)if(p.type==="childList")for(const W of p.addedNodes)W.tagName==="LINK"&&W.rel==="modulepreload"&&y(W)}).observe(document,{childList:!0,subtree:!0});function D(C){const p={};return C.integrity&&(p.integrity=C.integrity),C.referrerPolicy&&(p.referrerPolicy=C.referrerPolicy),C.crossOrigin==="use-credentials"?p.credentials="include":C.crossOrigin==="anonymous"?p.credentials="omit":p.credentials="same-origin",p}function y(C){if(C.ep)return;C.ep=!0;const p=D(C);fetch(C.href,p)}})();var oc={exports:{}},ze={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var A0;function cm(){if(A0)return ze;A0=1;var c=Symbol.for("react.transitional.element"),E=Symbol.for("react.fragment");function D(y,C,p){var W=null;if(p!==void 0&&(W=""+p),C.key!==void 0&&(W=""+C.key),"key"in C){p={};for(var rl in C)rl!=="key"&&(p[rl]=C[rl])}else p=C;return C=p.ref,{$$typeof:c,type:y,key:W,ref:C!==void 0?C:null,props:p}}return ze.Fragment=E,ze.jsx=D,ze.jsxs=D,ze}var E0;function sm(){return E0||(E0=1,oc.exports=cm()),oc.exports}var R=sm(),dc={exports:{}},G={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var _0;function om(){if(_0)return G;_0=1;var c=Symbol.for("react.transitional.element"),E=Symbol.for("react.portal"),D=Symbol.for("react.fragment"),y=Symbol.for("react.strict_mode"),C=Symbol.for("react.profiler"),p=Symbol.for("react.consumer"),W=Symbol.for("react.context"),rl=Symbol.for("react.forward_ref"),q=Symbol.for("react.suspense"),_=Symbol.for("react.memo"),F=Symbol.for("react.lazy"),Y=Symbol.for("react.activity"),dl=Symbol.iterator;function yl(d){return d===null||typeof d!="object"?null:(d=dl&&d[dl]||d["@@iterator"],typeof d=="function"?d:null)}var tl={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},vl=Object.assign,Rl={};function Sl(d,A,O){this.props=d,this.context=A,this.refs=Rl,this.updater=O||tl}Sl.prototype.isReactComponent={},Sl.prototype.setState=function(d,A){if(typeof d!="object"&&typeof d!="function"&&d!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,d,A,"setState")},Sl.prototype.forceUpdate=function(d){this.updater.enqueueForceUpdate(this,d,"forceUpdate")};function Et(){}Et.prototype=Sl.prototype;function xl(d,A,O){this.props=d,this.context=A,this.refs=Rl,this.updater=O||tl}var ct=xl.prototype=new Et;ct.constructor=xl,vl(ct,Sl.prototype),ct.isPureReactComponent=!0;var _t=Array.isArray;function Zl(){}var $={H:null,A:null,T:null,S:null},Vl=Object.prototype.hasOwnProperty;function Mt(d,A,O){var N=O.ref;return{$$typeof:c,type:d,key:A,ref:N!==void 0?N:null,props:O}}function Qa(d,A){return Mt(d.type,A,d.props)}function Ot(d){return typeof d=="object"&&d!==null&&d.$$typeof===c}function Ll(d){var A={"=":"=0",":":"=2"};return"$"+d.replace(/[=:]/g,function(O){return A[O]})}var za=/\/+/g;function Ht(d,A){return typeof d=="object"&&d!==null&&d.key!=null?Ll(""+d.key):A.toString(36)}function bt(d){switch(d.status){case"fulfilled":return d.value;case"rejected":throw d.reason;default:switch(typeof d.status=="string"?d.then(Zl,Zl):(d.status="pending",d.then(function(A){d.status==="pending"&&(d.status="fulfilled",d.value=A)},function(A){d.status==="pending"&&(d.status="rejected",d.reason=A)})),d.status){case"fulfilled":return d.value;case"rejected":throw d.reason}}throw d}function b(d,A,O,N,X){var V=typeof d;(V==="undefined"||V==="boolean")&&(d=null);var al=!1;if(d===null)al=!0;else switch(V){case"bigint":case"string":case"number":al=!0;break;case"object":switch(d.$$typeof){case c:case E:al=!0;break;case F:return al=d._init,b(al(d._payload),A,O,N,X)}}if(al)return X=X(d),al=N===""?"."+Ht(d,0):N,_t(X)?(O="",al!=null&&(O=al.replace(za,"$&/")+"/"),b(X,A,O,"",function(Uu){return Uu})):X!=null&&(Ot(X)&&(X=Qa(X,O+(X.key==null||d&&d.key===X.key?"":(""+X.key).replace(za,"$&/")+"/")+al)),A.push(X)),1;al=0;var Xl=N===""?".":N+":";if(_t(d))for(var zl=0;zl>>1,sl=b[nl];if(0>>1;nlC(O,x))NC(X,O)?(b[nl]=X,b[N]=x,nl=N):(b[nl]=O,b[A]=x,nl=A);else if(NC(X,x))b[nl]=X,b[N]=x,nl=N;else break l}}return M}function C(b,M){var x=b.sortIndex-M.sortIndex;return x!==0?x:b.id-M.id}if(c.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var p=performance;c.unstable_now=function(){return p.now()}}else{var W=Date,rl=W.now();c.unstable_now=function(){return W.now()-rl}}var q=[],_=[],F=1,Y=null,dl=3,yl=!1,tl=!1,vl=!1,Rl=!1,Sl=typeof setTimeout=="function"?setTimeout:null,Et=typeof clearTimeout=="function"?clearTimeout:null,xl=typeof setImmediate<"u"?setImmediate:null;function ct(b){for(var M=D(_);M!==null;){if(M.callback===null)y(_);else if(M.startTime<=b)y(_),M.sortIndex=M.expirationTime,E(q,M);else break;M=D(_)}}function _t(b){if(vl=!1,ct(b),!tl)if(D(q)!==null)tl=!0,Zl||(Zl=!0,Ll());else{var M=D(_);M!==null&&bt(_t,M.startTime-b)}}var Zl=!1,$=-1,Vl=5,Mt=-1;function Qa(){return Rl?!0:!(c.unstable_now()-Mtb&&Qa());){var nl=Y.callback;if(typeof nl=="function"){Y.callback=null,dl=Y.priorityLevel;var sl=nl(Y.expirationTime<=b);if(b=c.unstable_now(),typeof sl=="function"){Y.callback=sl,ct(b),M=!0;break t}Y===D(q)&&y(q),ct(b)}else y(q);Y=D(q)}if(Y!==null)M=!0;else{var d=D(_);d!==null&&bt(_t,d.startTime-b),M=!1}}break l}finally{Y=null,dl=x,yl=!1}M=void 0}}finally{M?Ll():Zl=!1}}}var Ll;if(typeof xl=="function")Ll=function(){xl(Ot)};else if(typeof MessageChannel<"u"){var za=new MessageChannel,Ht=za.port2;za.port1.onmessage=Ot,Ll=function(){Ht.postMessage(null)}}else Ll=function(){Sl(Ot,0)};function bt(b,M){$=Sl(function(){b(c.unstable_now())},M)}c.unstable_IdlePriority=5,c.unstable_ImmediatePriority=1,c.unstable_LowPriority=4,c.unstable_NormalPriority=3,c.unstable_Profiling=null,c.unstable_UserBlockingPriority=2,c.unstable_cancelCallback=function(b){b.callback=null},c.unstable_forceFrameRate=function(b){0>b||125nl?(b.sortIndex=x,E(_,b),D(q)===null&&b===D(_)&&(vl?(Et($),$=-1):vl=!0,bt(_t,x-nl))):(b.sortIndex=sl,E(q,b),tl||yl||(tl=!0,Zl||(Zl=!0,Ll()))),b},c.unstable_shouldYield=Qa,c.unstable_wrapCallback=function(b){var M=dl;return function(){var x=dl;dl=M;try{return b.apply(this,arguments)}finally{dl=x}}}})(mc)),mc}var D0;function ym(){return D0||(D0=1,vc.exports=dm()),vc.exports}var hc={exports:{}},Gl={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var U0;function vm(){if(U0)return Gl;U0=1;var c=Sc();function E(q){var _="https://react.dev/errors/"+q;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(c)}catch(E){console.error(E)}}return c(),hc.exports=vm(),hc.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var N0;function hm(){if(N0)return Ae;N0=1;var c=ym(),E=Sc(),D=mm();function y(l){var t="https://react.dev/errors/"+l;if(1sl||(l.current=nl[sl],nl[sl]=null,sl--)}function O(l,t){sl++,nl[sl]=l.current,l.current=t}var N=d(null),X=d(null),V=d(null),al=d(null);function Xl(l,t){switch(O(V,t),O(X,l),O(N,null),t.nodeType){case 9:case 11:l=(l=t.documentElement)&&(l=l.namespaceURI)?Kd(l):0;break;default:if(l=t.tagName,t=t.namespaceURI)t=Kd(t),l=Jd(t,l);else switch(l){case"svg":l=1;break;case"math":l=2;break;default:l=0}}A(N),O(N,l)}function zl(){A(N),A(X),A(V)}function Uu(l){l.memoizedState!==null&&O(al,l);var t=N.current,a=Jd(t,l.type);t!==a&&(O(X,l),O(N,a))}function _e(l){X.current===l&&(A(N),A(X)),al.current===l&&(A(al),ge._currentValue=x)}var Kn,Tc;function Aa(l){if(Kn===void 0)try{throw Error()}catch(a){var t=a.stack.trim().match(/\n( *(at )?)/);Kn=t&&t[1]||"",Tc=-1)":-1e||s[u]!==h[e]){var S=` +`+s[u].replace(" at new "," at ");return l.displayName&&S.includes("")&&(S=S.replace("",l.displayName)),S}while(1<=u&&0<=e);break}}}finally{Jn=!1,Error.prepareStackTrace=a}return(a=l?l.displayName||l.name:"")?Aa(a):""}function G0(l,t){switch(l.tag){case 26:case 27:case 5:return Aa(l.type);case 16:return Aa("Lazy");case 13:return l.child!==t&&t!==null?Aa("Suspense Fallback"):Aa("Suspense");case 19:return Aa("SuspenseList");case 0:case 15:return wn(l.type,!1);case 11:return wn(l.type.render,!1);case 1:return wn(l.type,!0);case 31:return Aa("Activity");default:return""}}function zc(l){try{var t="",a=null;do t+=G0(l,a),a=l,l=l.return;while(l);return t}catch(u){return` +Error generating stack: `+u.message+` +`+u.stack}}var Wn=Object.prototype.hasOwnProperty,$n=c.unstable_scheduleCallback,kn=c.unstable_cancelCallback,X0=c.unstable_shouldYield,Q0=c.unstable_requestPaint,Il=c.unstable_now,Z0=c.unstable_getCurrentPriorityLevel,Ac=c.unstable_ImmediatePriority,Ec=c.unstable_UserBlockingPriority,Me=c.unstable_NormalPriority,V0=c.unstable_LowPriority,_c=c.unstable_IdlePriority,L0=c.log,K0=c.unstable_setDisableYieldValue,pu=null,Pl=null;function kt(l){if(typeof L0=="function"&&K0(l),Pl&&typeof Pl.setStrictMode=="function")try{Pl.setStrictMode(pu,l)}catch{}}var lt=Math.clz32?Math.clz32:W0,J0=Math.log,w0=Math.LN2;function W0(l){return l>>>=0,l===0?32:31-(J0(l)/w0|0)|0}var Oe=256,De=262144,Ue=4194304;function Ea(l){var t=l&42;if(t!==0)return t;switch(l&-l){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return l&261888;case 262144:case 524288:case 1048576:case 2097152:return l&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return l&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return l}}function pe(l,t,a){var u=l.pendingLanes;if(u===0)return 0;var e=0,n=l.suspendedLanes,i=l.pingedLanes;l=l.warmLanes;var f=u&134217727;return f!==0?(u=f&~n,u!==0?e=Ea(u):(i&=f,i!==0?e=Ea(i):a||(a=f&~l,a!==0&&(e=Ea(a))))):(f=u&~n,f!==0?e=Ea(f):i!==0?e=Ea(i):a||(a=u&~l,a!==0&&(e=Ea(a)))),e===0?0:t!==0&&t!==e&&(t&n)===0&&(n=e&-e,a=t&-t,n>=a||n===32&&(a&4194048)!==0)?t:e}function Nu(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function $0(l,t){switch(l){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Mc(){var l=Ue;return Ue<<=1,(Ue&62914560)===0&&(Ue=4194304),l}function Fn(l){for(var t=[],a=0;31>a;a++)t.push(l);return t}function Hu(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function k0(l,t,a,u,e,n){var i=l.pendingLanes;l.pendingLanes=a,l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0,l.expiredLanes&=a,l.entangledLanes&=a,l.errorRecoveryDisabledLanes&=a,l.shellSuspendCounter=0;var f=l.entanglements,s=l.expirationTimes,h=l.hiddenUpdates;for(a=i&~a;0"u")return null;try{return l.activeElement||l.body}catch{return l.body}}var ay=/[\n"\\]/g;function ot(l){return l.replace(ay,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function ui(l,t,a,u,e,n,i,f){l.name="",i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"?l.type=i:l.removeAttribute("type"),t!=null?i==="number"?(t===0&&l.value===""||l.value!=t)&&(l.value=""+st(t)):l.value!==""+st(t)&&(l.value=""+st(t)):i!=="submit"&&i!=="reset"||l.removeAttribute("value"),t!=null?ei(l,i,st(t)):a!=null?ei(l,i,st(a)):u!=null&&l.removeAttribute("value"),e==null&&n!=null&&(l.defaultChecked=!!n),e!=null&&(l.checked=e&&typeof e!="function"&&typeof e!="symbol"),f!=null&&typeof f!="function"&&typeof f!="symbol"&&typeof f!="boolean"?l.name=""+st(f):l.removeAttribute("name")}function xc(l,t,a,u,e,n,i,f){if(n!=null&&typeof n!="function"&&typeof n!="symbol"&&typeof n!="boolean"&&(l.type=n),t!=null||a!=null){if(!(n!=="submit"&&n!=="reset"||t!=null)){ai(l);return}a=a!=null?""+st(a):"",t=t!=null?""+st(t):a,f||t===l.value||(l.value=t),l.defaultValue=t}u=u??e,u=typeof u!="function"&&typeof u!="symbol"&&!!u,l.checked=f?l.checked:!!u,l.defaultChecked=!!u,i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"&&(l.name=i),ai(l)}function ei(l,t,a){t==="number"&&Re(l.ownerDocument)===l||l.defaultValue===""+a||(l.defaultValue=""+a)}function wa(l,t,a,u){if(l=l.options,t){t={};for(var e=0;e"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),si=!1;if(qt)try{var ju={};Object.defineProperty(ju,"passive",{get:function(){si=!0}}),window.addEventListener("test",ju,ju),window.removeEventListener("test",ju,ju)}catch{si=!1}var It=null,oi=null,qe=null;function Kc(){if(qe)return qe;var l,t=oi,a=t.length,u,e="value"in It?It.value:It.textContent,n=e.length;for(l=0;l=xu),Fc=" ",Ic=!1;function Pc(l,t){switch(l){case"keyup":return Ny.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function ls(l){return l=l.detail,typeof l=="object"&&"data"in l?l.data:null}var Fa=!1;function Ry(l,t){switch(l){case"compositionend":return ls(t);case"keypress":return t.which!==32?null:(Ic=!0,Fc);case"textInput":return l=t.data,l===Fc&&Ic?null:l;default:return null}}function Cy(l,t){if(Fa)return l==="compositionend"||!hi&&Pc(l,t)?(l=Kc(),qe=oi=It=null,Fa=!1,l):null;switch(l){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:a,offset:t-l};l=u}l:{for(;a;){if(a.nextSibling){a=a.nextSibling;break l}a=a.parentNode}a=void 0}a=cs(a)}}function os(l,t){return l&&t?l===t?!0:l&&l.nodeType===3?!1:t&&t.nodeType===3?os(l,t.parentNode):"contains"in l?l.contains(t):l.compareDocumentPosition?!!(l.compareDocumentPosition(t)&16):!1:!1}function ds(l){l=l!=null&&l.ownerDocument!=null&&l.ownerDocument.defaultView!=null?l.ownerDocument.defaultView:window;for(var t=Re(l.document);t instanceof l.HTMLIFrameElement;){try{var a=typeof t.contentWindow.location.href=="string"}catch{a=!1}if(a)l=t.contentWindow;else break;t=Re(l.document)}return t}function Si(l){var t=l&&l.nodeName&&l.nodeName.toLowerCase();return t&&(t==="input"&&(l.type==="text"||l.type==="search"||l.type==="tel"||l.type==="url"||l.type==="password")||t==="textarea"||l.contentEditable==="true")}var Qy=qt&&"documentMode"in document&&11>=document.documentMode,Ia=null,bi=null,Zu=null,Ti=!1;function ys(l,t,a){var u=a.window===a?a.document:a.nodeType===9?a:a.ownerDocument;Ti||Ia==null||Ia!==Re(u)||(u=Ia,"selectionStart"in u&&Si(u)?u={start:u.selectionStart,end:u.selectionEnd}:(u=(u.ownerDocument&&u.ownerDocument.defaultView||window).getSelection(),u={anchorNode:u.anchorNode,anchorOffset:u.anchorOffset,focusNode:u.focusNode,focusOffset:u.focusOffset}),Zu&&Qu(Zu,u)||(Zu=u,u=Un(bi,"onSelect"),0>=i,e-=i,Dt=1<<32-lt(t)+e|a<Z?(w=H,H=null):w=H.sibling;var P=r(v,H,m[Z],T);if(P===null){H===null&&(H=w);break}l&&H&&P.alternate===null&&t(v,H),o=n(P,o,Z),I===null?j=P:I.sibling=P,I=P,H=w}if(Z===m.length)return a(v,H),k&&Bt(v,Z),j;if(H===null){for(;ZZ?(w=H,H=null):w=H.sibling;var Ta=r(v,H,P.value,T);if(Ta===null){H===null&&(H=w);break}l&&H&&Ta.alternate===null&&t(v,H),o=n(Ta,o,Z),I===null?j=Ta:I.sibling=Ta,I=Ta,H=w}if(P.done)return a(v,H),k&&Bt(v,Z),j;if(H===null){for(;!P.done;Z++,P=m.next())P=z(v,P.value,T),P!==null&&(o=n(P,o,Z),I===null?j=P:I.sibling=P,I=P);return k&&Bt(v,Z),j}for(H=u(H);!P.done;Z++,P=m.next())P=g(H,v,Z,P.value,T),P!==null&&(l&&P.alternate!==null&&H.delete(P.key===null?Z:P.key),o=n(P,o,Z),I===null?j=P:I.sibling=P,I=P);return l&&H.forEach(function(fm){return t(v,fm)}),k&&Bt(v,Z),j}function cl(v,o,m,T){if(typeof m=="object"&&m!==null&&m.type===vl&&m.key===null&&(m=m.props.children),typeof m=="object"&&m!==null){switch(m.$$typeof){case yl:l:{for(var j=m.key;o!==null;){if(o.key===j){if(j=m.type,j===vl){if(o.tag===7){a(v,o.sibling),T=e(o,m.props.children),T.return=v,v=T;break l}}else if(o.elementType===j||typeof j=="object"&&j!==null&&j.$$typeof===Vl&&qa(j)===o.type){a(v,o.sibling),T=e(o,m.props),Wu(T,m),T.return=v,v=T;break l}a(v,o);break}else t(v,o);o=o.sibling}m.type===vl?(T=pa(m.props.children,v.mode,T,m.key),T.return=v,v=T):(T=Le(m.type,m.key,m.props,null,v.mode,T),Wu(T,m),T.return=v,v=T)}return i(v);case tl:l:{for(j=m.key;o!==null;){if(o.key===j)if(o.tag===4&&o.stateNode.containerInfo===m.containerInfo&&o.stateNode.implementation===m.implementation){a(v,o.sibling),T=e(o,m.children||[]),T.return=v,v=T;break l}else{a(v,o);break}else t(v,o);o=o.sibling}T=Di(m,v.mode,T),T.return=v,v=T}return i(v);case Vl:return m=qa(m),cl(v,o,m,T)}if(bt(m))return U(v,o,m,T);if(Ll(m)){if(j=Ll(m),typeof j!="function")throw Error(y(150));return m=j.call(m),B(v,o,m,T)}if(typeof m.then=="function")return cl(v,o,Fe(m),T);if(m.$$typeof===xl)return cl(v,o,we(v,m),T);Ie(v,m)}return typeof m=="string"&&m!==""||typeof m=="number"||typeof m=="bigint"?(m=""+m,o!==null&&o.tag===6?(a(v,o.sibling),T=e(o,m),T.return=v,v=T):(a(v,o),T=Oi(m,v.mode,T),T.return=v,v=T),i(v)):a(v,o)}return function(v,o,m,T){try{wu=0;var j=cl(v,o,m,T);return su=null,j}catch(H){if(H===cu||H===$e)throw H;var I=at(29,H,null,v.mode);return I.lanes=T,I.return=v,I}finally{}}}var Ba=js(!0),Bs=js(!1),ua=!1;function Gi(l){l.updateQueue={baseState:l.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Xi(l,t){l=l.updateQueue,t.updateQueue===l&&(t.updateQueue={baseState:l.baseState,firstBaseUpdate:l.firstBaseUpdate,lastBaseUpdate:l.lastBaseUpdate,shared:l.shared,callbacks:null})}function ea(l){return{lane:l,tag:0,payload:null,callback:null,next:null}}function na(l,t,a){var u=l.updateQueue;if(u===null)return null;if(u=u.shared,(ll&2)!==0){var e=u.pending;return e===null?t.next=t:(t.next=e.next,e.next=t),u.pending=t,t=Ve(l),bs(l,null,a),t}return Ze(l,u,t,a),Ve(l)}function $u(l,t,a){if(t=t.updateQueue,t!==null&&(t=t.shared,(a&4194048)!==0)){var u=t.lanes;u&=l.pendingLanes,a|=u,t.lanes=a,Dc(l,a)}}function Qi(l,t){var a=l.updateQueue,u=l.alternate;if(u!==null&&(u=u.updateQueue,a===u)){var e=null,n=null;if(a=a.firstBaseUpdate,a!==null){do{var i={lane:a.lane,tag:a.tag,payload:a.payload,callback:null,next:null};n===null?e=n=i:n=n.next=i,a=a.next}while(a!==null);n===null?e=n=t:n=n.next=t}else e=n=t;a={baseState:u.baseState,firstBaseUpdate:e,lastBaseUpdate:n,shared:u.shared,callbacks:u.callbacks},l.updateQueue=a;return}l=a.lastBaseUpdate,l===null?a.firstBaseUpdate=t:l.next=t,a.lastBaseUpdate=t}var Zi=!1;function ku(){if(Zi){var l=fu;if(l!==null)throw l}}function Fu(l,t,a,u){Zi=!1;var e=l.updateQueue;ua=!1;var n=e.firstBaseUpdate,i=e.lastBaseUpdate,f=e.shared.pending;if(f!==null){e.shared.pending=null;var s=f,h=s.next;s.next=null,i===null?n=h:i.next=h,i=s;var S=l.alternate;S!==null&&(S=S.updateQueue,f=S.lastBaseUpdate,f!==i&&(f===null?S.firstBaseUpdate=h:f.next=h,S.lastBaseUpdate=s))}if(n!==null){var z=e.baseState;i=0,S=h=s=null,f=n;do{var r=f.lane&-536870913,g=r!==f.lane;if(g?(J&r)===r:(u&r)===r){r!==0&&r===iu&&(Zi=!0),S!==null&&(S=S.next={lane:0,tag:f.tag,payload:f.payload,callback:null,next:null});l:{var U=l,B=f;r=t;var cl=a;switch(B.tag){case 1:if(U=B.payload,typeof U=="function"){z=U.call(cl,z,r);break l}z=U;break l;case 3:U.flags=U.flags&-65537|128;case 0:if(U=B.payload,r=typeof U=="function"?U.call(cl,z,r):U,r==null)break l;z=Y({},z,r);break l;case 2:ua=!0}}r=f.callback,r!==null&&(l.flags|=64,g&&(l.flags|=8192),g=e.callbacks,g===null?e.callbacks=[r]:g.push(r))}else g={lane:r,tag:f.tag,payload:f.payload,callback:f.callback,next:null},S===null?(h=S=g,s=z):S=S.next=g,i|=r;if(f=f.next,f===null){if(f=e.shared.pending,f===null)break;g=f,f=g.next,g.next=null,e.lastBaseUpdate=g,e.shared.pending=null}}while(!0);S===null&&(s=z),e.baseState=s,e.firstBaseUpdate=h,e.lastBaseUpdate=S,n===null&&(e.shared.lanes=0),oa|=i,l.lanes=i,l.memoizedState=z}}function Ys(l,t){if(typeof l!="function")throw Error(y(191,l));l.call(t)}function xs(l,t){var a=l.callbacks;if(a!==null)for(l.callbacks=null,l=0;ln?n:8;var i=b.T,f={};b.T=f,cf(l,!1,t,a);try{var s=e(),h=b.S;if(h!==null&&h(f,s),s!==null&&typeof s=="object"&&typeof s.then=="function"){var S=ky(s,u);le(l,t,S,ft(l))}else le(l,t,u,ft(l))}catch(z){le(l,t,{then:function(){},status:"rejected",reason:z},ft())}finally{M.p=n,i!==null&&f.types!==null&&(i.types=f.types),b.T=i}}function av(){}function nf(l,t,a,u){if(l.tag!==5)throw Error(y(476));var e=go(l).queue;ro(l,e,t,x,a===null?av:function(){return So(l),a(u)})}function go(l){var t=l.memoizedState;if(t!==null)return t;t={memoizedState:x,baseState:x,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Xt,lastRenderedState:x},next:null};var a={};return t.next={memoizedState:a,baseState:a,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Xt,lastRenderedState:a},next:null},l.memoizedState=t,l=l.alternate,l!==null&&(l.memoizedState=t),t}function So(l){var t=go(l);t.next===null&&(t=l.alternate.memoizedState),le(l,t.next.queue,{},ft())}function ff(){return jl(ge)}function bo(){return El().memoizedState}function To(){return El().memoizedState}function uv(l){for(var t=l.return;t!==null;){switch(t.tag){case 24:case 3:var a=ft();l=ea(a);var u=na(t,l,a);u!==null&&(Fl(u,t,a),$u(u,t,a)),t={cache:ji()},l.payload=t;return}t=t.return}}function ev(l,t,a){var u=ft();a={lane:u,revertLane:0,gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},sn(l)?Ao(t,a):(a=_i(l,t,a,u),a!==null&&(Fl(a,l,u),Eo(a,t,u)))}function zo(l,t,a){var u=ft();le(l,t,a,u)}function le(l,t,a,u){var e={lane:u,revertLane:0,gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null};if(sn(l))Ao(t,e);else{var n=l.alternate;if(l.lanes===0&&(n===null||n.lanes===0)&&(n=t.lastRenderedReducer,n!==null))try{var i=t.lastRenderedState,f=n(i,a);if(e.hasEagerState=!0,e.eagerState=f,tt(f,i))return Ze(l,t,e,0),ol===null&&Qe(),!1}catch{}finally{}if(a=_i(l,t,e,u),a!==null)return Fl(a,l,u),Eo(a,t,u),!0}return!1}function cf(l,t,a,u){if(u={lane:2,revertLane:Xf(),gesture:null,action:u,hasEagerState:!1,eagerState:null,next:null},sn(l)){if(t)throw Error(y(479))}else t=_i(l,a,u,2),t!==null&&Fl(t,l,2)}function sn(l){var t=l.alternate;return l===Q||t!==null&&t===Q}function Ao(l,t){du=tn=!0;var a=l.pending;a===null?t.next=t:(t.next=a.next,a.next=t),l.pending=t}function Eo(l,t,a){if((a&4194048)!==0){var u=t.lanes;u&=l.pendingLanes,a|=u,t.lanes=a,Dc(l,a)}}var te={readContext:jl,use:en,useCallback:bl,useContext:bl,useEffect:bl,useImperativeHandle:bl,useLayoutEffect:bl,useInsertionEffect:bl,useMemo:bl,useReducer:bl,useRef:bl,useState:bl,useDebugValue:bl,useDeferredValue:bl,useTransition:bl,useSyncExternalStore:bl,useId:bl,useHostTransitionStatus:bl,useFormState:bl,useActionState:bl,useOptimistic:bl,useMemoCache:bl,useCacheRefresh:bl};te.useEffectEvent=bl;var _o={readContext:jl,use:en,useCallback:function(l,t){return Ql().memoizedState=[l,t===void 0?null:t],l},useContext:jl,useEffect:io,useImperativeHandle:function(l,t,a){a=a!=null?a.concat([l]):null,fn(4194308,4,oo.bind(null,t,l),a)},useLayoutEffect:function(l,t){return fn(4194308,4,l,t)},useInsertionEffect:function(l,t){fn(4,2,l,t)},useMemo:function(l,t){var a=Ql();t=t===void 0?null:t;var u=l();if(Ya){kt(!0);try{l()}finally{kt(!1)}}return a.memoizedState=[u,t],u},useReducer:function(l,t,a){var u=Ql();if(a!==void 0){var e=a(t);if(Ya){kt(!0);try{a(t)}finally{kt(!1)}}}else e=t;return u.memoizedState=u.baseState=e,l={pending:null,lanes:0,dispatch:null,lastRenderedReducer:l,lastRenderedState:e},u.queue=l,l=l.dispatch=ev.bind(null,Q,l),[u.memoizedState,l]},useRef:function(l){var t=Ql();return l={current:l},t.memoizedState=l},useState:function(l){l=lf(l);var t=l.queue,a=zo.bind(null,Q,t);return t.dispatch=a,[l.memoizedState,a]},useDebugValue:uf,useDeferredValue:function(l,t){var a=Ql();return ef(a,l,t)},useTransition:function(){var l=lf(!1);return l=ro.bind(null,Q,l.queue,!0,!1),Ql().memoizedState=l,[!1,l]},useSyncExternalStore:function(l,t,a){var u=Q,e=Ql();if(k){if(a===void 0)throw Error(y(407));a=a()}else{if(a=t(),ol===null)throw Error(y(349));(J&127)!==0||Ls(u,t,a)}e.memoizedState=a;var n={value:a,getSnapshot:t};return e.queue=n,io(Js.bind(null,u,n,l),[l]),u.flags|=2048,vu(9,{destroy:void 0},Ks.bind(null,u,n,a,t),null),a},useId:function(){var l=Ql(),t=ol.identifierPrefix;if(k){var a=Ut,u=Dt;a=(u&~(1<<32-lt(u)-1)).toString(32)+a,t="_"+t+"R_"+a,a=an++,0<\/script>",n=n.removeChild(n.firstChild);break;case"select":n=typeof u.is=="string"?i.createElement("select",{is:u.is}):i.createElement("select"),u.multiple?n.multiple=!0:u.size&&(n.size=u.size);break;default:n=typeof u.is=="string"?i.createElement(e,{is:u.is}):i.createElement(e)}}n[Cl]=t,n[Kl]=u;l:for(i=t.child;i!==null;){if(i.tag===5||i.tag===6)n.appendChild(i.stateNode);else if(i.tag!==4&&i.tag!==27&&i.child!==null){i.child.return=i,i=i.child;continue}if(i===t)break l;for(;i.sibling===null;){if(i.return===null||i.return===t)break l;i=i.return}i.sibling.return=i.return,i=i.sibling}t.stateNode=n;l:switch(Yl(n,e,u),e){case"button":case"input":case"select":case"textarea":u=!!u.autoFocus;break l;case"img":u=!0;break l;default:u=!1}u&&Zt(t)}}return hl(t),Af(t,t.type,l===null?null:l.memoizedProps,t.pendingProps,a),null;case 6:if(l&&t.stateNode!=null)l.memoizedProps!==u&&Zt(t);else{if(typeof u!="string"&&t.stateNode===null)throw Error(y(166));if(l=V.current,eu(t)){if(l=t.stateNode,a=t.memoizedProps,u=null,e=ql,e!==null)switch(e.tag){case 27:case 5:u=e.memoizedProps}l[Cl]=t,l=!!(l.nodeValue===a||u!==null&&u.suppressHydrationWarning===!0||Vd(l.nodeValue,a)),l||ta(t,!0)}else l=pn(l).createTextNode(u),l[Cl]=t,t.stateNode=l}return hl(t),null;case 31:if(a=t.memoizedState,l===null||l.memoizedState!==null){if(u=eu(t),a!==null){if(l===null){if(!u)throw Error(y(318));if(l=t.memoizedState,l=l!==null?l.dehydrated:null,!l)throw Error(y(557));l[Cl]=t}else Na(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;hl(t),l=!1}else a=Hi(),l!==null&&l.memoizedState!==null&&(l.memoizedState.hydrationErrors=a),l=!0;if(!l)return t.flags&256?(et(t),t):(et(t),null);if((t.flags&128)!==0)throw Error(y(558))}return hl(t),null;case 13:if(u=t.memoizedState,l===null||l.memoizedState!==null&&l.memoizedState.dehydrated!==null){if(e=eu(t),u!==null&&u.dehydrated!==null){if(l===null){if(!e)throw Error(y(318));if(e=t.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(y(317));e[Cl]=t}else Na(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;hl(t),e=!1}else e=Hi(),l!==null&&l.memoizedState!==null&&(l.memoizedState.hydrationErrors=e),e=!0;if(!e)return t.flags&256?(et(t),t):(et(t),null)}return et(t),(t.flags&128)!==0?(t.lanes=a,t):(a=u!==null,l=l!==null&&l.memoizedState!==null,a&&(u=t.child,e=null,u.alternate!==null&&u.alternate.memoizedState!==null&&u.alternate.memoizedState.cachePool!==null&&(e=u.alternate.memoizedState.cachePool.pool),n=null,u.memoizedState!==null&&u.memoizedState.cachePool!==null&&(n=u.memoizedState.cachePool.pool),n!==e&&(u.flags|=2048)),a!==l&&a&&(t.child.flags|=8192),mn(t,t.updateQueue),hl(t),null);case 4:return zl(),l===null&&Lf(t.stateNode.containerInfo),hl(t),null;case 10:return xt(t.type),hl(t),null;case 19:if(A(Al),u=t.memoizedState,u===null)return hl(t),null;if(e=(t.flags&128)!==0,n=u.rendering,n===null)if(e)ue(u,!1);else{if(Tl!==0||l!==null&&(l.flags&128)!==0)for(l=t.child;l!==null;){if(n=ln(l),n!==null){for(t.flags|=128,ue(u,!1),l=n.updateQueue,t.updateQueue=l,mn(t,l),t.subtreeFlags=0,l=a,a=t.child;a!==null;)Ts(a,l),a=a.sibling;return O(Al,Al.current&1|2),k&&Bt(t,u.treeForkCount),t.child}l=l.sibling}u.tail!==null&&Il()>bn&&(t.flags|=128,e=!0,ue(u,!1),t.lanes=4194304)}else{if(!e)if(l=ln(n),l!==null){if(t.flags|=128,e=!0,l=l.updateQueue,t.updateQueue=l,mn(t,l),ue(u,!0),u.tail===null&&u.tailMode==="hidden"&&!n.alternate&&!k)return hl(t),null}else 2*Il()-u.renderingStartTime>bn&&a!==536870912&&(t.flags|=128,e=!0,ue(u,!1),t.lanes=4194304);u.isBackwards?(n.sibling=t.child,t.child=n):(l=u.last,l!==null?l.sibling=n:t.child=n,u.last=n)}return u.tail!==null?(l=u.tail,u.rendering=l,u.tail=l.sibling,u.renderingStartTime=Il(),l.sibling=null,a=Al.current,O(Al,e?a&1|2:a&1),k&&Bt(t,u.treeForkCount),l):(hl(t),null);case 22:case 23:return et(t),Li(),u=t.memoizedState!==null,l!==null?l.memoizedState!==null!==u&&(t.flags|=8192):u&&(t.flags|=8192),u?(a&536870912)!==0&&(t.flags&128)===0&&(hl(t),t.subtreeFlags&6&&(t.flags|=8192)):hl(t),a=t.updateQueue,a!==null&&mn(t,a.retryQueue),a=null,l!==null&&l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(a=l.memoizedState.cachePool.pool),u=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(u=t.memoizedState.cachePool.pool),u!==a&&(t.flags|=2048),l!==null&&A(Ca),null;case 24:return a=null,l!==null&&(a=l.memoizedState.cache),t.memoizedState.cache!==a&&(t.flags|=2048),xt(_l),hl(t),null;case 25:return null;case 30:return null}throw Error(y(156,t.tag))}function sv(l,t){switch(pi(t),t.tag){case 1:return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 3:return xt(_l),zl(),l=t.flags,(l&65536)!==0&&(l&128)===0?(t.flags=l&-65537|128,t):null;case 26:case 27:case 5:return _e(t),null;case 31:if(t.memoizedState!==null){if(et(t),t.alternate===null)throw Error(y(340));Na()}return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 13:if(et(t),l=t.memoizedState,l!==null&&l.dehydrated!==null){if(t.alternate===null)throw Error(y(340));Na()}return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 19:return A(Al),null;case 4:return zl(),null;case 10:return xt(t.type),null;case 22:case 23:return et(t),Li(),l!==null&&A(Ca),l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 24:return xt(_l),null;case 25:return null;default:return null}}function Wo(l,t){switch(pi(t),t.tag){case 3:xt(_l),zl();break;case 26:case 27:case 5:_e(t);break;case 4:zl();break;case 31:t.memoizedState!==null&&et(t);break;case 13:et(t);break;case 19:A(Al);break;case 10:xt(t.type);break;case 22:case 23:et(t),Li(),l!==null&&A(Ca);break;case 24:xt(_l)}}function ee(l,t){try{var a=t.updateQueue,u=a!==null?a.lastEffect:null;if(u!==null){var e=u.next;a=e;do{if((a.tag&l)===l){u=void 0;var n=a.create,i=a.inst;u=n(),i.destroy=u}a=a.next}while(a!==e)}}catch(f){el(t,t.return,f)}}function ca(l,t,a){try{var u=t.updateQueue,e=u!==null?u.lastEffect:null;if(e!==null){var n=e.next;u=n;do{if((u.tag&l)===l){var i=u.inst,f=i.destroy;if(f!==void 0){i.destroy=void 0,e=t;var s=a,h=f;try{h()}catch(S){el(e,s,S)}}}u=u.next}while(u!==n)}}catch(S){el(t,t.return,S)}}function $o(l){var t=l.updateQueue;if(t!==null){var a=l.stateNode;try{xs(t,a)}catch(u){el(l,l.return,u)}}}function ko(l,t,a){a.props=xa(l.type,l.memoizedProps),a.state=l.memoizedState;try{a.componentWillUnmount()}catch(u){el(l,t,u)}}function ne(l,t){try{var a=l.ref;if(a!==null){switch(l.tag){case 26:case 27:case 5:var u=l.stateNode;break;case 30:u=l.stateNode;break;default:u=l.stateNode}typeof a=="function"?l.refCleanup=a(u):a.current=u}}catch(e){el(l,t,e)}}function pt(l,t){var a=l.ref,u=l.refCleanup;if(a!==null)if(typeof u=="function")try{u()}catch(e){el(l,t,e)}finally{l.refCleanup=null,l=l.alternate,l!=null&&(l.refCleanup=null)}else if(typeof a=="function")try{a(null)}catch(e){el(l,t,e)}else a.current=null}function Fo(l){var t=l.type,a=l.memoizedProps,u=l.stateNode;try{l:switch(t){case"button":case"input":case"select":case"textarea":a.autoFocus&&u.focus();break l;case"img":a.src?u.src=a.src:a.srcSet&&(u.srcset=a.srcSet)}}catch(e){el(l,l.return,e)}}function Ef(l,t,a){try{var u=l.stateNode;Hv(u,l.type,a,t),u[Kl]=t}catch(e){el(l,l.return,e)}}function Io(l){return l.tag===5||l.tag===3||l.tag===26||l.tag===27&&ha(l.type)||l.tag===4}function _f(l){l:for(;;){for(;l.sibling===null;){if(l.return===null||Io(l.return))return null;l=l.return}for(l.sibling.return=l.return,l=l.sibling;l.tag!==5&&l.tag!==6&&l.tag!==18;){if(l.tag===27&&ha(l.type)||l.flags&2||l.child===null||l.tag===4)continue l;l.child.return=l,l=l.child}if(!(l.flags&2))return l.stateNode}}function Mf(l,t,a){var u=l.tag;if(u===5||u===6)l=l.stateNode,t?(a.nodeType===9?a.body:a.nodeName==="HTML"?a.ownerDocument.body:a).insertBefore(l,t):(t=a.nodeType===9?a.body:a.nodeName==="HTML"?a.ownerDocument.body:a,t.appendChild(l),a=a._reactRootContainer,a!=null||t.onclick!==null||(t.onclick=Ct));else if(u!==4&&(u===27&&ha(l.type)&&(a=l.stateNode,t=null),l=l.child,l!==null))for(Mf(l,t,a),l=l.sibling;l!==null;)Mf(l,t,a),l=l.sibling}function hn(l,t,a){var u=l.tag;if(u===5||u===6)l=l.stateNode,t?a.insertBefore(l,t):a.appendChild(l);else if(u!==4&&(u===27&&ha(l.type)&&(a=l.stateNode),l=l.child,l!==null))for(hn(l,t,a),l=l.sibling;l!==null;)hn(l,t,a),l=l.sibling}function Po(l){var t=l.stateNode,a=l.memoizedProps;try{for(var u=l.type,e=t.attributes;e.length;)t.removeAttributeNode(e[0]);Yl(t,u,a),t[Cl]=l,t[Kl]=a}catch(n){el(l,l.return,n)}}var Vt=!1,Dl=!1,Of=!1,ld=typeof WeakSet=="function"?WeakSet:Set,Nl=null;function ov(l,t){if(l=l.containerInfo,wf=Bn,l=ds(l),Si(l)){if("selectionStart"in l)var a={start:l.selectionStart,end:l.selectionEnd};else l:{a=(a=l.ownerDocument)&&a.defaultView||window;var u=a.getSelection&&a.getSelection();if(u&&u.rangeCount!==0){a=u.anchorNode;var e=u.anchorOffset,n=u.focusNode;u=u.focusOffset;try{a.nodeType,n.nodeType}catch{a=null;break l}var i=0,f=-1,s=-1,h=0,S=0,z=l,r=null;t:for(;;){for(var g;z!==a||e!==0&&z.nodeType!==3||(f=i+e),z!==n||u!==0&&z.nodeType!==3||(s=i+u),z.nodeType===3&&(i+=z.nodeValue.length),(g=z.firstChild)!==null;)r=z,z=g;for(;;){if(z===l)break t;if(r===a&&++h===e&&(f=i),r===n&&++S===u&&(s=i),(g=z.nextSibling)!==null)break;z=r,r=z.parentNode}z=g}a=f===-1||s===-1?null:{start:f,end:s}}else a=null}a=a||{start:0,end:0}}else a=null;for(Wf={focusedElem:l,selectionRange:a},Bn=!1,Nl=t;Nl!==null;)if(t=Nl,l=t.child,(t.subtreeFlags&1028)!==0&&l!==null)l.return=t,Nl=l;else for(;Nl!==null;){switch(t=Nl,n=t.alternate,l=t.flags,t.tag){case 0:if((l&4)!==0&&(l=t.updateQueue,l=l!==null?l.events:null,l!==null))for(a=0;a title"))),Yl(n,u,a),n[Cl]=l,pl(n),u=n;break l;case"link":var i=i0("link","href",e).get(u+(a.href||""));if(i){for(var f=0;fcl&&(i=cl,cl=B,B=i);var v=ss(f,B),o=ss(f,cl);if(v&&o&&(g.rangeCount!==1||g.anchorNode!==v.node||g.anchorOffset!==v.offset||g.focusNode!==o.node||g.focusOffset!==o.offset)){var m=z.createRange();m.setStart(v.node,v.offset),g.removeAllRanges(),B>cl?(g.addRange(m),g.extend(o.node,o.offset)):(m.setEnd(o.node,o.offset),g.addRange(m))}}}}for(z=[],g=f;g=g.parentNode;)g.nodeType===1&&z.push({element:g,left:g.scrollLeft,top:g.scrollTop});for(typeof f.focus=="function"&&f.focus(),f=0;fa?32:a,b.T=null,a=Cf,Cf=null;var n=ya,i=Wt;if(Ul=0,Su=ya=null,Wt=0,(ll&6)!==0)throw Error(y(331));var f=ll;if(ll|=4,dd(n.current),cd(n,n.current,i,a),ll=f,de(0,!1),Pl&&typeof Pl.onPostCommitFiberRoot=="function")try{Pl.onPostCommitFiberRoot(pu,n)}catch{}return!0}finally{M.p=e,b.T=u,pd(l,t)}}function Hd(l,t,a){t=yt(a,t),t=yf(l.stateNode,t,2),l=na(l,t,2),l!==null&&(Hu(l,2),Nt(l))}function el(l,t,a){if(l.tag===3)Hd(l,l,a);else for(;t!==null;){if(t.tag===3){Hd(t,l,a);break}else if(t.tag===1){var u=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof u.componentDidCatch=="function"&&(da===null||!da.has(u))){l=yt(a,l),a=Ro(2),u=na(t,a,2),u!==null&&(Co(a,u,t,l),Hu(u,2),Nt(u));break}}t=t.return}}function Yf(l,t,a){var u=l.pingCache;if(u===null){u=l.pingCache=new vv;var e=new Set;u.set(t,e)}else e=u.get(t),e===void 0&&(e=new Set,u.set(t,e));e.has(a)||(pf=!0,e.add(a),l=Sv.bind(null,l,t,a),t.then(l,l))}function Sv(l,t,a){var u=l.pingCache;u!==null&&u.delete(t),l.pingedLanes|=l.suspendedLanes&a,l.warmLanes&=~a,ol===l&&(J&a)===a&&(Tl===4||Tl===3&&(J&62914560)===J&&300>Il()-Sn?(ll&2)===0&&bu(l,0):Nf|=a,gu===J&&(gu=0)),Nt(l)}function Rd(l,t){t===0&&(t=Mc()),l=Ua(l,t),l!==null&&(Hu(l,t),Nt(l))}function bv(l){var t=l.memoizedState,a=0;t!==null&&(a=t.retryLane),Rd(l,a)}function Tv(l,t){var a=0;switch(l.tag){case 31:case 13:var u=l.stateNode,e=l.memoizedState;e!==null&&(a=e.retryLane);break;case 19:u=l.stateNode;break;case 22:u=l.stateNode._retryCache;break;default:throw Error(y(314))}u!==null&&u.delete(t),Rd(l,a)}function zv(l,t){return $n(l,t)}var Mn=null,zu=null,xf=!1,On=!1,Gf=!1,ma=0;function Nt(l){l!==zu&&l.next===null&&(zu===null?Mn=zu=l:zu=zu.next=l),On=!0,xf||(xf=!0,Ev())}function de(l,t){if(!Gf&&On){Gf=!0;do for(var a=!1,u=Mn;u!==null;){if(l!==0){var e=u.pendingLanes;if(e===0)var n=0;else{var i=u.suspendedLanes,f=u.pingedLanes;n=(1<<31-lt(42|l)+1)-1,n&=e&~(i&~f),n=n&201326741?n&201326741|1:n?n|2:0}n!==0&&(a=!0,Bd(u,n))}else n=J,n=pe(u,u===ol?n:0,u.cancelPendingCommit!==null||u.timeoutHandle!==-1),(n&3)===0||Nu(u,n)||(a=!0,Bd(u,n));u=u.next}while(a);Gf=!1}}function Av(){Cd()}function Cd(){On=xf=!1;var l=0;ma!==0&&Cv()&&(l=ma);for(var t=Il(),a=null,u=Mn;u!==null;){var e=u.next,n=qd(u,t);n===0?(u.next=null,a===null?Mn=e:a.next=e,e===null&&(zu=a)):(a=u,(l!==0||(n&3)!==0)&&(On=!0)),u=e}Ul!==0&&Ul!==5||de(l),ma!==0&&(ma=0)}function qd(l,t){for(var a=l.suspendedLanes,u=l.pingedLanes,e=l.expirationTimes,n=l.pendingLanes&-62914561;0f)break;var S=s.transferSize,z=s.initiatorType;S&&Ld(z)&&(s=s.responseEnd,i+=S*(s"u"?null:document;function a0(l,t,a){var u=Au;if(u&&typeof t=="string"&&t){var e=ot(t);e='link[rel="'+l+'"][href="'+e+'"]',typeof a=="string"&&(e+='[crossorigin="'+a+'"]'),t0.has(e)||(t0.add(e),l={rel:l,crossOrigin:a,href:t},u.querySelector(e)===null&&(t=u.createElement("link"),Yl(t,"link",l),pl(t),u.head.appendChild(t)))}}function Zv(l){$t.D(l),a0("dns-prefetch",l,null)}function Vv(l,t){$t.C(l,t),a0("preconnect",l,t)}function Lv(l,t,a){$t.L(l,t,a);var u=Au;if(u&&l&&t){var e='link[rel="preload"][as="'+ot(t)+'"]';t==="image"&&a&&a.imageSrcSet?(e+='[imagesrcset="'+ot(a.imageSrcSet)+'"]',typeof a.imageSizes=="string"&&(e+='[imagesizes="'+ot(a.imageSizes)+'"]')):e+='[href="'+ot(l)+'"]';var n=e;switch(t){case"style":n=Eu(l);break;case"script":n=_u(l)}St.has(n)||(l=Y({rel:"preload",href:t==="image"&&a&&a.imageSrcSet?void 0:l,as:t},a),St.set(n,l),u.querySelector(e)!==null||t==="style"&&u.querySelector(he(n))||t==="script"&&u.querySelector(re(n))||(t=u.createElement("link"),Yl(t,"link",l),pl(t),u.head.appendChild(t)))}}function Kv(l,t){$t.m(l,t);var a=Au;if(a&&l){var u=t&&typeof t.as=="string"?t.as:"script",e='link[rel="modulepreload"][as="'+ot(u)+'"][href="'+ot(l)+'"]',n=e;switch(u){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":n=_u(l)}if(!St.has(n)&&(l=Y({rel:"modulepreload",href:l},t),St.set(n,l),a.querySelector(e)===null)){switch(u){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(a.querySelector(re(n)))return}u=a.createElement("link"),Yl(u,"link",l),pl(u),a.head.appendChild(u)}}}function Jv(l,t,a){$t.S(l,t,a);var u=Au;if(u&&l){var e=Ka(u).hoistableStyles,n=Eu(l);t=t||"default";var i=e.get(n);if(!i){var f={loading:0,preload:null};if(i=u.querySelector(he(n)))f.loading=5;else{l=Y({rel:"stylesheet",href:l,"data-precedence":t},a),(a=St.get(n))&&tc(l,a);var s=i=u.createElement("link");pl(s),Yl(s,"link",l),s._p=new Promise(function(h,S){s.onload=h,s.onerror=S}),s.addEventListener("load",function(){f.loading|=1}),s.addEventListener("error",function(){f.loading|=2}),f.loading|=4,Hn(i,t,u)}i={type:"stylesheet",instance:i,count:1,state:f},e.set(n,i)}}}function wv(l,t){$t.X(l,t);var a=Au;if(a&&l){var u=Ka(a).hoistableScripts,e=_u(l),n=u.get(e);n||(n=a.querySelector(re(e)),n||(l=Y({src:l,async:!0},t),(t=St.get(e))&&ac(l,t),n=a.createElement("script"),pl(n),Yl(n,"link",l),a.head.appendChild(n)),n={type:"script",instance:n,count:1,state:null},u.set(e,n))}}function Wv(l,t){$t.M(l,t);var a=Au;if(a&&l){var u=Ka(a).hoistableScripts,e=_u(l),n=u.get(e);n||(n=a.querySelector(re(e)),n||(l=Y({src:l,async:!0,type:"module"},t),(t=St.get(e))&&ac(l,t),n=a.createElement("script"),pl(n),Yl(n,"link",l),a.head.appendChild(n)),n={type:"script",instance:n,count:1,state:null},u.set(e,n))}}function u0(l,t,a,u){var e=(e=V.current)?Nn(e):null;if(!e)throw Error(y(446));switch(l){case"meta":case"title":return null;case"style":return typeof a.precedence=="string"&&typeof a.href=="string"?(t=Eu(a.href),a=Ka(e).hoistableStyles,u=a.get(t),u||(u={type:"style",instance:null,count:0,state:null},a.set(t,u)),u):{type:"void",instance:null,count:0,state:null};case"link":if(a.rel==="stylesheet"&&typeof a.href=="string"&&typeof a.precedence=="string"){l=Eu(a.href);var n=Ka(e).hoistableStyles,i=n.get(l);if(i||(e=e.ownerDocument||e,i={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},n.set(l,i),(n=e.querySelector(he(l)))&&!n._p&&(i.instance=n,i.state.loading=5),St.has(l)||(a={rel:"preload",as:"style",href:a.href,crossOrigin:a.crossOrigin,integrity:a.integrity,media:a.media,hrefLang:a.hrefLang,referrerPolicy:a.referrerPolicy},St.set(l,a),n||$v(e,l,a,i.state))),t&&u===null)throw Error(y(528,""));return i}if(t&&u!==null)throw Error(y(529,""));return null;case"script":return t=a.async,a=a.src,typeof a=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=_u(a),a=Ka(e).hoistableScripts,u=a.get(t),u||(u={type:"script",instance:null,count:0,state:null},a.set(t,u)),u):{type:"void",instance:null,count:0,state:null};default:throw Error(y(444,l))}}function Eu(l){return'href="'+ot(l)+'"'}function he(l){return'link[rel="stylesheet"]['+l+"]"}function e0(l){return Y({},l,{"data-precedence":l.precedence,precedence:null})}function $v(l,t,a,u){l.querySelector('link[rel="preload"][as="style"]['+t+"]")?u.loading=1:(t=l.createElement("link"),u.preload=t,t.addEventListener("load",function(){return u.loading|=1}),t.addEventListener("error",function(){return u.loading|=2}),Yl(t,"link",a),pl(t),l.head.appendChild(t))}function _u(l){return'[src="'+ot(l)+'"]'}function re(l){return"script[async]"+l}function n0(l,t,a){if(t.count++,t.instance===null)switch(t.type){case"style":var u=l.querySelector('style[data-href~="'+ot(a.href)+'"]');if(u)return t.instance=u,pl(u),u;var e=Y({},a,{"data-href":a.href,"data-precedence":a.precedence,href:null,precedence:null});return u=(l.ownerDocument||l).createElement("style"),pl(u),Yl(u,"style",e),Hn(u,a.precedence,l),t.instance=u;case"stylesheet":e=Eu(a.href);var n=l.querySelector(he(e));if(n)return t.state.loading|=4,t.instance=n,pl(n),n;u=e0(a),(e=St.get(e))&&tc(u,e),n=(l.ownerDocument||l).createElement("link"),pl(n);var i=n;return i._p=new Promise(function(f,s){i.onload=f,i.onerror=s}),Yl(n,"link",u),t.state.loading|=4,Hn(n,a.precedence,l),t.instance=n;case"script":return n=_u(a.src),(e=l.querySelector(re(n)))?(t.instance=e,pl(e),e):(u=a,(e=St.get(n))&&(u=Y({},a),ac(u,e)),l=l.ownerDocument||l,e=l.createElement("script"),pl(e),Yl(e,"link",u),l.head.appendChild(e),t.instance=e);case"void":return null;default:throw Error(y(443,t.type))}else t.type==="stylesheet"&&(t.state.loading&4)===0&&(u=t.instance,t.state.loading|=4,Hn(u,a.precedence,l));return t.instance}function Hn(l,t,a){for(var u=a.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),e=u.length?u[u.length-1]:null,n=e,i=0;i title"):null)}function kv(l,t,a){if(a===1||t.itemProp!=null)return!1;switch(l){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;switch(t.rel){case"stylesheet":return l=t.disabled,typeof t.precedence=="string"&&l==null;default:return!0}case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function c0(l){return!(l.type==="stylesheet"&&(l.state.loading&3)===0)}function Fv(l,t,a,u){if(a.type==="stylesheet"&&(typeof u.media!="string"||matchMedia(u.media).matches!==!1)&&(a.state.loading&4)===0){if(a.instance===null){var e=Eu(u.href),n=t.querySelector(he(e));if(n){t=n._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(l.count++,l=Cn.bind(l),t.then(l,l)),a.state.loading|=4,a.instance=n,pl(n);return}n=t.ownerDocument||t,u=e0(u),(e=St.get(e))&&tc(u,e),n=n.createElement("link"),pl(n);var i=n;i._p=new Promise(function(f,s){i.onload=f,i.onerror=s}),Yl(n,"link",u),a.instance=n}l.stylesheets===null&&(l.stylesheets=new Map),l.stylesheets.set(a,t),(t=a.state.preload)&&(a.state.loading&3)===0&&(l.count++,a=Cn.bind(l),t.addEventListener("load",a),t.addEventListener("error",a))}}var uc=0;function Iv(l,t){return l.stylesheets&&l.count===0&&jn(l,l.stylesheets),0uc?50:800)+t);return l.unsuspend=a,function(){l.unsuspend=null,clearTimeout(u),clearTimeout(e)}}:null}function Cn(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)jn(this,this.stylesheets);else if(this.unsuspend){var l=this.unsuspend;this.unsuspend=null,l()}}}var qn=null;function jn(l,t){l.stylesheets=null,l.unsuspend!==null&&(l.count++,qn=new Map,t.forEach(Pv,l),qn=null,Cn.call(l))}function Pv(l,t){if(!(t.state.loading&4)){var a=qn.get(l);if(a)var u=a.get(null);else{a=new Map,qn.set(l,a);for(var e=l.querySelectorAll("link[data-precedence],style[data-precedence]"),n=0;n"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(c)}catch(E){console.error(E)}}return c(),yc.exports=hm(),yc.exports}var gm=rm();function Sm(c){return c.invalidCount===0&&c.errorMessage===null?null:R.jsxs("div",{className:"data-notices",children:[c.invalidCount>0?R.jsxs("p",{className:"notice notice-warning",children:[c.invalidCount," record under ",R.jsx("code",{children:"todos/"})," didn’t match the todo shape and was skipped."]}):null,c.errorMessage!==null?R.jsx("p",{className:"notice notice-error",children:c.errorMessage}):null]})}const bm=["all","open","done"];function Tm(c){return c==="open"?"Open":c==="done"?"Done":"All"}function zm(c,E){return E==="open"?c.stats.open:E==="done"?c.stats.done:c.stats.total}function Am(c){return R.jsx("div",{className:"filter-tabs",role:"tablist","aria-label":"Todo filters",children:bm.map(E=>R.jsxs("button",{className:c.activeFilter===E?"filter-tab active":"filter-tab",type:"button",role:"tab","aria-selected":c.activeFilter===E,onClick:()=>c.onChange(E),children:[R.jsx("span",{children:Tm(E)}),R.jsx("span",{className:"filter-count",children:zm(c,E)})]},E))})}/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Em=c=>c.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),B0=(...c)=>c.filter((E,D,y)=>!!E&&E.trim()!==""&&y.indexOf(E)===D).join(" ").trim();/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */var _m={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Mm=Hl.forwardRef(({color:c="currentColor",size:E=24,strokeWidth:D=2,absoluteStrokeWidth:y,className:C="",children:p,iconNode:W,...rl},q)=>Hl.createElement("svg",{ref:q,..._m,width:E,height:E,stroke:c,strokeWidth:y?Number(D)*24/Number(E):D,className:B0("lucide",C),...rl},[...W.map(([_,F])=>Hl.createElement(_,F)),...Array.isArray(p)?p:[p]]));/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Du=(c,E)=>{const D=Hl.forwardRef(({className:y,...C},p)=>Hl.createElement(Mm,{ref:p,iconNode:E,className:B0(`lucide-${Em(c)}`,y),...C}));return D.displayName=`${c}`,D};/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Om=Du("CheckCheck",[["path",{d:"M18 6 7 17l-5-5",key:"116fxf"}],["path",{d:"m22 10-7.5 7.5L13 16",key:"ke71qq"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Y0=Du("Check",[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Dm=Du("ListTodo",[["rect",{x:"3",y:"5",width:"6",height:"6",rx:"1",key:"1defrl"}],["path",{d:"m3 17 2 2 4-4",key:"1jhpwq"}],["path",{d:"M13 6h8",key:"15sg57"}],["path",{d:"M13 12h8",key:"h98zly"}],["path",{d:"M13 18h8",key:"oe0vm4"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Um=Du("Plus",[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"M12 5v14",key:"s699le"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const pm=Du("Send",[["path",{d:"M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z",key:"1ffxy3"}],["path",{d:"m21.854 2.147-10.94 10.939",key:"12cjpa"}]]);/** + * @license lucide-react v0.460.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Nm=Du("Trash2",[["path",{d:"M3 6h18",key:"d0wm0j"}],["path",{d:"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6",key:"4alrt4"}],["path",{d:"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2",key:"v07s0e"}],["line",{x1:"10",x2:"10",y1:"11",y2:"17",key:"1uufr5"}],["line",{x1:"14",x2:"14",y1:"11",y2:"17",key:"xtxkd"}]]);function Hm(c){return c==="sending"?"Sending…":c==="sent"?"Sent":"Notify manager"}function Rm(c){const E=c.operationStatus==="sending";return R.jsxs("header",{className:"app-header",children:[R.jsxs("div",{className:"brand",children:[R.jsx("span",{className:"brand-mark","aria-hidden":"true",children:R.jsx(Y0,{size:16,strokeWidth:3})}),R.jsxs("div",{className:"brand-copy",children:[R.jsx("h1",{children:"Todo"}),R.jsx("p",{title:c.appId,children:c.appId})]})]}),R.jsx("div",{className:"header-actions",children:R.jsxs("button",{className:"secondary-button",type:"button",disabled:E,onClick:()=>{c.onNotifyManager()},children:[R.jsx(pm,{size:14,strokeWidth:2,"aria-hidden":"true"}),R.jsx("span",{children:Hm(c.operationStatus)})]})})]})}function Cm(c){return c.stats.total===0?0:Math.round(c.stats.done/c.stats.total*100)}function qm(c){const E=Cm(c),D=c.stats.total>0&&c.stats.done===c.stats.total;return R.jsxs("div",{className:"progress","data-complete":D,children:[R.jsxs("div",{className:"progress-label",children:[R.jsx("span",{children:D?"All done":"Progress"}),R.jsxs("span",{className:"progress-count",children:[c.stats.done,"/",c.stats.total]})]}),R.jsx("div",{className:"progress-track",role:"progressbar","aria-valuemin":0,"aria-valuemax":100,"aria-valuenow":E,"aria-label":"Completed todos",children:R.jsx("div",{className:"progress-fill",style:{width:`${E}%`}})})]})}function jm(c){const[E,D]=Hl.useState(""),y=!c.disabled&&E.trim().length>0,C=W=>{D(W.currentTarget.value)},p=W=>{W.preventDefault();const rl=E.trim();rl.length!==0&&(c.onAdd(rl),D(""))};return R.jsxs("form",{className:"todo-form",onSubmit:p,children:[R.jsxs("div",{className:"todo-input-wrap",children:[R.jsx(Um,{className:"todo-input-icon",size:16,strokeWidth:2,"aria-hidden":"true"}),R.jsx("input",{id:"todo-title",className:"todo-input","aria-label":"New todo",value:E,onChange:C,disabled:c.disabled,placeholder:"Add a task and press Enter"})]}),R.jsx("button",{className:"primary-button",type:"submit",disabled:!y,children:"Add"})]})}function Bm(c,E){return c==="done"?{title:"Nothing finished yet",body:"Check off a task to see it land here."}:c==="open"&&E?{title:"All caught up",body:"Every task is done. Add another to keep going."}:{title:"No todos yet",body:"Add one above, or write to todos/ with window.bb.data — it shows up here instantly."}}function Ym(c){const E=Bm(c.filter,c.hasTodos),D=c.filter==="open"&&c.hasTodos;return R.jsxs("div",{className:"empty-state",children:[R.jsx("span",{className:"empty-icon","aria-hidden":"true",children:D?R.jsx(Om,{size:22,strokeWidth:2}):R.jsx(Dm,{size:22,strokeWidth:2})}),R.jsx("p",{className:"empty-title",children:E.title}),R.jsx("p",{className:"empty-body",children:E.body})]})}function xm(c){const E=new Date(c);return Number.isNaN(E.getTime())?c:E.toLocaleString([],{month:"short",day:"numeric",hour:"numeric",minute:"2-digit"})}function Gm(c){const E=c.todo.done?"Mark as open":"Mark as done",D=()=>{c.onToggle(c.todo.id)},y=()=>{c.onRemove(c.todo.id)};return R.jsxs("li",{className:"todo-row","data-done":c.todo.done,children:[R.jsx("button",{className:"todo-check",type:"button","aria-pressed":c.todo.done,"aria-label":E,onClick:D,children:R.jsx(Y0,{size:14,strokeWidth:3,"aria-hidden":"true"})}),R.jsxs("div",{className:"todo-content",children:[R.jsx("span",{className:"todo-title",children:c.todo.title}),R.jsxs("span",{className:"todo-meta",children:[R.jsxs("code",{children:["todos/",c.todo.id]}),R.jsx("span",{className:"todo-dot","aria-hidden":"true"}),xm(c.todo.updatedAt)]})]}),R.jsx("button",{className:"icon-button",type:"button","aria-label":"Remove todo",onClick:y,children:R.jsx(Nm,{size:15,strokeWidth:2,"aria-hidden":"true"})})]})}function Xm(c){return c.filter==="open"?!c.todo.done:c.filter==="done"?c.todo.done:!0}function Qm(c){const E=c.todos.filter(D=>Xm({todo:D,filter:c.filter}));return E.length===0?R.jsx(Ym,{filter:c.filter,hasTodos:c.todos.length>0}):R.jsx("ul",{className:"todo-list",children:E.map(D=>R.jsx(Gm,{todo:D,onToggle:c.onToggle,onRemove:c.onRemove},D.id))})}const bc="todos",R0=`${bc}/`,C0={todos:[],invalidPaths:[]};function Zm(c){return typeof c=="object"&&c!==null&&!Array.isArray(c)}function Vn(c){const E=c.value[c.key];return typeof E=="string"&&E.trim().length>0?E:null}function Vm(c){const E=c.value[c.key];return typeof E=="boolean"?E:null}function Lm(c){if(!c.startsWith(R0))return null;const E=c.slice(R0.length);return E.length>0&&!E.includes("/")?E:null}function Km(c,E){return c.done!==E.done?c.done?1:-1:E.createdAt.localeCompare(c.createdAt)}function q0(c,E){return E===null||!c.some(D=>D.id===E)?c:c.filter(D=>D.id!==E)}function Jm(c,E){return c.includes(E)?c:[...c,E]}function j0(c,E){return c.includes(E)?c.filter(D=>D!==E):c}function rc(c){return`${bc}/${c}`}function wm(c){if(!Zm(c))return null;const E=Vn({value:c,key:"id"}),D=Vn({value:c,key:"title"}),y=Vm({value:c,key:"done"}),C=Vn({value:c,key:"createdAt"}),p=Vn({value:c,key:"updatedAt"});return E===null||D===null||y===null||C===null||p===null?null:{id:E,title:D,done:y,createdAt:C,updatedAt:p}}function gc(c){return{id:c.id,title:c.title,done:c.done,createdAt:c.createdAt,updatedAt:c.updatedAt}}function Wm(c){const E=Lm(c.event.path);if(c.event.deleted){const C=q0(c.state.todos,E),p=j0(c.state.invalidPaths,c.event.path);return C===c.state.todos&&p===c.state.invalidPaths?c.state:{todos:C,invalidPaths:p}}const D=wm(c.event.value);if(E===null||D===null||D.id!==E){const C=q0(c.state.todos,E),p=Jm(c.state.invalidPaths,c.event.path);return C===c.state.todos&&p===c.state.invalidPaths?c.state:{todos:C,invalidPaths:p}}const y=c.state.todos.filter(C=>C.id!==E);return y.push(D),{todos:y.sort(Km),invalidPaths:j0(c.state.invalidPaths,c.event.path)}}function $m(){return`todo_${(typeof crypto.randomUUID=="function"?crypto.randomUUID():`${Date.now().toString(36)}-${Math.random().toString(36).slice(2,10)}`).replaceAll("-","_")}`}function km(c){const E=new Date().toISOString();return{id:$m(),title:c,done:!1,createdAt:E,updatedAt:E}}function Fm(c){return{...c,done:!c.done,updatedAt:new Date().toISOString()}}function Ln(c){return c.message.trim().length>0?c.message:"The bb SDK request failed."}function Ee(){return"window.bb is not available. Open this app inside bb to read and write app data."}function Ou(){return window.bb??null}function Im(c){const E=c.filter(D=>D.done).length;return{total:c.length,open:c.length-E,done:E}}function Pm(c){return{kind:"todo-app.status",total:c.stats.total,open:c.stats.open,done:c.stats.done,todos:c.todos.map(gc)}}function l1(){const[c,E]=Hl.useState(C0),[D,y]=Hl.useState("idle"),[C,p]=Hl.useState(null),W=c.todos,rl=Hl.useMemo(()=>Im(W),[W]),q=Ou()!==null;Hl.useEffect(()=>{const yl=Ou();if(yl===null){p(Ee());return}let tl=!0;const vl=yl.data.onChange({prefix:bc,callback(Sl){tl&&E(Et=>Wm({state:Et,event:Sl}))}}),Rl=yl.on({event:"app-data:resync",callback(){tl&&E(C0)}});return()=>{tl=!1,vl(),Rl()}},[]);const _=Hl.useCallback(async yl=>{const tl=Ou();if(tl===null){p(Ee());return}const vl=yl.trim();if(vl.length===0)return;const Rl=km(vl);y("saving");try{await tl.data.write({path:rc(Rl.id),value:gc(Rl)}),y("idle"),p(null)}catch(Sl){y("error"),p(Sl instanceof Error?Ln(Sl):"Failed to add todo.")}},[]),F=Hl.useCallback(async yl=>{const tl=Ou();if(tl===null){p(Ee());return}const vl=W.find(Sl=>Sl.id===yl);if(vl===void 0)return;const Rl=Fm(vl);y("saving");try{await tl.data.write({path:rc(Rl.id),value:gc(Rl)}),y("idle"),p(null)}catch(Sl){y("error"),p(Sl instanceof Error?Ln(Sl):"Failed to update todo.")}},[W]),Y=Hl.useCallback(async yl=>{const tl=Ou();if(tl===null){p(Ee());return}y("saving");try{await tl.data.delete({path:rc(yl)}),y("idle"),p(null)}catch(vl){y("error"),p(vl instanceof Error?Ln(vl):"Failed to remove todo.")}},[]),dl=Hl.useCallback(async()=>{const yl=Ou();if(yl===null){p(Ee());return}y("sending");try{await yl.message.send({payload:Pm({stats:rl,todos:W})}),y("sent"),p(null)}catch(tl){y("error"),p(tl instanceof Error?Ln(tl):"Failed to notify the manager.")}},[rl,W]);return{addTodo:_,errorMessage:C,invalidCount:c.invalidPaths.length,isSdkAvailable:q,notifyManager:dl,operationStatus:D,removeTodo:Y,stats:rl,todos:W,toggleTodo:F}}function t1(){var c;return((c=window.bb)==null?void 0:c.applicationId)??"local preview"}function a1(){const[c,E]=Hl.useState("all"),D=Hl.useMemo(t1,[]),y=l1(),C=!y.isSdkAvailable||y.operationStatus==="saving";return R.jsxs("main",{className:"app-shell",children:[R.jsx(Rm,{appId:D,onNotifyManager:y.notifyManager,operationStatus:y.operationStatus}),R.jsxs("section",{className:"todo-card",children:[R.jsx(jm,{disabled:C,onAdd:y.addTodo}),R.jsxs("div",{className:"todo-toolbar",children:[R.jsx(Am,{activeFilter:c,onChange:E,stats:y.stats}),R.jsx(qm,{stats:y.stats})]}),R.jsx(Qm,{filter:c,todos:y.todos,onToggle:y.toggleTodo,onRemove:y.removeTodo})]}),R.jsx(Sm,{errorMessage:y.errorMessage,invalidCount:y.invalidCount})]})}const x0=document.getElementById("root");if(x0===null)throw new Error("Missing root element.");gm.createRoot(x0).render(R.jsx(Hl.StrictMode,{children:R.jsx(a1,{})})); diff --git a/apps/server/src/services/threads/app-scaffold-template/public/index-DavEw_I6.css b/apps/server/src/services/threads/app-scaffold-template/public/index-DavEw_I6.css new file mode 100644 index 000000000..3d4cc7388 --- /dev/null +++ b/apps/server/src/services/threads/app-scaffold-template/public/index-DavEw_I6.css @@ -0,0 +1 @@ +/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}:root{color-scheme:light;--background:oklch(95.51% 0 0);--foreground:oklch(32.11% 0 0);--card:oklch(97.02% 0 0);--muted:oklch(88.53% 0 0);--muted-foreground:oklch(44% 0 0);--subtle-foreground:oklch(50% 0 0);--border:oklch(85.76% 0 0);--border-hairline:oklch(89% 0 0);--input:oklch(78% 0 0);--ring:oklch(48.91% 0 0);--accent:oklch(90% 0 0);--primary:oklch(48.91% 0 0);--primary-foreground:oklch(100% 0 0);--state-hover:oklch(91% 0 0);--state-active:oklch(87% 0 0);--success:oklch(62% .15 155);--success-foreground:oklch(100% 0 0);--warning:oklch(68% .15 65);--warning-text:oklch(52% .13 60);--destructive:oklch(52% .19 25.8625);--destructive-foreground:oklch(100% 0 0);--surface-recessed:oklch(92.5% 0 0);--surface-selected:var(--primary)}@supports (color:color-mix(in lab,red,red)){:root{--surface-selected:color-mix(in oklch, var(--primary) 9%, var(--card))}}:root{--radius:.5rem;--font-sans:"Inter Variable", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--font-mono:"Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace;--shadow-card:0 1px 2px var(--foreground), 0 12px 32px -28px var(--foreground)}@supports (color:color-mix(in lab,red,red)){:root{--shadow-card:0 1px 2px color-mix(in srgb, var(--foreground) 8%, transparent), 0 12px 32px -28px color-mix(in srgb, var(--foreground) 30%, transparent)}}:root{--shadow-raised:0 1px 1px var(--foreground)}@supports (color:color-mix(in lab,red,red)){:root{--shadow-raised:0 1px 1px color-mix(in srgb, var(--foreground) 10%, transparent)}}@media(prefers-color-scheme:dark){:root{color-scheme:dark;--background:oklch(19.5% 0 0);--foreground:oklch(88.53% 0 0);--card:oklch(24.35% 0 0);--muted:oklch(28.5% 0 0);--muted-foreground:oklch(72% 0 0);--subtle-foreground:oklch(60% 0 0);--border:oklch(32.9% 0 0);--border-hairline:oklch(30% 0 0);--input:oklch(42% 0 0);--ring:oklch(70.58% 0 0);--accent:oklch(31% 0 0);--primary:oklch(80% 0 0);--primary-foreground:oklch(21.78% 0 0);--state-hover:oklch(30% 0 0);--state-active:oklch(35% 0 0);--success:oklch(70% .15 155);--warning:oklch(75% .16 60);--warning-text:oklch(80% .13 70);--destructive:oklch(62% .19 22);--surface-recessed:oklch(22.5% 0 0);--surface-selected:var(--primary)}@supports (color:color-mix(in lab,red,red)){:root{--surface-selected:color-mix(in oklch, var(--primary) 16%, var(--card))}}:root{--shadow-card:0 1px 2px #0006, 0 16px 40px -30px #000000b3;--shadow-raised:0 1px 1px #00000059}}*,:before,:after{box-sizing:border-box}html{background:var(--background);min-height:100%}body{background:var(--background);min-height:100%;color:var(--foreground);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;text-rendering:optimizelegibility;margin:0;font-size:13px;line-height:1.5}button,input{font:inherit;color:inherit}button{cursor:pointer}button:disabled,input:disabled{cursor:not-allowed}code{font-family:var(--font-mono)}:focus-visible{outline:2px solid var(--ring);outline-offset:2px}.app-shell{gap:16px;width:min(100%,640px);margin:0 auto;padding:28px 20px 40px;display:grid}.app-header{flex-wrap:wrap;align-items:center;gap:12px;padding:2px 2px 4px;display:flex}.brand{flex:auto;align-items:center;gap:11px;min-width:0;display:flex}.brand-mark{background:var(--primary);width:34px;height:34px;color:var(--primary-foreground);box-shadow:var(--shadow-raised);border-radius:10px;flex-shrink:0;place-items:center;display:grid}.brand-copy{min-width:0}.brand-copy h1{letter-spacing:-.01em;margin:0;font-size:19px;font-weight:640}.brand-copy p{color:var(--muted-foreground);font-family:var(--font-mono);text-overflow:ellipsis;white-space:nowrap;margin:1px 0 0;font-size:11px;overflow:hidden}.header-actions{flex-shrink:0;align-items:center;gap:10px;display:flex}.primary-button,.secondary-button,.icon-button,.todo-check,.filter-tab{border-radius:calc(var(--radius) - 2px);border:1px solid #0000;justify-content:center;align-items:center;transition:background .13s,border-color .13s,color .13s,box-shadow .13s,transform .13s;display:inline-flex}.primary-button{background:var(--primary);height:38px;color:var(--primary-foreground);box-shadow:var(--shadow-raised);gap:6px;padding:0 18px;font-weight:600}.primary-button:hover:not(:disabled){background:var(--primary)}@supports (color:color-mix(in lab,red,red)){.primary-button:hover:not(:disabled){background:color-mix(in oklch,var(--primary) 88%,var(--foreground))}}.primary-button:active:not(:disabled){transform:translateY(.5px)}.secondary-button{border-color:var(--border);background:var(--card);height:34px;color:var(--foreground);gap:7px;padding:0 13px;font-weight:550}.secondary-button:hover:not(:disabled){background:var(--state-hover)}.secondary-button svg{color:var(--muted-foreground)}.primary-button:disabled,.secondary-button:disabled{opacity:.55}.icon-button{width:32px;height:32px;color:var(--muted-foreground);opacity:.6;background:0 0;flex-shrink:0}.icon-button:hover{background:var(--state-hover);color:var(--destructive);opacity:1}.todo-card{border:1px solid var(--border);border-radius:var(--radius);background:var(--card);box-shadow:var(--shadow-card);gap:16px;padding:16px;display:grid}.todo-form{gap:9px;display:flex}.todo-input-wrap{flex:auto;min-width:0;position:relative}.todo-input-icon{color:var(--muted-foreground);pointer-events:none;position:absolute;top:50%;left:11px;transform:translateY(-50%)}.todo-input{border:1px solid var(--border);border-radius:calc(var(--radius) - 2px);background:var(--background);outline:none;width:100%;height:38px;padding:0 12px 0 34px;transition:border-color .13s,box-shadow .13s}.todo-input::placeholder{color:var(--subtle-foreground)}.todo-input:focus{border-color:var(--ring);box-shadow:0 0 0 3px var(--ring)}@supports (color:color-mix(in lab,red,red)){.todo-input:focus{box-shadow:0 0 0 3px color-mix(in srgb,var(--ring) 16%,transparent)}}.todo-input:disabled{opacity:.6}.todo-toolbar{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:12px;display:flex}.filter-tabs{border-radius:var(--radius);background:var(--surface-recessed);gap:2px;padding:3px;display:inline-flex}.filter-tab{height:28px;color:var(--muted-foreground);white-space:nowrap;background:0 0;gap:6px;padding:0 11px;font-size:12px;font-weight:550}.filter-tab:hover:not(.active){color:var(--foreground)}.filter-tab.active{background:var(--card);color:var(--foreground);box-shadow:var(--shadow-raised)}.filter-count{font-family:var(--font-mono);color:var(--subtle-foreground);font-size:10px}.filter-tab.active .filter-count{color:var(--muted-foreground)}.progress{flex:150px;gap:5px;max-width:220px;display:grid}.progress-label{color:var(--muted-foreground);justify-content:space-between;align-items:baseline;gap:8px;font-size:11px;display:flex}.progress-count{font-family:var(--font-mono);font-size:11px}.progress-track{background:var(--surface-recessed);border-radius:999px;height:6px;overflow:hidden}.progress-fill{background:var(--muted-foreground);border-radius:999px;height:100%;transition:width .36s cubic-bezier(.22,1,.36,1),background .2s}.progress[data-complete=true] .progress-fill{background:var(--success)}.progress[data-complete=true] .progress-label{color:var(--success)}.todo-list{gap:6px;margin:0;padding:0;list-style:none;display:grid}.todo-row{border-radius:calc(var(--radius) - 1px);border:1px solid #0000;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:12px;padding:10px 8px 10px 10px;transition:background .13s,border-color .13s;animation:.22s row-in;display:grid}.todo-row:hover{background:var(--state-hover)}@supports (color:color-mix(in lab,red,red)){.todo-row:hover{background:color-mix(in srgb,var(--state-hover) 55%,transparent)}}.todo-row:hover{border-color:var(--border-hairline)}@keyframes row-in{0%{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:translateY(0)}}.todo-check{border:1.5px solid var(--input);background:var(--background);width:22px;height:22px;color:var(--success-foreground);border-radius:999px;flex-shrink:0}.todo-check svg{opacity:0;transition:opacity .13s,transform .16s cubic-bezier(.34,1.56,.64,1);transform:scale(.5)}.todo-check:hover{border-color:var(--success)}.todo-row[data-done=true] .todo-check{background:var(--success);border-color:var(--success)}.todo-row[data-done=true] .todo-check svg{opacity:1;transform:scale(1)}.todo-content{gap:2px;min-width:0;display:grid}.todo-title{overflow-wrap:anywhere;font-size:14px;font-weight:500;transition:color .13s}.todo-row[data-done=true] .todo-title{color:var(--muted-foreground);text-decoration:line-through;-webkit-text-decoration-color:var(--muted-foreground);text-decoration-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.todo-row[data-done=true] .todo-title{-webkit-text-decoration-color:color-mix(in srgb,var(--muted-foreground) 60%,transparent);text-decoration-color:color-mix(in srgb,var(--muted-foreground) 60%,transparent)}}.todo-meta{min-width:0;color:var(--subtle-foreground);white-space:nowrap;align-items:center;gap:7px;font-size:11px;display:flex}.todo-meta code{text-overflow:ellipsis;min-width:0;overflow:hidden}.todo-dot{opacity:.6;background:currentColor;border-radius:999px;flex-shrink:0;width:3px;height:3px}.empty-state{text-align:center;border:1px dashed var(--border);border-radius:var(--radius);background:var(--surface-recessed);justify-items:center;gap:8px;padding:40px 24px;display:grid}@supports (color:color-mix(in lab,red,red)){.empty-state{background:color-mix(in srgb,var(--surface-recessed) 45%,transparent)}}.empty-icon{background:var(--card);border:1px solid var(--border);width:44px;height:44px;color:var(--muted-foreground);border-radius:999px;place-items:center;display:grid}.empty-title{color:var(--foreground);margin:0;font-size:14px;font-weight:600}.empty-body{max-width:320px;color:var(--muted-foreground);margin:0;font-size:12px;line-height:1.55}.empty-body code{color:var(--foreground);font-size:11px}.data-notices{gap:8px;display:grid}.notice{border-radius:calc(var(--radius) - 2px);margin:0;padding:9px 11px;font-size:12px;line-height:1.5}.notice code{font-size:11px}.notice-warning{background:var(--warning)}@supports (color:color-mix(in lab,red,red)){.notice-warning{background:color-mix(in srgb,var(--warning) 14%,var(--background))}}.notice-warning{color:var(--warning-text)}.notice-error{background:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.notice-error{background:color-mix(in srgb,var(--destructive) 12%,var(--background))}}.notice-error{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.notice-error{color:color-mix(in oklch,var(--destructive) 72%,var(--foreground))}}@media(max-width:460px){.app-shell{padding:18px 14px 28px}.header-actions{justify-content:space-between;width:100%}.secondary-button{flex:0 auto}.todo-toolbar{align-items:stretch}.filter-tabs{justify-content:space-between}.filter-tab{flex:1 1 0}.progress{max-width:none}}@media(hover:none){.icon-button{opacity:1}}@media(prefers-reduced-motion:reduce){*,:before,:after{transition-duration:.001ms!important;animation-duration:.001ms!important}}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} diff --git a/apps/server/src/services/threads/app-scaffold-template/public/index.html b/apps/server/src/services/threads/app-scaffold-template/public/index.html new file mode 100644 index 000000000..fd8ae525b --- /dev/null +++ b/apps/server/src/services/threads/app-scaffold-template/public/index.html @@ -0,0 +1,13 @@ + + + + + + bb Todo Starter + + + + + + + diff --git a/apps/server/src/services/threads/app-scaffold-template/skills/add-todos/SKILL.md b/apps/server/src/services/threads/app-scaffold-template/skills/add-todos/SKILL.md new file mode 100644 index 000000000..6f27ec98f --- /dev/null +++ b/apps/server/src/services/threads/app-scaffold-template/skills/add-todos/SKILL.md @@ -0,0 +1,60 @@ +--- +name: add-todos +description: Add todos to this bb Todo app by writing per-item app data records. +--- + +# Add Todos + +Use this skill when you need to add todos to this app from an agent or script. +The app listens to app data changes under `todos/`, so records written out of +band appear in the UI through the live `window.bb.data.onChange` binding. + +## Record format + +Write one JSON record per todo at: + +```text +todos/ +``` + +The `` must match the record's `id` field and must be a valid app data path +segment. Use a stable lowercase id such as `todo_20260603_review_notes`. + +```json +{ + "id": "todo_20260603_review_notes", + "title": "Review notes from the manager", + "done": false, + "createdAt": "2026-06-03T20:00:00.000Z", + "updatedAt": "2026-06-03T20:00:00.000Z" +} +``` + +Required fields: `id`, `title`, `done`, `createdAt`, `updatedAt`. +Use ISO 8601 timestamps. Do not store todos in `state.json`; the app binds to +per-item records under `todos/`. + +## CLI write + +```bash +application_id="" +todo_id="todo_20260603_review_notes" +created_at="$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" +json="{\"id\":\"$todo_id\",\"title\":\"Review notes from the manager\",\"done\":false,\"createdAt\":\"$created_at\",\"updatedAt\":\"$created_at\"}" +printf '%s\n' "$json" | bb app data write "$application_id" "todos/$todo_id" --stdin +``` + +## In-app write + +```ts +await window.bb.data.write({ + path: `todos/${id}`, + value: { + id, + title, + done: false, + createdAt, + updatedAt: createdAt, + }, +}); +``` diff --git a/apps/server/src/services/threads/app-scaffold-template/source/index.html b/apps/server/src/services/threads/app-scaffold-template/source/index.html new file mode 100644 index 000000000..d39778bf3 --- /dev/null +++ b/apps/server/src/services/threads/app-scaffold-template/source/index.html @@ -0,0 +1,12 @@ + + + + + + bb Todo Starter + + + + + + diff --git a/apps/server/src/services/threads/app-scaffold-template/source/package.json b/apps/server/src/services/threads/app-scaffold-template/source/package.json new file mode 100644 index 000000000..5ccbf844e --- /dev/null +++ b/apps/server/src/services/threads/app-scaffold-template/source/package.json @@ -0,0 +1,25 @@ +{ + "name": "bb-todo-app-template-source", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^0.460.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/apps/server/src/services/threads/app-scaffold-template/source/pnpm-lock.yaml b/apps/server/src/services/threads/app-scaffold-template/source/pnpm-lock.yaml new file mode 100644 index 000000000..b313727cf --- /dev/null +++ b/apps/server/src/services/threads/app-scaffold-template/source/pnpm-lock.yaml @@ -0,0 +1,1483 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + lucide-react: + specifier: ^0.460.0 + version: 0.460.0(react@19.2.7) + react: + specifier: ^19.0.0 + version: 19.2.7 + react-dom: + specifier: ^19.0.0 + version: 19.2.7(react@19.2.7) + devDependencies: + '@tailwindcss/vite': + specifier: ^4.0.0 + version: 4.3.0(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0)) + '@types/react': + specifier: ^19.0.0 + version: 19.2.16 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.16) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0)) + tailwindcss: + specifier: ^4.0.0 + version: 4.3.0 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.3(jiti@2.7.0)(lightningcss@1.32.0) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.61.0': + resolution: {integrity: sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.61.0': + resolution: {integrity: sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.61.0': + resolution: {integrity: sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.61.0': + resolution: {integrity: sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.61.0': + resolution: {integrity: sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.61.0': + resolution: {integrity: sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.61.0': + resolution: {integrity: sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.61.0': + resolution: {integrity: sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.61.0': + resolution: {integrity: sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.61.0': + resolution: {integrity: sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.61.0': + resolution: {integrity: sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.61.0': + resolution: {integrity: sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.61.0': + resolution: {integrity: sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.61.0': + resolution: {integrity: sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.61.0': + resolution: {integrity: sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.61.0': + resolution: {integrity: sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.61.0': + resolution: {integrity: sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.61.0': + resolution: {integrity: sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.61.0': + resolution: {integrity: sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.61.0': + resolution: {integrity: sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.61.0': + resolution: {integrity: sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.61.0': + resolution: {integrity: sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.61.0': + resolution: {integrity: sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.61.0': + resolution: {integrity: sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.61.0': + resolution: {integrity: sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==} + cpu: [x64] + os: [win32] + + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.0': + resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.16': + resolution: {integrity: sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + baseline-browser-mapping@2.10.33: + resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.366: + resolution: {integrity: sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg==} + + enhanced-resolve@5.22.2: + resolution: {integrity: sha512-0rxICaFZ7NQho/sHely2bvOPRP0Eu2B0NZ9zM54YvRvWMn7jfz3DmnOZDR9LlXDdDcqntAVc6Hfy4gr/tdH/Ag==} + engines: {node: '>=10.13.0'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.460.0: + resolution: {integrity: sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.47: + resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} + engines: {node: '>=18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + rollup@4.61.0: + resolution: {integrity: sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.61.0': + optional: true + + '@rollup/rollup-android-arm64@4.61.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.61.0': + optional: true + + '@rollup/rollup-darwin-x64@4.61.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.61.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.61.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.61.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.61.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.61.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.61.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.61.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.61.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.61.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.61.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.61.0': + optional: true + + '@tailwindcss/node@4.3.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.22.2 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + + '@tailwindcss/oxide-android-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide@4.3.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/vite@4.3.0(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + vite: 6.4.3(jiti@2.7.0)(lightningcss@1.32.0) + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/estree@1.0.9': {} + + '@types/react-dom@19.2.3(@types/react@19.2.16)': + dependencies: + '@types/react': 19.2.16 + + '@types/react@19.2.16': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@4.7.0(vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.3(jiti@2.7.0)(lightningcss@1.32.0) + transitivePeerDependencies: + - supports-color + + baseline-browser-mapping@2.10.33: {} + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.33 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.366 + node-releases: 2.0.47 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + caniuse-lite@1.0.30001793: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + electron-to-chromium@1.5.366: {} + + enhanced-resolve@5.22.2: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + graceful-fs@4.2.11: {} + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.460.0(react@19.2.7): + dependencies: + react: 19.2.7 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + node-releases@2.0.47: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-refresh@0.17.0: {} + + react@19.2.7: {} + + rollup@4.61.0: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.61.0 + '@rollup/rollup-android-arm64': 4.61.0 + '@rollup/rollup-darwin-arm64': 4.61.0 + '@rollup/rollup-darwin-x64': 4.61.0 + '@rollup/rollup-freebsd-arm64': 4.61.0 + '@rollup/rollup-freebsd-x64': 4.61.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.61.0 + '@rollup/rollup-linux-arm-musleabihf': 4.61.0 + '@rollup/rollup-linux-arm64-gnu': 4.61.0 + '@rollup/rollup-linux-arm64-musl': 4.61.0 + '@rollup/rollup-linux-loong64-gnu': 4.61.0 + '@rollup/rollup-linux-loong64-musl': 4.61.0 + '@rollup/rollup-linux-ppc64-gnu': 4.61.0 + '@rollup/rollup-linux-ppc64-musl': 4.61.0 + '@rollup/rollup-linux-riscv64-gnu': 4.61.0 + '@rollup/rollup-linux-riscv64-musl': 4.61.0 + '@rollup/rollup-linux-s390x-gnu': 4.61.0 + '@rollup/rollup-linux-x64-gnu': 4.61.0 + '@rollup/rollup-linux-x64-musl': 4.61.0 + '@rollup/rollup-openbsd-x64': 4.61.0 + '@rollup/rollup-openharmony-arm64': 4.61.0 + '@rollup/rollup-win32-arm64-msvc': 4.61.0 + '@rollup/rollup-win32-ia32-msvc': 4.61.0 + '@rollup/rollup-win32-x64-gnu': 4.61.0 + '@rollup/rollup-win32-x64-msvc': 4.61.0 + fsevents: 2.3.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + source-map-js@1.2.1: {} + + tailwindcss@4.3.0: {} + + tapable@2.3.3: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + typescript@5.9.3: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite@6.4.3(jiti@2.7.0)(lightningcss@1.32.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.61.0 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + + yallist@3.1.1: {} diff --git a/apps/server/src/services/threads/app-scaffold-template/source/src/App.tsx b/apps/server/src/services/threads/app-scaffold-template/source/src/App.tsx new file mode 100644 index 000000000..9bd04befe --- /dev/null +++ b/apps/server/src/services/threads/app-scaffold-template/source/src/App.tsx @@ -0,0 +1,54 @@ +import { useMemo, useState } from "react"; +import { DataNotices } from "./components/DataNotices"; +import { FilterTabs } from "./components/FilterTabs"; +import { Header } from "./components/Header"; +import { ProgressMeter } from "./components/ProgressMeter"; +import { TodoForm } from "./components/TodoForm"; +import { TodoList } from "./components/TodoList"; +import { useTodos } from "./useTodos"; +import type { TodoFilter } from "./types"; + +function currentAppId(): string { + return window.bb?.applicationId ?? "local preview"; +} + +export function App() { + const [filter, setFilter] = useState("all"); + const appId = useMemo(currentAppId, []); + const todos = useTodos(); + const formDisabled = + !todos.isSdkAvailable || todos.operationStatus === "saving"; + + return ( + + + + + + + + + + + + + + + ); +} diff --git a/apps/server/src/services/threads/app-scaffold-template/source/src/bb-sdk.d.ts b/apps/server/src/services/threads/app-scaffold-template/source/src/bb-sdk.d.ts new file mode 100644 index 000000000..e7462a09f --- /dev/null +++ b/apps/server/src/services/threads/app-scaffold-template/source/src/bb-sdk.d.ts @@ -0,0 +1,337 @@ +// GENERATED - do not edit. Run pnpm --filter @bb/sdk generate:app-globals-dts to regenerate. +// Source: @bb/sdk current app runtime types. +export {}; + +declare global { + type ApplicationId = string; + + type AppDataPath = string; + + interface JsonObject { + [key: string]: JsonValue; + } + + type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject; + + type ThreadEventType = "thread/started" | "thread/identity" | "turn/started" | "turn/completed" | "turn/input/accepted" | "thread/name/updated" | "thread/compacted" | "item/started" | "item/completed" | "item/agentMessage/delta" | "item/commandExecution/outputDelta" | "item/fileChange/outputDelta" | "item/reasoning/summaryTextDelta" | "item/reasoning/textDelta" | "item/plan/delta" | "item/mcpToolCall/progress" | "item/toolCall/progress" | "item/backgroundTask/progress" | "item/backgroundTask/completed" | "thread/tokenUsage/updated" | "thread/contextWindowUsage/updated" | "turn/plan/updated" | "turn/diff/updated" | "provider/error" | "provider/warning" | "provider/unhandled" | "client/thread/start" | "client/turn/requested" | "client/turn/start" | "system/error" | "system/manager/user_message" | "system/thread/interrupted" | "system/operation" | "system/permissionGrant/lifecycle" | "system/userQuestion/lifecycle" | "system/thread-provisioning" | "system/provider-turn-watchdog"; + + type ThreadChangeKind = "thread-created" | "thread-deleted" | "events-appended" | "interactions-changed" | "status-changed" | "title-changed" | "queue-changed" | "archived-changed" | "pin-state-changed" | "parent-changed" | "read-state-changed" | "manager-assignment-changed" | "order-changed" | "terminals-changed"; + + type ProjectChangeKind = "project-created" | "project-updated" | "project-deleted" | "project-sources-changed" | "threads-changed" | "project-order-changed" | "automations-changed" | "nudges-changed"; + + type EnvironmentChangeKind = "status-changed" | "environment-created" | "environment-deleted" | "metadata-changed" | "work-status-changed" | "git-refs-changed" | "thread-storage-changed"; + + type HostChangeKind = "host-connected" | "host-disconnected"; + + type SystemChangeKind = "config-changed" | "apps-changed"; + + type AppChangeKind = "apps-changed" | "content-changed"; + + interface ThreadChangeMetadata { + eventTypes?: readonly ThreadEventType[] | undefined; + hasPendingInteraction?: boolean | undefined; + projectId?: string | undefined; + } + + interface ThreadChangedMessage { + type: "changed"; + entity: "thread"; + changes: readonly ThreadChangeKind[]; + id?: string | undefined; + metadata?: ThreadChangeMetadata | undefined; + } + + interface ProjectChangedMessage { + type: "changed"; + entity: "project"; + changes: readonly ProjectChangeKind[]; + id?: string | undefined; + } + + interface EnvironmentChangedMessage { + type: "changed"; + entity: "environment"; + changes: readonly EnvironmentChangeKind[]; + id?: string | undefined; + } + + interface HostChangedMessage { + type: "changed"; + entity: "host"; + changes: readonly HostChangeKind[]; + id?: string | undefined; + } + + interface SystemChangedMessage { + type: "changed"; + entity: "system"; + changes: readonly SystemChangeKind[]; + } + + interface AppChangedMessage { + type: "changed"; + entity: "app"; + changes: readonly AppChangeKind[]; + id?: string | undefined; + } + + type ChangedMessage = ThreadChangedMessage | ProjectChangedMessage | EnvironmentChangedMessage | HostChangedMessage | SystemChangedMessage | AppChangedMessage; + + type AppDataBroadcastMessage = { type: "app-data.changed"; applicationId: string; path: string; value: JsonValue; deleted: boolean; version: string | null; } | { type: "app-data.resync"; applicationId: string; }; + + interface AppDataEntry { + path: AppDataPath; + value: JsonValue; + version: string; + sizeBytes: number; + modifiedAtMs: number; + } + + interface BbDataEntry { + path: AppDataPath; + value: JsonValue; + } + + interface BbDataReadArgs { + path: AppDataPath; + } + + interface BbDataWriteArgs extends BbDataReadArgs { + value: JsonValue; + } + + interface BbDataDeleteArgs extends BbDataReadArgs { + } + + interface BbDataListArgs { + prefix?: AppDataPath | ""; + } + + interface BbDataChangeEvent { + path: AppDataPath; + value: JsonValue | undefined; + deleted: boolean; + } + + type BbDataChangeCallback = (event: BbDataChangeEvent) => void; + + interface BbDataOnChangeArgs { + callback: BbDataChangeCallback; + prefix?: AppDataPath | ""; + } + + interface BbMessageSendArgs { + payload: JsonValue; + targetThreadId?: string; + } + + type BbRealtimeUnsubscribe = () => void; + + type BbRealtimeEventName = "thread:changed" | "project:changed" | "environment:changed" | "host:changed" | "system:changed" | "system:config-changed" | "system:apps-changed" | "app:changed" | "app-data:changed" | "app-data:resync" | "realtime:connection"; + + type ThreadRealtimeEvent = Extract; + + type ProjectRealtimeEvent = Extract; + + type EnvironmentRealtimeEvent = Extract; + + type HostRealtimeEvent = Extract; + + type SystemRealtimeEvent = Extract; + + type AppRealtimeEvent = Extract; + + type AppDataChangedRealtimeEvent = Extract; + + type AppDataResyncRealtimeEvent = Extract