Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions apps/app/src/components/promptbox/FollowUpPromptBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -151,6 +152,7 @@ export interface FollowUpPromptBoxProps {
}

export const FollowUpPromptBox = memo(function FollowUpPromptBox({
id,
attachments,
stack,
composer,
Expand Down Expand Up @@ -225,6 +227,7 @@ export const FollowUpPromptBox = memo(function FollowUpPromptBox({
{stack}
</div>
<PromptBoxWithScrollAnchor
id={id}
promptBoxRef={promptBoxRef}
voice={voice}
minHeight={elasticTextareaMinHeight}
Expand Down
28 changes: 28 additions & 0 deletions apps/app/src/components/secondary-panel/AppIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { AppIcon as ThreadAppIcon } from "@bb/server-contract";
import { Icon } from "@/components/ui/icon.js";
import { cn } from "@/lib/utils";

export interface ResolvedAppIconProps {
icon: ThreadAppIcon;
className?: string;
}

export function ResolvedAppIcon({ icon, className }: ResolvedAppIconProps) {
if (icon.kind === "logo") {
return (
<img
src={icon.url}
alt=""
className={cn("size-4 shrink-0 rounded-sm object-contain", className)}
/>
);
}

return (
<Icon
name={icon.name}
className={cn("size-4 shrink-0 text-muted-foreground", className)}
aria-hidden
/>
);
}
79 changes: 79 additions & 0 deletions apps/app/src/components/secondary-panel/AppTabContent.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("@/lib/api")>();

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(<AppTabContent threadId="thr_1" appId="status" />, { 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(<AppTabContent threadId="thr_1" appId="readme" />, { 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),
);
});
});
151 changes: 151 additions & 0 deletions apps/app/src/components/secondary-panel/AppTabContent.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<FilePreviewSurface
path={appId}
headerMode={APP_HEADER_MODE}
state={{
kind: "error",
message:
appDetail.error instanceof Error
? appDetail.error.message
: "Failed to load app.",
}}
/>
);
}

if (!appDetail.data) {
return (
<FilePreviewSurface
path={appId}
headerMode={APP_HEADER_MODE}
state={{ kind: "loading" }}
/>
);
}

if (appDetail.data.entry.kind === "html") {
return (
<FilePreviewSurface
path={appDetail.data.name}
headerMode={APP_HEADER_MODE}
state={{
kind: "iframe",
sandbox: null,
title: appDetail.data.name,
url: buildThreadAppEntryUrl(threadId, appId),
}}
/>
);
}

if (markdownPreview.isError) {
return (
<FilePreviewSurface
path={appDetail.data.name}
headerMode={APP_HEADER_MODE}
state={{
kind: "error",
message:
markdownPreview.error instanceof Error
? markdownPreview.error.message
: "Failed to load app entry.",
}}
/>
);
}

if (!markdownPreview.data) {
return (
<FilePreviewSurface
path={appDetail.data.name}
headerMode={APP_HEADER_MODE}
state={{ kind: "loading" }}
/>
);
}

if (markdownPreview.data.kind !== "text") {
return (
<FilePreviewSurface
path={appDetail.data.name}
headerMode={APP_HEADER_MODE}
state={{
kind: "error",
message: `Preview not available for ${markdownPreview.data.mimeType}.`,
}}
/>
);
}

if (markdownPreview.data.content.length === 0) {
return (
<FilePreviewSurface
path={appDetail.data.name}
headerMode={APP_HEADER_MODE}
state={{ kind: "empty" }}
/>
);
}

return (
<FilePreviewSurface
path={appDetail.data.name}
headerMode={APP_HEADER_MODE}
state={{
kind: "ready",
lineNumber: null,
showMarkdownModeToggle: false,
markdownUrlTransform,
file: {
name: markdownPreview.data.name ?? appDetail.data.entry.path,
contents: markdownPreview.data.content,
},
}}
/>
);
}
12 changes: 0 additions & 12 deletions apps/app/src/components/secondary-panel/FilePreview.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -217,17 +216,6 @@ export function Overview() {
/>
</PreviewStage>
</StoryRow>
<StoryRow
label="manager status pending"
hint="STATUS.md doesn't exist yet for a freshly-created manager"
>
<PreviewStage>
<FilePreview
path={STATUS_PATH}
state={{ kind: "manager-status-pending" }}
/>
</PreviewStage>
</StoryRow>
<StoryRow
label="failed to load"
hint="Preview fetch failed for some other reason (network, 500, etc.)"
Expand Down
16 changes: 2 additions & 14 deletions apps/app/src/components/secondary-panel/FilePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export type FilePreviewState =
| { kind: "loading" }
| { kind: "empty" }
| { kind: "not-found" }
| { kind: "manager-status-pending" }
| { kind: "error"; message?: string }
| { kind: "image"; url: string }
| ({ kind: "iframe" } & IframeFilePreviewTarget)
Expand Down Expand Up @@ -222,11 +221,6 @@ function FilePreviewBody({ state, path, viewMode }: FilePreviewBodyProps) {
if (state.kind === "not-found") {
return <FilePreviewMessage message="File not found." role="alert" />;
}
if (state.kind === "manager-status-pending") {
return (
<FilePreviewMessage message="Manager hasn't written a status yet." />
);
}
if (state.kind === "error") {
return (
<FilePreviewMessage
Expand Down Expand Up @@ -460,10 +454,7 @@ function FilePreviewLoading() {
);
}

function FilePreviewMessage({
message,
role,
}: FilePreviewMessageProps) {
function FilePreviewMessage({ message, role }: FilePreviewMessageProps) {
return (
<p
role={role}
Expand All @@ -474,10 +465,7 @@ function FilePreviewMessage({
);
}

function FilePreviewCode({
file,
lineNumber,
}: FilePreviewCodeProps) {
function FilePreviewCode({ file, lineNumber }: FilePreviewCodeProps) {
const preferredTheme = usePreferredTheme();
const containerRef = useRef<HTMLDivElement>(null);
const options = useMemo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ export default {
function PanelStage({ children }: { children: ReactNode }) {
return (
<div className="flex h-[360px] w-full max-w-[460px] min-w-0 flex-col overflow-hidden rounded-md border border-border bg-background px-4 py-3">
<DetailCard
className="h-full min-h-0 flex-1 rounded-none border-0 bg-transparent px-0 py-0"
>
<DetailCard className="h-full min-h-0 flex-1 rounded-none border-0 bg-transparent px-0 py-0">
{children}
</DetailCard>
</div>
Expand All @@ -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"),
];

Expand Down
Loading
Loading