From 41bad371c8d9b7524736d56e504784ed4d5895be Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 28 May 2026 15:28:47 -0700 Subject: [PATCH 01/32] desktop: native window chrome follows bb's resolved theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS traffic lights + native title-bar chrome tracked the OS appearance because the window was created without setting nativeTheme.themeSource (Electron defaults it to "system"). When the OS was dark but bb was set to light (or vice versa) the window chrome mismatched the bb UI. Bridge bb's resolved theme renderer → main over a new bb-desktop:set-theme IPC channel (validated via a shared zod enum): a useDesktopThemeSync() hook pushes usePreferredTheme() to window.bbDesktop.setTheme on mount and on every change (including OS appearance changes when the preference is "system"); main assigns nativeTheme.themeSource. The log viewer window inherits it since themeSource is app-global. No-ops cleanly in the web build. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/app/src/App.tsx | 4 + .../src/components/layout/AppLayout.test.tsx | 3 + .../ThreadSecondaryPanel.test.tsx | 3 + .../src/hooks/useDesktopThemeSync.test.tsx | 122 ++++++++++++++++++ apps/app/src/hooks/useDesktopThemeSync.ts | 17 +++ .../hooks/useUpdateAvailableToast.test.tsx | 3 + apps/desktop/src/desktop-update-ipc.ts | 1 + apps/desktop/src/main.ts | 15 ++- apps/desktop/src/preload.ts | 5 + packages/server-contract/src/api-types.ts | 10 ++ packages/server-contract/src/index.ts | 2 + 11 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 apps/app/src/hooks/useDesktopThemeSync.test.tsx create mode 100644 apps/app/src/hooks/useDesktopThemeSync.ts diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 84a192c49..2283307a3 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -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, @@ -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 ( diff --git a/apps/app/src/components/layout/AppLayout.test.tsx b/apps/app/src/components/layout/AppLayout.test.tsx index e5e8629b9..abf046791 100644 --- a/apps/app/src/components/layout/AppLayout.test.tsx +++ b/apps/app/src/components/layout/AppLayout.test.tsx @@ -64,6 +64,9 @@ function createBbDesktopApi(info: BbDesktopInfo): BbDesktopApi { onChange(_listener: BbDesktopInfoChangeHandler) { return () => undefined; }, + setTheme() { + // no-op + }, }; } diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.tsx index 58edd6bbe..3315f2dcf 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.tsx @@ -76,6 +76,9 @@ function createBbDesktopApi(info: BbDesktopInfo): BbDesktopApi { onChange(_listener: BbDesktopInfoChangeHandler) { return () => undefined; }, + setTheme() { + // no-op + }, }; } diff --git a/apps/app/src/hooks/useDesktopThemeSync.test.tsx b/apps/app/src/hooks/useDesktopThemeSync.test.tsx new file mode 100644 index 000000000..a8840b72a --- /dev/null +++ b/apps/app/src/hooks/useDesktopThemeSync.test.tsx @@ -0,0 +1,122 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + BbDesktopApi, + BbDesktopInfo, + BbDesktopInfoChangeHandler, + BbDesktopTheme, +} from "@bb/server-contract"; + +interface DesktopApiStub { + api: BbDesktopApi; + setThemeCalls: BbDesktopTheme[]; +} + +function createDesktopApiStub(): DesktopApiStub { + const setThemeCalls: BbDesktopTheme[] = []; + const initialInfo: BbDesktopInfo = { + lastCheckedAt: null, + latestVersion: null, + pendingVersion: null, + platform: "macos", + updateAvailable: false, + updateDownloaded: false, + version: "0.0.1", + }; + const api: BbDesktopApi = { + get lastCheckedAt() { + return initialInfo.lastCheckedAt; + }, + get latestVersion() { + return initialInfo.latestVersion; + }, + get pendingVersion() { + return initialInfo.pendingVersion; + }, + platform: "macos", + get updateAvailable() { + return initialInfo.updateAvailable; + }, + get updateDownloaded() { + return initialInfo.updateDownloaded; + }, + get version() { + return initialInfo.version; + }, + async checkForUpdates() { + return initialInfo; + }, + async getInfo() { + return initialInfo; + }, + async installUpdate() { + // no-op + }, + onChange(_listener: BbDesktopInfoChangeHandler) { + return () => { + // no-op + }; + }, + setTheme(theme: BbDesktopTheme): void { + setThemeCalls.push(theme); + }, + }; + return { api, setThemeCalls }; +} + +async function loadModules() { + const { useDesktopThemeSync } = await import("./useDesktopThemeSync"); + const { setPreferredTheme } = await import("./useTheme"); + return { setPreferredTheme, useDesktopThemeSync }; +} + +afterEach(() => { + cleanup(); + delete window.bbDesktop; + window.localStorage.clear(); + vi.resetModules(); +}); + +describe("useDesktopThemeSync", () => { + beforeEach(() => { + delete window.bbDesktop; + window.localStorage.clear(); + }); + + it("pushes the resolved theme to the desktop bridge on mount", async () => { + const desktopStub = createDesktopApiStub(); + window.bbDesktop = desktopStub.api; + + const { useDesktopThemeSync } = await loadModules(); + renderHook(() => useDesktopThemeSync()); + + expect(desktopStub.setThemeCalls).toEqual(["light"]); + }); + + it("pushes the new resolved theme when the preference changes", async () => { + const desktopStub = createDesktopApiStub(); + window.bbDesktop = desktopStub.api; + + const { setPreferredTheme, useDesktopThemeSync } = await loadModules(); + renderHook(() => useDesktopThemeSync()); + + expect(desktopStub.setThemeCalls).toEqual(["light"]); + + act(() => { + setPreferredTheme("dark"); + }); + + expect(desktopStub.setThemeCalls).toEqual(["light", "dark"]); + }); + + it("no-ops when the desktop bridge is absent", async () => { + const { useDesktopThemeSync } = await loadModules(); + expect(window.bbDesktop).toBeUndefined(); + + expect(() => + renderHook(() => useDesktopThemeSync()), + ).not.toThrow(); + }); +}); diff --git a/apps/app/src/hooks/useDesktopThemeSync.ts b/apps/app/src/hooks/useDesktopThemeSync.ts new file mode 100644 index 000000000..100b181e1 --- /dev/null +++ b/apps/app/src/hooks/useDesktopThemeSync.ts @@ -0,0 +1,17 @@ +import { useEffect } from "react"; +import { getBbDesktopInfo } from "@/lib/bb-desktop"; +import { usePreferredTheme } from "./useTheme"; + +/** + * Push the renderer-resolved theme to the Electron main process so the + * NSWindow chrome (traffic lights + inactive title-bar) follows bb's theme + * rather than the OS appearance. Mounts once at the app root; safely no-ops + * in the web build where `window.bbDesktop` is undefined. + */ +export function useDesktopThemeSync(): void { + const theme = usePreferredTheme(); + useEffect(() => { + const desktopApi = getBbDesktopInfo(); + desktopApi?.setTheme(theme); + }, [theme]); +} diff --git a/apps/app/src/hooks/useUpdateAvailableToast.test.tsx b/apps/app/src/hooks/useUpdateAvailableToast.test.tsx index fe8a1601a..38c5edf66 100644 --- a/apps/app/src/hooks/useUpdateAvailableToast.test.tsx +++ b/apps/app/src/hooks/useUpdateAvailableToast.test.tsx @@ -204,6 +204,9 @@ function createDesktopApiStub(initialInfo: BbDesktopInfo): DesktopApiStub { listeners.delete(listener); }; }, + setTheme() { + // no-op + }, }; return { api, diff --git a/apps/desktop/src/desktop-update-ipc.ts b/apps/desktop/src/desktop-update-ipc.ts index 6939530b1..e03f0508b 100644 --- a/apps/desktop/src/desktop-update-ipc.ts +++ b/apps/desktop/src/desktop-update-ipc.ts @@ -3,3 +3,4 @@ export const BB_DESKTOP_CHECK_FOR_UPDATES_CHANNEL = export const BB_DESKTOP_GET_INFO_CHANNEL = "bb-desktop:get-info"; export const BB_DESKTOP_INFO_CHANGED_CHANNEL = "bb-desktop:info-changed"; export const BB_DESKTOP_INSTALL_UPDATE_CHANNEL = "bb-desktop:install-update"; +export const BB_DESKTOP_SET_THEME_CHANNEL = "bb-desktop:set-theme"; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2abc2f764..db9c74c99 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -7,12 +7,13 @@ import { clipboard, ipcMain, nativeImage, + nativeTheme, session, shell, type Event, } from "electron"; import { autoUpdater } from "electron-updater"; -import type { BbDesktopInfo } from "@bb/server-contract"; +import { bbDesktopThemeSchema, type BbDesktopInfo } from "@bb/server-contract"; import { z } from "zod"; import { assertPathExists, @@ -65,6 +66,7 @@ import { BB_DESKTOP_GET_INFO_CHANNEL, BB_DESKTOP_INFO_CHANGED_CHANNEL, BB_DESKTOP_INSTALL_UPDATE_CHANNEL, + BB_DESKTOP_SET_THEME_CHANNEL, } from "./desktop-update-ipc.js"; import { ensurePackagedMacOsUserShellPath } from "./desktop-shell-path.js"; import { clearPackagedSessionHttpCache } from "./desktop-session-cache.js"; @@ -656,6 +658,17 @@ function registerDesktopUpdateIpc(): void { await finishQuit(); desktopAutoUpdateService.installUpdate(); }); + // Renderer pushes the resolved bb theme so the NSWindow appearance — + // traffic lights and inactive title-bar chrome — follows bb's theme + // rather than the OS appearance. `themeSource` is app-global so a single + // assignment covers every BrowserWindow, including the log viewer. + ipcMain.on(BB_DESKTOP_SET_THEME_CHANNEL, (_event, payload: unknown) => { + const parsed = bbDesktopThemeSchema.safeParse(payload); + if (!parsed.success) { + return; + } + nativeTheme.themeSource = parsed.data; + }); } async function startOwnedRuntime( diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index e98f19b45..556175e99 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -5,12 +5,14 @@ import { type BbDesktopInfo, type BbDesktopInfoChangeHandler, type BbDesktopInfoUnsubscribe, + type BbDesktopTheme, } from "@bb/server-contract"; import { BB_DESKTOP_CHECK_FOR_UPDATES_CHANNEL, BB_DESKTOP_GET_INFO_CHANNEL, BB_DESKTOP_INFO_CHANGED_CHANNEL, BB_DESKTOP_INSTALL_UPDATE_CHANNEL, + BB_DESKTOP_SET_THEME_CHANNEL, } from "./desktop-update-ipc.js"; function getDesktopVersion(version: string | undefined): string { @@ -101,6 +103,9 @@ const bbDesktopApi: BbDesktopApi = { listeners.delete(listener); }; }, + setTheme(theme: BbDesktopTheme): void { + ipcRenderer.send(BB_DESKTOP_SET_THEME_CHANNEL, theme); + }, }; ipcRenderer.on(BB_DESKTOP_INFO_CHANGED_CHANNEL, (_event, payload: unknown) => { diff --git a/packages/server-contract/src/api-types.ts b/packages/server-contract/src/api-types.ts index 4a5a2ea9f..93e4ec90c 100644 --- a/packages/server-contract/src/api-types.ts +++ b/packages/server-contract/src/api-types.ts @@ -117,6 +117,9 @@ export const bbDesktopInfoSchema = z.object({ }); export type BbDesktopInfo = z.infer; +export const bbDesktopThemeSchema = z.enum(["light", "dark"]); +export type BbDesktopTheme = z.infer; + export type BbDesktopInfoChangeHandler = (info: BbDesktopInfo) => void; export type BbDesktopInfoUnsubscribe = () => void; @@ -125,6 +128,13 @@ export interface BbDesktopApi extends BbDesktopInfo { getInfo(): Promise; installUpdate(): Promise; onChange(listener: BbDesktopInfoChangeHandler): BbDesktopInfoUnsubscribe; + /** + * Push the renderer-resolved theme to the Electron main process so the + * NSWindow appearance — traffic lights and inactive title-bar chrome — + * follows bb's theme rather than the OS appearance. No-op on the web build + * where `window.bbDesktop` is undefined. + */ + setTheme(theme: BbDesktopTheme): void; } // --- Thread creation: environment + workspace discriminated unions --- diff --git a/packages/server-contract/src/index.ts b/packages/server-contract/src/index.ts index 3de829129..e2b5021f7 100644 --- a/packages/server-contract/src/index.ts +++ b/packages/server-contract/src/index.ts @@ -153,6 +153,7 @@ export { automationValidationIssueSchema, automationValidationSchema, bbDesktopInfoSchema, + bbDesktopThemeSchema, bbDesktopVersionFeedFileSchema, bbDesktopVersionFeedSchema, commitActionResponseSchema, @@ -323,6 +324,7 @@ export type { BbDesktopInfo, BbDesktopInfoChangeHandler, BbDesktopInfoUnsubscribe, + BbDesktopTheme, BbDesktopVersionFeed, BbDesktopVersionFeedFile, CommitActionResponse, From 3e00417d32b131c04399c363b585e7f2a9a7760c Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 28 May 2026 16:00:39 -0700 Subject: [PATCH 02/32] app: make New Tab Create App tile keyboard-navigable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Create App… tile was skipped by the launcher's ArrowUp/ArrowDown + Enter active-descendant flow because it rendered as a plain button outside the listbox. Fold it into the navigation model via a section-layer discriminated union (FileSearchSectionEntry = {kind:"suggestion"} | {kind:"create-app"}) so the data-layer suggestion union stays honest; one navigableEntries index space drives keyboard nav, with Create App appended to the end of the Apps section (reachable even in the empty state). Extract a shared LauncherTile shell so the app row and Create App tile share one role/aria/active contract; the tile now renders role="option" inside the listbox and aria-activedescendant resolves to its stable id. Enter routes to the same prefill path as click (confirm + attachment-clear preserved). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../secondary-panel/NewTabFileSearch.tsx | 302 ++++++++++++------ .../secondary-panel/NewTabPage.test.tsx | 52 ++- 2 files changed, 253 insertions(+), 101 deletions(-) diff --git a/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx b/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx index a8c3f504a..ba2b1f086 100644 --- a/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx +++ b/apps/app/src/components/secondary-panel/NewTabFileSearch.tsx @@ -5,6 +5,7 @@ import { useRef, useState, type KeyboardEvent, + type ReactNode, } from "react"; import type { ThreadType } from "@bb/domain"; import { Icon } from "@/components/ui/icon.js"; @@ -75,8 +76,17 @@ interface FileSearchMessageProps { message: string; } +/** + * A navigable entry in a section. Search results carry a {@link FileSearchSuggestion}; + * the synthetic Create App action carries no data and routes to the prefill flow. + * Keeping both in one union lets the keyboard handler walk a single index space. + */ +type FileSearchSectionEntry = + | { kind: "suggestion"; suggestion: FileSearchSuggestion } + | { kind: "create-app" }; + interface FileSearchSectionItem { - suggestion: FileSearchSuggestion; + entry: FileSearchSectionEntry; index: number; } @@ -107,10 +117,23 @@ interface GetAvailableFileSearchSourcesArgs { interface GroupFileSearchSectionsArgs { suggestions: readonly FileSearchSuggestion[]; availableSources: readonly FileSearchSource[]; + includeCreateAppEntry: boolean; +} + +interface LauncherTileProps { + id: string; + isActive: boolean; + onActivate: () => void; + onSelect: () => void; + title?: string; + children: ReactNode; } interface CreateAppTileProps { - onClick: () => void; + id: string; + isActive: boolean; + onActivate: () => void; + onSelect: () => void; } const FILE_SEARCH_LIMIT = 20; @@ -130,6 +153,8 @@ const FILE_SEARCH_SOURCE_LABELS = { "thread-storage": "Manager Storage", } satisfies Record; +const CREATE_APP_ENTRY_ID = "file-search-result-create-app"; + 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 = @@ -173,6 +198,13 @@ function getFileSearchResultId(suggestion: FileSearchSuggestion): string { )}`; } +function getFileSearchEntryId(entry: FileSearchSectionEntry): string { + if (entry.kind === "create-app") { + return CREATE_APP_ENTRY_ID; + } + return getFileSearchResultId(entry.suggestion); +} + function splitPath(path: string): SplitPathResult { const lastSlash = path.lastIndexOf("/"); if (lastSlash === -1) { @@ -199,30 +231,45 @@ function getFileSearchSectionKind( function groupFileSearchSections({ availableSources, + includeCreateAppEntry, suggestions, }: GroupFileSearchSectionsArgs): FileSearchSection[] { const allowedSources = new Set(availableSources); const sectionsByKind = new Map(); - for (const suggestion of suggestions) { - const source = suggestion.source; - if (!allowedSources.has(source)) { - continue; - } - const sectionKind = getFileSearchSectionKind(suggestion); + const ensureSection = ( + sectionKind: FileSearchSectionKind, + ): FileSearchSection => { const existing = sectionsByKind.get(sectionKind); if (existing) { - existing.items.push({ - suggestion, - index: existing.items.length, - }); - continue; + return existing; } - - sectionsByKind.set(sectionKind, { + const created: FileSearchSection = { kind: sectionKind, label: FILE_SEARCH_SECTION_LABELS[sectionKind], - items: [{ suggestion, index: 0 }], + items: [], + }; + sectionsByKind.set(sectionKind, created); + return created; + }; + + for (const suggestion of suggestions) { + if (!allowedSources.has(suggestion.source)) { + continue; + } + ensureSection(getFileSearchSectionKind(suggestion)).items.push({ + entry: { kind: "suggestion", suggestion }, + index: 0, + }); + } + + if (includeCreateAppEntry) { + // The Create App action sits at the end of the Apps section so arrowing + // down through the real app rows lands on it last. The section is created + // even when there are no app rows so it stays reachable in the empty state. + ensureSection("apps").items.push({ + entry: { kind: "create-app" }, + index: 0, }); } @@ -235,10 +282,10 @@ function groupFileSearchSections({ return [ { ...section, - items: section.items.map(({ suggestion }) => { + items: section.items.map(({ entry }) => { const index = nextIndex; nextIndex += 1; - return { suggestion, index }; + return { entry, index }; }), }, ]; @@ -260,32 +307,59 @@ function FileSearchMessage({ ); } -function AppResultRow({ +/** + * Shared button shell for the Apps-section launcher tiles (app rows and the + * Create App action). Centralizing it keeps the listbox option/keyboard + * contract — `role="option"`, `aria-selected`, `id`, hover-to-activate — + * identical across every navigable tile. + */ +function LauncherTile({ id, - suggestion, isActive, onActivate, onSelect, -}: AppResultRowProps) { - const handleSelect = useCallback(() => { - onSelect(suggestion); - }, [onSelect, suggestion]); - const showAppId = suggestion.appId !== slugifyAppName(suggestion.name); - + title, + children, +}: LauncherTileProps) { return ( + ); +} + +function AppResultRow({ + id, + suggestion, + isActive, + onActivate, + onSelect, +}: AppResultRowProps) { + const handleSelect = useCallback(() => { + onSelect(suggestion); + }, [onSelect, suggestion]); + const showAppId = suggestion.appId !== slugifyAppName(suggestion.name); + + return ( + ) : null} - + ); } -function CreateAppTile({ onClick }: CreateAppTileProps) { +function CreateAppTile({ + id, + isActive, + onActivate, + onSelect, +}: CreateAppTileProps) { return ( - + ); } @@ -412,28 +492,38 @@ export function NewTabFileSearch({ }), [currentThreadId, currentThreadType, projectId], ); - const sections = useMemo( - () => groupFileSearchSections({ availableSources, suggestions }), - [availableSources, suggestions], + const hasAppSuggestions = useMemo( + () => suggestions.some((suggestion) => suggestion.entryKind === "app"), + [suggestions], ); - const hasAppsSection = sections.some((section) => section.kind === "apps"); - const showAppsSectionShell = + // Mirror the visible Apps section: offer Create App when nothing is typed, or + // when the query actually matches apps — never alongside a files-only result. + const showCreateAppEntry = !isUnavailable && canPrefillCreateAppPrompt && - (!hasQuery || hasAppsSection); - const visualSuggestions = useMemo( + (!hasQuery || hasAppSuggestions); + const sections = useMemo( + () => + groupFileSearchSections({ + availableSources, + includeCreateAppEntry: showCreateAppEntry, + suggestions, + }), + [availableSources, showCreateAppEntry, suggestions], + ); + const navigableEntries = useMemo( () => sections.flatMap((section) => - section.items.map(({ suggestion }) => suggestion), + section.items.map(({ entry }) => entry), ), [sections], ); - const activeSuggestion = useMemo( + const activeEntry = useMemo( () => - activeIndex >= 0 && activeIndex < visualSuggestions.length - ? (visualSuggestions[activeIndex] ?? null) + activeIndex >= 0 && activeIndex < navigableEntries.length + ? (navigableEntries[activeIndex] ?? null) : null, - [activeIndex, visualSuggestions], + [activeIndex, navigableEntries], ); useEffect(() => { @@ -448,8 +538,8 @@ export function NewTabFileSearch({ }, [focusRequest]); useEffect(() => { - setActiveIndex(visualSuggestions.length > 0 ? 0 : -1); - }, [visualSuggestions]); + setActiveIndex(navigableEntries.length > 0 ? 0 : -1); + }, [navigableEntries]); const handleAppSelect = useCallback( (suggestion: AppSearchSuggestion) => { @@ -497,30 +587,39 @@ export function NewTabFileSearch({ const handleInputKeyDown = useCallback( (event) => { - if (visualSuggestions.length === 0) { + if (navigableEntries.length === 0) { return; } if (event.key === "ArrowDown") { event.preventDefault(); - setActiveIndex((current) => (current + 1) % visualSuggestions.length); + setActiveIndex((current) => (current + 1) % navigableEntries.length); return; } if (event.key === "ArrowUp") { event.preventDefault(); setActiveIndex((current) => - current <= 0 ? visualSuggestions.length - 1 : current - 1, + current <= 0 ? navigableEntries.length - 1 : current - 1, ); return; } - if (event.key === "Enter" && activeSuggestion) { + if (event.key === "Enter" && activeEntry) { event.preventDefault(); - handleSuggestionSelect(activeSuggestion); + if (activeEntry.kind === "create-app") { + handleCreateAppPromptPrefill(); + return; + } + handleSuggestionSelect(activeEntry.suggestion); } }, - [activeSuggestion, handleSuggestionSelect, visualSuggestions.length], + [ + activeEntry, + handleCreateAppPromptPrefill, + handleSuggestionSelect, + navigableEntries.length, + ], ); return ( @@ -538,9 +637,7 @@ export function NewTabFileSearch({ disabled={isUnavailable} aria-label="Search apps and files" aria-activedescendant={ - activeSuggestion - ? getFileSearchResultId(activeSuggestion) - : undefined + activeEntry ? getFileSearchEntryId(activeEntry) : undefined } placeholder={ isUnavailable ? "No searchable source" : "Search apps and files" @@ -563,7 +660,6 @@ export function NewTabFileSearch({ ) : ( )} @@ -581,7 +676,6 @@ export function NewTabFileSearch({ interface NewTabResultsProps { activeIndex: number; - canCreateApp: boolean; hasQuery: boolean; isError: boolean; isLoading: boolean; @@ -590,12 +684,10 @@ interface NewTabResultsProps { onCreateApp: () => void; onFileSelect: (suggestion: FilePathSearchSuggestion) => void; sections: readonly FileSearchSection[]; - showAppsSectionShell: boolean; } function NewTabResults({ activeIndex, - canCreateApp, hasQuery, isError, isLoading, @@ -604,11 +696,12 @@ function NewTabResults({ onCreateApp, onFileSelect, sections, - showAppsSectionShell, }: NewTabResultsProps) { const appsSection = sections.find((section) => section.kind === "apps"); const filesSection = sections.find((section) => section.kind === "files"); - const showAppsSection = showAppsSectionShell || appsSection !== undefined; + // The Apps section now owns the Create App entry, so its mere presence (real + // app rows and/or the Create App action) is enough to render the shell. + const showAppsSection = appsSection !== undefined; const showFilesSection = filesSection !== undefined; const showLoading = isLoading && !showFilesSection; const showError = isError && !showFilesSection && !showLoading; @@ -630,40 +723,46 @@ function NewTabResults({ return (
- {showAppsSection ? ( + {appsSection ? (
{FILE_SEARCH_SECTION_LABELS.apps}
- {appsSection && appsSection.items.length > 0 ? ( -
- {appsSection.items.map(({ suggestion, index }) => - suggestion.entryKind === "app" ? ( - + {appsSection.items.map(({ entry, index }) => { + const isActive = index === activeIndex; + const id = getFileSearchEntryId(entry); + if (entry.kind === "create-app") { + return ( + onActivateIndex(index)} - onSelect={onAppSelect} + onSelect={onCreateApp} /> - ) : null, - )} -
- ) : null} - {canCreateApp ? ( -
0 && "mt-px", - )} - > - -
- ) : null} + ); + } + if (entry.suggestion.entryKind !== "app") { + return null; + } + const suggestion = entry.suggestion; + return ( + onActivateIndex(index)} + onSelect={onAppSelect} + /> + ); + })} +
) : null} @@ -677,18 +776,25 @@ function NewTabResults({ aria-label={FILE_SEARCH_SECTION_LABELS.files} className="flex flex-col gap-px" > - {filesSection.items.map(({ suggestion, index }) => - suggestion.entryKind === "file" ? ( + {filesSection.items.map(({ entry, index }) => { + if ( + entry.kind !== "suggestion" || + entry.suggestion.entryKind !== "file" + ) { + return null; + } + const suggestion = entry.suggestion; + return ( onActivateIndex(index)} onSelect={onFileSelect} /> - ) : null, - )} + ); + })} ) : showLoading || showError ? ( diff --git a/apps/app/src/components/secondary-panel/NewTabPage.test.tsx b/apps/app/src/components/secondary-panel/NewTabPage.test.tsx index 875cfd1de..179bca612 100644 --- a/apps/app/src/components/secondary-panel/NewTabPage.test.tsx +++ b/apps/app/src/components/secondary-panel/NewTabPage.test.tsx @@ -188,7 +188,7 @@ describe("NewTabPage", () => { vi.mocked(api.listThreadApps).mockResolvedValue([]); renderNewTabPage({ projectId: "proj-1" }); - fireEvent.click(screen.getByRole("button", { name: /Create App/u })); + fireEvent.click(screen.getByRole("option", { name: /Create App/u })); expect(getStoredThreadDraft()).toEqual({ text: CREATE_APP_PROMPT_TEMPLATE, @@ -212,7 +212,7 @@ describe("NewTabPage", () => { setStoredThreadDraft(DRAFT_WITH_ATTACHMENT); renderNewTabPage({ projectId: "proj-1" }); - fireEvent.click(screen.getByRole("button", { name: /Create App/u })); + fireEvent.click(screen.getByRole("option", { name: /Create App/u })); expect(getStoredThreadDraft()).toEqual(DRAFT_WITH_ATTACHMENT); }); @@ -223,7 +223,53 @@ describe("NewTabPage", () => { setStoredThreadDraft(DRAFT_WITH_ATTACHMENT); renderNewTabPage({ projectId: "proj-1" }); - fireEvent.click(screen.getByRole("button", { name: /Create App/u })); + fireEvent.click(screen.getByRole("option", { name: /Create App/u })); + + expect(getStoredThreadDraft()).toEqual({ + text: CREATE_APP_PROMPT_TEMPLATE, + attachments: [], + }); + }); + + it("arrows past the app rows onto the create-app entry as the last option", async () => { + vi.mocked(api.listThreadApps).mockResolvedValue([STATUS_APP]); + renderNewTabPage({ projectId: "proj-1" }); + + const input = screen.getByRole("textbox", { + name: "Search apps and files", + }); + const appOption = await screen.findByRole("option", { name: /Status/u }); + const createAppOption = screen.getByRole("option", { + name: /Create App/u, + }); + + // The first navigable entry is the real app row; Create App sits after it. + expect(input.getAttribute("aria-activedescendant")).toBe(appOption.id); + expect(appOption.getAttribute("aria-selected")).toBe("true"); + expect(createAppOption.getAttribute("aria-selected")).toBe("false"); + + fireEvent.keyDown(input, { key: "ArrowDown" }); + + // Arrowing down lands the active descendant on the Create App tile. + expect(createAppOption.id).toBe("file-search-result-create-app"); + expect(input.getAttribute("aria-activedescendant")).toBe( + createAppOption.id, + ); + expect(createAppOption.getAttribute("aria-selected")).toBe("true"); + expect(appOption.getAttribute("aria-selected")).toBe("false"); + }); + + it("prefills the composer draft when the create-app entry is activated by Enter", async () => { + vi.mocked(api.listThreadApps).mockResolvedValue([STATUS_APP]); + renderNewTabPage({ projectId: "proj-1" }); + + const input = screen.getByRole("textbox", { + name: "Search apps and files", + }); + await screen.findByRole("option", { name: /Status/u }); + + fireEvent.keyDown(input, { key: "ArrowDown" }); + fireEvent.keyDown(input, { key: "Enter" }); expect(getStoredThreadDraft()).toEqual({ text: CREATE_APP_PROMPT_TEMPLATE, From dfc5197511751fa94f5588b1a86308969900ec51 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 28 May 2026 16:44:35 -0700 Subject: [PATCH 03/32] app: show installed-app icons on sidebar thread rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manager thread rows now show a cluster of their installed-app icons at the trailing edge, left of the branch/environment icon (the status glyph stays far-right). Click an icon to open that app in the secondary panel (navigating to the thread if needed). Visible icons cap at 3 with an informational +N chip whose tooltip names the hidden apps; empty rows render nothing. The apps query + open-app hooks live inside ThreadRowAppCluster, which ThreadRow mounts ONLY for manager rows — so non-manager rows never instantiate a useThreadApps observer or cache entry (only managers have apps today). The 30s staleTime is centralized as the useThreadApps default so the sidebar and detail view share one cache window. Reuses the secondary panel's open-app path via an extracted useOpenThreadAppTab(threadId) hook. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../secondary-panel/useThreadFileTabs.ts | 58 ++-- .../components/sidebar/ThreadRow.stories.tsx | 151 +++++++++- .../src/components/sidebar/ThreadRow.test.tsx | 268 ++++++++++++++++++ apps/app/src/components/sidebar/ThreadRow.tsx | 4 + .../sidebar/ThreadRowAppCluster.tsx | 115 ++++++++ apps/app/src/hooks/queries/thread-queries.ts | 10 +- 6 files changed, 582 insertions(+), 24 deletions(-) create mode 100644 apps/app/src/components/sidebar/ThreadRow.test.tsx create mode 100644 apps/app/src/components/sidebar/ThreadRowAppCluster.tsx diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts index ecc890f55..5fbacde75 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts @@ -367,6 +367,41 @@ function buildOrderedSecondaryFileTabs({ ]; } +/** + * Opens (or re-activates) an app tab in a thread's secondary panel and reveals + * the panel. Keyed by `threadId`, so callers outside the active thread detail + * view — e.g. the sidebar app-icon cluster — can drive the same panel state the + * detail view reads. This is the canonical open-app-tab path; `useThreadFileTabs` + * exposes it as `openApp`. + */ +export function useOpenThreadAppTab( + threadId: string | null | undefined, +): (appId: string) => void { + const updateFixedPanelTabsState = useUpdateFixedPanelTabsState(threadId); + return useCallback( + (appId: string) => { + const nextTab = createAppTab(appId); + updateFixedPanelTabsState((state) => { + const tabs = upsertSecondaryTab(state.secondary.tabs, nextTab); + if ( + tabs === state.secondary.tabs && + state.secondary.activeTabId === nextTab.id && + state.secondary.isOpen + ) { + return state; + } + return setSecondaryTabs({ + activeTabId: nextTab.id, + isOpen: true, + state, + tabs, + }); + }); + }, + [updateFixedPanelTabsState], + ); +} + export function useThreadFileTabs({ apps, threadId, @@ -673,28 +708,7 @@ export function useThreadFileTabs({ [updateFixedPanelTabsState], ); - const openApp = useCallback( - (appId: string) => { - const nextTab = createAppTab(appId); - updateFixedPanelTabsState((state) => { - const tabs = upsertSecondaryTab(state.secondary.tabs, nextTab); - if ( - tabs === state.secondary.tabs && - state.secondary.activeTabId === nextTab.id && - state.secondary.isOpen - ) { - return state; - } - return setSecondaryTabs({ - activeTabId: nextTab.id, - isOpen: true, - state, - tabs, - }); - }); - }, - [updateFixedPanelTabsState], - ); + const openApp = useOpenThreadAppTab(threadId); const closeAppTab = useCallback( (appId: string) => { diff --git a/apps/app/src/components/sidebar/ThreadRow.stories.tsx b/apps/app/src/components/sidebar/ThreadRow.stories.tsx index 16816c55b..9f4af160e 100644 --- a/apps/app/src/components/sidebar/ThreadRow.stories.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.stories.tsx @@ -1,8 +1,12 @@ -import type { ReactNode } from "react"; +import { useMemo, type ReactNode } from "react"; +import { QueryClientProvider } from "@tanstack/react-query"; import type { ThreadListEntry } from "@bb/domain"; +import type { AppSummary } from "@bb/server-contract"; import { makeThreadListEntry } from "../../../.ladle/story-fixtures"; import { SidebarMenu, SidebarMenuItem } from "@/components/ui/sidebar.js"; import { ThreadActionsProvider } from "@/components/thread/ThreadActionsProvider"; +import { createAppQueryClient } from "@/lib/query-client"; +import { threadAppsQueryKey } from "@/hooks/queries/query-keys"; import { ThreadRow, type ThreadRowOptions } from "./ThreadRow"; import { NO_COLLAPSED_CHILD_ACTIVITY, @@ -355,3 +359,148 @@ export function Overview() { ); } + +// --- App-icon cluster ------------------------------------------------------- +// Only managers have apps today, so these rows are managers whose installed +// apps are seeded into the thread-apps query cache (the same query the row +// reads in production). The cluster renders left of the trailing branch icon. + +interface MakeAppArgs { + id: string; + name: string; + icon: AppSummary["icon"]; +} + +function makeApp({ id, name, icon }: MakeAppArgs): AppSummary { + return { + id, + name, + entry: { path: "index.html", kind: "html" }, + capabilities: [], + icon, + }; +} + +const APP_FIXTURES = { + status: makeApp({ + id: "status", + name: "Status", + icon: { kind: "builtin", name: "ListTodo" }, + }), + terminal: makeApp({ + id: "terminal", + name: "Terminal", + icon: { kind: "builtin", name: "Terminal" }, + }), + notes: makeApp({ + id: "notes", + name: "Notes", + icon: { kind: "builtin", name: "File" }, + }), + preview: makeApp({ + id: "preview", + name: "Preview", + icon: { kind: "builtin", name: "GridView" }, + }), + deploy: makeApp({ + id: "deploy", + name: "Deploy", + icon: { kind: "builtin", name: "Zap" }, + }), +} as const; + +interface AppRowSeed { + id: string; + title: string; + apps: AppSummary[]; + hint: string; +} + +const APP_ROW_SEEDS: readonly AppRowSeed[] = [ + { + id: "thr_apps_none", + title: "Update API docs", + apps: [], + hint: "no apps — trailing edge stays clean, nothing reserved", + }, + { + id: "thr_apps_one", + title: "Write integration tests", + apps: [APP_FIXTURES.status], + hint: "single app icon, left of the branch icon", + }, + { + id: "thr_apps_two", + title: "Refactor auth middleware", + apps: [APP_FIXTURES.status, APP_FIXTURES.terminal], + hint: "two app icons", + }, + { + id: "thr_apps_three", + title: "Onboarding revamp", + apps: [APP_FIXTURES.status, APP_FIXTURES.terminal, APP_FIXTURES.notes], + hint: "three app icons — the visible cap", + }, + { + id: "thr_apps_overflow", + title: "Migrate billing schema", + apps: [ + APP_FIXTURES.status, + APP_FIXTURES.terminal, + APP_FIXTURES.notes, + APP_FIXTURES.preview, + APP_FIXTURES.deploy, + ], + hint: "caps at 3 icons; the +2 chip tooltip lists Preview · Deploy", + }, +]; + +function useAppRowsQueryClient() { + return useMemo(() => { + const queryClient = createAppQueryClient({ + showMutationErrorToasts: false, + defaultOptions: { + mutations: { retry: false }, + queries: { gcTime: Infinity, retry: false }, + }, + }); + for (const seed of APP_ROW_SEEDS) { + queryClient.setQueryData(threadAppsQueryKey(seed.id), seed.apps); + } + return queryClient; + }, []); +} + +export function AppIcons() { + const queryClient = useAppRowsQueryClient(); + return ( + + + {APP_ROW_SEEDS.map((seed) => ( + + + + + + ))} + + + ); +} diff --git a/apps/app/src/components/sidebar/ThreadRow.test.tsx b/apps/app/src/components/sidebar/ThreadRow.test.tsx new file mode 100644 index 000000000..3e1e670a6 --- /dev/null +++ b/apps/app/src/components/sidebar/ThreadRow.test.tsx @@ -0,0 +1,268 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { BrowserRouter } from "react-router-dom"; +import type { ThreadListEntry } from "@bb/domain"; +import type { AppSummary } from "@bb/server-contract"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; +import { jsonResponse } from "@/test/http-test-utils"; +import { threadAppsQueryKey } from "@/hooks/queries/query-keys"; +import { useFixedPanelTabsState } from "@/lib/fixed-panel-tabs"; +import { ThreadActionsProvider } from "@/components/thread/ThreadActionsProvider"; +import { NO_COLLAPSED_CHILD_ACTIVITY } from "@/lib/thread-activity"; +import { makeThreadListEntry } from "../../../.ladle/story-fixtures"; +import { ThreadRow, type ThreadRowOptions } from "./ThreadRow"; + +const PROJECT_ID = "proj_demo"; +const noop = () => {}; + +interface MakeAppArgs { + id: string; + name: string; + icon: AppSummary["icon"]; +} + +function makeApp({ id, name, icon }: MakeAppArgs): AppSummary { + return { + id, + name, + entry: { path: "index.html", kind: "html" }, + capabilities: [], + icon, + }; +} + +const STATUS_APP = makeApp({ + id: "status", + name: "Status", + icon: { kind: "builtin", name: "ListTodo" }, +}); +const TERMINAL_APP = makeApp({ + id: "terminal", + name: "Terminal", + icon: { kind: "builtin", name: "Terminal" }, +}); +const NOTES_APP = makeApp({ + id: "notes", + name: "Notes", + icon: { kind: "builtin", name: "File" }, +}); +const PREVIEW_APP = makeApp({ + id: "preview", + name: "Preview", + icon: { kind: "builtin", name: "GridView" }, +}); +const DEPLOY_APP = makeApp({ + id: "deploy", + name: "Deploy", + icon: { kind: "builtin", name: "Zap" }, +}); + +function managerOptions(): ThreadRowOptions { + return { + kind: "manager", + indent: "project-child", + isCollapsed: false, + managedChildCount: 0, + managedChildActivity: NO_COLLAPSED_CHILD_ACTIVITY, + onToggleCollapsed: noop, + }; +} + +const defaultOptions: ThreadRowOptions = { + kind: "default", + indent: "project-child", +}; + +function makeManagerThread(id: string): ThreadListEntry { + return makeThreadListEntry({ + id, + type: "manager", + title: "Onboarding revamp", + titleFallback: "Onboarding revamp", + environmentWorkspaceDisplayKind: "managed-worktree", + }); +} + +interface PanelStateProbeProps { + threadId: string; +} + +function PanelStateProbe({ threadId }: PanelStateProbeProps) { + const state = useFixedPanelTabsState(threadId); + return ( +
+ {JSON.stringify({ + activeTabId: state.secondary.activeTabId, + isOpen: state.secondary.isOpen, + })} +
+ ); +} + +interface RenderRowArgs { + apps?: AppSummary[]; + thread: ThreadListEntry; + options: ThreadRowOptions; +} + +function renderRow({ apps, thread, options }: RenderRowArgs) { + const harness = createQueryClientTestHarness(); + if (apps !== undefined) { + harness.queryClient.setQueryData(threadAppsQueryKey(thread.id), apps); + } + const result = render( + , + { + wrapper: ({ children }: { children: ReactNode }) => + harness.wrapper({ + children: ( + + + {children} + + + + ), + }), + }, + ); + return { ...result, queryClient: harness.queryClient }; +} + +afterEach(() => { + cleanup(); + window.localStorage.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("ThreadRow app cluster", () => { + it("renders a manager's app icons left of the branch/environment icon", async () => { + const thread = makeManagerThread("thr_apps_two"); + const { container } = renderRow({ + apps: [STATUS_APP, TERMINAL_APP], + thread, + options: managerOptions(), + }); + + const statusButton = await screen.findByRole("button", { name: "Status" }); + expect(screen.getByRole("button", { name: "Terminal" })).toBeTruthy(); + + const branchIcon = container.querySelector("[data-icon='GitBranch']"); + if (!branchIcon) { + throw new Error("expected the trailing branch/environment icon"); + } + // The app icons must precede the trailing branch/environment icon. + expect( + statusButton.compareDocumentPosition(branchIcon) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it("opens the app in the thread's secondary panel when an icon is clicked", async () => { + const thread = makeManagerThread("thr_apps_open"); + renderRow({ apps: [STATUS_APP], thread, options: managerOptions() }); + + fireEvent.click(await screen.findByRole("button", { name: "Status" })); + + await waitFor(() => { + const probe = screen.getByTestId("panel-state"); + expect(probe.textContent).toContain('"activeTabId":"app:status"'); + expect(probe.textContent).toContain('"isOpen":true'); + }); + }); + + it("collapses extra apps into an informational +N chip that names the hidden apps", async () => { + const thread = makeManagerThread("thr_apps_overflow"); + renderRow({ + apps: [STATUS_APP, TERMINAL_APP, NOTES_APP, PREVIEW_APP, DEPLOY_APP], + thread, + options: managerOptions(), + }); + + expect(await screen.findByRole("button", { name: "Status" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Terminal" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Notes" })).toBeTruthy(); + // The hidden apps are not rendered as their own openable icons. + expect(screen.queryByRole("button", { name: "Preview" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Deploy" })).toBeNull(); + + // The chip surfaces the count and names the hidden apps in its label. + const overflowChip = screen.getByRole("button", { + name: "2 more apps: Preview, Deploy", + }); + expect(overflowChip.textContent).toBe("+2"); + + // It is purely informational — clicking it opens nothing. + fireEvent.click(overflowChip); + const probe = screen.getByTestId("panel-state"); + expect(probe.textContent).toContain('"activeTabId":null'); + expect(probe.textContent).toContain('"isOpen":false'); + }); + + it("renders no app cluster for a manager without apps", async () => { + const thread = makeManagerThread("thr_apps_none"); + const { container } = renderRow({ + apps: [], + thread, + options: managerOptions(), + }); + + await waitFor(() => { + expect(container.querySelector("[data-icon='GitBranch']")).toBeTruthy(); + }); + expect(screen.queryByRole("button", { name: "Status" })).toBeNull(); + expect(screen.queryByRole("button", { name: /more app/ })).toBeNull(); + }); + + it("instantiates the apps query only for manager rows", async () => { + // Stub fetch so the manager's query resolves without real network. + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse([])), + ); + + // A manager row mounts the cluster, which creates the thread-apps query. + const managerRender = renderRow({ + thread: makeManagerThread("thr_manager_query"), + options: managerOptions(), + }); + await waitFor(() => { + expect( + managerRender.queryClient.getQueryState( + threadAppsQueryKey("thr_manager_query"), + ), + ).toBeDefined(); + }); + + // A non-manager row never mounts the cluster, so no query observer or cache + // entry is ever created for its apps key. + const leafRender = renderRow({ + thread: makeThreadListEntry({ id: "thr_leaf_no_query" }), + options: defaultOptions, + }); + await act(async () => { + await Promise.resolve(); + }); + expect( + leafRender.queryClient.getQueryState( + threadAppsQueryKey("thr_leaf_no_query"), + ), + ).toBeUndefined(); + }); +}); diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx index 7afb1b2e6..fd2ae3f0a 100644 --- a/apps/app/src/components/sidebar/ThreadRow.tsx +++ b/apps/app/src/components/sidebar/ThreadRow.tsx @@ -44,6 +44,7 @@ import { getSidebarThreadRowPaddingClass, type SidebarThreadRowIndent, } from "./sidebarRowClasses"; +import { ThreadRowAppCluster } from "./ThreadRowAppCluster"; import type { ConsumeDragClickSuppression } from "./useDragClickSuppression"; export type ThreadRowOptions = @@ -397,6 +398,9 @@ function ThreadRowComponent({ COARSE_POINTER_COMPACT_ROW_HEIGHT_CLASS, )} > + {isManager ? ( + + ) : null}