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 (