From 591dc7a2d7225c1c3f7a230263f8663fccb4d767 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Mon, 7 Sep 2026 03:14:19 -0700 Subject: [PATCH 001/128] Use indexed task thread lifecycle lookups --- plugins/tasks/db/store.ts | 14 ++++++++++ plugins/tasks/lifecycle/index.ts | 8 +++--- plugins/tasks/lifecycle/lifecycle.test.ts | 31 +++++++++++++++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/plugins/tasks/db/store.ts b/plugins/tasks/db/store.ts index 6c11a2163e..72b5421fee 100644 --- a/plugins/tasks/db/store.ts +++ b/plugins/tasks/db/store.ts @@ -1594,6 +1594,19 @@ export function createTasksStore(db: PluginDatabase) { return row ? taskThreadFromRow(row) : undefined; } + function listTaskThreadsByThreadId(threadId: string): TaskThread[] { + return db + .prepare<[string], TaskThreadRow>( + ` + SELECT * FROM task_threads + WHERE thread_id = ? + ORDER BY task_id, id + `, + ) + .all(threadId) + .map(taskThreadFromRow); + } + function requireTaskThread(id: string): TaskThread { const thread = getTaskThread(id); if (!thread) throw new Error(`Task thread not found: ${id}`); @@ -1856,6 +1869,7 @@ export function createTasksStore(db: PluginDatabase) { upsertTaskThread, getTaskThread, getTaskThreadByThreadId, + listTaskThreadsByThreadId, listTaskThreads, updateTaskThreadStatus, deleteTaskThread, diff --git a/plugins/tasks/lifecycle/index.ts b/plugins/tasks/lifecycle/index.ts index 361ffb3f91..e054196667 100644 --- a/plugins/tasks/lifecycle/index.ts +++ b/plugins/tasks/lifecycle/index.ts @@ -27,13 +27,11 @@ function liveStatusFromThread(thread: SdkThread): TaskThreadLiveStatus { } } -function trackedThreads(store: TasksApiStore, threadId?: string): TaskThread[] { +function trackedThreads(store: TasksApiStore): TaskThread[] { const tracked: TaskThread[] = []; for (const task of store.tasks.listTasks()) { for (const thread of store.tasks.listTaskThreads(task.id)) { - if (threadId === undefined || thread.threadId === threadId) { - tracked.push(thread); - } + tracked.push(thread); } } return tracked; @@ -90,7 +88,7 @@ function transitionTrackedThread( threadId: string, liveStatus: TaskThreadLiveStatus, ): void { - for (const thread of trackedThreads(store, threadId)) { + for (const thread of store.tasks.listTaskThreadsByThreadId(threadId)) { transitionThread(bb, store, thread, liveStatus); } } diff --git a/plugins/tasks/lifecycle/lifecycle.test.ts b/plugins/tasks/lifecycle/lifecycle.test.ts index 8854a5eeeb..98c4f4f00a 100644 --- a/plugins/tasks/lifecycle/lifecycle.test.ts +++ b/plugins/tasks/lifecycle/lifecycle.test.ts @@ -364,4 +364,35 @@ describe("task thread lifecycle", () => { await harness.dispose(); }); + + it("looks up an unrelated lifecycle event without scanning tasks", async () => { + const { bb, harness } = createFakePluginHost({ pluginId: "tasks" }); + const store = createStore(bb); + const project = store.tasks.createProject({ + name: "Lookup scope", + prefix: "SCOPE", + color: "blue", + }); + for (let index = 0; index < 3; index += 1) { + store.tasks.createTask({ + projectId: project.id, + title: `Unrelated task ${index}`, + }); + } + await registerLifecycle(bb, store); + const listTasks = vi.spyOn(store.tasks, "listTasks"); + const listTaskThreads = vi.spyOn(store.tasks, "listTaskThreads"); + + await harness.emitThreadEvent("thread.idle", { + thread: makeThreadResponse({ id: "thr_untracked", status: "idle" }), + lastAssistantText: null, + }); + + expect({ + listTasks: listTasks.mock.calls.length, + listTaskThreads: listTaskThreads.mock.calls.length, + }).toEqual({ listTasks: 0, listTaskThreads: 0 }); + + await harness.dispose(); + }); }); From e752954d48c5ef3a3e02ef8e1dfecfa32984703c Mon Sep 17 00:00:00 2001 From: Michael Yong <610102+ymichael@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:14:35 -0700 Subject: [PATCH 002/128] Fix project directory picker scroll reset (#3251) ## Human comments ## What was wrong When a user opened a child folder after scrolling deeply through a large directory, the virtualized folder browser retained the prior directory's scroll offset. A similarly large child therefore opened partway down rather than at its first entry. ## What changed The shared remote path browser now resets its virtualizer offset whenever it navigates to a directory. Its virtualizer option callbacks are also stable between unrelated renders, avoiding unnecessary measurement churn. Added a regression for browsing between two large directories. No wire, CLI, guide, or protocol changes. ## How you verified - `pnpm exec turbo run test --filter=@bb/app --force -- --run src/components/dialogs/RemotePathBrowser.createFolder.test.tsx` - `pnpm exec turbo run typecheck --filter=@bb/app` - `pnpm exec oxfmt --check apps/app/src/components/dialogs/RemotePathBrowser.tsx apps/app/src/components/dialogs/RemotePathBrowser.createFolder.test.tsx` - `git diff --check` Fixes # > AGENT GENERATED --- .../RemotePathBrowser.createFolder.test.tsx | 47 +++++++++++++++++++ .../components/dialogs/RemotePathBrowser.tsx | 44 +++++++++++------ 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/apps/app/src/components/dialogs/RemotePathBrowser.createFolder.test.tsx b/apps/app/src/components/dialogs/RemotePathBrowser.createFolder.test.tsx index 55fd6752c7..3bc232036a 100644 --- a/apps/app/src/components/dialogs/RemotePathBrowser.createFolder.test.tsx +++ b/apps/app/src/components/dialogs/RemotePathBrowser.createFolder.test.tsx @@ -248,6 +248,53 @@ describe("RemotePathBrowser new folder", () => { }); describe("RemotePathBrowser entry list", () => { + it("returns to the top when browsing into another large directory", async () => { + const names = Array.from( + { length: 4999 }, + (_, i) => `file_${String(i).padStart(5, "0")}`, + ); + const rootEntries = [...names, "next"]; + directory.mockImplementation(({ path }) => + Promise.resolve( + listing( + path === "/home/me/manyfiles/next" + ? "/home/me/manyfiles/next" + : "/home/me/manyfiles", + path === "/home/me/manyfiles/next" ? names : rootEntries, + ), + ), + ); + const { wrapper: Wrapper } = createQueryClientTestHarness(); + + const { container } = render( + + + , + ); + + await screen.findByText("file_00000"); + const list = container.querySelector("ul"); + const scrollBox = list?.parentElement; + if (!(scrollBox instanceof HTMLElement)) throw new Error("no scroll box"); + Object.defineProperty(scrollBox, "scrollTo", { + configurable: true, + value: ({ top }: ScrollToOptions) => { + scrollBox.scrollTop = top ?? scrollBox.scrollTop; + scrollBox.dispatchEvent(new Event("scroll")); + }, + }); + scrollBox.scrollTop = 4_999 * ENTRY_TEST_ROW_HEIGHT_PX; + fireEvent.scroll(scrollBox); + fireEvent.click(await screen.findByRole("button", { name: "next" })); + + expect(scrollBox.scrollTop).toBe(0); + expect(await screen.findByText("file_00000")).not.toBeNull(); + }); + it("mounts only the entries near the viewport for a huge directory", async () => { const names = Array.from( { length: 5000 }, diff --git a/apps/app/src/components/dialogs/RemotePathBrowser.tsx b/apps/app/src/components/dialogs/RemotePathBrowser.tsx index dd3766e0e9..dc08179d46 100644 --- a/apps/app/src/components/dialogs/RemotePathBrowser.tsx +++ b/apps/app/src/components/dialogs/RemotePathBrowser.tsx @@ -1,4 +1,10 @@ -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { normalizeProjectPathInput } from "@bb/domain"; @@ -96,14 +102,34 @@ export function RemotePathBrowser({ const entries = data?.entries ?? NO_ENTRIES; const scrollRef = useRef(null); + const getScrollElement = useCallback(() => scrollRef.current, []); + const estimateEntrySize = useCallback( + () => DIRECTORY_ENTRY_ROW_HEIGHT_PX, + [], + ); + const getEntryKey = useCallback( + (index: number) => entries[index]?.path ?? index, + [entries], + ); const entryVirtualizer = useVirtualizer({ count: entries.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => DIRECTORY_ENTRY_ROW_HEIGHT_PX, - getItemKey: (index) => entries[index]?.path ?? index, + getScrollElement, + estimateSize: estimateEntrySize, + getItemKey: getEntryKey, overscan: DIRECTORY_ENTRY_OVERSCAN_ROWS, }); + const cancelCreatingFolder = () => { + setIsCreatingFolder(false); + setNewFolderError(null); + }; + + const navigateTo = (path: string) => { + cancelCreatingFolder(); + entryVirtualizer.scrollToOffset(0); + setCurrentPath(path); + }; + const startCreatingFolder = () => { if ( !allowCreateFolder || @@ -120,16 +146,6 @@ export function RemotePathBrowser({ setIsCreatingFolder(true); }; - const cancelCreatingFolder = () => { - setIsCreatingFolder(false); - setNewFolderError(null); - }; - - const navigateTo = (path: string) => { - cancelCreatingFolder(); - setCurrentPath(path); - }; - const createFolder = useMutation({ mutationFn: async ({ parent, name }: { parent: string; name: string }) => { const path = joinHostPath(parent, name); From 081e2e30c684f9382c888328ba9780682ebc835c Mon Sep 17 00:00:00 2001 From: Michael Yong <610102+ymichael@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:34:59 -0700 Subject: [PATCH 003/128] Fix local Markdown images with shared preview and file-serving routing (#3200) ## Human comments ## What was wrong Markdown image URLs were only routed to host files when a caller supplied `linkRouting.localImage`. Assistant messages supplied it, but file previews and several other Markdown surfaces did not. Consequently, absolute host paths were requested from the app origin and relative paths were resolved against the SPA URL instead of the Markdown file's directory. For example, `docs/report.md` containing `![chart](images/chart.png)` should load `docs/images/chart.png`. ## What changed - Share file-image routing across workspace, project, thread-host, host-scoped, and thread-storage previews. Resolve relative images from the Markdown directory using the existing local-path resolver, and use existing content endpoints for each source. - Share lease routing with local skill previews and message routing across assistant, user, generated/system, and thread-context Plugin SDK Markdown. Preserve intentionally suppressed images and explicit caller routing overrides. - Preserve existing root restrictions for scoped relative paths and existing trusted absolute-host access. These restrictions are endpoint constraints, not a sandbox for Markdown that can also reference absolute host paths. - Consolidate seven backend file-serving handlers through one host-read/error-handling function, retaining their existing target selection, caching, HTML sandbox headers, and size limits. No public plugin API, CLI, endpoint contract, or server/daemon wire behavior changes; no daemon protocol bump is needed. ## How you verified - Added rendered-preview regression coverage for absolute and file-relative images, all five preview adapters, root escapes, explicit routing overrides, nested lease paths, user/system messages, Plugin SDK Markdown, and local skill files. - `pnpm exec turbo run typecheck lint test --filter=@bb/app`: 481 test files passed; 3,890 tests passed and 3 skipped. App lint completed with 182 warnings and 0 errors. - `pnpm exec turbo run typecheck lint --filter=@bb/server`: typecheck passed (the server package has no lint task). - `pnpm exec turbo run test --filter=@bb/server -- test/hosts/daemon-file-response.test.ts test/files/host-file-routes.test.ts test/public/public-projects-local-host.test.ts test/public/public-project-workspace-routing.test.ts test/public/public-thread-data.test.ts`: 5 files / 100 tests passed, including byte responses, root propagation, host selection, validators, HTML policies, and error mapping. - Rebuilt and started the final code with `pnpm start:worktree`; confirmed HTTP 200 and daemon connectivity. The dev QA thread has workspace and thread-storage fixtures; the other preview types are covered by automated tests, not completed manual demos. No linked GitHub issue. > AGENT GENERATED --- ...ryPanelTabContent.markdown-images.test.tsx | 175 ++++++++++++++++++ .../ThreadSecondaryPanelTabContent.tsx | 121 +++++++++++- .../ConversationMessageContent.test.tsx | 37 ++++ .../timeline/ConversationMessageContent.tsx | 80 ++++---- .../GeneratedConversationMessage.test.tsx | 14 ++ .../timeline/GeneratedConversationMessage.tsx | 18 +- .../ThreadTimelineNavigationContext.tsx | 4 + .../thread/timeline/ThreadTimelineRows.tsx | 2 + .../src/components/tools/SkillDetailView.tsx | 7 + .../src/components/tools/SkillsCollection.tsx | 4 + .../src/components/tools/SkillsLibrary.tsx | 21 +++ .../tools/detail-page-recipes.test.tsx | 28 +++ .../ui/markdown-file-image-routing.test.tsx | 169 +++++++++++++++++ .../ui/markdown-file-image-routing.ts | 80 ++++++++ .../components/ui/markdown-link-routing.ts | 5 +- .../ui/markdown-message-link-routing.ts | 62 +++++++ .../src/components/ui/markdown-preview.tsx | 39 +++- apps/app/src/lib/file-content-urls.test.ts | 18 ++ apps/app/src/lib/file-content-urls.ts | 15 ++ apps/app/src/lib/plugin-sdk-app-impl.test.tsx | 6 +- apps/app/src/lib/plugin-sdk-app-impl.tsx | 31 ++-- .../src/views/RootComposePanelTabContent.tsx | 5 +- .../views/thread-detail/ThreadDetailView.tsx | 1 + apps/server/src/routes/files.ts | 61 +++--- apps/server/src/routes/projects.ts | 30 ++- apps/server/src/routes/threads/data.ts | 94 ++++------ .../services/hosts/daemon-file-response.ts | 21 +++ 27 files changed, 959 insertions(+), 189 deletions(-) create mode 100644 apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.markdown-images.test.tsx create mode 100644 apps/app/src/components/ui/markdown-file-image-routing.test.tsx create mode 100644 apps/app/src/components/ui/markdown-file-image-routing.ts create mode 100644 apps/app/src/components/ui/markdown-message-link-routing.ts create mode 100644 apps/app/src/lib/file-content-urls.test.ts diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.markdown-images.test.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.markdown-images.test.tsx new file mode 100644 index 0000000000..bc100f9df9 --- /dev/null +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.markdown-images.test.tsx @@ -0,0 +1,175 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + HostFilePreviewTabContent, + HostScopedFilePreviewTabContent, + ProjectFilePreviewTabContent, + ThreadStorageFilePreviewTabContent, + WorkspaceFilePreviewTabContent, +} from "./ThreadSecondaryPanelTabContent"; +import type { FilePreview } from "@bb/client-core"; + +function markdownPreview(path: string, url = `/content/${path}`): FilePreview { + return { + kind: "text", + content: [ + "![absolute](/workspace/generated.png)", + "![relative](images/chart.png)", + "![escape](../../outside.png)", + ].join("\n\n"), + mimeType: "text/markdown", + name: path.split("/").at(-1), + path, + url, + }; +} + +function previewQuery(path: string, url?: string) { + return { + data: markdownPreview(path, url), + error: null, + isFetching: false, + isLoading: false, + refetch: vi.fn(), + }; +} + +vi.mock("@/hooks/queries/environment-queries", () => ({ + useEnvironment: () => ({ + data: { path: "/workspace", projectId: "proj_preview" }, + }), + useEnvironmentDiffFiles: vi.fn(), + useEnvironmentFilePreview: (_environmentId: string, path: string) => + previewQuery(path), +})); + +vi.mock("@/hooks/queries/project-queries", () => ({ + useProjectFilePreview: (_projectId: string, path: string) => + previewQuery(path), +})); + +vi.mock("@/hooks/queries/thread-queries", () => ({ + useThreadHostFilePreview: ( + _threadId: string, + _environmentId: string, + path: string, + ) => previewQuery(path), + useThreadStorageFilePreview: (_threadId: string, path: string) => + previewQuery(path), +})); + +vi.mock("@/hooks/queries/host-file-preview-query", () => ({ + useHostFilePreview: (_hostId: string, path: string) => + previewQuery(path, "/api/v1/file-previews/lease_preview/readme.md"), +})); + +afterEach(cleanup); + +function imageSrc(name: string): string | null { + return screen.getByRole("img", { name }).getAttribute("src"); +} + +describe("secondary-panel Markdown image routing", () => { + it("routes workspace images when the preview caller supplies no routing", () => { + render( + , + ); + + expect(imageSrc("absolute")).toBe( + "/api/v1/threads/thr_preview/host-files/content?path=%2Fworkspace%2Fgenerated.png", + ); + expect(imageSrc("relative")).toBe( + "/api/v1/threads/thr_preview/worktree/files/docs/images/chart.png", + ); + expect(imageSrc("escape")).toBe("../../outside.png"); + }); + + it("routes project images through the selected project source", () => { + render( + , + ); + + expect(imageSrc("absolute")).toContain( + "/api/v1/projects/proj_preview/files/content?", + ); + expect(imageSrc("absolute")).toContain("path=generated.png"); + expect(imageSrc("relative")).toContain("path=docs%2Fimages%2Fchart.png"); + expect(imageSrc("absolute")).toContain("hostId=host_preview"); + expect(imageSrc("escape")).toBe("../../outside.png"); + }); + + it("routes thread host-file images through the host content endpoint", () => { + render( + , + ); + + expect(imageSrc("absolute")).toBe( + "/api/v1/threads/thr_preview/host-files/content?path=%2Fworkspace%2Fgenerated.png", + ); + expect(imageSrc("relative")).toBe( + "/api/v1/threads/thr_preview/host-files/content?path=%2Fworkspace%2Fdocs%2Fimages%2Fchart.png", + ); + expect(imageSrc("escape")).toBe("../../outside.png"); + }); + + it("confines host-scoped relative images to the preview lease root", () => { + render( + , + ); + + expect(imageSrc("absolute")).toBe("/workspace/generated.png"); + expect(imageSrc("relative")).toBe( + "/api/v1/file-previews/lease_preview/images/chart.png", + ); + expect(imageSrc("escape")).toBe("../../outside.png"); + }); + + it("routes thread-storage images when the preview caller supplies no routing", () => { + render( + , + ); + + expect(imageSrc("absolute")).toBe( + "/api/v1/threads/thr_preview/host-files/content?path=%2Fworkspace%2Fgenerated.png", + ); + expect(imageSrc("relative")).toBe( + "/api/v1/threads/thr_preview/thread-storage/files/docs/images/chart.png", + ); + expect(imageSrc("escape")).toBe("../../outside.png"); + }); +}); diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx index 090b9c85fe..ce2174534e 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import type { DiffPresentation } from "@/components/code/code-rendering"; import type { WorkspaceDiffTarget } from "@bb/domain"; import type { MarkdownLinkRouting } from "@/components/ui/markdown-link-routing.js"; @@ -6,6 +6,7 @@ import { Skeleton } from "@bb/shared-ui/skeleton"; import { EmptyStatePanel } from "@bb/shared-ui/empty-state"; import { useEnvironmentDiffFiles, + useEnvironment, useEnvironmentFilePreview, } from "@/hooks/queries/environment-queries"; import { useProjectFilePreview } from "@/hooks/queries/project-queries"; @@ -15,7 +16,10 @@ import { } from "@/hooks/queries/thread-queries"; import { useHostFilePreview } from "@/hooks/queries/host-file-preview-query"; import { + buildProjectFileContentUrl, buildRawFilesystemHtmlContentUrl, + buildThreadHostFileContentUrl, + buildThreadStorageRawContentUrl, buildThreadWorktreeRawContentUrl, } from "@/lib/file-content-urls"; import type { @@ -32,6 +36,11 @@ import { SecondaryPanelFilePreview, ThreadStorageFilePreview, } from "./ThreadStorageFilePreview"; +import { + buildMarkdownFileImageRouting, + buildMarkdownLeaseImageRouting, +} from "@/components/ui/markdown-file-image-routing"; +import { getAbsoluteDirname } from "@/lib/absolute-file-path"; const GIT_DIFF_SKELETON_FILE_COUNT = 3; const PANEL_SCROLL_SLOT_CLASS = @@ -72,9 +81,12 @@ interface ProjectFilePreviewTabContentProps { environmentId: string | null; hostId: string | null; lineRange: FilePreviewLineRange | null; + markdownLinkRouting?: MarkdownLinkRouting; onSelectionAddToChat?: (text: string) => void; onOpenInEditor?: (path: string) => void; projectId: string; + rootPath?: string | null; + threadId?: string | null; } interface HostFilePreviewTabContentProps { @@ -309,6 +321,13 @@ export function WorkspaceFilePreviewTabContent({ statusLabel, threadId, }: WorkspaceFilePreviewTabContentProps) { + const environmentQuery = useEnvironment(environmentId ?? null, { + enabled: + environmentId !== null && + environmentId !== undefined && + markdownLinkRouting?.localImage === undefined, + staleTime: 5_000, + }); const { data: workspaceFilePreview, error: workspaceFilePreviewError, @@ -318,6 +337,42 @@ export function WorkspaceFilePreviewTabContent({ } = useEnvironmentFilePreview(environmentId, activePath, source, { enabled: isPanelOpen, }); + const environmentRootPath = environmentQuery.data?.path ?? null; + const environmentProjectId = environmentQuery.data?.projectId; + const resolvedMarkdownLinkRouting = useMemo(() => { + if ( + source === null || + environmentId === null || + environmentId === undefined || + (!threadId && environmentProjectId === undefined) + ) { + return markdownLinkRouting; + } + return buildMarkdownFileImageRouting({ + path: activePath, + rootPath: environmentRootPath, + threadId: threadId ?? null, + linkRouting: markdownLinkRouting, + resolveRelativeSrc: (path) => { + if (threadId && source.kind === "working-tree") { + return buildThreadWorktreeRawContentUrl(threadId, path); + } + return environmentProjectId === undefined + ? path + : buildProjectFileContentUrl(environmentProjectId, path, { + environmentId, + }); + }, + }); + }, [ + activePath, + environmentId, + environmentProjectId, + environmentRootPath, + markdownLinkRouting, + source, + threadId, + ]); return ( void refetchWorkspaceFilePreview()} @@ -349,9 +404,12 @@ export function ProjectFilePreviewTabContent({ hostId, isPanelOpen, lineRange, + markdownLinkRouting, onSelectionAddToChat, onOpenInEditor, projectId, + rootPath = null, + threadId = null, }: ProjectFilePreviewTabContentProps) { const { data: projectFilePreview, @@ -365,6 +423,30 @@ export function ProjectFilePreviewTabContent({ { environmentId, hostId }, { enabled: isPanelOpen }, ); + const resolvedMarkdownLinkRouting = useMemo(() => { + return buildMarkdownFileImageRouting({ + path: activePath, + rootPath, + threadId, + linkRouting: markdownLinkRouting, + resolveRelativeSrc: (path) => + buildProjectFileContentUrl(projectId, path, { + ...(environmentId !== null + ? { environmentId } + : hostId !== null + ? { hostId } + : {}), + }), + }); + }, [ + activePath, + environmentId, + hostId, + markdownLinkRouting, + projectId, + rootPath, + threadId, + ]); return ( void refetchProjectFilePreview()} @@ -403,6 +486,18 @@ export function HostFilePreviewTabContent({ } = useThreadHostFilePreview(threadId, environmentId, activePath, { enabled: isPanelOpen, }); + const resolvedMarkdownLinkRouting = useMemo(() => { + return buildMarkdownFileImageRouting({ + path: activePath, + rootPath: + markdownLinkRouting?.localFile?.relativeLinks?.rootPath ?? + getAbsoluteDirname({ path: activePath }), + threadId, + linkRouting: markdownLinkRouting, + resolveRelativeSrc: (_relativePath, path) => + buildThreadHostFileContentUrl(threadId, path), + }); + }, [activePath, markdownLinkRouting, threadId]); return ( void refetchHostFilePreview()} @@ -437,6 +532,13 @@ export function HostScopedFilePreviewTabContent({ isLoading, refetch, } = useHostFilePreview(hostId, activePath, { enabled: isPanelOpen }); + const markdownLinkRouting = useMemo(() => { + return buildMarkdownLeaseImageRouting({ + path: activePath, + rootPath: getAbsoluteDirname({ path: activePath }), + previewUrl: hostFilePreview?.url, + }); + }, [activePath, hostFilePreview?.url]); return ( void refetch()} statusLabel={null} @@ -473,6 +576,16 @@ export function ThreadStorageFilePreviewTabContent({ } = useThreadStorageFilePreview(threadId, activePath, { enabled: isPanelOpen, }); + const resolvedMarkdownLinkRouting = useMemo(() => { + return buildMarkdownFileImageRouting({ + path: activePath, + rootPath: null, + threadId, + linkRouting: markdownLinkRouting, + resolveRelativeSrc: (path) => + buildThreadStorageRawContentUrl(threadId, path), + }); + }, [activePath, markdownLinkRouting, threadId]); return ( void refetchThreadStorageFilePreview()} diff --git a/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx b/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx index b2c83b8627..06eee6eb8d 100644 --- a/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx +++ b/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx @@ -62,6 +62,43 @@ describe("ConversationMessageContent assistant images", () => { }); }); +describe("ConversationMessageContent user images", () => { + it("uses the same local image routing as assistant messages", () => { + render( + + + + + , + ); + + expect( + screen.getByRole("img", { name: "diagram" }).getAttribute("src"), + ).toBe( + "/api/v1/threads/thr_image/host-files/content?path=%2Fworkspace%2Foutput%2Fdiagram.png", + ); + }); +}); + describe("ConversationMessageContent assistant thread mentions", () => { it("renders an agent-authored thread token with the referenced thread title", () => { const mentionedThread = threadListEntry({ diff --git a/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx b/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx index 30842b9887..940e473a8b 100644 --- a/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx +++ b/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx @@ -68,7 +68,7 @@ import { } from "./SelectableMessageProse.js"; import type { ThreadTimelinePluginMessageAction } from "./types.js"; import type { PromptDraftAttachment } from "@bb/client-core"; -import { buildThreadHostFileContentUrl } from "@/lib/file-content-urls"; +import { buildMarkdownMessageLinkRouting } from "@/components/ui/markdown-message-link-routing"; interface ConversationMessageContentBaseProps { attachments: TimelineConversationAttachments | null; @@ -78,6 +78,7 @@ interface ConversationMessageContentBaseProps { projectId?: string; resolveUserAttachmentImageSrc?: UserAttachmentImageSrcResolver; text: string; + workspaceRootPath?: string; } interface ConversationMessageContentUserProps extends ConversationMessageContentBaseProps { @@ -98,6 +99,7 @@ interface ConversationMessageContentUserProps extends ConversationMessageContent senderIsPluginSideChat: boolean; systemMessageKind: TimelineUserConversationRow["systemMessageKind"]; systemMessageSubject: TimelineUserConversationRow["systemMessageSubject"]; + threadId?: string; turnRequest: TimelineUserConversationRow["turnRequest"]; } @@ -164,7 +166,9 @@ interface UserConversationMessageProps { systemMessageKind: TimelineUserConversationRow["systemMessageKind"]; systemMessageSubject: TimelineUserConversationRow["systemMessageSubject"]; text: string; + threadId?: string; turnRequest: TimelineUserConversationRow["turnRequest"]; + workspaceRootPath?: string; } interface AssistantConversationMessageProps extends AssistantMessageRowIdentity { @@ -188,19 +192,19 @@ interface AssistantConversationMessageProps extends AssistantMessageRowIdentity } interface CollapsibleMessageTextProps { + linkRouting?: MarkdownLinkRouting; mentions: readonly PromptTextMention[]; resolveMentionLink?: PromptMentionLinkResolver; resolveSegmentLinkHref?: TimelineTitleLinkResolver; - onOpenLink?: ThreadTimelineLinkHandler; text: string; mutePrefixLength?: number; } function CollapsibleMessageText({ + linkRouting, mentions, resolveMentionLink, resolveSegmentLinkHref, - onOpenLink, text, mutePrefixLength, }: CollapsibleMessageTextProps) { @@ -244,11 +248,6 @@ function CollapsibleMessageText({ }), [body.mentions], ); - const linkRouting = useMemo( - () => (onOpenLink ? { onOpenLink } : undefined), - [onOpenLink], - ); - const isOverflowing = useIsOverflowing({ elementRef: bodyRef, enabled: !isExpanded, @@ -347,8 +346,20 @@ function UserConversationMessage({ systemMessageKind, systemMessageSubject, text, + threadId, turnRequest, + workspaceRootPath, }: UserConversationMessageProps) { + const linkRouting = useMemo( + () => + buildMarkdownMessageLinkRouting({ + onOpenLink, + onOpenLocalFileLink, + threadId, + workspaceRootPath, + }), + [onOpenLink, onOpenLocalFileLink, threadId, workspaceRootPath], + ); if (initiator === "agent" && senderThreadId !== null) { const body = generatedConversationBodySlice({ initiator, text }); const bodyMentions = shiftMentionsToTextRange({ @@ -377,7 +388,9 @@ function UserConversationMessage({ systemMessageKind={systemMessageKind} systemMessageSubject={systemMessageSubject} text={body.text} + threadId={threadId} turnRequest={turnRequest} + workspaceRootPath={workspaceRootPath} /> ); } @@ -408,7 +421,9 @@ function UserConversationMessage({ systemMessageKind={systemMessageKind} systemMessageSubject={systemMessageSubject} text={body.text} + threadId={threadId} turnRequest={turnRequest} + workspaceRootPath={workspaceRootPath} /> ); } @@ -436,7 +451,7 @@ function UserConversationMessage({ mentions={mentions} resolveMentionLink={resolveMentionLink} resolveSegmentLinkHref={resolveSegmentLinkHref} - onOpenLink={onOpenLink} + linkRouting={linkRouting} text={text} mutePrefixLength={mutePrefixLength || undefined} /> @@ -494,41 +509,16 @@ function AssistantConversationMessage({ () => (streaming ? splitStreamingMarkdown(text) : null), [streaming, text], ); - const linkRouting = useMemo(() => { - const localImage: NonNullable = { - absolutePaths: { - kind: "trusted-host", - }, - resolveSrc: ({ path }) => buildThreadHostFileContentUrl(threadId, path), - }; - const routing: MarkdownLinkRouting = { - localImage, - }; - if (workspaceRootPath !== undefined) { - localImage.relativePaths = { - baseDir: workspaceRootPath, - rootPath: workspaceRootPath, - }; - } - if (onOpenLink) { - routing.onOpenLink = onOpenLink; - } - if (onOpenLocalFileLink) { - routing.localFile = { - absoluteLinks: { - kind: "trusted-host", - }, - onOpenLink: onOpenLocalFileLink, - }; - if (workspaceRootPath !== undefined) { - routing.localFile.relativeLinks = { - baseDir: workspaceRootPath, - rootPath: workspaceRootPath, - }; - } - } - return routing; - }, [onOpenLink, onOpenLocalFileLink, threadId, workspaceRootPath]); + const linkRouting = useMemo( + () => + buildMarkdownMessageLinkRouting({ + onOpenLink, + onOpenLocalFileLink, + threadId, + workspaceRootPath, + }), + [onOpenLink, onOpenLocalFileLink, threadId, workspaceRootPath], + ); const messageDirectiveRegistry = useMessageDirectiveRegistry(); const openDirectiveWorkspaceFile = useMemo< @@ -689,7 +679,9 @@ export function ConversationMessageContent( systemMessageKind={props.systemMessageKind} systemMessageSubject={props.systemMessageSubject} text={text} + threadId={props.threadId} turnRequest={props.turnRequest} + workspaceRootPath={props.workspaceRootPath} /> ); } diff --git a/apps/app/src/components/thread/timeline/GeneratedConversationMessage.test.tsx b/apps/app/src/components/thread/timeline/GeneratedConversationMessage.test.tsx index 0778395de9..fe391a9fac 100644 --- a/apps/app/src/components/thread/timeline/GeneratedConversationMessage.test.tsx +++ b/apps/app/src/components/thread/timeline/GeneratedConversationMessage.test.tsx @@ -72,7 +72,9 @@ function renderChildCompleted(text = MARKDOWN_BODY) { attachments={null} mentions={mentions} text={text} + threadId="thr_parent" turnRequest={{ kind: "message", status: "accepted" }} + workspaceRootPath="/workspace" projectId="proj_demo" /> @@ -86,6 +88,18 @@ afterEach(() => { vi.unstubAllGlobals(); }); +describe("GeneratedConversationMessage images", () => { + it("routes images in generated system messages through the current thread", () => { + renderChildCompleted("![report](reports/result.png)"); + + expect( + screen.getByRole("img", { name: "report" }).getAttribute("src"), + ).toBe( + "/api/v1/threads/thr_parent/host-files/content?path=%2Fworkspace%2Freports%2Fresult.png", + ); + }); +}); + const AGENT_BODY = "# notes\nedited path:src/app.ts here"; const AGENT_PATH_TOKEN = "path:src/app.ts"; const AGENT_PATH_START = AGENT_BODY.indexOf(AGENT_PATH_TOKEN); diff --git a/apps/app/src/components/thread/timeline/GeneratedConversationMessage.tsx b/apps/app/src/components/thread/timeline/GeneratedConversationMessage.tsx index e625897c79..bde0dde017 100644 --- a/apps/app/src/components/thread/timeline/GeneratedConversationMessage.tsx +++ b/apps/app/src/components/thread/timeline/GeneratedConversationMessage.tsx @@ -10,6 +10,7 @@ import type { TimelineTitle, TimelineTitleSegment } from "@bb/thread-view"; import { type IconName } from "@bb/shared-ui/icon"; import { MarkdownPreview } from "@/components/ui/markdown-preview.js"; import type { MarkdownLinkRouting } from "@/components/ui/markdown-link-routing.js"; +import { buildMarkdownMessageLinkRouting } from "@/components/ui/markdown-message-link-routing"; import { cn } from "@bb/shared-ui/lib/utils"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; import { @@ -62,7 +63,9 @@ interface GeneratedConversationMessageProps { systemMessageKind: SystemMessageKind; systemMessageSubject: SystemMessageSubject | null; text: string; + threadId?: string; turnRequest: TimelineUserConversationRow["turnRequest"]; + workspaceRootPath?: string; } type GeneratedConversationSourceKind = "agent" | "system"; @@ -447,7 +450,9 @@ export const GeneratedConversationMessage = memo( systemMessageKind, systemMessageSubject, text, + threadId, turnRequest, + workspaceRootPath, }: GeneratedConversationMessageProps) { const trimStartLength = text.length - text.trimStart().length; const messageText = text.trim(); @@ -461,9 +466,16 @@ export const GeneratedConversationMessage = memo( [mentions, messageText.length, trimStartLength], ); const requestLabel = turnRequestLabel(turnRequest); - const linkRouting = useMemo(() => { - return onOpenLink === undefined ? undefined : { onOpenLink }; - }, [onOpenLink]); + const linkRouting = useMemo( + () => + buildMarkdownMessageLinkRouting({ + onOpenLink, + onOpenLocalFileLink, + threadId, + workspaceRootPath, + }), + [onOpenLink, onOpenLocalFileLink, threadId, workspaceRootPath], + ); const title = useMemo( () => generatedConversationTitle({ diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineNavigationContext.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineNavigationContext.tsx index 8609700c78..281dff8f9d 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineNavigationContext.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineNavigationContext.tsx @@ -10,6 +10,7 @@ interface ThreadTimelineNavigation { onOpenLink: ThreadTimelineLinkHandler; onOpenLocalFileLink: ThreadTimelineLocalFileLinkHandler; resolveMentionLink: PromptMentionLinkResolver; + threadId?: string; workspaceRootPath: string | undefined; } @@ -22,6 +23,7 @@ export function ThreadTimelineNavigationProvider({ onOpenLink, onOpenLocalFileLink, resolveMentionLink, + threadId, workspaceRootPath, }: ThreadTimelineNavigation & { children: ReactNode }) { const navigation = useMemo( @@ -30,6 +32,7 @@ export function ThreadTimelineNavigationProvider({ onOpenLink, onOpenLocalFileLink, resolveMentionLink, + threadId, workspaceRootPath, }), [ @@ -37,6 +40,7 @@ export function ThreadTimelineNavigationProvider({ onOpenLink, onOpenLocalFileLink, resolveMentionLink, + threadId, workspaceRootPath, ], ); diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx index ec23d4f8ac..7beec17018 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx @@ -1023,7 +1023,9 @@ const ConversationRowContent = memo(function ConversationRowContent({ systemMessageSubject={row.systemMessageSubject} pluginActions={rowPluginActions} text={row.text} + threadId={row.threadId} turnRequest={row.turnRequest} + workspaceRootPath={workspaceRootPath} /> ); } diff --git a/apps/app/src/components/tools/SkillDetailView.tsx b/apps/app/src/components/tools/SkillDetailView.tsx index 3613959968..366fcef4e0 100644 --- a/apps/app/src/components/tools/SkillDetailView.tsx +++ b/apps/app/src/components/tools/SkillDetailView.tsx @@ -20,6 +20,7 @@ import { import { FilePreview } from "@/components/secondary-panel/FilePreview.js"; import { ProvenancePill } from "@/components/tools/ProvenancePill"; import { useClipboardCopy } from "@/lib/clipboard"; +import type { MarkdownLinkRouting } from "@/components/ui/markdown-link-routing"; type SkillDetailTitleBadge = { label: string; @@ -45,6 +46,7 @@ interface SkillDetailViewProps { onSelectFile: (path: string) => void; contentState: SkillDetailContentState; footer?: ReactNode; + markdownLinkRouting?: MarkdownLinkRouting; } function SkillPath({ path, href }: { path: string; href?: string }) { @@ -160,10 +162,12 @@ function ScrollingSkillContent({ path, content, markdown, + markdownLinkRouting, }: { path: string; content: string; markdown: boolean; + markdownLinkRouting?: MarkdownLinkRouting; }) { const chunks = useMemo( () => (markdown ? splitMarkdownIntoChunks(content) : [content]), @@ -183,6 +187,7 @@ function ScrollingSkillContent({ key={index} path={path} headerMode="none" + markdownLinkRouting={markdownLinkRouting} state={{ kind: "ready", file: { @@ -220,6 +225,7 @@ export function SkillDetailView({ onSelectFile, contentState, footer, + markdownLinkRouting, }: SkillDetailViewProps) { const directoryPath = getSkillDirectoryPath(path); const selectedDisplayPath = formatHomePathForDisplay(selectedPath); @@ -292,6 +298,7 @@ export function SkillDetailView({ path={selectedPath} content={contentState.content} markdown={selectedFileIsMarkdown} + markdownLinkRouting={markdownLinkRouting} /> )} diff --git a/apps/app/src/components/tools/SkillsCollection.tsx b/apps/app/src/components/tools/SkillsCollection.tsx index 3d172cc3d7..40bd79010c 100644 --- a/apps/app/src/components/tools/SkillsCollection.tsx +++ b/apps/app/src/components/tools/SkillsCollection.tsx @@ -31,6 +31,7 @@ import { skillScopeLabel } from "@/components/tools/skill-taxonomy"; import type { ProviderInfo } from "@bb/domain"; import { ProviderIconMark } from "@/components/settings/ProviderIconMark"; import { getProviderIconInfo } from "@/lib/provider-icon"; +import type { MarkdownLinkRouting } from "@/components/ui/markdown-link-routing"; type ResourceProviderFilter = "bb" | SkillProvider; export type ProviderRoster = ReadonlyMap; @@ -609,6 +610,7 @@ interface SkillDetailDialogViewProps { canDelete: boolean; canOpenInEditor: boolean; isDeleting: boolean; + markdownLinkRouting?: MarkdownLinkRouting; onEdit: () => void; onRetry: () => void; onDelete: () => void; @@ -628,6 +630,7 @@ export function SkillDetailDialogView({ canDelete, canOpenInEditor, isDeleting, + markdownLinkRouting, onEdit, onRetry, onDelete, @@ -728,6 +731,7 @@ export function SkillDetailDialogView({ : undefined } files={files.length > 0 ? files : ["SKILL.md"]} + markdownLinkRouting={markdownLinkRouting} selectedPath={selectedPath} onSelectFile={onSelectPath} contentState={ diff --git a/apps/app/src/components/tools/SkillsLibrary.tsx b/apps/app/src/components/tools/SkillsLibrary.tsx index f6542c04b4..da991ce9e0 100644 --- a/apps/app/src/components/tools/SkillsLibrary.tsx +++ b/apps/app/src/components/tools/SkillsLibrary.tsx @@ -26,6 +26,10 @@ import { import { useSystemProviders } from "@/hooks/queries/system-queries"; import { isSkillEditable } from "@/components/tools/skill-taxonomy"; import { CREATE_SKILL_PROMPT } from "@bb/client-core"; +import { usePrimaryHost } from "@/hooks/queries/host-queries"; +import { useHostFilePreview } from "@/hooks/queries/host-file-preview-query"; +import { getAbsoluteDirname } from "@/lib/absolute-file-path"; +import { buildMarkdownLeaseImageRouting } from "@/components/ui/markdown-file-image-routing"; import { buildRegistrySkillReferencePrompt, fetchRegistrySkillDetail, @@ -84,6 +88,14 @@ function SkillDetailPage({ }, [skill?.id]); const filesQuery = useSkillFiles(projectId, skill); const contentQuery = useSkillContent(projectId, skill, selectedPath); + const primaryHost = usePrimaryHost({ enabled: skill !== null }); + const previewHostId = + primaryHost?.status === "connected" ? primaryHost.id : null; + const skillFilePreview = useHostFilePreview( + previewHostId, + skill?.filePath ?? null, + { enabled: skill !== null && previewHostId !== null }, + ); const deleteSkill = useDeleteSkill(projectId); const { canOpenPreferredFileTarget, openPathInPreferredFileTarget } = useLocalOpenTargets({ enabled: skill !== null }); @@ -92,6 +104,14 @@ function SkillDetailPage({ skill && skill.manageable && isSkillEditable(skill) ? skill.scope : null; const editableScope: EditableSkillScope | null = skill && isSkillEditable(skill) ? skill.scope : null; + const markdownLinkRouting = useMemo(() => { + if (skill === null) return undefined; + return buildMarkdownLeaseImageRouting({ + path: selectedPath, + rootPath: getAbsoluteDirname({ path: skill.filePath }), + previewUrl: skillFilePreview.data?.url, + }); + }, [selectedPath, skill, skillFilePreview.data?.url]); return ( { if (skill) onEdit(skill); }} diff --git a/apps/app/src/components/tools/detail-page-recipes.test.tsx b/apps/app/src/components/tools/detail-page-recipes.test.tsx index 67fad9be5a..ef6d19d06d 100644 --- a/apps/app/src/components/tools/detail-page-recipes.test.tsx +++ b/apps/app/src/components/tools/detail-page-recipes.test.tsx @@ -95,6 +95,7 @@ import { makePluginListItem, makePluginRegistrationSet, } from "@/test/fixtures/plugins"; +import { buildMarkdownFileImageRouting } from "@/components/ui/markdown-file-image-routing"; afterEach(() => { cleanup(); @@ -611,6 +612,33 @@ function renderSkill(files: readonly string[]) { } describe("Skill detail recipe", () => { + it("routes relative images from Markdown skill files", () => { + const markdownLinkRouting = buildMarkdownFileImageRouting({ + path: "/skills/writing-voice/SKILL.md", + rootPath: "/skills/writing-voice", + threadId: null, + resolveRelativeSrc: (path) => `/skill-preview/${path}`, + }); + render( + {}} + contentState={{ + kind: "ready", + content: "![example](assets/example.png)", + }} + markdownLinkRouting={markdownLinkRouting} + />, + ); + + expect( + screen.getByRole("img", { name: "example" }).getAttribute("src"), + ).toBe("/skill-preview/assets/example.png"); + }); + it("shows only Definition for a single-file skill", () => { const { container } = renderSkill(["/skills/writing-voice/SKILL.md"]); diff --git a/apps/app/src/components/ui/markdown-file-image-routing.test.tsx b/apps/app/src/components/ui/markdown-file-image-routing.test.tsx new file mode 100644 index 0000000000..864d0656dd --- /dev/null +++ b/apps/app/src/components/ui/markdown-file-image-routing.test.tsx @@ -0,0 +1,169 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FilePreview } from "@/components/secondary-panel/FilePreview"; +import { + buildMarkdownFileImageRouting, + buildMarkdownLeaseImageRouting, +} from "./markdown-file-image-routing"; +import type { MarkdownLinkRouting } from "./markdown-link-routing"; +import { + buildThreadStorageRawContentUrl, + buildThreadWorktreeRawContentUrl, +} from "@/lib/file-content-urls"; + +afterEach(cleanup); + +function renderMarkdownFilePreview({ + content, + imageContent, + path, + rootPath, +}: { + content: string; + imageContent: { + kind: "thread-storage" | "worktree"; + threadId: string; + }; + path: string; + rootPath: string; +}) { + render( + + imageContent.kind === "thread-storage" + ? buildThreadStorageRawContentUrl( + imageContent.threadId, + relativePath, + ) + : buildThreadWorktreeRawContentUrl( + imageContent.threadId, + relativePath, + ), + rootPath, + })} + path={path} + state={{ + kind: "ready", + file: { contents: content, name: path }, + lineRange: null, + textPreviewKind: "markdown", + }} + />, + ); +} + +describe("Markdown file preview image routing", () => { + it("preserves explicit image routing and file-link handlers", () => { + const linkRouting: MarkdownLinkRouting = { + onOpenLink: vi.fn(() => false), + localImage: { + absolutePaths: { kind: "trusted-host" }, + resolveSrc: vi.fn(() => "/custom/image.png"), + }, + }; + expect( + buildMarkdownFileImageRouting({ + path: "docs/report.md", + rootPath: "/workspace", + threadId: "thr_preview", + linkRouting, + resolveRelativeSrc: vi.fn(), + }), + ).toBe(linkRouting); + }); + + it("resolves nested skill and host files within their shared preview lease", () => { + render( + , + ); + for (const name of ["relative", "absolute"]) { + expect(screen.getByRole("img", { name }).getAttribute("src")).toBe( + "/api/v1/file-previews/lease_skill/assets/chart.png", + ); + } + expect( + screen.getByRole("img", { name: "escape" }).getAttribute("src"), + ).toBe("../../outside.png"); + }); + + it("routes absolute and file-relative images in thread-storage Markdown previews", () => { + renderMarkdownFilePreview({ + content: [ + "![absolute](/Users/me/.bb/thread-storage/thr_preview/generated.png)", + "![relative](screenshots/chart.png)", + ].join("\n\n"), + imageContent: { kind: "thread-storage", threadId: "thr_preview" }, + path: "reports/nested/report.md", + rootPath: "/Users/me/.bb/thread-storage/thr_preview", + }); + + expect( + screen.getByRole("img", { name: "absolute" }).getAttribute("src"), + ).toBe( + "/api/v1/threads/thr_preview/host-files/content?path=%2FUsers%2Fme%2F.bb%2Fthread-storage%2Fthr_preview%2Fgenerated.png", + ); + expect( + screen.getByRole("img", { name: "relative" }).getAttribute("src"), + ).toBe( + "/api/v1/threads/thr_preview/thread-storage/files/reports/nested/screenshots/chart.png", + ); + }); + + it("routes absolute and file-relative images in workspace Markdown previews", () => { + renderMarkdownFilePreview({ + content: [ + "![absolute](/Users/me/project/generated.png)", + "![relative](../assets/chart.png)", + ].join("\n\n"), + imageContent: { kind: "worktree", threadId: "thr_preview" }, + path: "docs/guides/report.md", + rootPath: "/Users/me/project", + }); + + expect( + screen.getByRole("img", { name: "absolute" }).getAttribute("src"), + ).toBe( + "/api/v1/threads/thr_preview/host-files/content?path=%2FUsers%2Fme%2Fproject%2Fgenerated.png", + ); + expect( + screen.getByRole("img", { name: "relative" }).getAttribute("src"), + ).toBe("/api/v1/threads/thr_preview/worktree/files/docs/assets/chart.png"); + }); + + it("does not rewrite relative images that escape the workspace root", () => { + renderMarkdownFilePreview({ + content: "![escape](../../outside.png)", + imageContent: { kind: "worktree", threadId: "thr_preview" }, + path: "docs/report.md", + rootPath: "/Users/me/project", + }); + + expect( + screen.getByRole("img", { name: "escape" }).getAttribute("src"), + ).toBe("../../outside.png"); + }); +}); diff --git a/apps/app/src/components/ui/markdown-file-image-routing.ts b/apps/app/src/components/ui/markdown-file-image-routing.ts new file mode 100644 index 0000000000..7b6f0f13a7 --- /dev/null +++ b/apps/app/src/components/ui/markdown-file-image-routing.ts @@ -0,0 +1,80 @@ +import type { MarkdownLinkRouting } from "./markdown-link-routing"; +import { + getAbsoluteDirname, + buildAbsoluteFilePath, + normalizeAbsoluteFilePath, +} from "@/lib/absolute-file-path"; +import { + buildFilePreviewLeaseContentUrl, + buildThreadHostFileContentUrl, + getFilePreviewLeaseBaseUrl, +} from "@/lib/file-content-urls"; + +const ROUTE_ROOT = "/__bb_markdown_file_root__"; + +export function buildMarkdownFileImageRouting({ + path, + rootPath, + threadId, + linkRouting, + resolveRelativeSrc, +}: { + path: string; + rootPath: string | null; + threadId: string | null; + linkRouting?: MarkdownLinkRouting; + resolveRelativeSrc: ( + rootRelativePath: string, + absolutePath: string, + ) => string; +}): MarkdownLinkRouting | undefined { + if (linkRouting?.localImage !== undefined) return linkRouting; + const root = normalizeAbsoluteFilePath({ path: rootPath ?? ROUTE_ROOT }); + if (root === null) return linkRouting; + const filePath = buildAbsoluteFilePath({ + path: rootPath === null ? path.replace(/^\/+/, "") : path, + rootPath: root, + }); + return { + ...linkRouting, + localImage: { + absolutePaths: + threadId === null + ? { kind: "contained", rootPath: root } + : { kind: "trusted-host" }, + relativePaths: { + baseDir: getAbsoluteDirname({ path: filePath }), + rootPath: root, + }, + resolveSrc: (image, sourceKind) => { + if (sourceKind === "absolute" && threadId !== null) { + return buildThreadHostFileContentUrl(threadId, image.path); + } + return resolveRelativeSrc( + image.path.slice(root === "/" ? 1 : root.length + 1), + image.path, + ); + }, + }, + }; +} + +export function buildMarkdownLeaseImageRouting({ + path, + rootPath, + previewUrl, +}: { + path: string; + rootPath: string; + previewUrl: string | undefined; +}): MarkdownLinkRouting | undefined { + const baseUrl = getFilePreviewLeaseBaseUrl(previewUrl ?? ""); + if (baseUrl === null) return undefined; + return buildMarkdownFileImageRouting({ + path, + rootPath, + threadId: null, + resolveRelativeSrc: (relativePath) => + buildFilePreviewLeaseContentUrl(baseUrl, relativePath), + }); +} diff --git a/apps/app/src/components/ui/markdown-link-routing.ts b/apps/app/src/components/ui/markdown-link-routing.ts index 382d40ccae..fc3a2f8602 100644 --- a/apps/app/src/components/ui/markdown-link-routing.ts +++ b/apps/app/src/components/ui/markdown-link-routing.ts @@ -50,7 +50,10 @@ export interface MarkdownLocalFileLinkRouting { export interface MarkdownLocalImageRouting { absolutePaths: MarkdownAbsoluteLocalFileLinkRouting; relativePaths?: MarkdownRelativeLocalFileLinkRouting; - resolveSrc: (image: MarkdownPreviewLocalFileLink) => string; + resolveSrc: ( + image: MarkdownPreviewLocalFileLink, + sourceKind: "absolute" | "relative", + ) => string; } export interface MarkdownLinkRouting { diff --git a/apps/app/src/components/ui/markdown-message-link-routing.ts b/apps/app/src/components/ui/markdown-message-link-routing.ts new file mode 100644 index 0000000000..d868a6ae64 --- /dev/null +++ b/apps/app/src/components/ui/markdown-message-link-routing.ts @@ -0,0 +1,62 @@ +import type { + MarkdownLinkRouting, + MarkdownLocalFileLinkRouting, +} from "./markdown-link-routing.js"; +import type { MarkdownPreviewLinkHandler } from "./markdown-link.js"; +import type { MarkdownPreviewLocalFileLinkHandler } from "./markdown-local-file-link.js"; +import { buildThreadHostFileContentUrl } from "@/lib/file-content-urls"; + +interface BuildMarkdownMessageLinkRoutingArgs { + onOpenLink?: MarkdownPreviewLinkHandler; + onOpenLocalFileLink?: MarkdownPreviewLocalFileLinkHandler; + threadId?: string; + workspaceRootPath?: string; +} + +export function buildMarkdownMessageLinkRouting({ + onOpenLink, + onOpenLocalFileLink, + threadId, + workspaceRootPath, +}: BuildMarkdownMessageLinkRoutingArgs): MarkdownLinkRouting | undefined { + if ( + onOpenLink === undefined && + onOpenLocalFileLink === undefined && + threadId === undefined + ) { + return undefined; + } + + const routing: MarkdownLinkRouting = {}; + if (onOpenLink !== undefined) { + routing.onOpenLink = onOpenLink; + } + if (threadId !== undefined) { + routing.localImage = { + absolutePaths: { kind: "trusted-host" }, + resolveSrc: ({ path }) => buildThreadHostFileContentUrl(threadId, path), + ...(workspaceRootPath === undefined + ? {} + : { + relativePaths: { + baseDir: workspaceRootPath, + rootPath: workspaceRootPath, + }, + }), + }; + } + if (onOpenLocalFileLink !== undefined) { + const localFile: MarkdownLocalFileLinkRouting = { + absoluteLinks: { kind: "trusted-host" }, + onOpenLink: onOpenLocalFileLink, + }; + if (workspaceRootPath !== undefined) { + localFile.relativeLinks = { + baseDir: workspaceRootPath, + rootPath: workspaceRootPath, + }; + } + routing.localFile = localFile; + } + return routing; +} diff --git a/apps/app/src/components/ui/markdown-preview.tsx b/apps/app/src/components/ui/markdown-preview.tsx index 77354be0e5..a8c5dc54da 100644 --- a/apps/app/src/components/ui/markdown-preview.tsx +++ b/apps/app/src/components/ui/markdown-preview.tsx @@ -166,6 +166,11 @@ interface BuildLocalAwareUrlTransformArgs { localImageRouting: MarkdownLocalImageRouting | undefined; } +interface ResolvedMarkdownLocalPath { + image: MarkdownPreviewLocalFileLink; + sourceKind: "absolute" | "relative"; +} + interface MarkdownImageRendererArgs { alt: ComponentPropsWithoutRef<"img">["alt"]; imageAttributes: MarkdownImageRenderAttributes; @@ -451,25 +456,38 @@ function resolveMarkdownLocalPath( value: string, absolutePaths: MarkdownAbsoluteLocalFileLinkRouting, relativePaths: MarkdownRelativeLocalFileLinkRouting | undefined, -): MarkdownPreviewLocalFileLink | null { +): ResolvedMarkdownLocalPath | null { const absolutePath = parseLocalFileHref({ absoluteLinks: absolutePaths, href: value, }); - if (absolutePath !== null || relativePaths === undefined) { - return absolutePath; + if (absolutePath !== null) { + return { + image: absolutePath, + sourceKind: "absolute", + }; + } + if (relativePaths === undefined) { + return null; } const resolvedHref = resolveRelativeLocalFileHref({ href: value, ...relativePaths, }); - return resolvedHref === null + if (resolvedHref === null) { + return null; + } + const relativePath = parseLocalFileHref({ + absoluteLinks: absolutePaths, + href: resolvedHref, + }); + return relativePath === null ? null - : parseLocalFileHref({ - absoluteLinks: absolutePaths, - href: resolvedHref, - }); + : { + image: relativePath, + sourceKind: "relative", + }; } function buildLocalAwareUrlTransform({ @@ -512,7 +530,10 @@ function buildLocalAwareUrlTransform({ localImageRouting.relativePaths, ); if (localImage !== null) { - return localImageRouting.resolveSrc(localImage); + return localImageRouting.resolveSrc( + localImage.image, + localImage.sourceKind, + ); } } diff --git a/apps/app/src/lib/file-content-urls.test.ts b/apps/app/src/lib/file-content-urls.test.ts new file mode 100644 index 0000000000..1a394c2dee --- /dev/null +++ b/apps/app/src/lib/file-content-urls.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { getFilePreviewLeaseBaseUrl } from "./file-content-urls"; + +describe("getFilePreviewLeaseBaseUrl", () => { + it.each([ + [ + "/api/v1/file-previews/lease-1/readme.md", + "/api/v1/file-previews/lease-1", + ], + [ + "https://bb.test/api/v1/file-previews/lease-2/docs/readme.md", + "https://bb.test/api/v1/file-previews/lease-2", + ], + ["/workspace/file-previews/not-a-lease/readme.md", null], + ])("extracts only a file-preview API lease from %s", (url, expected) => { + expect(getFilePreviewLeaseBaseUrl(url)).toBe(expected); + }); +}); diff --git a/apps/app/src/lib/file-content-urls.ts b/apps/app/src/lib/file-content-urls.ts index a805b87d9e..e1b3cb43d2 100644 --- a/apps/app/src/lib/file-content-urls.ts +++ b/apps/app/src/lib/file-content-urls.ts @@ -99,3 +99,18 @@ export function buildEnvironmentDiffFileContentUrl( }), ); } + +export function getFilePreviewLeaseBaseUrl(url: string): string | null { + return ( + /^((?:https?:\/\/[^/?#]+)?\/api\/v1\/file-previews\/[^/?#]+)(?:\/|$)/u.exec( + url, + )?.[1] ?? null + ); +} + +export function buildFilePreviewLeaseContentUrl( + baseUrl: string, + path: string, +): string { + return `${baseUrl.replace(/\/+$/u, "")}/${encodePathSegments(path)}`; +} diff --git a/apps/app/src/lib/plugin-sdk-app-impl.test.tsx b/apps/app/src/lib/plugin-sdk-app-impl.test.tsx index 5237c9ac99..d2830e2851 100644 --- a/apps/app/src/lib/plugin-sdk-app-impl.test.tsx +++ b/apps/app/src/lib/plugin-sdk-app-impl.test.tsx @@ -126,9 +126,10 @@ describe("plugin SDK Markdown", () => { onOpenLink={onOpenLink} onOpenLocalFileLink={onOpenLocalFileLink} resolveMentionLink={() => null} + threadId="thr_plugin" workspaceRootPath="/workspace" > - + , ); @@ -140,6 +141,9 @@ describe("plugin SDK Markdown", () => { lineRange: null, path: "/workspace/README.md", }); + expect(screen.getByRole("img", { name: "chart" }).getAttribute("src")).toBe( + "/api/v1/threads/thr_plugin/host-files/content?path=%2Fworkspace%2Fimages%2Fchart.png", + ); fireEvent.click(screen.getByRole("link", { name: "the docs" })); expect(openUrl).toHaveBeenCalledWith({ diff --git a/apps/app/src/lib/plugin-sdk-app-impl.tsx b/apps/app/src/lib/plugin-sdk-app-impl.tsx index 176e4fb0d4..6df291901f 100644 --- a/apps/app/src/lib/plugin-sdk-app-impl.tsx +++ b/apps/app/src/lib/plugin-sdk-app-impl.tsx @@ -9,10 +9,8 @@ import { PluginThreadChat } from "@/components/plugin/PluginThreadChat"; import { PluginUrlLink } from "@/components/plugin/PluginUrlLink"; import { ExperimentalFileLink } from "@/components/plugin/ExperimentalFileLink"; import { MarkdownPreview } from "@/components/ui/markdown-preview"; -import type { - MarkdownLinkRouting, - MarkdownLocalFileLinkRouting, -} from "@/components/ui/markdown-link-routing"; +import type { MarkdownLinkRouting } from "@/components/ui/markdown-link-routing"; +import { buildMarkdownMessageLinkRouting } from "@/components/ui/markdown-message-link-routing"; import type { MarkdownPreviewLinkHandler } from "@/components/ui/markdown-link"; import { useThreadTimelineNavigation } from "@/components/thread/timeline/ThreadTimelineNavigationContext"; import { definePluginApp } from "./plugin-app-definition"; @@ -74,6 +72,7 @@ export const pluginSdkAppImplementation = installDeprecatedAliases( function PluginMarkdown({ content, className }: MarkdownProps) { const timelineNavigation = useThreadTimelineNavigation(); const onOpenLocalFileLink = timelineNavigation?.onOpenLocalFileLink; + const threadId = timelineNavigation?.threadId; const workspaceRootPath = timelineNavigation?.workspaceRootPath; const navigation = useAppNavigationHost(); const onOpenLink = useCallback( @@ -81,21 +80,15 @@ function PluginMarkdown({ content, className }: MarkdownProps) { [navigation], ); const linkRouting = useMemo(() => { - if (onOpenLocalFileLink === undefined) { - return { onOpenLink }; - } - const localFile: MarkdownLocalFileLinkRouting = { - absoluteLinks: { kind: "trusted-host" }, - onOpenLink: onOpenLocalFileLink, - }; - if (workspaceRootPath !== undefined) { - localFile.relativeLinks = { - baseDir: workspaceRootPath, - rootPath: workspaceRootPath, - }; - } - return { localFile, onOpenLink }; - }, [onOpenLink, onOpenLocalFileLink, workspaceRootPath]); + return ( + buildMarkdownMessageLinkRouting({ + onOpenLink, + onOpenLocalFileLink, + threadId, + workspaceRootPath, + }) ?? { onOpenLink } + ); + }, [onOpenLink, onOpenLocalFileLink, threadId, workspaceRootPath]); return ( ) : projectPreviewId !== null ? ( ) : ( { + const headers = new Headers({ + "cache-control": "no-store", + "x-content-type-options": "nosniff", + }); + if (isHtmlMimeType(result.mimeType)) { + assertRawFilesystemHtmlPreviewResult(result); + headers.set("content-security-policy", HTML_PREVIEW_CSP); + headers.set("content-type", HTML_PREVIEW_CONTENT_TYPE); + } + return createDaemonFileContentResponse(result, { headers }); + }, + ); }); } diff --git a/apps/server/src/routes/projects.ts b/apps/server/src/routes/projects.ts index ab1c464a7b..3c5eb78e6d 100644 --- a/apps/server/src/routes/projects.ts +++ b/apps/server/src/routes/projects.ts @@ -61,7 +61,7 @@ import { } from "../services/skills/skill-listing.js"; import { createDaemonFileContentResponse, - remapDaemonFileRouteError, + serveDaemonFileContent, requestMatchesEntityTag, } from "../services/hosts/daemon-file-response.js"; import { parseBoundedPositiveOptionalInteger } from "../services/lib/validation.js"; @@ -648,23 +648,19 @@ export function registerProjectRoutes(app: Hono, deps: AppDeps): void { }); const filePath = parseSafeRelativeRoutePath(query.path); - try { - const result = await callHostRetryableOnlineRpc(deps, { + return serveDaemonFileContent( + deps, + { hostId: target.hostId, - timeoutMs: COMMAND_TIMEOUT_MS, - command: { - type: "host.read_file", - path: path.join(target.path, filePath.relativePath), - rootPath: target.path, - }, - }); - return createDaemonFileContentResponse(result, { - headers: { "x-bb-content-encoding": result.contentEncoding }, - ifNoneMatch: context.req.header("if-none-match"), - }); - } catch (error) { - return remapDaemonFileRouteError(error); - } + path: path.join(target.path, filePath.relativePath), + rootPath: target.path, + }, + (result) => + createDaemonFileContentResponse(result, { + headers: { "x-bb-content-encoding": result.contentEncoding }, + ifNoneMatch: context.req.header("if-none-match"), + }), + ); }); get(routes.paths, async (context, query) => { diff --git a/apps/server/src/routes/threads/data.ts b/apps/server/src/routes/threads/data.ts index a563270281..d520636c6e 100644 --- a/apps/server/src/routes/threads/data.ts +++ b/apps/server/src/routes/threads/data.ts @@ -38,7 +38,7 @@ import { callHostRetryableOnlineRpc } from "../../services/hosts/online-rpc.js"; import { createDaemonFileContentResponse, type DaemonFileReadResult, - remapDaemonFileRouteError, + serveDaemonFileContent, } from "../../services/hosts/daemon-file-response.js"; import { requireThreadStoragePath } from "../../services/threads/thread-storage.js"; import { toThreadQueuedMessage } from "../../services/threads/thread-queued-messages.js"; @@ -248,20 +248,15 @@ async function serveThreadStorageRawFile( const filePath = parseSafeRelativeRoutePath(rawPath); const target = await requireThreadStorageTarget(deps, { threadId }); - try { - const result = await callHostRetryableOnlineRpc(deps, { + return serveDaemonFileContent( + deps, + { hostId: target.hostId, - timeoutMs: COMMAND_TIMEOUT_MS, - command: { - type: "host.read_file", - path: path.join(target.storagePath, filePath.relativePath), - rootPath: target.storagePath, - }, - }); - return createRawFilePreviewResponse(result, filePath.relativePath); - } catch (error) { - return remapDaemonFileRouteError(error); - } + path: path.join(target.storagePath, filePath.relativePath), + rootPath: target.storagePath, + }, + (result) => createRawFilePreviewResponse(result, filePath.relativePath), + ); } async function serveThreadWorktreeRawFile( @@ -276,20 +271,15 @@ async function serveThreadWorktreeRawFile( } const environment = requireReadyEnvironment(deps.db, thread.environmentId); - try { - const result = await callHostRetryableOnlineRpc(deps, { + return serveDaemonFileContent( + deps, + { hostId: environment.hostId, - timeoutMs: COMMAND_TIMEOUT_MS, - command: { - type: "host.read_file", - path: path.join(environment.path, filePath.relativePath), - rootPath: environment.path, - }, - }); - return createRawFilePreviewResponse(result, filePath.relativePath); - } catch (error) { - return remapDaemonFileRouteError(error); - } + path: path.join(environment.path, filePath.relativePath), + rootPath: environment.path, + }, + (result) => createRawFilePreviewResponse(result, filePath.relativePath), + ); } export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void { @@ -653,22 +643,18 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void { threadId: context.req.param("id"), }); - try { - const result = await callHostRetryableOnlineRpc(deps, { + return serveDaemonFileContent( + deps, + { hostId: target.hostId, - timeoutMs: COMMAND_TIMEOUT_MS, - command: { - type: "host.read_file", - path: path.join(target.storagePath, query.path), - rootPath: target.storagePath, - }, - }); - return createDaemonFileContentResponse(result, { - ifNoneMatch: context.req.header("if-none-match"), - }); - } catch (error) { - return remapDaemonFileRouteError(error); - } + path: path.join(target.storagePath, query.path), + rootPath: target.storagePath, + }, + (result) => + createDaemonFileContentResponse(result, { + ifNoneMatch: context.req.header("if-none-match"), + }), + ); }); get(routes.hostFileContent, async (context, query) => { @@ -680,20 +666,16 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void { } const environment = requireEnvironment(deps.db, thread.environmentId); - try { - const result = await callHostRetryableOnlineRpc(deps, { + return serveDaemonFileContent( + deps, + { hostId: environment.hostId, - timeoutMs: COMMAND_TIMEOUT_MS, - command: { - type: "host.read_file", - path: query.path, - }, - }); - return createDaemonFileContentResponse(result, { - ifNoneMatch: context.req.header("if-none-match"), - }); - } catch (error) { - return remapDaemonFileRouteError(error); - } + path: query.path, + }, + (result) => + createDaemonFileContentResponse(result, { + ifNoneMatch: context.req.header("if-none-match"), + }), + ); }); } diff --git a/apps/server/src/services/hosts/daemon-file-response.ts b/apps/server/src/services/hosts/daemon-file-response.ts index 24322d6d1b..5a36f596d7 100644 --- a/apps/server/src/services/hosts/daemon-file-response.ts +++ b/apps/server/src/services/hosts/daemon-file-response.ts @@ -1,6 +1,9 @@ import { Buffer } from "node:buffer"; import type { HostDaemonOnlineRpcResultByType } from "@bb/host-daemon-contract"; import { ApiError } from "../../errors.js"; +import { COMMAND_TIMEOUT_MS } from "../../constants.js"; +import type { LoggedWorkSessionDeps } from "../../types.js"; +import { callHostRetryableOnlineRpc } from "./online-rpc.js"; const OCTET_STREAM_MIME_TYPE = "application/octet-stream"; const REVALIDATE_CACHE_CONTROL = "private, no-cache"; @@ -14,6 +17,24 @@ interface CreateDaemonFileContentResponseOptions { ifNoneMatch?: string | undefined; } +export async function serveDaemonFileContent( + deps: LoggedWorkSessionDeps, + target: { hostId: string; path: string; rootPath?: string }, + createResponse: (result: DaemonFileReadResult) => Response, +): Promise { + const { hostId, ...file } = target; + try { + const result = await callHostRetryableOnlineRpc(deps, { + hostId, + timeoutMs: COMMAND_TIMEOUT_MS, + command: { type: "host.read_file", ...file }, + }); + return createResponse(result); + } catch (error) { + return remapDaemonFileRouteError(error); + } +} + function daemonFileEntityTag(result: DaemonFileReadResult): string { return `"${result.sha256}"`; } From 7eb9dd5668eeb338166b5fb548f9f89004704d5e Mon Sep 17 00:00:00 2001 From: Michael Yong <610102+ymichael@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:50:21 -0700 Subject: [PATCH 004/128] Fix branch picker empty-state spacing (#3252) ## Human comments ## What was wrong The loaded-empty branch picker followed a terminal fallback that omitted the Branches section and used double vertical padding (`py-6`), while the loading state rendered within the section with the standard compact row spacing. ## What changed Loaded-empty results without a current selection now remain in the options section, preserving the Branches heading and the same spacing as loading. The terminal fallback also uses the compact row padding. Added an Empty states Ladle story and a regression test. ## How you verified - `pnpm exec turbo run test --filter=@bb/app -- --run src/components/pickers/BranchPicker.scroll.test.tsx` - `pnpm exec turbo run typecheck --filter=@bb/app` - `pnpm exec turbo run lint --filter=@bb/app` (passes with existing warnings) - Captured before/after Ladle screenshots using SawyerHood/doobie. Fixes # > AGENT GENERATED --- .../pickers/BranchPicker.scroll.test.tsx | 11 +++++ .../pickers/BranchPicker.stories.tsx | 42 +++++++++++++++++-- .../src/components/pickers/BranchPicker.tsx | 3 +- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/apps/app/src/components/pickers/BranchPicker.scroll.test.tsx b/apps/app/src/components/pickers/BranchPicker.scroll.test.tsx index caa0c34afe..827ed41501 100644 --- a/apps/app/src/components/pickers/BranchPicker.scroll.test.tsx +++ b/apps/app/src/components/pickers/BranchPicker.scroll.test.tsx @@ -63,4 +63,15 @@ describe("BranchPicker search", () => { Node.DOCUMENT_POSITION_FOLLOWING, ).not.toBe(0); }); + + it("keeps an empty result in the branches section", () => { + render( + , + ); + + fireEvent.click(screen.getByRole("combobox", { name: "Branch" })); + + expect(screen.getByText("Branches")).toBeTruthy(); + expect(screen.getByText("No branches found.").className).toContain("py-3"); + }); }); diff --git a/apps/app/src/components/pickers/BranchPicker.stories.tsx b/apps/app/src/components/pickers/BranchPicker.stories.tsx index ea63bb3ad2..1e44b00d0c 100644 --- a/apps/app/src/components/pickers/BranchPicker.stories.tsx +++ b/apps/app/src/components/pickers/BranchPicker.stories.tsx @@ -28,7 +28,10 @@ const noop = () => {}; type BranchPickerStoryConfig = Omit< BranchPickerProps, "onChange" | "options" | "variant" -> & { currentBranch?: string | null }; +> & { + branchOptions?: readonly string[]; + currentBranch?: string | null; +}; interface BranchPickerStoryRowProps { label: string; @@ -190,6 +193,7 @@ function BranchPickerStoryRow({ const [state, setState] = useState(() => getInitialBranchPickerStoryState({ picker }), ); + const { branchOptions, ...branchPickerProps } = picker; const triggerLabel = getStoryTriggerLabel({ picker, state }); const triggerTitle = getStoryTriggerTitle({ picker, state }); const handleCreate = picker.onCreate @@ -221,10 +225,10 @@ function BranchPickerStoryRow({ return ( ); } + +export function EmptyStates() { + return ( + + + + + ); +} diff --git a/apps/app/src/components/pickers/BranchPicker.tsx b/apps/app/src/components/pickers/BranchPicker.tsx index e918443c2f..bc45923703 100644 --- a/apps/app/src/components/pickers/BranchPicker.tsx +++ b/apps/app/src/components/pickers/BranchPicker.tsx @@ -813,6 +813,7 @@ export function BranchPicker({ loading || showCreateItem || hasBranchOptions || + (!hasCurrentItem && !branchChooserDisabled) || ((branchOptionsDisabled || createDisabled) && options.length + remoteOptions.length > 0); const showOptionsSearch = showBranchChooser && !branchChooserDisabled; @@ -1157,7 +1158,7 @@ export function BranchPicker({ )} ) : hasCurrentItem ? null : ( -

+

{loading ? "Loading branches..." : "No branches found."}

)} From aabc61c9785322394cd2b6d39db1947d935e6c27 Mon Sep 17 00:00:00 2001 From: Michael Yong <610102+ymichael@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:20:12 -0700 Subject: [PATCH 005/128] Share native and plugin question forms with compact interaction UI (#3147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Human comments ## What was wrong Built-in questions and the ask-user-question plugin duplicated their form UI, state, and shortcut handling. Their containers also diverged: built-in prompts had an orange border and collapse controls while plugin forms used a neutral border without collapse controls. Compact permission prompts wrapped across multiple lines on mobile. ## What changed - Use one `PendingInteractionShell` for built-in questions, approvals, plan reviews, and plugin forms: neutral border, attention dot, title/caret disclosure controls, and a single-line compact mobile layout. - Use one `QuestionForm` and answer-state implementation for built-in and plugin questions, including tabs, single/multiple selection, Other/free text, validation, navigation, and option previews. Delete the duplicate implementations and consolidate form tests. - Keep small transport adapters: built-in questions submit a `user_answer` resolution and cancellation stops the thread; the plugin submits `{answers}` and retains its interaction cancellation behavior. - Route both forms through the app's configured question shortcuts, limited to the focused pane. Collapsing pauses shortcut effects while preserving draft text, selections, and the active question. Typing in an input does not select answers. - Share the internal question-host React context across plugin bundles through a runtime shim. No public Plugin SDK, CLI, or server/daemon wire changes. - Title taps toggle the form; approval actions remain separate controls in the expanded form. Escape collapses and restores focus. Attention-dot policy is unchanged by the form refactor. ## How you verified - App: 50 targeted tests passed across shared form/state, native adapter/shell, plugin composer, shortcuts, and runtime installation. The native adapter regression also checks the submitted resolution and thread cancellation. - Ask-user-question plugin: 30 tests passed, including submission, cancellation, invalid payloads, server behavior, and translation. - Plugin build: 11 tests passed, including a bundled-module check that the question-host hook uses the host runtime instance. - `pnpm exec turbo run typecheck lint --filter=@bb/app --filter=@bb/shared-ui --filter=bb-plugin-ask-user-question --filter=@bb/plugin-build` passed. Formatting and diff checks passed. - Dev-browser against native and actual plugin-source Ladle fixtures: desktop selections, multiselect/free text, mobile single-line collapse, title-tap expansion, and retained draft/current question. Earlier permission checks cover compact decisions and mobile expansion. - Rebuilt and restarted using `pnpm start:worktree`; app responds HTTP 200 and the host daemon connects. - Verification inventory has an unrelated existing failure: unmapped `browser` CLI family. ## Screenshots [Before / After screenshot gallery](https://get-bb.github.io/reports/prs/3147.html) — 19 surfaces/states × desktop/mobile × compact/expanded, compared with PR base `6cdb4ba612`. Both columns use the same fixture data and viewport sizes; select a surface to compare it directly. Plugin forms without an earlier compact mode show their actual expanded Before view with an explicit label. Each image opens at full size. All 38 compact captures measure 38px high with no horizontal overflow. This includes built-in and plugin questions, command/file/tool permissions, child-thread approvals, plan review, resolving requests, secrets, unavailable plugins, errors, Other answers, and free-text drafts. Compact errors use a red dot without competing with the title; expanded errors show the full message. Compact mode now shows the short label (for example, **Approval needed**), dot, and caret; details, source links, and decisions appear after expansion. Expanded headings remain readable, with child-thread links truncating first. Attention dots align with the first line when headings wrap. All 3,935 app tests passed (3 skipped), along with typecheck and lint. | Desktop compact | Mobile compact | | --- | --- | | ![Desktop compact before and after](https://raw.githubusercontent.com/get-bb/reports/426258efc4d2b0366185b558db9310214e66647f/prs/assets/3147-comparison-desktop-compact.png) | ![Mobile compact before and after](https://raw.githubusercontent.com/get-bb/reports/426258efc4d2b0366185b558db9310214e66647f/prs/assets/3147-comparison-mobile-compact.png) | | Desktop expanded | Mobile expanded | | --- | --- | | ![Desktop expanded before and after](https://raw.githubusercontent.com/get-bb/reports/426258efc4d2b0366185b558db9310214e66647f/prs/assets/3147-comparison-desktop-expanded.png) | ![Mobile expanded before and after](https://raw.githubusercontent.com/get-bb/reports/426258efc4d2b0366185b558db9310214e66647f/prs/assets/3147-comparison-mobile-expanded.png) | Browser captures use synthetic Ladle previews at 1280px and 390px viewport widths; mobile is Chromium emulation. No credentials entered. Following the compact-error change, 20 shell/controller tests, app typecheck, and lint passed. > AGENT GENERATED --- apps/app/.ladle/config.mjs | 6 +- .../PluginPendingInteractionComposer.test.tsx | 110 +++- .../PluginPendingInteractionComposer.tsx | 120 ++-- .../InteractionStates.stories.tsx | 106 ++++ .../PendingInteractionShell.tsx | 182 +++++++ ...ThreadPendingInteractionBanner.stories.tsx | 48 +- .../ThreadPendingInteractionBanner.test.tsx | 43 +- .../ThreadPendingInteractionBanner.tsx | 279 +--------- ...PendingInteractionUserQuestion.stories.tsx | 5 +- .../user-questions/QuestionForm.test.tsx | 278 ++++++++++ .../user-questions/ThreadQuestionFormHost.tsx | 56 ++ .../UserQuestionInteractionContent.tsx | 512 ++---------------- ...st.ts => question-shortcut-choice.test.ts} | 7 +- .../user-question-form-state.test.ts | 56 +- .../user-question-form-state.ts | 96 ---- apps/app/src/lib/plugin-frontend.test.ts | 1 + apps/app/src/lib/plugin-frontend.ts | 3 + .../generate-runtime-export-manifest.mjs | 10 + .../plugin-build/src/build-plugin-app.test.ts | 7 +- packages/plugin-build/src/runtime-shims.mjs | 1 + packages/shared-ui/package.json | 15 + .../src/components/ui/question-form-host.tsx | 21 + .../src/components/ui/question-form-state.ts | 51 +- .../src/components/ui/question-form.tsx | 473 ++++++++++++++++ .../thread-view-promptbox.md | 6 +- plugins/ask-user-question/app.stories.tsx | 75 +++ plugins/ask-user-question/app.test.tsx | 121 +---- plugins/ask-user-question/app.tsx | 505 +---------------- 28 files changed, 1630 insertions(+), 1563 deletions(-) create mode 100644 apps/app/src/components/thread/pending-interactions/InteractionStates.stories.tsx create mode 100644 apps/app/src/components/thread/pending-interactions/PendingInteractionShell.tsx create mode 100644 apps/app/src/components/thread/user-questions/QuestionForm.test.tsx create mode 100644 apps/app/src/components/thread/user-questions/ThreadQuestionFormHost.tsx rename apps/app/src/components/thread/user-questions/{UserQuestionInteractionContent.test.ts => question-shortcut-choice.test.ts} (75%) delete mode 100644 apps/app/src/components/thread/user-questions/user-question-form-state.ts create mode 100644 packages/shared-ui/src/components/ui/question-form-host.tsx rename plugins/ask-user-question/src/form-state.ts => packages/shared-ui/src/components/ui/question-form-state.ts (75%) create mode 100644 packages/shared-ui/src/components/ui/question-form.tsx create mode 100644 plugins/ask-user-question/app.stories.tsx diff --git a/apps/app/.ladle/config.mjs b/apps/app/.ladle/config.mjs index 38c1490835..4ec7d5759e 100644 --- a/apps/app/.ladle/config.mjs +++ b/apps/app/.ladle/config.mjs @@ -39,7 +39,11 @@ function formatNetworkUrls(serverUrl) { /** @type {import("@ladle/react").UserConfig} */ export default { - stories: ["src/**/*.stories.tsx", "../../plugins/workflows/**/*.stories.tsx"], + stories: [ + "src/**/*.stories.tsx", + "../../plugins/workflows/**/*.stories.tsx", + "../../plugins/ask-user-question/*.stories.tsx", + ], defaultStory: "", viteConfig: "./.ladle/vite.config.ts", host: "0.0.0.0", diff --git a/apps/app/src/components/plugin/PluginPendingInteractionComposer.test.tsx b/apps/app/src/components/plugin/PluginPendingInteractionComposer.test.tsx index 44cf1ec9de..fd406085ef 100644 --- a/apps/app/src/components/plugin/PluginPendingInteractionComposer.test.tsx +++ b/apps/app/src/components/plugin/PluginPendingInteractionComposer.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react"; +import { useEffect, useState } from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { PluginPendingInteraction } from "@bb/domain"; @@ -56,6 +57,113 @@ afterEach(() => { }); describe("PluginPendingInteractionComposer", () => { + it("preserves drafts and pauses keyboard listeners while collapsed", () => { + const onShortcut = vi.fn(); + function QuestionRenderer() { + const [answer, setAnswer] = useState(""); + useEffect(() => { + window.addEventListener("keydown", onShortcut); + return () => window.removeEventListener("keydown", onShortcut); + }, []); + return ( + setAnswer(event.target.value)} + /> + ); + } + setPluginSlotRegistrations( + "secrets", + registrations([{ id: "secret-request", component: QuestionRenderer }]), + ); + renderComposer( + , + ); + fireEvent.change(screen.getByRole("textbox", { name: "Answer" }), { + target: { value: "Keep my draft" }, + }); + fireEvent.keyDown(window, { key: "1" }); + expect(onShortcut).toHaveBeenCalledTimes(1); + const toggle = screen.getByRole("button", { name: "Hide details" }); + toggle.focus(); + fireEvent.click(toggle); + expect(screen.queryByRole("textbox")).toBeNull(); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Show details" }), + ); + fireEvent.keyDown(window, { key: "2" }); + expect(onShortcut).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Show details" })); + expect(screen.getByRole("textbox").getAttribute("value")).toBe( + "Keep my draft", + ); + fireEvent.keyDown(window, { key: "3" }); + expect(onShortcut).toHaveBeenCalledTimes(2); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" }); + expect(screen.queryByRole("textbox")).toBeNull(); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Show details" }), + ); + fireEvent.click(screen.getByRole("button", { name: "Show details" })); + expect(screen.getByRole("textbox").getAttribute("value")).toBe( + "Keep my draft", + ); + }); + + it("opens a new interaction with a fresh form after the previous one was collapsed", () => { + function Renderer() { + const [answer, setAnswer] = useState(""); + return ( + setAnswer(event.target.value)} + /> + ); + } + setPluginSlotRegistrations( + "secrets", + registrations([{ id: "secret-request", component: Renderer }]), + ); + const client = new QueryClient(); + const composer = (id: string) => ( + + + + ); + const view = render(composer(interaction.id)); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "Previous answer" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Hide details" })); + view.rerender(composer("pint_new")); + expect(screen.getByRole("textbox").getAttribute("value")).toBe(""); + expect( + screen + .getByRole("button", { name: "Hide details" }) + .getAttribute("aria-expanded"), + ).toBe("true"); + }); + it("mounts only the renderer registered by the interaction's plugin", () => { function WrongRenderer() { return
wrong plugin renderer
; diff --git a/apps/app/src/components/plugin/PluginPendingInteractionComposer.tsx b/apps/app/src/components/plugin/PluginPendingInteractionComposer.tsx index 39fea4c19e..452e929aa2 100644 --- a/apps/app/src/components/plugin/PluginPendingInteractionComposer.tsx +++ b/apps/app/src/components/plugin/PluginPendingInteractionComposer.tsx @@ -1,3 +1,7 @@ +import { + PendingInteractionShell, + type PendingInteractionSourceThread, +} from "@/components/thread/pending-interactions/PendingInteractionShell"; import { useCallback, useMemo, useState } from "react"; import { Button } from "@bb/shared-ui/button"; import type { JsonValue, PendingInteraction } from "@bb/domain"; @@ -21,12 +25,14 @@ interface PluginPendingInteractionComposerProps { >; request: PluginPendingInteractionRequest; dismissal: "cancel" | "stop-turn"; + sourceThread?: PendingInteractionSourceThread; } export function PluginPendingInteractionComposer({ interaction, request, dismissal, + sourceThread, }: PluginPendingInteractionComposerProps) { const { pendingInteractions } = usePluginSlots(); const stopThread = useStopThread(); @@ -83,25 +89,62 @@ export function PluginPendingInteractionComposer({ const dismissLabel = dismissal === "cancel" ? "Cancel" : "Stop turn"; return ( -
-
-

- {request.title} -

-

- {dismissal === "cancel" ? "Requested by " : "The agent asks through "} - {request.pluginId} -

-
- {slot ? ( - + {() => ( + <> +

+ {dismissal === "cancel" + ? "Requested by " + : "The agent asks through "} + {request.pluginId} +

+ {slot ? ( + +

+ The plugin form crashed. {dismissLabel} to continue. +

+ + + } + > +
+ +
+
+ ) : (

- The plugin form crashed. {dismissLabel} to continue. + The plugin form is unavailable. {dismissLabel} to continue.

- } - > -
- -
-
- ) : ( -
-

- The plugin form is unavailable. {dismissLabel} to continue. -

- -
+ )} + )} - {error ? ( -

- {error} -

- ) : null} -
+ ); } diff --git a/apps/app/src/components/thread/pending-interactions/InteractionStates.stories.tsx b/apps/app/src/components/thread/pending-interactions/InteractionStates.stories.tsx new file mode 100644 index 0000000000..c2adc3e5d4 --- /dev/null +++ b/apps/app/src/components/thread/pending-interactions/InteractionStates.stories.tsx @@ -0,0 +1,106 @@ +import { useEffect } from "react"; +import { installTestPluginRuntime } from "@get-bb/plugin-sdk/testing/app"; +import { collectPluginAppRegistrations } from "@/lib/plugin-app-definition"; +import { makePluginRegistrationSet } from "@/test/fixtures/plugins"; +import { + setPluginSlotRegistrations, + removePluginSlotRegistrations, +} from "@/lib/plugin-slots"; +import { PluginPendingInteractionComposer } from "@/components/plugin/PluginPendingInteractionComposer"; +import { PendingInteractionShell } from "./PendingInteractionShell"; +import { ThreadPendingInteractionBanner } from "./ThreadPendingInteractionBanner"; + +installTestPluginRuntime(); +const { default: secretsApp } = + await import("../../../../../../plugins/secrets/app"); + +export default { title: "thread/Pending Interaction/Additional States" }; + +export function Overview() { + useEffect(() => { + setPluginSlotRegistrations( + "secrets", + makePluginRegistrationSet({ + pendingInteractions: + collectPluginAppRegistrations(secretsApp).pendingInteractions, + }), + ); + return () => removePluginSlotRegistrations("secrets"); + }, []); + return ( +
+ + + + + {() => ( +

+ Your draft answer is preserved. Expand the form to review it and + retry. +

+ )} +
+
+ ); +} diff --git a/apps/app/src/components/thread/pending-interactions/PendingInteractionShell.tsx b/apps/app/src/components/thread/pending-interactions/PendingInteractionShell.tsx new file mode 100644 index 0000000000..b1f6aa685a --- /dev/null +++ b/apps/app/src/components/thread/pending-interactions/PendingInteractionShell.tsx @@ -0,0 +1,182 @@ +import { ThreadQuestionFormHost } from "../user-questions/ThreadQuestionFormHost"; +import { + Activity, + useId, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; +import { NavLink } from "react-router-dom"; +import { Icon } from "@bb/shared-ui/icon"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { ExpandableLine } from "@/components/ui/expandable-line.js"; + +export interface PendingInteractionSourceThread { + href: string; + title: string; +} + +interface PendingInteractionShellProps { + label: string; + title?: string; + initiallyExpanded: boolean; + errorMessage?: string | null; + footer?: ReactNode; + children?: (isExpanded: boolean) => ReactNode; + sourceThread?: PendingInteractionSourceThread; + testId: string; +} + +export function PendingInteractionShell({ + label, + title, + initiallyExpanded, + errorMessage, + footer, + children, + sourceThread, + testId, +}: PendingInteractionShellProps) { + const [isExpanded, setIsExpanded] = useState(initiallyExpanded); + const toggleRef = useRef(null); + const contentId = useId(); + const errorId = useId(); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && isExpanded && !event.defaultPrevented) { + event.preventDefault(); + event.stopPropagation(); + setIsExpanded(false); + toggleRef.current?.focus(); + } + }; + const handleToggle = () => setIsExpanded((value) => !value); + const toggle = ( + + ); + const errorNode = errorMessage ? ( +
+ {errorMessage} +
+ ) : null; + const sourceThreadLink = sourceThread ? ( + + From {sourceThread.title} + + ) : null; + + return ( +
+
+ + {isExpanded ? sourceThreadLink : null} + {toggle} +
+ + + + + + {errorNode} +
+ ); +} + +function AttentionDot({ hasError }: { hasError: boolean }) { + return ( +