diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bc2632ea..955644ca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,3 @@ # Changelog -- Removed `bb status-state`; write reactive STATUS JSON directly under `$BB_THREAD_STORAGE/STATUS-data/`. +- Removed the legacy STATUS system; use the built-in `status` app and `apps/status/data/state.json` for manager status. diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx index 5518ac894..363bb1013 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.tsx @@ -119,6 +119,7 @@ type ContextWindowUsage = ComponentProps< >["usage"]; export interface FollowUpPromptBoxProps { + id?: string; attachments: AttachmentsConfig; /** * Slot for the stack of context cards above the prompt input — today @@ -151,6 +152,7 @@ export interface FollowUpPromptBoxProps { } export const FollowUpPromptBox = memo(function FollowUpPromptBox({ + id, attachments, stack, composer, @@ -225,6 +227,7 @@ export const FollowUpPromptBox = memo(function FollowUpPromptBox({ {stack} + ); + } + + return ( + + ); +} diff --git a/apps/app/src/components/secondary-panel/AppTabContent.test.tsx b/apps/app/src/components/secondary-panel/AppTabContent.test.tsx new file mode 100644 index 000000000..70cbfaaf4 --- /dev/null +++ b/apps/app/src/components/secondary-panel/AppTabContent.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from "@testing-library/react"; +import type { AppDetail } from "@bb/server-contract"; +import * as api from "@/lib/api"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; +import { AppTabContent } from "./AppTabContent"; + +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + getThreadApp: vi.fn(), + getThreadAppMarkdownPreview: vi.fn(), + }; +}); + +const HTML_APP: AppDetail = { + id: "status", + name: "Status", + entry: { path: "index.html", kind: "html" }, + capabilities: ["data", "message"], + icon: { kind: "builtin", name: "ListTodo" }, +}; + +const MARKDOWN_APP: AppDetail = { + id: "readme", + name: "Readme", + entry: { path: "docs/index.md", kind: "md" }, + capabilities: [], + icon: { kind: "builtin", name: "GridView" }, +}; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("AppTabContent", () => { + it("renders HTML apps in the injected app iframe route", async () => { + vi.mocked(api.getThreadApp).mockResolvedValue(HTML_APP); + const { wrapper } = createQueryClientTestHarness(); + + render(, { wrapper }); + + const frame = await screen.findByTitle("Status"); + expect(frame.getAttribute("src")).toBe( + "/api/v1/threads/thr_1/apps/status/", + ); + expect(frame.getAttribute("sandbox")).toBeNull(); + expect(api.getThreadAppMarkdownPreview).not.toHaveBeenCalled(); + }); + + it("renders markdown apps through the static markdown preview path", async () => { + vi.mocked(api.getThreadApp).mockResolvedValue(MARKDOWN_APP); + vi.mocked(api.getThreadAppMarkdownPreview).mockResolvedValue({ + kind: "text", + path: "docs/index.md", + name: "index.md", + url: "/api/v1/threads/thr_1/apps/readme/docs/index.md", + mimeType: "text/markdown", + content: "# App Notes\n\nStatic content.", + }); + const { wrapper } = createQueryClientTestHarness(); + + render(, { wrapper }); + + expect(await screen.findByText("App Notes")).toBeTruthy(); + expect(screen.getByText("Static content.")).toBeTruthy(); + expect(api.getThreadAppMarkdownPreview).toHaveBeenCalledWith( + "thr_1", + "readme", + "docs/index.md", + expect.any(AbortSignal), + ); + }); +}); diff --git a/apps/app/src/components/secondary-panel/AppTabContent.tsx b/apps/app/src/components/secondary-panel/AppTabContent.tsx new file mode 100644 index 000000000..7cda4ccdb --- /dev/null +++ b/apps/app/src/components/secondary-panel/AppTabContent.tsx @@ -0,0 +1,151 @@ +import { useMemo } from "react"; +import { + useThreadApp, + useThreadAppMarkdownPreview, +} from "@/hooks/queries/thread-queries"; +import { + buildThreadAppAssetBaseUrl, + buildThreadAppEntryUrl, +} from "@/lib/file-content-urls"; +import { createAssetMarkdownUrlTransform } from "@/lib/markdown-url-transform"; +import { FilePreview as FilePreviewSurface } from "./FilePreview"; + +const APP_HEADER_MODE = "none"; + +export interface AppTabContentProps { + appId: string; + threadId: string; +} + +export function AppTabContent({ appId, threadId }: AppTabContentProps) { + const appDetail = useThreadApp(threadId, appId); + const markdownEntryPath = + appDetail.data?.entry.kind === "md" ? appDetail.data.entry.path : null; + const markdownPreview = useThreadAppMarkdownPreview( + threadId, + appId, + markdownEntryPath, + { + enabled: markdownEntryPath !== null, + }, + ); + const markdownAssetBaseUrl = useMemo(() => { + if (markdownEntryPath === null) { + return null; + } + return buildThreadAppAssetBaseUrl(threadId, appId, markdownEntryPath); + }, [appId, markdownEntryPath, threadId]); + const markdownUrlTransform = useMemo(() => { + if (markdownAssetBaseUrl === null) { + return undefined; + } + return createAssetMarkdownUrlTransform(markdownAssetBaseUrl); + }, [markdownAssetBaseUrl]); + + if (appDetail.isError) { + return ( + + ); + } + + if (!appDetail.data) { + return ( + + ); + } + + if (appDetail.data.entry.kind === "html") { + return ( + + ); + } + + if (markdownPreview.isError) { + return ( + + ); + } + + if (!markdownPreview.data) { + return ( + + ); + } + + if (markdownPreview.data.kind !== "text") { + return ( + + ); + } + + if (markdownPreview.data.content.length === 0) { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/app/src/components/secondary-panel/FilePreview.stories.tsx b/apps/app/src/components/secondary-panel/FilePreview.stories.tsx index 13bbfd73a..f54d62e2d 100644 --- a/apps/app/src/components/secondary-panel/FilePreview.stories.tsx +++ b/apps/app/src/components/secondary-panel/FilePreview.stories.tsx @@ -92,7 +92,6 @@ Button.displayName = "Button"; const README_PATH = "docs/secondary-panel/README.md"; const BUTTON_PATH = "apps/app/src/components/ui/button.tsx"; const DELETED_BUTTON_PATH = "apps/app/src/components/ui/legacy-button.tsx"; -const STATUS_PATH = "agents/manager-42/STATUS.md"; const SCREENSHOT_PATH = "docs/screenshots/secondary-panel.svg"; const SAMPLE_IMAGE_URL = @@ -217,17 +216,6 @@ export function Overview() { /> - - - - - ; } - if (state.kind === "manager-status-pending") { - return ( - - ); - } if (state.kind === "error") { return ( (null); const options = useMemo( diff --git a/apps/app/src/components/secondary-panel/ManagerThreadStorageBrowser.stories.tsx b/apps/app/src/components/secondary-panel/ManagerThreadStorageBrowser.stories.tsx index f78dcad00..dc1fe0d6a 100644 --- a/apps/app/src/components/secondary-panel/ManagerThreadStorageBrowser.stories.tsx +++ b/apps/app/src/components/secondary-panel/ManagerThreadStorageBrowser.stories.tsx @@ -14,9 +14,7 @@ export default { function PanelStage({ children }: { children: ReactNode }) { return (
- + {children}
@@ -30,7 +28,8 @@ function makeFile(path: string): WorkspaceFile { const FILES: WorkspaceFile[] = [ makeFile("ASYNC.md"), - makeFile("STATUS.md"), + makeFile("apps/status/manifest.json"), + makeFile("apps/status/data/state.json"), makeFile("PREFERENCES.md"), ]; diff --git a/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx b/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx index a19e618a7..a8c3f504a 100644 --- a/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx +++ b/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx @@ -10,12 +10,37 @@ import type { ThreadType } from "@bb/domain"; import { Icon } from "@/components/ui/icon.js"; import { Input } from "@/components/ui/input.js"; import { TruncateStart } from "@/components/ui/truncate-start.js"; +import { ResolvedAppIcon } from "./AppIcon"; import { useFileSearchSuggestions, + type AppSearchSuggestion, + type FilePathSearchSuggestion, type FileSearchSuggestion, } from "@/hooks/useFileSearchSuggestions"; +import { usePromptDraftStorage } from "@/hooks/usePromptDraftStorage"; import type { FileSearchSelection } from "./useThreadFileTabs"; import { cn } from "@/lib/utils"; +import { isPromptDraftEmpty, type PromptDraftState } from "@/lib/prompt-draft"; + +export const CREATE_APP_PROMPT_TEMPLATE = `You are creating a new bb app for this thread. + +Apps system reference — run \`bb guide app\` for full detail. Layout: +- apps//manifest.json — { manifestVersion: 1, id, name, icon | logo.svg, entry, contributions: ["thread.app"], capabilities: ["data"?, "message"?] } +- apps//assets/index.html — self-contained inline HTML/CSS/JS/SVG (no build step needed) +- apps//data/state.json — initial state if the app uses window.bb.data + +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. + +Scaffold with \`bb app new\` — the default styling is wired up already, so build on top of it and keep the UI polished, accessible, and dense like the rest of bb. + +What I want: + +`; + +const CREATE_APP_PROMPT_DRAFT = { + text: CREATE_APP_PROMPT_TEMPLATE, + attachments: [], +} satisfies PromptDraftState; export interface NewTabFileSearchProps { projectId: string | undefined; @@ -25,14 +50,23 @@ export interface NewTabFileSearchProps { focusRequest: number; initialQuery?: string; onSelect: (selection: FileSearchSelection) => void; + onCreateAppPromptPrefill?: CreateAppPromptPrefillHandler; } -interface FileSearchResultRowProps { +interface AppResultRowProps { id: string; - suggestion: FileSearchSuggestion; + suggestion: AppSearchSuggestion; + isActive: boolean; + onActivate: () => void; + onSelect: (suggestion: AppSearchSuggestion) => void; +} + +interface FileResultRowProps { + id: string; + suggestion: FilePathSearchSuggestion; isActive: boolean; onActivate: () => void; - onSelect: (suggestion: FileSearchSuggestion) => void; + onSelect: (suggestion: FilePathSearchSuggestion) => void; } interface FileSearchMessageProps { @@ -47,7 +81,7 @@ interface FileSearchSectionItem { } interface FileSearchSection { - source: FileSearchSource; + kind: FileSearchSectionKind; label: string; items: FileSearchSectionItem[]; } @@ -60,7 +94,9 @@ interface SplitPathResult { type SearchInputKeyDownHandler = ( event: KeyboardEvent, ) => void; +type CreateAppPromptPrefillHandler = () => void; type FileSearchSource = FileSearchSuggestion["source"]; +type FileSearchSectionKind = "apps" | "files"; interface GetAvailableFileSearchSourcesArgs { projectId: string | undefined; @@ -73,23 +109,53 @@ interface GroupFileSearchSectionsArgs { availableSources: readonly FileSearchSource[]; } +interface CreateAppTileProps { + onClick: () => void; +} + const FILE_SEARCH_LIMIT = 20; -const FILE_SEARCH_SECTION_ORDER: readonly FileSearchSource[] = [ - "workspace", - "thread-storage", +const FILE_SEARCH_SECTION_ORDER: readonly FileSearchSectionKind[] = [ + "apps", + "files", ]; const FILE_SEARCH_SECTION_LABELS = { + apps: "Apps", + files: "Files", +} satisfies Record; + +const FILE_SEARCH_SOURCE_LABELS = { + app: "App", workspace: "Workspace", "thread-storage": "Manager Storage", } satisfies Record; +const SECTION_HEADER_CLASS = + "sticky top-0 z-10 bg-background px-1 pb-2 text-xs font-medium uppercase tracking-wider text-subtle-foreground"; +const LAUNCHER_TILE_BASE_CLASS = + "group flex w-full min-w-0 items-center gap-3 rounded-md px-2 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; +const LAUNCHER_TILE_ICON_CLASS = + "flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border-hairline bg-surface-raised"; +const LAUNCHER_TILE_ICON_CLASS_DASHED = + "flex size-9 shrink-0 items-center justify-center rounded-md border border-dashed border-border bg-surface-raised text-muted-foreground group-hover:text-foreground"; + +function slugifyAppName(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + function getAvailableFileSearchSources({ projectId, currentThreadId, currentThreadType, }: GetAvailableFileSearchSourcesArgs): readonly FileSearchSource[] { const sources: FileSearchSource[] = []; + if (currentThreadId.length > 0) { + sources.push("app"); + } if (projectId) { sources.push("workspace"); } @@ -100,8 +166,10 @@ function getAvailableFileSearchSources({ } function getFileSearchResultId(suggestion: FileSearchSuggestion): string { + const idSegment = + suggestion.entryKind === "app" ? suggestion.appId : suggestion.path; return `file-search-result-${suggestion.source}-${encodeURIComponent( - suggestion.path, + idSegment, )}`; } @@ -117,7 +185,16 @@ function splitPath(path: string): SplitPathResult { } function getFileSearchResultTitle(suggestion: FileSearchSuggestion): string { - return `${FILE_SEARCH_SECTION_LABELS[suggestion.source]}: ${suggestion.path}`; + if (suggestion.entryKind === "app") { + return `${FILE_SEARCH_SOURCE_LABELS.app}: ${suggestion.name}`; + } + return `${FILE_SEARCH_SOURCE_LABELS[suggestion.source]}: ${suggestion.path}`; +} + +function getFileSearchSectionKind( + suggestion: FileSearchSuggestion, +): FileSearchSectionKind { + return suggestion.entryKind === "app" ? "apps" : "files"; } function groupFileSearchSections({ @@ -125,14 +202,15 @@ function groupFileSearchSections({ suggestions, }: GroupFileSearchSectionsArgs): FileSearchSection[] { const allowedSources = new Set(availableSources); - const sectionsBySource = new Map(); + const sectionsByKind = new Map(); for (const suggestion of suggestions) { const source = suggestion.source; if (!allowedSources.has(source)) { continue; } - const existing = sectionsBySource.get(source); + const sectionKind = getFileSearchSectionKind(suggestion); + const existing = sectionsByKind.get(sectionKind); if (existing) { existing.items.push({ suggestion, @@ -141,16 +219,16 @@ function groupFileSearchSections({ continue; } - sectionsBySource.set(source, { - source, - label: FILE_SEARCH_SECTION_LABELS[source], + sectionsByKind.set(sectionKind, { + kind: sectionKind, + label: FILE_SEARCH_SECTION_LABELS[sectionKind], items: [{ suggestion, index: 0 }], }); } let nextIndex = 0; - return FILE_SEARCH_SECTION_ORDER.flatMap((source) => { - const section = sectionsBySource.get(source); + return FILE_SEARCH_SECTION_ORDER.flatMap((sectionKind) => { + const section = sectionsByKind.get(sectionKind); if (!section) { return []; } @@ -182,18 +260,17 @@ function FileSearchMessage({ ); } -function FileSearchResultRow({ +function AppResultRow({ id, suggestion, isActive, onActivate, onSelect, -}: FileSearchResultRowProps) { - const { directory } = splitPath(suggestion.path); - const secondaryDirectory = directory || null; +}: AppResultRowProps) { const handleSelect = useCallback(() => { onSelect(suggestion); }, [onSelect, suggestion]); + const showAppId = suggestion.appId !== slugifyAppName(suggestion.name); return ( + ); +} + +function CreateAppTile({ onClick }: CreateAppTileProps) { + return ( + + ); +} + +function FileResultRow({ + id, + suggestion, + isActive, + onActivate, + onSelect, +}: FileResultRowProps) { + const handleSelect = useCallback(() => { + onSelect(suggestion); + }, [onSelect, suggestion]); + const { directory } = splitPath(suggestion.path); + const secondaryDirectory = directory || null; + + return ( +