Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
41 changes: 23 additions & 18 deletions apps/app/.ladle/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { WorkerPoolContextProvider } from "@pierre/diffs/react";
import { Provider as JotaiProvider, createStore } from "jotai";
import { MemoryRouter } from "react-router-dom";
import { AppToaster } from "../src/components/AppToaster";
import { RouteNavigationProvider } from "../src/components/ui/app-route-anchor";
import { TooltipProvider } from "@bb/shared-ui/tooltip";
import { setPreferredTheme } from "../src/hooks/useTheme";
import {
Expand Down Expand Up @@ -57,24 +58,28 @@ export const Provider: GlobalProvider = ({ globalState, children }) => {

return (
<MemoryRouter initialEntries={["/"]}>
<JotaiProvider store={store}>
<QueryClientProvider client={queryClient}>
<WorkerPoolContextProvider
poolOptions={{
workerFactory: createDiffWorker,
poolSize: getDiffWorkerPoolSize(),
}}
highlighterOptions={{}}
>
<TooltipProvider delayDuration={300} disableHoverableContent>
<div className="min-h-screen text-foreground">
{children}
<AppToaster position="bottom-right" />
</div>
</TooltipProvider>
</WorkerPoolContextProvider>
</QueryClientProvider>
</JotaiProvider>
{/* Sidebar rows, thread actions and the fork handler navigate through
useRouteNavigate, which throws at the click without this provider. */}
<RouteNavigationProvider>
<JotaiProvider store={store}>
<QueryClientProvider client={queryClient}>
<WorkerPoolContextProvider
poolOptions={{
workerFactory: createDiffWorker,
poolSize: getDiffWorkerPoolSize(),
}}
highlighterOptions={{}}
>
<TooltipProvider delayDuration={300} disableHoverableContent>
<div className="min-h-screen text-foreground">
{children}
<AppToaster position="bottom-right" />
</div>
</TooltipProvider>
</WorkerPoolContextProvider>
</QueryClientProvider>
</JotaiProvider>
</RouteNavigationProvider>
</MemoryRouter>
);
};
135 changes: 60 additions & 75 deletions apps/app/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar.js";
import { ThreadTitleMentionResourcesProvider } from "@/components/thread/ThreadTitleMentions";
import {
ThreadTitleMentionResourcesProvider,
useSidebarThreadTitleMentionResources,
} from "@/components/thread/ThreadTitleMentions";
import { AppCommandShortcutHint } from "@/components/commands/AppCommandShortcutHint";
import {
resolveAutomationBreadcrumbs,
Expand Down Expand Up @@ -538,27 +541,9 @@ export function AppLayout({ children }: AppLayoutProps) {
...sidebarNavigation.personalProject.threads,
];
}, [sidebarNavigationQuery.data]);
const titleMentionResources = useMemo(() => {
const sectionNamesById = new Map<string, string>();
const projectNamesById = new Map<string, string>();
const threadById = new Map(
sidebarThreads.map((entry) => [entry.id, entry]),
);
const navigation = sidebarNavigationQuery.data;
if (navigation) {
for (const section of navigation.sections) {
sectionNamesById.set(section.id, section.name);
}
for (const projectEntry of navigation.projects) {
projectNamesById.set(projectEntry.id, projectEntry.name);
}
projectNamesById.set(
navigation.personalProject.id,
navigation.personalProject.name,
);
}
return { sectionNamesById, projectNamesById, threadById };
}, [sidebarNavigationQuery.data, sidebarThreads]);
const titleMentionResources = useSidebarThreadTitleMentionResources(
sidebarNavigationQuery.data,
);
const threadDetailBootstrapQuery = useThreadDetailBootstrap(threadId ?? "", {
enabled: isThreadView && Boolean(threadId),
timelinePrefetch: isThreadView && Boolean(threadId),
Expand Down Expand Up @@ -821,59 +806,59 @@ export function AppLayout({ children }: AppLayoutProps) {
}, [documentTitle]);

return (
<ProjectActionsProvider>
<ThreadTitleMentionResourcesProvider {...titleMentionResources}>
<ThreadActionsProvider>
<IframeDragGuardOverlay active={isSidebarResizing} />
<SidebarStateBridge
providerRef={providerRef}
style={sidebarProviderStyle}
>
<AppLayoutSidebar
mode={
isGlobalSettingsView
? "settings"
: isGlobalToolsView
? "tools"
: "app"
}
onResizeMouseDown={handleResizeMouseDown}
isResizing={isSidebarResizing}
appRoutePath={appRoutePath}
settingsRoutePath={settingsRoutePath}
toolsBackRoutePath={toolsBackRoutePath}
toolsRoutePath={toolsRoutePath}
/>
<SidebarInset>
<div
ref={contentShellRef}
data-testid="app-layout-content-shell"
className="relative flex h-full min-h-0 min-w-0 w-full flex-col pt-[env(safe-area-inset-top)] pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)]"
>
{showHeader ? (
<AppHeader
usesDesktopChrome={usesDesktopChrome}
usesProjectChromeStyle={
isRootView || isArchivedView || isSettingsView
}
isSettingsView={isSettingsView}
projectId={projectId}
project={project}
pluginPanel={pluginPanel}
pluginPanelSubPath={pluginPanelSubPath}
meta={meta}
/>
) : null}
<main className="flex min-h-0 flex-1 flex-col p-4 md:p-5">
{children}
</main>
</div>
</SidebarInset>
<SidebarTriggerOverlay
reserveMacosTrafficLights={reserveMacosTrafficLights}
usesDesktopChrome={usesDesktopChrome}
/>
</SidebarStateBridge>
<ProjectActionsProvider>
<ThreadTitleMentionResourcesProvider {...titleMentionResources}>
<ThreadActionsProvider>
<IframeDragGuardOverlay active={isSidebarResizing} />
<SidebarStateBridge
providerRef={providerRef}
style={sidebarProviderStyle}
>
<AppLayoutSidebar
mode={
isGlobalSettingsView
? "settings"
: isGlobalToolsView
? "tools"
: "app"
}
onResizeMouseDown={handleResizeMouseDown}
isResizing={isSidebarResizing}
appRoutePath={appRoutePath}
settingsRoutePath={settingsRoutePath}
toolsBackRoutePath={toolsBackRoutePath}
toolsRoutePath={toolsRoutePath}
/>
<SidebarInset>
<div
ref={contentShellRef}
data-testid="app-layout-content-shell"
className="relative flex h-full min-h-0 min-w-0 w-full flex-col pt-[env(safe-area-inset-top)] pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)]"
>
{showHeader ? (
<AppHeader
usesDesktopChrome={usesDesktopChrome}
usesProjectChromeStyle={
isRootView || isArchivedView || isSettingsView
}
isSettingsView={isSettingsView}
projectId={projectId}
project={project}
pluginPanel={pluginPanel}
pluginPanelSubPath={pluginPanelSubPath}
meta={meta}
/>
) : null}
<main className="flex min-h-0 flex-1 flex-col p-4 md:p-5">
{children}
</main>
</div>
</SidebarInset>
<SidebarTriggerOverlay
reserveMacosTrafficLights={reserveMacosTrafficLights}
usesDesktopChrome={usesDesktopChrome}
/>
</SidebarStateBridge>
<ProjectPathDialog
target={quickCreateProject.projectPathDialog.target}
pending={quickCreateProject.isCreating}
Expand Down
96 changes: 96 additions & 0 deletions apps/app/src/components/promptbox/banner/AnimatedBody.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { workflowRow } from "@/test/fixtures/thread-timeline-rows";
import { AnimatedBody } from "./AnimatedBody";
import { ThreadBackgroundCommandsCard } from "./ThreadBackgroundCommandsCard";

afterEach(() => {
cleanup();
vi.useRealTimers();
});

describe("AnimatedBody", () => {
it("realizes the body on the first expand and retains it after collapse", () => {
const { rerender } = render(
<AnimatedBody
id="body"
labelledBy="toggle"
isExpanded={false}
collapsedBorder="none"
>
<span>expensive body</span>
</AnimatedBody>,
);
expect(screen.queryByText("expensive body")).toBeNull();

rerender(
<AnimatedBody
id="body"
labelledBy="toggle"
isExpanded
collapsedBorder="none"
>
<span>expensive body</span>
</AnimatedBody>,
);
expect(screen.getByText("expensive body")).not.toBeNull();

rerender(
<AnimatedBody
id="body"
labelledBy="toggle"
isExpanded={false}
collapsedBorder="none"
>
<span>expensive body</span>
</AnimatedBody>,
);
// Retained: re-opening must be instant.
expect(screen.getByText("expensive body")).not.toBeNull();
expect(
screen.getByRole("region", { hidden: true }).getAttribute("aria-hidden"),
).toBe("true");
});
});

describe("prompt-stack card bodies", () => {
it("does not mount per-row live durations for a collapsed background-activity card", () => {
vi.useFakeTimers();
const setInterval = vi.spyOn(globalThis, "setInterval");
const startedAt = Date.now() - 5_000;
function Card() {
const [isExpanded, setIsExpanded] = useState(false);
return (
<ThreadBackgroundCommandsCard
commands={[1, 2, 3].map((index) =>
workflowRow({
id: `wf_${index}`,
description: `Background agent ${index}`,
model: "haiku",
startedAt,
status: "pending",
taskStatus: "running",
taskType: "local_agent",
workflowName: null,
}),
)}
isExpanded={isExpanded}
onToggle={() => setIsExpanded((value) => !value)}
/>
);
}
render(<Card />);
// Collapsed: the rows (and their live durations) are not in the DOM, so
// no timer runs for a card nobody opened.
expect(screen.queryByText("Background agent 2")).toBeNull();
expect(setInterval).not.toHaveBeenCalled();

fireEvent.click(screen.getByRole("button", { expanded: false }));
expect(screen.getByText("Background agent 2")).not.toBeNull();
// Three live durations mounted; they share one 1 Hz ticker.
expect(setInterval).toHaveBeenCalledTimes(1);
});
});
61 changes: 61 additions & 0 deletions apps/app/src/components/promptbox/banner/AnimatedBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useState, type ReactNode } from "react";
import { cn } from "@bb/shared-ui/lib/utils";

export interface AnimatedBodyProps {
id: string;
labelledBy: string;
isExpanded: boolean;
/**
* `reserve` keeps a transparent 1px top border while collapsed so the card
* height does not jump by a pixel on expand; `none` draws the border only
* while expanded (the prompt-stack cards' existing look).
*/
collapsedBorder: "reserve" | "none";
children: ReactNode;
}

/**
* Collapsible card body with the shared grid-rows expand animation.
*
* Realizes `children` only after the first expand, then retains them. A
* collapsed body still costs layout for every node inside it, and the bodies
* behind the prompt-stack cards (agent trees, changed-file lists, per-row
* live durations) are the expensive part; the DOM must not carry them before
* anyone opens the card. Retained after the first open so re-expanding is
* instant.
*/
export function AnimatedBody({
id,
labelledBy,
isExpanded,
collapsedBorder,
children,
}: AnimatedBodyProps) {
const [hasRealizedBody, setHasRealizedBody] = useState(isExpanded);
if (isExpanded && !hasRealizedBody) {
setHasRealizedBody(true);
}
const isBodyRealized = hasRealizedBody || isExpanded;

return (
<section
id={id}
role="region"
aria-labelledby={labelledBy}
aria-hidden={!isExpanded}
className={cn(
"grid overflow-hidden transition-[grid-template-rows,opacity,border-color] duration-200 ease-out",
isExpanded
? "grid-rows-[1fr] border-t border-border opacity-100"
: cn(
"pointer-events-none grid-rows-[0fr] opacity-0",
collapsedBorder === "reserve" && "border-t border-transparent",
),
)}
>
<div className="overflow-hidden bg-popover">
{isBodyRealized ? children : null}
</div>
</section>
);
}
Loading