Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
41bad37
desktop: native window chrome follows bb's resolved theme
SawyerHood May 28, 2026
3e00417
app: make New Tab Create App tile keyboard-navigable
SawyerHood May 28, 2026
dfc5197
app: show installed-app icons on sidebar thread rows
SawyerHood May 28, 2026
141c90d
app: collapse the conversation pane to expand the secondary panel
SawyerHood May 29, 2026
eaf8108
app: guard sidebar resize over iframes
SawyerHood May 29, 2026
f10f27f
app: collapsed conversation tucks into a 48px left rail
SawyerHood May 29, 2026
07f8112
app: unify panel/collapse controls into one directional seam arrow
SawyerHood May 29, 2026
c1e8e4e
desktop: web browser surface in the secondary panel (v1)
SawyerHood May 29, 2026
d9eff20
app: navigate to created thread from composer (#70)
SawyerHood May 29, 2026
91a1e0d
docs: clarify bb apps need no web server (#71)
SawyerHood May 29, 2026
9757247
feat: swap a running thread's model + reasoning level (#75)
SawyerHood May 29, 2026
c79c409
feat: New Tab launcher Recent section (#77)
SawyerHood May 29, 2026
d251f45
feat: relocate panel toggle to header + rail chevrons, drop centered …
SawyerHood May 29, 2026
5221088
app: use a chat icon instead of the 'Conversation' label on the colla…
SawyerHood May 29, 2026
a4c0cf5
feat: closed right panel shows a PanelRight 'open panel' button in th…
SawyerHood May 29, 2026
309ada9
fix: desktop top chrome no longer collides with traffic lights when s…
SawyerHood May 29, 2026
88b9ccf
feat: apps as first-class sidebar rows nested under their manager (#83)
SawyerHood May 29, 2026
708f195
feat: app sidebar row feedback — plain glyph, no drag grip, click ope…
SawyerHood May 29, 2026
83576ba
feat: minimal bb-native browser New Tab screen, drop isolated-session…
SawyerHood May 29, 2026
6948cb7
fix: collapsed rail single seam edge + thinner (36px) (#89)
SawyerHood May 29, 2026
11e760a
fix: collapsed rail no longer cuts through macOS traffic lights (#91)
SawyerHood May 29, 2026
97e01f4
feat: secondary-panel tab overflow — horizontal scroll (#90)
SawyerHood May 29, 2026
563f391
feat: panel-header expand/collapse toggle + per-thread collapse + age…
SawyerHood May 29, 2026
2575b8b
fix: leading panel-header clears traffic lights + aligns with sidebar…
SawyerHood May 29, 2026
ccd7832
fix: browser tab no longer pops in/out on tab switch (#94)
SawyerHood May 29, 2026
5522ca8
app: drop redundant chevron from collapsed conversation rail (#98)
SawyerHood May 29, 2026
a937fbc
fix: browser tab no longer flashes during panel resize (#96)
SawyerHood May 29, 2026
9de8928
feat: setting to open chat http(s) links in the in-app browser (#100)
SawyerHood May 29, 2026
80411d0
fix: split secondary-panel tab scroll chevrons to flank the tabs (#103)
SawyerHood May 30, 2026
8fdde79
fix: solid background on tab-overflow scroll chevrons (#105)
SawyerHood May 30, 2026
e901cd9
polish: secondary-panel visual refinements
SawyerHood May 30, 2026
46913f6
test: drop stale quick-link test for removed New Tab feature
SawyerHood May 30, 2026
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
4 changes: 4 additions & 0 deletions apps/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AuthCallbackView } from "./views/AuthCallbackView";
import { RootComposeRoute } from "./views/RootComposeView";
import { QuickCreateProjectProvider } from "./hooks/useQuickCreateProject";
import { ProviderCliHealthToasts } from "./components/provider-cli/ProviderCliHealthToasts";
import { useDesktopThemeSync } from "./hooks/useDesktopThemeSync";
import {
useDesktopUpdateAvailableToast,
useUpdateAvailableToast,
Expand Down Expand Up @@ -96,6 +97,9 @@ export function App() {
useUpdateAvailableToast();
// Show a separate toast when the Electron shell reports a desktop update.
useDesktopUpdateAvailableToast();
// Keep the Electron window chrome (traffic lights, inactive title bar)
// in sync with bb's resolved theme.
useDesktopThemeSync();

return (
<QuickCreateProjectProvider>
Expand Down
18 changes: 16 additions & 2 deletions apps/app/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,23 @@
* toast in the stack.
*/
:where(
[data-sonner-toast].bb-app-toast[data-expanded="false"][data-front="false"]
) {
[data-sonner-toast].bb-app-toast[data-expanded="false"][data-front="false"]
) {
overflow: hidden;
border-radius: var(--radius-md);
}

/*
* Opt out of the global thin scrollbar for horizontally-scrolling strips
* (e.g. the secondary-panel tab strip) where the scrollbar would clutter a
* short row. The element still scrolls; only the scrollbar chrome is hidden.
*/
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}

.no-scrollbar::-webkit-scrollbar {
display: none;
}
}
78 changes: 77 additions & 1 deletion apps/app/src/components/layout/AppLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { Suspense, type ReactNode } from "react";
Expand All @@ -16,6 +17,7 @@ import type {
BbDesktopInfoChangeHandler,
SystemConfigResponse,
} from "@bb/server-contract";
import { createNoopDesktopBrowserApi } from "@/test/bb-desktop-test-utils";
import { afterEach, describe, expect, it } from "vitest";
import { QuickCreateProjectProvider } from "@/hooks/useQuickCreateProject";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
Expand All @@ -32,6 +34,7 @@ import {
} from "@/lib/bb-desktop";

interface RenderAppLayoutArgs {
children?: ReactNode;
desktopInfo: BbDesktopApi | null;
initialEntry: string;
}
Expand All @@ -40,6 +43,11 @@ interface TestProvidersProps {
children: ReactNode;
}

interface SidebarResizeEndScenario {
name: string;
finishDrag: () => void;
}

const testSystemConfig: SystemConfigResponse = {
featureFlags: {
askUserQuestion: false,
Expand All @@ -52,6 +60,7 @@ const testSystemConfig: SystemConfigResponse = {
function createBbDesktopApi(info: BbDesktopInfo): BbDesktopApi {
return {
...info,
browser: createNoopDesktopBrowserApi(),
async checkForUpdates() {
return info;
},
Expand All @@ -64,6 +73,9 @@ function createBbDesktopApi(info: BbDesktopInfo): BbDesktopApi {
onChange(_listener: BbDesktopInfoChangeHandler) {
return () => undefined;
},
setTheme() {
// no-op
},
};
}

Expand Down Expand Up @@ -113,7 +125,7 @@ async function renderAppLayout(args: RenderAppLayoutArgs): Promise<void> {
path="*"
element={
<AppLayout>
<div>Layout content</div>
{args.children ?? <div>Layout content</div>}
</AppLayout>
}
/>
Expand Down Expand Up @@ -313,4 +325,68 @@ describe("AppLayout desktop chrome", () => {
MACOS_SIDEBAR_TRIGGER_OFFSET_CLASS,
);
});

it.each<SidebarResizeEndScenario>([
{
name: "mouseup",
finishDrag: () => {
fireEvent.mouseUp(window, { clientX: 360 });
},
},
{
name: "window blur",
finishDrag: () => {
fireEvent.blur(window);
},
},
{
name: "Escape",
finishDrag: () => {
fireEvent.keyDown(window, { key: "Escape" });
},
},
])(
"disables iframe pointer events while sidebar resize is active and restores them after $name",
async ({ finishDrag }) => {
const iframePointerEventsNoneClass = "[&_iframe]:pointer-events-none";

await renderAppLayout({
desktopInfo: null,
initialEntry: "/projects/proj_sidebar_resize",
children: <iframe title="Status app" />,
});

const appLayoutRoot = await screen.findByTestId("app-layout-root");
const resizeHandle = screen.getByTestId("app-sidebar-resize-handle");
const iframe = screen.getByTitle("Status app");

expect(appLayoutRoot.contains(iframe)).toBe(true);
expect(appLayoutRoot.className).not.toContain(
iframePointerEventsNoneClass,
);

fireEvent.mouseDown(resizeHandle, {
buttons: 1,
clientX: 320,
clientY: 0,
});

await waitFor(() => {
expect(appLayoutRoot.className).toContain(
iframePointerEventsNoneClass,
);
});
expect(document.body.style.cursor).toBe("col-resize");

finishDrag();

await waitFor(() => {
expect(appLayoutRoot.className).not.toContain(
iframePointerEventsNoneClass,
);
});
expect(document.body.style.cursor).toBe("");
expect(document.body.style.userSelect).toBe("");
},
);
});
70 changes: 49 additions & 21 deletions apps/app/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { Fragment, type CSSProperties, type Ref, type ReactNode } from "react";
import {
Fragment,
type CSSProperties,
type MouseEvent as ReactMouseEvent,
type Ref,
type ReactNode,
} from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";
Expand Down Expand Up @@ -46,6 +52,7 @@ import {
import { useQuickCreateProjectController } from "@/hooks/useQuickCreateProject";
import { useStandardManagerTimelinePreference } from "@/lib/manager-timeline-view-preference";
import { useSetRootComposeProjectId } from "@/lib/root-compose-selection";
import { IFRAME_POINTER_EVENTS_NONE_CLASS } from "@/lib/iframe-drag-guard";

const SIDEBAR_WIDTH_KEY = "bb.sidebar.width";
const SIDEBAR_OPEN_KEY = "bb.sidebar.open";
Expand Down Expand Up @@ -98,16 +105,20 @@ const sidebarOpenAtom = atomWithStorage<boolean>(
);

interface SidebarStateBridgeProps {
className?: string;
providerRef: Ref<HTMLDivElement>;
style: CSSProperties;
children: ReactNode;
}

type SidebarResizeMouseEvent = ReactMouseEvent<HTMLDivElement>;

type SidebarProviderStyle = CSSProperties & {
"--sidebar-width": string;
};

function SidebarStateBridge({
className,
providerRef,
style,
children,
Expand All @@ -117,6 +128,8 @@ function SidebarStateBridge({
<SidebarProvider
ref={providerRef}
style={style}
className={className}
data-testid="app-layout-root"
open={open}
onOpenChange={setOpen}
>
Expand All @@ -135,6 +148,12 @@ function FloatingSidebarTrigger() {
);
}

function resetSidebarResizeDocumentState(): void {
document.body.classList.remove("sidebar-resizing");
document.body.style.cursor = "";
document.body.style.userSelect = "";
}

/**
* Desktop-only sidebar toggle, pinned to the window's top-left just right of
* the macOS traffic lights. Rendered once at the layout root — outside the
Expand Down Expand Up @@ -443,7 +462,7 @@ export function AppLayout({ children }: AppLayoutProps) {
})();

const handleResizeMouseDown = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
(event: SidebarResizeMouseEvent) => {
event.preventDefault();
setIsSidebarResizing(true);
startXRef.current = event.clientX;
Expand All @@ -455,6 +474,20 @@ export function AppLayout({ children }: AppLayoutProps) {
[],
);

const finishSidebarResize = useCallback(() => {
if (animationFrameRef.current !== null) {
window.cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
providerRef.current?.style.setProperty(
"--sidebar-width",
`${liveWidthRef.current}px`,
);
setSidebarWidth(liveWidthRef.current);
setIsSidebarResizing(false);
resetSidebarResizeDocumentState();
}, [setSidebarWidth]);

useEffect(() => {
if (!isSidebarResizing) return;

Expand All @@ -475,36 +508,28 @@ export function AppLayout({ children }: AppLayoutProps) {
}
};

const handleMouseUp = () => {
if (animationFrameRef.current !== null) {
window.cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
finishSidebarResize();
}
providerRef.current?.style.setProperty(
"--sidebar-width",
`${liveWidthRef.current}px`,
);
setSidebarWidth(liveWidthRef.current);
setIsSidebarResizing(false);
document.body.classList.remove("sidebar-resizing");
document.body.style.cursor = "";
document.body.style.userSelect = "";
};

window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
window.addEventListener("mouseup", finishSidebarResize);
window.addEventListener("blur", finishSidebarResize);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
window.removeEventListener("mouseup", finishSidebarResize);
window.removeEventListener("blur", finishSidebarResize);
window.removeEventListener("keydown", handleKeyDown);
if (animationFrameRef.current !== null) {
window.cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
document.body.classList.remove("sidebar-resizing");
document.body.style.cursor = "";
document.body.style.userSelect = "";
resetSidebarResizeDocumentState();
};
}, [isSidebarResizing, setSidebarWidth]);
}, [finishSidebarResize, isSidebarResizing]);

useEffect(() => {
liveWidthRef.current = sidebarWidth;
Expand All @@ -519,6 +544,9 @@ export function AppLayout({ children }: AppLayoutProps) {
<ProjectActionsProvider>
<ThreadActionsProvider>
<SidebarStateBridge
className={
isSidebarResizing ? IFRAME_POINTER_EVENTS_NONE_CLASS : undefined
}
providerRef={providerRef}
style={sidebarProviderStyle}
>
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/components/pickers/ModelReasoningPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ interface ModelReasoningPickerProps {
formatModelLabel?: (displayName: string) => string;
// Reasoning state — supported efforts are per-model, so callers derive
// these options from the SELECTED model and reconcile the level on model
// change via `reconcileReasoningLevel` in src/lib.
// change via `reconcileReasoningLevel` in @bb/domain.
reasoningValue: ReasoningLevel;
reasoningOptions: readonly PickerOption<ReasoningLevel>[];
onReasoningChange: (value: ReasoningLevel) => void;
Expand Down
Loading
Loading