diff --git a/apps/app/.ladle/settings-story-fixtures.tsx b/apps/app/.ladle/settings-story-fixtures.tsx index d81d02fd2e..d1aac8fbd8 100644 --- a/apps/app/.ladle/settings-story-fixtures.tsx +++ b/apps/app/.ladle/settings-story-fixtures.tsx @@ -1,7 +1,7 @@ import { useState, type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; import { QueryClientProvider } from "@tanstack/react-query"; -import { PERSONAL_PROJECT_ID } from "@bb/domain"; +import { PERSONAL_PROJECT_ID, type ProviderInfo } from "@bb/domain"; import { UPDATE_ACTION_ICON } from "@bb/domain/update-state"; import type { SidebarBootstrapResponse, @@ -15,6 +15,7 @@ import { pluginMarketplacesQueryKey, sidebarNavigationQueryKey, systemConfigQueryKey, + systemProvidersQueryKey, systemVersionQueryKey, } from "../src/hooks/queries/query-keys"; import { @@ -23,6 +24,7 @@ import { } from "../src/hooks/useUpdateInventory"; import { createAppQueryClient } from "../src/lib/query-client"; import { makeSystemConfig } from "../src/test/fixtures/system-config"; +import { makeProviderInfo } from "../src/test/provider-info-fixture"; import { getSettingsRoutePath } from "../src/lib/route-paths"; import { BbAppUpdateRows, @@ -39,6 +41,9 @@ import { makeProject, makeProviderCliStatus, } from "./story-fixtures"; +import codexLogoUrl from "../../../plugins/provider-codex/icons/codex.svg"; +import claudeCodeLogoUrl from "../../../plugins/provider-claude-code/icons/claude-code.svg"; +import cursorLogoUrl from "../../../plugins/provider-acp/icons/cursor.svg"; const SETTINGS_STORY_NOW = Date.parse("2026-08-19T08:00:00.000Z"); @@ -140,6 +145,24 @@ const systemVersion = { upgradeCommand: "npx bb-app@latest", } satisfies SystemVersionResponse; +const systemProviders = [ + makeProviderInfo({ + id: "codex", + displayName: "Codex", + logoUrl: codexLogoUrl, + }), + makeProviderInfo({ + id: "claude-code", + displayName: "Claude Code", + logoUrl: claudeCodeLogoUrl, + }), + makeProviderInfo({ + id: "acp-cursor", + displayName: "Cursor", + logoUrl: cursorLogoUrl, + }), +] satisfies ProviderInfo[]; + const settingsUpdateMachine = { host: SETTINGS_STORY_PRIMARY_HOST, isPrimary: true, @@ -167,7 +190,6 @@ export function SettingsUpdatesStory() { label="Update all 1 CLI tool" tooltipLabel="Update all" icon={UPDATE_ACTION_ICON} - iconPosition="end" visibleLabel="Update all" variant="default" onClick={noop} @@ -208,6 +230,7 @@ function createSettingsStoryQueryClient() { }); queryClient.setQueryData(hostsQueryKey(), SETTINGS_STORY_HOSTS); queryClient.setQueryData(systemConfigQueryKey(), systemConfig); + queryClient.setQueryData(systemProvidersQueryKey(), systemProviders); queryClient.setQueryData(systemVersionQueryKey(), systemVersion); queryClient.setQueryData(sidebarNavigationQueryKey(), sidebarNavigation); queryClient.setQueryData(pluginMarketplacesQueryKey(), []); diff --git a/apps/app/src/components/dialogs/EnvironmentRenameDialog.stories.tsx b/apps/app/src/components/dialogs/EnvironmentRenameDialog.stories.tsx index 8b97c87fe0..2b021239be 100644 --- a/apps/app/src/components/dialogs/EnvironmentRenameDialog.stories.tsx +++ b/apps/app/src/components/dialogs/EnvironmentRenameDialog.stories.tsx @@ -26,11 +26,37 @@ const customNameTarget: EnvironmentRenameDialogTarget = { canClearName: true, }; +export function BranchContext() { + const inputRef = useRef(null); + return ( + + + + + + + + ); +} + export function Overview() { const inputRef = useRef(null); return ( - + diff --git a/apps/app/src/components/dialogs/EnvironmentRenameDialog.tsx b/apps/app/src/components/dialogs/EnvironmentRenameDialog.tsx index 38f3d1446a..121169b76d 100644 --- a/apps/app/src/components/dialogs/EnvironmentRenameDialog.tsx +++ b/apps/app/src/components/dialogs/EnvironmentRenameDialog.tsx @@ -5,7 +5,7 @@ const ENVIRONMENT_NAME_MAX_LENGTH = 80; const ENVIRONMENT_NAME_LENGTH_RULE = { limit: ENVIRONMENT_NAME_MAX_LENGTH, - message: `Environment name must be ${ENVIRONMENT_NAME_MAX_LENGTH} characters or fewer.`, + message: `Worktree name must be ${ENVIRONMENT_NAME_MAX_LENGTH} characters or fewer.`, }; export interface EnvironmentRenameDialogTarget { @@ -65,17 +65,24 @@ export function EnvironmentRenameDialogContent({ }: EnvironmentRenameDialogContentProps) { return ( + Branch: {target.branchName} +

+ ) : undefined + } maxLength={ENVIRONMENT_NAME_LENGTH_RULE} autoCapitalize="sentences" clearAction={ target.canClearName ? { - label: "Use branch name", + label: "Clear custom name", onClear: () => onRename(target.id, null), } : undefined diff --git a/apps/app/src/components/dialogs/RenameDialog.tsx b/apps/app/src/components/dialogs/RenameDialog.tsx index 36a7616112..31dff6372a 100644 --- a/apps/app/src/components/dialogs/RenameDialog.tsx +++ b/apps/app/src/components/dialogs/RenameDialog.tsx @@ -51,6 +51,7 @@ interface RenameDialogContentProps { pending: boolean; errorMessage?: string | null; placeholder?: string; + inputDetails?: ReactNode; maxLength?: { limit: number; message: string }; autoCapitalize: "words" | "sentences"; compact?: boolean; @@ -65,6 +66,7 @@ export function RenameDialogContent({ pending, errorMessage, placeholder, + inputDetails, maxLength, autoCapitalize, compact = false, @@ -119,6 +121,7 @@ export function RenameDialogContent({ clearMessage(); }} /> + {inputDetails} {displayedErrorMessage ? (

{displayedErrorMessage}

) : null} diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx index 0722fd60b9..b60acad123 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx @@ -159,6 +159,7 @@ function renderPicker({ modelLoadError = null, compact = false, splitPane = false, + muted = false, }: { onSelectedProviderChange?: ((value: string) => void) | null; onModelChange?: (value: string) => void; @@ -176,6 +177,7 @@ function renderPicker({ modelLoadError?: SystemExecutionOptionsModelLoadError | null; compact?: boolean; splitPane?: boolean; + muted?: boolean; } = {}) { const { queryClient, wrapper } = createQueryClientTestHarness(); queryClient.setQueryData( @@ -215,6 +217,7 @@ function renderPicker({ fastModeEnabled={false} onFastModeChange={vi.fn()} showFastModeToggle={false} + muted={muted} modal={false} /> @@ -248,6 +251,18 @@ afterEach(() => { }); describe("ModelReasoningPicker", () => { + it("uses the lower-emphasis chrome token for the composer caret", () => { + renderPicker({ muted: true }); + + const trigger = screen.getByRole("button", { + name: "Provider, model and reasoning", + }); + expect( + trigger.querySelector('[data-icon="ChevronDown"]')?.classList, + ).toContain("text-subtle-foreground/75"); + expect(trigger.classList).toContain("font-normal"); + }); + it("gives a non-SVG provider mark the same 16px trigger size as button SVGs", () => { renderPicker({ pickerProviderOptions: [ diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.tsx index 08f99cfb71..ec25beaeb7 100644 --- a/apps/app/src/components/pickers/ModelReasoningPicker.tsx +++ b/apps/app/src/components/pickers/ModelReasoningPicker.tsx @@ -790,6 +790,7 @@ export function ModelReasoningPicker({ OPTION_INTERACTIVE_CLASS_NAME, LIST_HOVER_TRANSITION, muted && OPTION_MUTED_CLASS_NAME, + muted && "font-normal", disabled && "cursor-default disabled:opacity-100", className, )} @@ -855,7 +856,10 @@ export function ModelReasoningPicker({ {disabled ? null : ( )} { value: T; @@ -41,6 +40,7 @@ interface OptionPickerProps { options: readonly PickerOption[]; onChange: (value: T) => void; className?: string; + caretClassName?: string; contentClassName?: string; muted?: boolean; defaultOpen?: boolean; @@ -62,6 +62,7 @@ export function OptionPicker({ options, onChange, className, + caretClassName, contentClassName, muted, defaultOpen, @@ -124,10 +125,8 @@ export function OptionPicker({ )} diff --git a/apps/app/src/components/pickers/PermissionModePicker.test.tsx b/apps/app/src/components/pickers/PermissionModePicker.test.tsx index d072da0810..f18c80a453 100644 --- a/apps/app/src/components/pickers/PermissionModePicker.test.tsx +++ b/apps/app/src/components/pickers/PermissionModePicker.test.tsx @@ -16,6 +16,22 @@ afterEach(() => { }); describe("PermissionModePicker", () => { + it("keeps the warning mode caret aligned with the other prompt box carets", () => { + const { container } = render( + , + ); + + const caret = container.querySelector('[data-icon="ChevronDown"]'); + expect(caret).not.toBeNull(); + expect(caret!.classList).toContain("text-subtle-foreground/75"); + expect(caret!.classList).not.toContain("text-warning-text"); + }); + it("can show an effective display override without changing the selected value", () => { const onChange = vi.fn(); render( diff --git a/apps/app/src/components/pickers/PermissionModePicker.tsx b/apps/app/src/components/pickers/PermissionModePicker.tsx index 6451a8928b..5ca339990e 100644 --- a/apps/app/src/components/pickers/PermissionModePicker.tsx +++ b/apps/app/src/components/pickers/PermissionModePicker.tsx @@ -81,6 +81,7 @@ export function PermissionModePicker({ options={compactOptions} onChange={onChange} className={cn(LIST_HOVER_TRANSITION, className)} + caretClassName="text-subtle-foreground/75" contentClassName="max-w-72" muted={muted} defaultOpen={defaultOpen} diff --git a/apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx b/apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx index db69c92935..4576dfe48e 100644 --- a/apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx +++ b/apps/app/src/components/plugin/AppFileExternalNavigationDispatcher.tsx @@ -1,21 +1,27 @@ import { useEffect, useRef } from "react"; -import type { ExperimentalFileOpenOptions } from "@get-bb/plugin-sdk"; +import type { ExperimentalResolvedFileOpenOptions } from "@get-bb/plugin-sdk"; import { appToast } from "@/components/ui/app-toast"; import { useLocalOpenTargets } from "@/hooks/useLocalOpenTargets"; import { useResolvedLiveFileTarget } from "@/hooks/useResolvedLiveFileTarget"; -import { getExperimentalFileLocationStart } from "@/lib/live-file-navigation"; +import { + getExperimentalFileLocationStart, + liveFileTargetFromIdentity, +} from "@/lib/live-file-navigation"; export function AppFileExternalNavigationDispatcher({ intent, onSettled, }: { - intent: ExperimentalFileOpenOptions; + intent: ExperimentalResolvedFileOpenOptions; onSettled: () => void; }) { const didSettleRef = useRef(false); - const resolvedTarget = useResolvedLiveFileTarget(intent.target, { - enabled: true, - }); + const resolvedTarget = useResolvedLiveFileTarget( + liveFileTargetFromIdentity(intent.identity), + { + enabled: true, + }, + ); const { isLoading: areLocalTargetsLoading, openPathInPreferredFileTarget } = useLocalOpenTargets({ enabled: resolvedTarget.status === "available", @@ -40,14 +46,14 @@ export function AppFileExternalNavigationDispatcher({ }); return; } - const location = getExperimentalFileLocationStart(intent.location); + const location = getExperimentalFileLocationStart(intent.identity.location); void openPathInPreferredFileTarget({ columnNumber: location.columnNumber, lineNumber: location.lineNumber, path: resolvedTarget.absolutePath, }); }, [ - intent.location, + intent.identity.location, areLocalTargetsLoading, openPathInPreferredFileTarget, onSettled, diff --git a/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx b/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx index 6f120d58fb..0d8dcd90f2 100644 --- a/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx +++ b/apps/app/src/components/plugin/AppFileExternalNavigationHost.test.tsx @@ -38,12 +38,17 @@ function Probe() { type="button" onClick={() => navigation.openFileExternally({ - target: { - kind: "workspace", - environmentId: "env_1", - path: "src/example.ts", + identity: { + source: { + store: "workspace", + ownerId: "env_1", + path: "src/example.ts", + }, + displayName: "example.ts", + mimeType: null, + sizeBytes: null, + location: { kind: "line", line: 12, column: 3 }, }, - location: { kind: "line", line: 12, column: 3 }, }) } > @@ -53,20 +58,30 @@ function Probe() { type="button" onClick={() => { const firstAccepted = navigation.openFileExternally({ - target: { - kind: "workspace", - environmentId: "env_1", - path: "src/first.ts", + identity: { + source: { + store: "workspace", + ownerId: "env_1", + path: "src/first.ts", + }, + displayName: "first.ts", + mimeType: null, + sizeBytes: null, + location: { kind: "line", line: 10, column: 2 }, }, - location: { kind: "line", line: 10, column: 2 }, }); const secondAccepted = navigation.openFileExternally({ - target: { - kind: "workspace", - environmentId: "env_1", - path: "src/second.ts", + identity: { + source: { + store: "workspace", + ownerId: "env_1", + path: "src/second.ts", + }, + displayName: "second.ts", + mimeType: null, + sizeBytes: null, + location: { kind: "line", line: 20, column: 4 }, }, - location: { kind: "line", line: 20, column: 4 }, }); recordAccepted(firstAccepted, secondAccepted); }} diff --git a/apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx b/apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx index e3404ddd27..e3e52be89e 100644 --- a/apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx +++ b/apps/app/src/components/plugin/AppFileExternalNavigationHost.tsx @@ -7,7 +7,7 @@ import { useState, type ReactNode, } from "react"; -import type { ExperimentalFileOpenOptions } from "@get-bb/plugin-sdk"; +import type { ExperimentalResolvedFileOpenOptions } from "@get-bb/plugin-sdk"; import { AppNavigationHostProvider } from "@/lib/app-navigation-host"; const MAX_PENDING_EXTERNAL_FILE_INTENTS = 32; @@ -21,7 +21,7 @@ const LazyAppFileExternalNavigationDispatcher = lazy(() => interface ExternalFileIntentRequest { id: number; - intent: ExperimentalFileOpenOptions; + intent: ExperimentalResolvedFileOpenOptions; } export function AppFileExternalNavigationHost({ @@ -37,7 +37,7 @@ export function AppFileExternalNavigationHost({ setQueue(next); }, []); const openFileExternally = useCallback( - (intent: ExperimentalFileOpenOptions): boolean => { + (intent: ExperimentalResolvedFileOpenOptions): boolean => { if (queueRef.current.length >= MAX_PENDING_EXTERNAL_FILE_INTENTS) { return false; } diff --git a/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx b/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx index 18b9b71a49..67959ad861 100644 --- a/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx +++ b/apps/app/src/components/plugin/ExperimentalFileLink.test.tsx @@ -14,6 +14,17 @@ const target = { environmentId: "env_1", path: "src/example.ts", }; +const identity = { + source: { + store: "workspace" as const, + ownerId: "env_1", + path: "src/example.ts", + }, + displayName: "example.ts", + mimeType: null, + sizeBytes: null, + location: null, +}; describe("ExperimentalFileLink", () => { it("sends ordinary activation to the shared preview host", () => { @@ -34,8 +45,10 @@ describe("ExperimentalFileLink", () => { ); fireEvent.click(screen.getByRole("link", { name: "example.ts:12" })); expect(openFilePreview).toHaveBeenCalledWith({ - target, - location: { kind: "line", line: 12, column: 4 }, + identity: { + ...identity, + location: { kind: "line", line: 12, column: 4 }, + }, }); }); @@ -72,8 +85,11 @@ describe("ExperimentalFileLink", () => { fireEvent.click(link); expect(openFilePreview).toHaveBeenCalledWith({ - target: { ...target, path: "vscode:foo" }, - location: null, + identity: { + ...identity, + source: { ...identity.source, path: "vscode:foo" }, + displayName: "vscode:foo", + }, }); }); diff --git a/apps/app/src/components/plugin/ExperimentalFileLink.tsx b/apps/app/src/components/plugin/ExperimentalFileLink.tsx index ce00129a63..18d842ddcd 100644 --- a/apps/app/src/components/plugin/ExperimentalFileLink.tsx +++ b/apps/app/src/components/plugin/ExperimentalFileLink.tsx @@ -38,6 +38,7 @@ function shouldHandleFileClick( } export function ExperimentalFileLink({ + identity, target, location = null, onClick, @@ -46,8 +47,11 @@ export function ExperimentalFileLink({ const navigation = useAppNavigationHost(); const [isMenuOpen, setMenuOpen] = useState(false); const intent = useMemo( - () => normalizeExperimentalFileOpenOptions({ target, location }), - [location, target], + () => + normalizeExperimentalFileOpenOptions( + identity === undefined ? { target, location } : { identity }, + ), + [identity, location, target], ); const handleClick = useCallback( (event: ReactMouseEvent) => { @@ -60,8 +64,20 @@ export function ExperimentalFileLink({ }, [intent, navigation, onClick], ); + const resource = + intent === null + ? null + : intent.identity.source.store === "tasks-attachment" + ? intent.identity.source.attachmentId + : intent.identity.source.store === "remote" + ? intent.identity.source.url + : intent.identity.source.path; const href = - intent === null ? undefined : `./${encodeURIComponent(intent.target.path)}`; + resource === null + ? undefined + : intent?.identity.source.store === "remote" + ? resource + : `./${encodeURIComponent(resource)}`; const anchor = ( ); diff --git a/apps/app/src/components/plugin/ExperimentalFileLinkMenu.tsx b/apps/app/src/components/plugin/ExperimentalFileLinkMenu.tsx index bfb42aaeb8..a6ee3adb5d 100644 --- a/apps/app/src/components/plugin/ExperimentalFileLinkMenu.tsx +++ b/apps/app/src/components/plugin/ExperimentalFileLinkMenu.tsx @@ -1,4 +1,4 @@ -import type { ExperimentalFileOpenOptions } from "@get-bb/plugin-sdk"; +import type { ExperimentalResolvedFileOpenOptions } from "@get-bb/plugin-sdk"; import { ContextMenuItem, ContextMenuSeparator, @@ -10,7 +10,10 @@ import { useLocalOpenTargets } from "@/hooks/useLocalOpenTargets"; import { useResolvedLiveFileTarget } from "@/hooks/useResolvedLiveFileTarget"; import { useAppNavigationHost } from "@/lib/app-navigation-host"; import { copyToClipboardWithToast } from "@/lib/clipboard"; -import { getExperimentalFileLocationStart } from "@/lib/live-file-navigation"; +import { + getExperimentalFileLocationStart, + liveFileTargetFromIdentity, +} from "@/lib/live-file-navigation"; import { usePluginSlots } from "@/lib/plugin-slots"; function getFileBasename(path: string): string { @@ -29,10 +32,11 @@ function getFileExtension(path: string): string | null { export function ExperimentalFileLinkMenu({ intent, }: { - intent: ExperimentalFileOpenOptions; + intent: ExperimentalResolvedFileOpenOptions; }) { const navigation = useAppNavigationHost(); - const resolved = useResolvedLiveFileTarget(intent.target, { enabled: true }); + const liveTarget = liveFileTargetFromIdentity(intent.identity); + const resolved = useResolvedLiveFileTarget(liveTarget, { enabled: true }); const localTargets = useLocalOpenTargets({ enabled: resolved.status === "available", ...(resolved.status === "available" @@ -40,12 +44,12 @@ export function ExperimentalFileLinkMenu({ : {}), }); const { fileOpeners } = usePluginSlots(); - const extension = getFileExtension(intent.target.path); + const extension = getFileExtension(intent.identity.displayName); const matchingOpeners = extension === null ? [] : fileOpeners.filter((opener) => opener.extensions.includes(extension)); - const location = getExperimentalFileLocationStart(intent.location); + const location = getExperimentalFileLocationStart(intent.identity.location); return ( <> @@ -122,7 +126,7 @@ export function ExperimentalFileLinkMenu({ void copyToClipboardWithToast( resolved.status === "available" ? resolved.absolutePath - : intent.target.path, + : (liveTarget?.path ?? intent.identity.displayName), { successMessage: "File path copied", errorMessage: "Failed to copy file path", @@ -134,7 +138,7 @@ export function ExperimentalFileLinkMenu({ { - void copyToClipboardWithToast(getFileBasename(intent.target.path), { + void copyToClipboardWithToast(intent.identity.displayName, { successMessage: "File name copied", errorMessage: "Failed to copy file name", }); diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx index 167cf09065..fc6d0800e2 100644 --- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx +++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx @@ -488,12 +488,17 @@ function FileIntentButtons() { type="button" onClick={() => navigation.openFilePreview({ - target: { - kind: "workspace", - environmentId: "env-explicit", - path: "src/example.ts", + identity: { + source: { + store: "workspace", + ownerId: "env-explicit", + path: "src/example.ts", + }, + displayName: "example.ts", + mimeType: null, + sizeBytes: null, + location: { kind: "line", line: 7, column: null }, }, - location: { kind: "line", line: 7, column: null }, }) } > @@ -503,12 +508,17 @@ function FileIntentButtons() { type="button" onClick={() => navigation.openFilePreview({ - target: { - kind: "host", - hostId: "host-explicit", - path: "/tmp/example.log", + identity: { + source: { + store: "host", + ownerId: "host-explicit", + path: "/tmp/example.log", + }, + displayName: "example.log", + mimeType: null, + sizeBytes: null, + location: null, }, - location: null, }) } > @@ -518,12 +528,17 @@ function FileIntentButtons() { type="button" onClick={() => navigation.openFilePreview({ - target: { - kind: "thread-storage", - threadId: "thr-explicit", - path: "reports/result.md", + identity: { + source: { + store: "thread-storage", + ownerId: "thr-explicit", + path: "reports/result.md", + }, + displayName: "result.md", + mimeType: null, + sizeBytes: null, + location: { kind: "range", startLine: 2, endLine: 4 }, }, - location: { kind: "range", startLine: 2, endLine: 4 }, }) } > diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx index 17d24ad199..442d7d1c21 100644 --- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx +++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx @@ -25,6 +25,7 @@ import { getRightPanelToggleIconName } from "@/components/secondary-panel/panelT import { SecondaryPanelLayout } from "@/components/secondary-panel/SecondaryPanelLayout"; import { LazyBrowserTabDeck, + LazyByteFilePreviewTabContent, LazyHostScopedFilePreviewTabContent, LazyNewTabPage, LazyThreadSecondaryPanel, @@ -78,6 +79,10 @@ import { type AppFixedTabDestination, type AppFixedTabTargetState, } from "@/lib/app-fixed-tab-navigation"; +import { + byteFileTabFromIdentity, + identityFromByteFileTab, +} from "@/lib/file-resolver"; import { normalizeExperimentalFileOpenOptions, toFilePreviewLineRange, @@ -278,6 +283,7 @@ export function PluginPanelRightPanelHost({ closeTab, openTab, orderedSecondaryFileTabs, + reopenClosedTab, reorderTab, updateBrowserTab, } = useThreadFileTabs({ @@ -413,40 +419,54 @@ export function PluginPanelRightPanelHost({ (intent: AppFilePreviewIntent) => { const normalized = normalizeExperimentalFileOpenOptions(intent); if (normalized === null || panel === null) return false; - const lineRange = toFilePreviewLineRange(normalized.location); - const { target } = normalized; + const lineRange = toFilePreviewLineRange(normalized.identity.location); + const { source } = normalized.identity; const tab = - target.kind === "workspace" + source.store === "workspace" ? openTab( { kind: "workspace-file-preview", - environmentId: target.environmentId, + environmentId: source.ownerId, tab: { lineRange, - path: target.path, + path: source.path, source: { kind: "working-tree" }, statusLabel: null, }, }, { viewer: intent.viewer }, ) - : target.kind === "host" + : source.store === "host" ? openTab( { kind: "host-file-preview", - hostId: target.hostId, - tab: { lineRange, path: target.path }, + hostId: source.ownerId, + tab: { lineRange, path: source.path }, }, { viewer: intent.viewer }, ) - : openTab( - { - kind: "thread-storage-file-preview", - threadId: target.threadId, - tab: { lineRange, path: target.path }, - }, - { viewer: intent.viewer }, - ); + : source.store === "thread-storage" + ? openTab( + { + kind: "thread-storage-file-preview", + threadId: source.ownerId, + tab: { lineRange, path: source.path }, + }, + { viewer: intent.viewer }, + ) + : source.store === "project-attachment" || + source.store === "tasks-attachment" + ? (() => { + const byteTab = byteFileTabFromIdentity( + normalized.identity, + ); + return byteTab === null + ? null + : openTab({ kind: "byte-file-preview", tab: byteTab }); + })() + : source.store === "remote" + ? openTab({ kind: "browser", url: source.url }) + : null; if (tab === null) return false; revealPanel(); return true; @@ -487,6 +507,11 @@ export function PluginPanelRightPanelHost({ openNewTab(); return true; }); + useAppCommandHandler("panel.reopenClosedTab", () => { + if (!isFocused || panel === null || !reopenClosedTab()) return false; + revealPanel(); + return true; + }); const [togglePortalTarget, setTogglePortalTarget] = useState(null); @@ -737,6 +762,13 @@ export function PluginPanelRightPanelHost({ threadId={tab.threadId} /> ); + case "byte-file-preview": + return ( + + ); case "plugin-panel": { const originalTab = createFileOpenerOriginalTab(tab); return ( @@ -820,12 +852,16 @@ export function PluginPanelRightPanelHost({ case "workspace-file-preview": case "host-file-preview": case "thread-storage-file-preview": + case "byte-file-preview": return [ { ...shared, isPinned: tab.kind === "thread-storage-file-preview" && tab.isPinned, - label: tab.path.split(/[\\/]/u).at(-1) ?? tab.path, + label: + tab.kind === "byte-file-preview" + ? tab.displayName + : (tab.path.split(/[\\/]/u).at(-1) ?? tab.path), leadingVisual: , statusLabel: tab.kind === "workspace-file-preview" diff --git a/apps/app/src/components/plugin/PluginThreadChat.test.tsx b/apps/app/src/components/plugin/PluginThreadChat.test.tsx index 26e000ef2c..945b0c5313 100644 --- a/apps/app/src/components/plugin/PluginThreadChat.test.tsx +++ b/apps/app/src/components/plugin/PluginThreadChat.test.tsx @@ -30,6 +30,7 @@ vi.mock("@/lib/sdk", () => ({ })); vi.mock("@/hooks/useRealtimeSubscription", () => ({ + useHostListRealtimeSubscription: vi.fn(), useThreadDetailRealtimeSubscription: vi.fn(), useThreadListRealtimeSubscription: vi.fn(), useEnvironmentDetailRealtimeSubscription: vi.fn(), diff --git a/apps/app/src/components/plugin/PluginThreadChat.tsx b/apps/app/src/components/plugin/PluginThreadChat.tsx index b1ca9195ad..39c8db5202 100644 --- a/apps/app/src/components/plugin/PluginThreadChat.tsx +++ b/apps/app/src/components/plugin/PluginThreadChat.tsx @@ -22,10 +22,13 @@ import { useThreadTimelineNavigation } from "@/components/thread/timeline/Thread import { PluginContext } from "@/components/plugin/plugin-context"; import { ThreadProviderContext } from "@/components/thread/thread-provider-context"; import { useEnvironment } from "@/hooks/queries/environment-queries"; +import { useHosts } from "@/hooks/queries/host-queries"; import { useSystemProviderInfo } from "@/hooks/queries/system-queries"; import { useThread } from "@/hooks/queries/thread-queries"; import { useHostDaemon } from "@/hooks/useHostDaemon"; -import { getEnvironmentWorkspaceLabelIconName } from "@/lib/environment-workspace-display"; +import { + getEnvironmentWorkspaceSummaryDisplay, +} from "@/lib/environment-workspace-display"; import { formatWorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display"; import { BbHttpError } from "@/lib/sdk"; import { @@ -107,6 +110,11 @@ function PluginThreadChatBody({ const { isLocalDaemonHost } = useHostDaemon(); const environmentQuery = useEnvironment(thread?.environmentId ?? null); const environment = environmentQuery.data ?? null; + const hostsQuery = useHosts({ enabled: environment !== null }); + const environmentHostName = environment + ? (hostsQuery.data?.find((host) => host.id === environment.hostId)?.name ?? + null) + : null; const timelineNavigation = useThreadTimelineNavigation(); const canUseHostFileNavigation = thread !== undefined && @@ -171,25 +179,25 @@ function PluginThreadChatBody({ const environmentSummary = useMemo(() => { if (environment === null) { - return ( - - ); + return null; } const host: EnvironmentDisplayHostContext = { locality: isLocalDaemonHost(environment.hostId) ? "local" : "remote", identity: null, }; const display = formatEnvironmentDisplay({ environment, host }); + const summaryDisplay = getEnvironmentWorkspaceSummaryDisplay({ + display, + environmentName: environment.name, + locality: host.locality, + hostName: environmentHostName ?? undefined, + }); return ( ); - }, [environment, isLocalDaemonHost]); + }, [environment, environmentHostName, isLocalDaemonHost]); const isThreadMissing = threadQuery.error instanceof BbHttpError && diff --git a/apps/app/src/components/promptbox/AttachmentPreview.test.tsx b/apps/app/src/components/promptbox/AttachmentPreview.test.tsx index 6d7e6ac7da..641ad09093 100644 --- a/apps/app/src/components/promptbox/AttachmentPreview.test.tsx +++ b/apps/app/src/components/promptbox/AttachmentPreview.test.tsx @@ -71,7 +71,7 @@ describe("AttachmentPreview", () => { const images = getAllByRole("img"); expect(images.map((image) => image.getAttribute("src"))).toEqual([ "blob:local-1", - "/api/v1/projects/proj_1/attachments/content?path=restored-2-def.png", + "/api/v1/projects/proj_1/attachments/preview?path=restored-2-def.png", ]); expect( images.every((image) => image.getAttribute("decoding") === "async"), diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx index 31f6cd8ae9..7fafbfd488 100644 --- a/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx +++ b/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx @@ -1,4 +1,10 @@ -import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { + useCallback, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import type { Environment, PermissionMode, @@ -24,7 +30,7 @@ import { getFollowUpPromptPlaceholder, getCompactFollowUpPromptPlaceholder, } from "@/components/promptbox/follow-up-placeholder"; -import { getEnvironmentWorkspaceLabelIconName } from "@/lib/environment-workspace-display"; +import { getEnvironmentWorkspaceSummaryDisplay } from "@/lib/environment-workspace-display"; import { INERT_TYPEAHEAD_COMMAND_CONFIG, type AttachmentsConfig, @@ -42,6 +48,7 @@ import { type QueuedMessageInlineEditor, } from "@/components/promptbox/banner/QueuedMessagesList"; import { ThreadEnvironmentSummary } from "@/components/promptbox/ThreadEnvironmentSummary"; +import { EnvironmentRenameDialogContent } from "@/components/dialogs/EnvironmentRenameDialog"; import { formatWorkspaceCheckoutDisplay, type WorkspaceCheckoutDisplay, @@ -49,6 +56,7 @@ import { import type { PickerOption } from "@/components/pickers/OptionPicker"; import { selectWorkspaceChangedFilesSection } from "@/components/workspace/workspace-change-summary"; import { StoryCard, StoryRow } from "../../../.ladle/story-card"; +import { DialogStage } from "../../../.ladle/story-dialog-stage"; import { makeEnvironment, makeExecutionControlsProps, @@ -168,6 +176,8 @@ const readOnlyPermission: ExecutionPermissionConfig = { interface EnvironmentSummaryArgs { environment: Environment; host: EnvironmentDisplayHostContext; + projectName?: string; + machineName?: string; branchName?: string; environmentCheckout?: WorkspaceCheckoutDisplay; onCreateNewThreadInWorktree?: () => void; @@ -176,6 +186,8 @@ interface EnvironmentSummaryArgs { function makeEnvironmentSummary({ environment, host, + projectName, + machineName, branchName, environmentCheckout, onCreateNewThreadInWorktree, @@ -184,6 +196,13 @@ function makeEnvironmentSummary({ environment, host, }); + const summaryDisplay = getEnvironmentWorkspaceSummaryDisplay({ + display, + environmentName: environment.name, + locality: host.locality, + hostName: machineName, + machinePrefix: machineName ? `${machineName} · ` : "", + }); const checkoutDisplay = environmentCheckout ?? (branchName @@ -197,11 +216,11 @@ function makeEnvironmentSummary({ : undefined); return ( @@ -226,6 +245,20 @@ const localEnvironmentSummary: ReactNode = makeEnvironmentSummary({ status: "ready", }), host: localEnvironmentDisplayHost, + machineName: "Bersabel's MacBook Pro", + branchName: STORY_BRANCH_NAME, +}); + +const longHostEnvironmentSummary: ReactNode = makeEnvironmentSummary({ + environment: makeEnvironment({ + managed: false, + isWorktree: false, + workspaceProvisionType: "unmanaged", + status: "ready", + }), + host: localEnvironmentDisplayHost, + projectName: "bb UI QA", + machineName: "Bersabel's MacBook Pro", branchName: STORY_BRANCH_NAME, }); @@ -237,6 +270,7 @@ const remoteEnvironmentSummary: ReactNode = makeEnvironmentSummary({ status: "ready", }), host: remoteEnvironmentDisplayHost, + machineName: "Build Mac mini", branchName: STORY_BRANCH_NAME, }); @@ -247,6 +281,45 @@ const worktreeEnvironmentSummary: ReactNode = makeEnvironmentSummary({ status: "ready", }), host: localEnvironmentDisplayHost, + machineName: "Bersabel's MacBook Pro", + branchName: STORY_BRANCH_NAME, + onCreateNewThreadInWorktree: noop, +}); + +const remoteWorktreeEnvironmentSummary: ReactNode = makeEnvironmentSummary({ + environment: makeEnvironment({ + isWorktree: true, + workspaceProvisionType: "managed-worktree", + status: "ready", + }), + host: remoteEnvironmentDisplayHost, + machineName: "Build Mac mini", + branchName: STORY_BRANCH_NAME, + onCreateNewThreadInWorktree: noop, +}); + +const unmanagedWorktreeEnvironmentSummary: ReactNode = makeEnvironmentSummary({ + environment: makeEnvironment({ + name: "Linked review tree", + managed: false, + isWorktree: true, + workspaceProvisionType: "unmanaged", + status: "ready", + }), + host: localEnvironmentDisplayHost, + machineName: "Bersabel's MacBook Pro", + branchName: STORY_BRANCH_NAME, + onCreateNewThreadInWorktree: noop, +}); + +const namedWorktreeEnvironmentSummary: ReactNode = makeEnvironmentSummary({ + environment: makeEnvironment({ + name: "Design system polish", + isWorktree: true, + workspaceProvisionType: "managed-worktree", + status: "ready", + }), + host: localEnvironmentDisplayHost, branchName: STORY_BRANCH_NAME, onCreateNewThreadInWorktree: noop, }); @@ -258,6 +331,7 @@ const detachedWorktreeEnvironmentSummary: ReactNode = makeEnvironmentSummary({ status: "ready", }), host: localEnvironmentDisplayHost, + machineName: "Bersabel's MacBook Pro", environmentCheckout: formatWorkspaceCheckoutDisplay({ checkout: { kind: "detached", @@ -783,10 +857,23 @@ function StackedCardsWithPillsRow() { stack={contextBannerElement} queuedMessages={queuedMessages} contextWindowUsage={usage} + environmentSummary={remoteEnvironmentSummary} /> ); } +export function ControlEmphasis() { + return ( +
+ +
+ ); +} + export function Overview() { return ( @@ -801,6 +888,7 @@ export function Overview() { submitMode={{ kind: "queue", onStop: noop }} threadRuntimeDisplayStatus="active" contextWindowUsage={usage} + environmentSummary={worktreeEnvironmentSummary} />
- + @@ -893,6 +988,7 @@ export function Overview() { loadError: null, }, }} + environmentSummary={detachedWorktreeEnvironmentSummary} /> - + + + + + + + + + +
@@ -1009,6 +1140,155 @@ export function StackedCardsWithPills() { ); } +export function EnvironmentMatrix() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export function ProvisioningEnvironmentSummary() { + return ( + + +
+ {provisioningEnvironmentSummary} +
+
+
+ ); +} + +export function WorktreeNamingContract() { + const inputRef = useRef(null); + return ( + + + + + + + +
+ {worktreeEnvironmentSummary} +
+
+
+ ); +} + +export function WorktreeCopyAction() { + return ( + + + + + + ); +} + export function QueuedWorkspace() { return ( diff --git a/apps/app/src/components/promptbox/PromptBoxActionsMenu.test.tsx b/apps/app/src/components/promptbox/PromptBoxActionsMenu.test.tsx index 76661136fb..e9363d6e46 100644 --- a/apps/app/src/components/promptbox/PromptBoxActionsMenu.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxActionsMenu.test.tsx @@ -43,10 +43,9 @@ describe("PromptBoxActionsMenu", () => { const onAttach = vi.fn(); render( {}} onAttach={onAttach} />); - fireEvent.pointerDown( - screen.getByRole("button", { name: "Prompt actions" }), - { button: 0 }, - ); + const trigger = screen.getByRole("button", { name: "Prompt actions" }); + expect(trigger.classList).toContain("text-subtle-foreground/75"); + fireEvent.pointerDown(trigger, { button: 0 }); fireEvent.click( await screen.findByRole("menuitem", { name: "Attach files" }), ); diff --git a/apps/app/src/components/promptbox/PromptBoxActionsMenu.tsx b/apps/app/src/components/promptbox/PromptBoxActionsMenu.tsx index c90e48e91b..a6a94b4fd3 100644 --- a/apps/app/src/components/promptbox/PromptBoxActionsMenu.tsx +++ b/apps/app/src/components/promptbox/PromptBoxActionsMenu.tsx @@ -17,6 +17,7 @@ import { useResolvedComposerPlusMenuItems } from "@/components/plugin/composer-s import { useOptionalPluginComposerView } from "@/components/plugin/plugin-composer-host"; import { Icon, type IconName } from "@bb/shared-ui/icon"; import { COARSE_POINTER_PROMPT_ICON_ACTION_BUTTON_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; +import { CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { CREATE_PLUGIN_PROMPT } from "@bb/client-core"; import type { ProviderPromptActionCommand } from "@bb/client-core"; @@ -181,6 +182,7 @@ export function PromptBoxActionsMenu({ aria-label="Prompt actions" className={cn( COARSE_POINTER_PROMPT_ICON_ACTION_BUTTON_CLASS, + CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS, "-ml-1.5", )} > diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 8df62039a1..18737695b8 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -1648,9 +1648,17 @@ describe("PromptBoxInternal size controls", () => { expect( screen.queryByRole("button", { name: /Make prompt box/u }), ).toBeNull(); - fireEvent.click( - screen.getByRole("button", { name: "Collapse prompt box" }), - ); + const collapseButton = screen.getByRole("button", { + name: "Collapse prompt box", + }); + expect(collapseButton.classList).toContain("text-subtle-foreground/75"); + expect(collapseButton.classList).toContain("w-6"); + expect(collapseButton.classList).toContain("px-0"); + expect(collapseButton.parentElement?.classList).toContain("right-[13px]"); + expect( + collapseButton.querySelector('[data-icon="ChevronDown"]')?.classList, + ).toContain("size-3.5"); + fireEvent.click(collapseButton); expect(onCollapse).toHaveBeenCalledOnce(); expect(document.activeElement).not.toBe(getPromptEditorElement()); diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 940d37723f..57c22ea8b1 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -68,6 +68,7 @@ import { COARSE_POINTER_PROMPT_ACTION_BUTTON_CLASS, COARSE_POINTER_PROMPT_ICON_ACTION_BUTTON_CLASS, } from "@bb/shared-ui/coarse-pointer-sizing"; +import { CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS } from "@bb/shared-ui/chrome-style-tokens"; import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; import { getMediaQuerySnapshot, @@ -86,6 +87,7 @@ import { type PromptDraftState, } from "@bb/client-core"; import { cn } from "@bb/shared-ui/lib/utils"; +import { PROMPT_STACK_EDGE_CARET_BUTTON_WIDTH_CLASS } from "./banner/PromptStackCard"; import { AttachmentPreview } from "./AttachmentPreview"; import { VoiceRecordingBar } from "./VoiceRecordingBar"; import { @@ -3018,7 +3020,7 @@ export function PromptBoxInternal({ data-promptbox-expanded-only="" data-promptbox-standard-actions="" inert={showVoiceActionGroup ? true : undefined} - className="absolute right-2 top-2 z-20 flex items-center" + className="absolute right-[13px] top-2 z-20 flex items-center" > ) : null} diff --git a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx index 98f28956f0..fe31dd83f7 100644 --- a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx +++ b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx @@ -1,29 +1,111 @@ // @vitest-environment jsdom -import { fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { TooltipProvider } from "@bb/shared-ui/tooltip"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { ThreadEnvironmentSummary } from "./ThreadEnvironmentSummary"; +afterEach(cleanup); + describe("ThreadEnvironmentSummary", () => { it("uses a host-free environment label in compact prompt boxes", () => { - render( - , + const { container } = render( + + + , ); expect( - document.querySelector('[data-promptbox-full-label=""]')?.textContent, + container.querySelector('[data-promptbox-full-label=""]')?.textContent, ).toBe("Mac Studio · New worktree"); expect( - document.querySelector('[data-promptbox-compact-label=""]')?.textContent, + container.querySelector('[data-promptbox-compact-label=""]')?.textContent, ).toBe("Worktree"); }); - it("explains the create-thread action in a tooltip", async () => { + it("reveals the full host and mode when the environment label is constrained", async () => { + const { container } = render( + + + , + ); + + const environmentDisplay = container.querySelector( + '[data-option-display=""]', + ); + expect(environmentDisplay).not.toBeNull(); + expect(environmentDisplay!.className).not.toContain("max-w-[10rem]"); + fireEvent.focus(environmentDisplay!); + + expect((await screen.findByRole("tooltip")).textContent).toBe( + "Bersabel's MacBook Pro", + ); + }); + + it("keeps matching environment and branch labels visibly separate", () => { render( + + + , + ); + + const copyButton = screen.getByRole("button", { + name: "bb/fix-environment-summary", + }); + expect(screen.getAllByText("bb/fix-environment-summary")).toHaveLength(3); + expect(copyButton.textContent).toBe("bb/fix-environment-summary"); + expect(copyButton.querySelector('[data-icon="GitBranch"]')).not.toBeNull(); + expect(copyButton.querySelector('[data-icon="Copy"]')).toBeNull(); + }); + + it.each(["Local worktree", "Remote worktree", "Local", "Remote"] as const)( + "shows the %s environment type from the environment icon", + async (environmentTypeLabel) => { + render( + + + , + ); + + fireEvent.focus( + screen.getByRole("img", { + name: `Environment type: ${environmentTypeLabel}`, + }), + ); + + expect((await screen.findByRole("tooltip")).textContent).toBe( + environmentTypeLabel, + ); + }, + ); + + it("explains the create-thread action in a tooltip", async () => { + const { container } = render( { , ); - fireEvent.focus( - screen.getByRole("button", { - name: "Create new thread in this worktree", - }), + const createThreadButton = screen.getByRole("button", { + name: "Create thread in worktree", + }); + expect(createThreadButton.classList).toContain( + "text-subtle-foreground/75", ); + expect(createThreadButton.classList).toContain( + "hover:text-muted-foreground", + ); + expect( + container.querySelector('[data-icon="MessageSquarePlus"]'), + ).not.toBeNull(); + fireEvent.focus(createThreadButton); expect((await screen.findByRole("tooltip")).textContent).toBe( - "Create new thread in this worktree", + "Create thread in worktree", ); }); }); diff --git a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx index fdcbdd52b7..76091aee8a 100644 --- a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx +++ b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx @@ -3,6 +3,9 @@ import { OptionDisplay } from "@bb/shared-ui/option-display"; import { copyToClipboardWithToast } from "@/lib/clipboard"; import { Icon, type IconName } from "@bb/shared-ui/icon"; import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS } from "@bb/shared-ui/chrome-style-tokens"; +import type { EnvironmentWorkspaceTypeLabel } from "@/lib/environment-workspace-display"; import type { WorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display"; const CHECKOUT_CHIP_BASE_CLASS_NAME = @@ -14,6 +17,7 @@ interface ThreadEnvironmentSummaryProps { environmentLabel?: string; environmentCompactLabel?: string; environmentIcon?: IconName; + environmentTypeLabel?: EnvironmentWorkspaceTypeLabel; environmentCheckout?: WorkspaceCheckoutDisplay; onCreateNewThreadInWorktree?: () => void; } @@ -23,10 +27,16 @@ export const ThreadEnvironmentSummary = memo(function ThreadEnvironmentSummary({ environmentLabel, environmentCompactLabel, environmentIcon, + environmentTypeLabel, environmentCheckout, onCreateNewThreadInWorktree, }: ThreadEnvironmentSummaryProps) { - if (!environmentLabel) { + if ( + !projectName && + !environmentLabel && + !environmentCheckout && + !onCreateNewThreadInWorktree + ) { return null; } @@ -39,42 +49,69 @@ export const ThreadEnvironmentSummary = memo(function ThreadEnvironmentSummary({ value={projectName} compactValue={projectName} leading={} - className="h-6 max-w-[10rem] shrink-0" - title={`Project: ${projectName}`} + className="h-6 min-w-0 max-w-[10rem] shrink" + tooltip={`Project: ${projectName}`} muted /> ) : null} - - ) : null - } - className="h-6 max-w-[10rem] shrink-0" - title={`Environment: ${environmentLabel}`} - muted - /> + {environmentLabel ? ( +
+ {environmentIcon && environmentTypeLabel ? ( + + + + + + + {environmentTypeLabel} + + ) : environmentIcon ? ( + + ) : null} + +
+ ) : null} {environmentCheckout && checkoutCopyValue !== null ? ( - + + + + + {environmentCheckout.title} + ) : environmentCheckout ? ( - Create new thread in this worktree + Create thread in worktree ) : null} diff --git a/apps/app/src/components/promptbox/banner/PromptStackCard.tsx b/apps/app/src/components/promptbox/banner/PromptStackCard.tsx index c2ca565d1f..f5a0f12999 100644 --- a/apps/app/src/components/promptbox/banner/PromptStackCard.tsx +++ b/apps/app/src/components/promptbox/banner/PromptStackCard.tsx @@ -9,6 +9,7 @@ export const PROMPT_STACK_INLAY_SEGMENT_CLASS = cn( "min-h-6 px-2 py-1", PROMPT_STACK_INLAY_RADIUS_CLASS, ); +export const PROMPT_STACK_EDGE_CARET_BUTTON_WIDTH_CLASS = "w-6 px-0"; const BASE_CHROME = cn( PROMPT_STACK_CARD_RADIUS_CLASS, "border border-border bg-surface-raised-solid", diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx index 9fdd9010a1..a97ff193ed 100644 --- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx +++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.test.tsx @@ -141,7 +141,7 @@ afterEach(() => { describe("QueuedMessagesList", () => { it("toggles a few messages between the fitted drawer and collapsed modes", () => { - const { container, getByRole } = renderQueuedMessages([ + const { container, getByRole, getByText } = renderQueuedMessages([ makeQueuedMessage("q_one", "First queued message"), makeQueuedMessage("q_two", "Second queued message"), ]); @@ -149,27 +149,30 @@ describe("QueuedMessagesList", () => { "[data-queued-messages-mode]", ); const surface = container.querySelector( - 'section[aria-label="Queued messages"]', + 'section[aria-label="Follow-ups"]', ); + const heading = getByText("Follow-ups"); expect(header?.getAttribute("data-queued-messages-mode")).toBe("drawer"); + expect(heading.className).toContain("font-normal"); + expect(heading.className).toContain("text-subtle-foreground"); expect(surface?.style.height).toBe("123px"); expect( - getByRole("button", { name: "Collapse queued messages" }).querySelector( + getByRole("button", { name: "Collapse follow-ups" }).querySelector( '[data-icon="ChevronDown"]', ), ).not.toBeNull(); - fireEvent.click(getByRole("button", { name: "Collapse queued messages" })); + fireEvent.click(getByRole("button", { name: "Collapse follow-ups" })); expect(header?.getAttribute("data-queued-messages-mode")).toBe("collapsed"); expect( - getByRole("button", { name: "Show queued messages" }).querySelector( + getByRole("button", { name: "Show follow-ups" }).querySelector( '[data-icon="ChevronUp"]', ), ).not.toBeNull(); expect(surface?.style.height).toBe("44px"); - fireEvent.click(getByRole("button", { name: "Show queued messages" })); + fireEvent.click(getByRole("button", { name: "Show follow-ups" })); expect(header?.getAttribute("data-queued-messages-mode")).toBe("drawer"); expect(surface?.style.height).toBe("123px"); }); @@ -185,11 +188,11 @@ describe("QueuedMessagesList", () => { ); expect(header?.getAttribute("data-queued-messages-mode")).toBe("drawer"); - fireEvent.click(getByRole("button", { name: "Expand queued messages" })); + fireEvent.click(getByRole("button", { name: "Expand follow-ups" })); expect(header?.getAttribute("data-queued-messages-mode")).toBe("workspace"); - fireEvent.click(getByRole("button", { name: "Collapse queued messages" })); + fireEvent.click(getByRole("button", { name: "Collapse follow-ups" })); expect(header?.getAttribute("data-queued-messages-mode")).toBe("collapsed"); - fireEvent.click(getByRole("button", { name: "Expand queued messages" })); + fireEvent.click(getByRole("button", { name: "Expand follow-ups" })); expect(header?.getAttribute("data-queued-messages-mode")).toBe("workspace"); }); @@ -210,7 +213,7 @@ describe("QueuedMessagesList", () => { , ); - fireEvent.click(getByRole("button", { name: "Collapse queued messages" })); + fireEvent.click(getByRole("button", { name: "Collapse follow-ups" })); expect( container .querySelector("[data-queued-messages-mode]") @@ -242,7 +245,7 @@ describe("QueuedMessagesList", () => { makeQueuedMessage("q_two", "Second queued message"), ]); const handle = getByRole("button", { - name: "Drag up to open the queue workspace", + name: "Drag up to open the follow-up workspace", }); Object.defineProperty(handle, "setPointerCapture", { configurable: true, @@ -299,17 +302,17 @@ describe("QueuedMessagesList", () => { ]); const sendButton = getByRole("button", { - name: "Send queued message 1 now", + name: "Send follow-up 1 now", }); const editButton = getByRole("button", { - name: "Edit queued message 1", + name: "Edit follow-up 1", }); const deleteButton = getByRole("button", { - name: "Delete queued message 1", + name: "Delete follow-up 1", }); expect( - getByRole("button", { name: "Queued message 1 actions" }), + getByRole("button", { name: "Follow-up 1 actions" }), ).toBeTruthy(); expect(editButton).toBeTruthy(); expect(deleteButton).toBeTruthy(); @@ -390,14 +393,14 @@ describe("QueuedMessagesList", () => { item.hasAttribute("data-queued-message-inline-editor"), ), ).toBe(true); - const editingLabel = getByText(/Editing queued message/u); + const editingLabel = getByText(/Editing follow-up/u); expect( editingLabel.closest('[data-inline-message-editor-frame="embedded"]'), ).not.toBeNull(); expect(getByTestId("inline-queue-editor")).toBeTruthy(); fireEvent.click( - getByRole("button", { name: "Stop editing queued message" }), + getByRole("button", { name: "Stop editing follow-up" }), ); expect(onDismiss).toHaveBeenCalledOnce(); }); @@ -467,7 +470,7 @@ describe("QueuedMessagesList", () => { />, ); const surface = container.querySelector( - 'section[aria-label="Queued messages"]', + 'section[aria-label="Follow-ups"]', ); expect(surface?.style.height).toBe("240px"); @@ -514,7 +517,7 @@ describe("QueuedMessagesList", () => { />, ); - fireEvent.click(getByRole("button", { name: "Collapse queued messages" })); + fireEvent.click(getByRole("button", { name: "Collapse follow-ups" })); expect(onDismiss).toHaveBeenCalledOnce(); rerender(); @@ -540,7 +543,7 @@ describe("QueuedMessagesList", () => { if (this.hasAttribute("data-queue-test-footer")) { return new DOMRect(0, 0, 600, 420); } - if (this.getAttribute("aria-label") === "Queued messages") { + if (this.getAttribute("aria-label") === "Follow-ups") { return new DOMRect(0, 0, 600, 240); } return nativeGetBoundingClientRect.call(this); @@ -585,7 +588,7 @@ describe("QueuedMessagesList", () => { ); const { container, rerender } = render(renderSurface(true)); const surface = container.querySelector( - 'section[aria-label="Queued messages"]', + 'section[aria-label="Follow-ups"]', ); const composer = container.querySelector("[data-test-bottom-composer]"); @@ -618,7 +621,7 @@ describe("QueuedMessagesList", () => { if (this.hasAttribute("data-queue-test-footer")) { return new DOMRect(0, 0, 600, 340); } - if (this.getAttribute("aria-label") === "Queued messages") { + if (this.getAttribute("aria-label") === "Follow-ups") { return new DOMRect(0, 0, 600, 240); } if (this.hasAttribute("data-queued-messages-scroll")) { @@ -670,7 +673,7 @@ describe("QueuedMessagesList", () => { , ); const surface = container.querySelector( - 'section[aria-label="Queued messages"]', + 'section[aria-label="Follow-ups"]', ); await waitFor(() => expect(surface?.style.height).toBe("360px")); @@ -701,7 +704,7 @@ describe("QueuedMessagesList", () => { if (this.hasAttribute("data-queue-test-footer")) { return new DOMRect(0, 0, 600, 340); } - if (this.getAttribute("aria-label") === "Queued messages") { + if (this.getAttribute("aria-label") === "Follow-ups") { return new DOMRect(0, 0, 600, 240); } if (this.hasAttribute("data-queued-messages-scroll")) { @@ -807,7 +810,7 @@ describe("QueuedMessagesList", () => { if (this.hasAttribute("data-queue-test-footer")) { return new DOMRect(0, 0, 600, 340); } - if (this.getAttribute("aria-label") === "Queued messages") { + if (this.getAttribute("aria-label") === "Follow-ups") { return new DOMRect(0, 0, 600, 240); } if (this.hasAttribute("data-queued-messages-scroll")) { @@ -860,7 +863,7 @@ describe("QueuedMessagesList", () => { , ); const surface = container.querySelector( - 'section[aria-label="Queued messages"]', + 'section[aria-label="Follow-ups"]', ); const scroll = container.querySelector( "[data-queued-messages-scroll]", @@ -876,7 +879,7 @@ describe("QueuedMessagesList", () => { '[data-inline-message-editor-frame="embedded"]', ); expect(editorFrame?.firstElementChild?.textContent).toContain( - "Editing queued message", + "Editing follow-up", ); expect(scroll?.scrollTop).toBe(expectedScrollTop); }, diff --git a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx index 35ae8cdd51..f0def6d812 100644 --- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx +++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx @@ -51,7 +51,10 @@ import { DropdownMenuTrigger, } from "@bb/shared-ui/dropdown-menu"; import { Icon } from "@bb/shared-ui/icon"; -import { PromptStackCard } from "@/components/promptbox/banner/PromptStackCard"; +import { + PROMPT_STACK_EDGE_CARET_BUTTON_WIDTH_CLASS, + PromptStackCard, +} from "@/components/promptbox/banner/PromptStackCard"; import { useScrollOverflowState } from "@/components/thread/timeline/useScrollOverflowState"; import { OverflowFade } from "@/components/ui/overflow-fade"; import { InlineMessageEditorFrame } from "@/components/promptbox/InlineMessageEditorFrame"; @@ -642,7 +645,7 @@ const QueuedMessageRow = memo(function QueuedMessageRow({ !dragDisabled && "cursor-grab active:cursor-grabbing", )} disabled={dragDisabled} - aria-label={`Reorder queued message ${index + 1}`} + aria-label={`Reorder follow-up ${index + 1}`} {...attributes} {...listeners} > @@ -713,7 +716,7 @@ const QueuedMessageRow = memo(function QueuedMessageRow({ )} disabled={actionDisabled || sendDisabled} onClick={() => onSendImmediately(queuedMessage.id)} - aria-label={`Send queued message ${index + 1} now`} + aria-label={`Send follow-up ${index + 1} now`} > @@ -737,7 +740,7 @@ const QueuedMessageRow = memo(function QueuedMessageRow({ queuedMessageIndex: index, }) } - aria-label={`Edit queued message ${index + 1}`} + aria-label={`Edit follow-up ${index + 1}`} > @@ -756,7 +759,7 @@ const QueuedMessageRow = memo(function QueuedMessageRow({ )} disabled={actionDisabled} onClick={() => onDelete(queuedMessage.id)} - aria-label={`Delete queued message ${index + 1}`} + aria-label={`Delete follow-up ${index + 1}`} > @@ -781,7 +784,7 @@ const QueuedMessageRow = memo(function QueuedMessageRow({ compact ? "size-7" : "size-8", )} disabled={actionDisabled} - aria-label={`Queued message ${index + 1} actions`} + aria-label={`Follow-up ${index + 1} actions`} > @@ -905,8 +908,8 @@ function QueuedMessageInlineEditorSlot({ > @@ -1454,10 +1457,10 @@ export function QueuedMessagesList({ const caretWillCollapse = mode === "workspace" || (mode === "drawer" && queueFitsDrawer); const caretLabel = caretWillCollapse - ? "Collapse queued messages" + ? "Collapse follow-ups" : mode === "collapsed" && queueFitsDrawer - ? "Show queued messages" - : "Expand queued messages"; + ? "Show follow-ups" + : "Expand follow-ups"; const handleCaretClick = () => { if (caretWillCollapse) { collapseDrawer(); @@ -1471,7 +1474,7 @@ export function QueuedMessagesList({ return (
- Queued + + Follow-ups + {queuedMessages.length} @@ -1503,8 +1508,8 @@ export function QueuedMessagesList({ )} aria-label={ mode === "workspace" - ? "Drag down to dock the queue" - : "Drag up to open the queue workspace" + ? "Drag down to dock follow-ups" + : "Drag up to open the follow-up workspace" } onPointerDown={handleSurfacePointerDown} onPointerMove={handleSurfacePointerMove} @@ -1522,7 +1527,10 @@ export function QueuedMessagesList({ type="button" size="icon" variant="ghost" - className="size-6 text-muted-foreground hover:bg-surface-recessed" + className={cn( + "h-6 text-muted-foreground hover:bg-surface-recessed", + PROMPT_STACK_EDGE_CARET_BUTTON_WIDTH_CLASS, + )} onClick={handleCaretClick} aria-label={caretLabel} aria-expanded={mode !== "collapsed"} diff --git a/apps/app/src/components/secondary-panel/FilePreview.test.tsx b/apps/app/src/components/secondary-panel/FilePreview.test.tsx index 3741f65d6c..0ecc53358f 100644 --- a/apps/app/src/components/secondary-panel/FilePreview.test.tsx +++ b/apps/app/src/components/secondary-panel/FilePreview.test.tsx @@ -16,6 +16,7 @@ import { } from "./FilePreview"; import { SOURCE_CODE_MAX_LINES } from "@/components/code/source-code-budget"; import { SecondaryPanelFilePreview } from "./ThreadStorageFilePreview"; +import { HttpError } from "@/lib/api"; import { PierreWorkerPoolGateContext, type PierreWorkerPoolGate, @@ -511,7 +512,7 @@ describe("FilePreview", () => { expect(screen.queryByRole("button", { name: "Load full file" })).toBeNull(); }); - it("opens a rendered HTML preview in the external browser", () => { + it("keeps a session-bound rendered HTML preview inside BB", () => { const openSpy = vi.spyOn(window, "open").mockReturnValue(null); render( @@ -530,19 +531,14 @@ describe("FilePreview", () => { />, ); - fireEvent.click( - screen.getByRole("button", { name: "Open in external browser" }), - ); - - expect(openSpy).toHaveBeenCalledWith( - `${window.location.origin}/api/v1/threads/thr_1/worktree/files/docs/progress-vis.html`, - "_blank", - "noopener,noreferrer", - ); + expect( + screen.queryByRole("button", { name: "Open in external browser" }), + ).toBeNull(); + expect(openSpy).not.toHaveBeenCalled(); openSpy.mockRestore(); }); - it("hands the desktop shell an absolute preview url", () => { + it("does not hand a session-bound preview to the desktop shell", () => { const openExternalUrl = vi.fn(); (window as unknown as { bbDesktop: unknown }).bbDesktop = { openExternalUrl, @@ -561,13 +557,10 @@ describe("FilePreview", () => { />, ); - fireEvent.click( - screen.getByRole("button", { name: "Open in external browser" }), - ); - - expect(openExternalUrl).toHaveBeenCalledWith( - `${window.location.origin}/api/v1/threads/thr_1/worktree/files/docs/progress-vis.html`, - ); + expect( + screen.queryByRole("button", { name: "Open in external browser" }), + ).toBeNull(); + expect(openExternalUrl).not.toHaveBeenCalled(); } finally { delete (window as unknown as { bbDesktop?: unknown }).bbDesktop; } @@ -591,6 +584,23 @@ describe("FilePreview", () => { ).toBeNull(); }); + it("offers Download for every byte-backed preview, including images", () => { + render( + , + ); + + const download = screen.getByRole("link", { name: "Download file" }); + expect(download.getAttribute("download")).toBe("資料 100%.png"); + expect(download.getAttribute("href")).toContain("/files/download?"); + }); + it("toggles source line wrap from the header button", async () => { render( { expect(screen.getByRole("cell", { name: "10" })).not.toBeNull(); }); + it.each([ + [401, "You do not have access to this file."], + [409, "The file source is disconnected or unavailable."], + [413, "This file exceeds the preview size limit."], + ])("shows the canonical HTTP %s file error", (status, message) => { + render( + , + ); + expect(screen.getByRole("alert").textContent).toContain(message); + }); + + it("shows missing files with the canonical not-found state", () => { + render( + , + ); + expect(screen.getByRole("alert").textContent).toContain("File not found."); + }); + it("does not show the file preview actions menu for non-text previews", () => { render( void; onOpenInEditor?: (path: string) => void; @@ -110,6 +112,7 @@ interface FilePreviewHeaderProps { copyPath: string | null; rawContents: string | null; externalUrl: string | null; + downloadUrl: string | null; onOpenInEditor?: (path: string) => void; onRefresh?: () => void; isRefreshing: boolean; @@ -155,6 +158,11 @@ interface FilePreviewVideoProps { title: string; } +interface FilePreviewAudioProps { + url: string; + title: string; +} + interface FilePreviewMessageProps { message: string; role?: "alert"; @@ -216,6 +224,19 @@ function getFilePreviewExternalUrl(state: FilePreviewState): string | null { return null; } +function getFilePreviewDownloadName(path: string): string { + return path.replaceAll("\\", "/").split("/").at(-1) ?? path; +} + +function isSessionBoundPreviewUrl(url: string): boolean { + if (typeof window === "undefined") return true; + try { + return new URL(url, window.location.href).origin === window.location.origin; + } catch { + return true; + } +} + function toAbsolutePreviewUrl(url: string): string { if (typeof window === "undefined") { return url; @@ -423,6 +444,7 @@ export function FilePreview({ state, path, copyPath = null, + downloadUrl = null, headerMode = "file", onSelectionAddToChat, onOpenInEditor, @@ -434,7 +456,12 @@ export function FilePreview({ const toggleKind = getFilePreviewToggleKind(state); const filePreviewLineRange = getFilePreviewLineRange(state); const rawContents = getRawFilePreviewContents(state); - const externalUrl = getFilePreviewExternalUrl(state); + const candidateExternalUrl = getFilePreviewExternalUrl(state); + const externalUrl = + candidateExternalUrl === null || + isSessionBoundPreviewUrl(candidateExternalUrl) + ? null + : candidateExternalUrl; const [viewMode, setViewMode] = useState( getInitialFilePreviewViewMode({ lineRange: filePreviewLineRange, @@ -489,6 +516,7 @@ export function FilePreview({ copyPath={copyPath} rawContents={rawContents} externalUrl={externalUrl} + downloadUrl={downloadUrl} onOpenInEditor={onOpenInEditor} onRefresh={onRefresh} isRefreshing={isRefreshing} @@ -534,13 +562,16 @@ function FilePreviewBody({ return ( ); } if (state.kind === "image") { return ; } + if (state.kind === "audio") { + return ; + } if (state.kind === "video") { return ; } @@ -597,6 +628,7 @@ function FilePreviewHeader({ copyPath, rawContents, externalUrl, + downloadUrl, onOpenInEditor, onRefresh, isRefreshing, @@ -675,6 +707,30 @@ function FilePreviewHeader({ )} + {downloadUrl === null ? null : ( + + + + + Download file + + )} {externalUrl === null ? null : ( @@ -1061,6 +1117,14 @@ function FilePreviewVideo({ url, title }: FilePreviewVideoProps) { ); } +function FilePreviewAudio({ url, title }: FilePreviewAudioProps) { + return ( +
+
+ ); +} + function IframeFilePreview({ sandbox, title, url }: IframeFilePreviewTarget) { const [loadState, setLoadState] = useState("loading"); const [showLoadingIndicator, setShowLoadingIndicator] = useState(false); diff --git a/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx b/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx index d243fc7521..aa9babb980 100644 --- a/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx +++ b/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx @@ -33,6 +33,7 @@ import { createSidebarSplitState, focusSidebarPane, getSidebarGroupForPane, + getSidebarTabPlacement, isCanonicalSidebarSplitState, moveSidebarPaneToSide, moveSidebarTab, @@ -43,6 +44,7 @@ import { reorderSidebarTab, replaceSidebarTab, resizeSidebarSplit, + restoreSidebarTabPlacement, selectSidebarTab, serializeSidebarSplitState, setSidebarPaneMaximized, @@ -50,6 +52,7 @@ import { sidebarSplitStorageKey, toggleSidebarPaneMaximize, type SidebarSplitState, + type SidebarTabPlacement, type SidebarTabGroup, } from "./sidebarSplitLayout"; import type { SecondaryPanelTabReorderRequest } from "./secondaryPanelTab"; @@ -131,6 +134,8 @@ export function SidebarSplitContainer({ value: initialStorageValue, }); const previousActiveTabId = useRef(activeTabId); + const previousAvailableTabIds = useRef(availableTabIds); + const removedTabPlacements = useRef(new Map()); const previousFullScreen = useRef(isFullScreen); const dimsInactiveSplits = useAtomValue(dimInactiveSplitsAtom); const [resizeCursor, setResizeCursor] = @@ -146,20 +151,38 @@ export function SidebarSplitContainer({ useEffect(() => { const previousExternalActiveTabId = previousActiveTabId.current; + const previousAvailable = previousAvailableTabIds.current; const shouldFollowExternalSelection = previousExternalActiveTabId !== activeTabId; previousActiveTabId.current = activeTabId; + previousAvailableTabIds.current = availableTabIds; const current = stateRef.current; + const availableTabIdSet = new Set(availableTabIds); + for (const tabId of previousAvailable) { + if (availableTabIdSet.has(tabId)) continue; + const placement = getSidebarTabPlacement(current, tabId); + if (placement !== null) { + removedTabPlacements.current.set(tabId, placement); + } + } const withActiveTabReplacement = shouldFollowExternalSelection && !availableTabIds.includes(previousExternalActiveTabId) ? replaceSidebarTab(current, previousExternalActiveTabId, activeTabId) : current; - const reconciled = reconcileSidebarSplitState( + let reconciled = reconcileSidebarSplitState( withActiveTabReplacement, availableTabIds, activeTabId, ); + const previousAvailableTabIdSet = new Set(previousAvailable); + for (const tabId of availableTabIds) { + if (previousAvailableTabIdSet.has(tabId)) continue; + const placement = removedTabPlacements.current.get(tabId); + if (placement === undefined) continue; + reconciled = restoreSidebarTabPlacement(reconciled, tabId, placement); + removedTabPlacements.current.delete(tabId); + } const activePane = shouldFollowExternalSelection ? listPanes(reconciled.layout.root).find((pane) => getSidebarGroupForPane(reconciled, pane.paneId)?.tabIds.includes( diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx index 697466ef24..ee3a7730ec 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.test.tsx @@ -118,7 +118,7 @@ describe("ThreadMetadataCard", () => { describe("EnvironmentRow", () => { it("shows the create-thread action for a provisioned worktree", () => { expect(renderEnvironmentRow(makeEnvironment())).toContain( - 'aria-label="Create new thread in this worktree"', + 'aria-label="Create thread in worktree"', ); }); @@ -137,12 +137,12 @@ describe("EnvironmentRow", () => { fireEvent.focus( screen.getByRole("button", { - name: "Create new thread in this worktree", + name: "Create thread in worktree", }), ); expect((await screen.findByRole("tooltip")).textContent).toBe( - "Create new thread in this worktree", + "Create thread in worktree", ); }); @@ -155,9 +155,7 @@ describe("EnvironmentRow", () => { }), ); - expect(markup).not.toContain( - 'aria-label="Create new thread in this worktree"', - ); + expect(markup).not.toContain('aria-label="Create thread in worktree"'); }); it("hides the create-thread action before a prepared worktree has a path", () => { @@ -168,9 +166,7 @@ describe("EnvironmentRow", () => { }), ); - expect(markup).not.toContain( - 'aria-label="Create new thread in this worktree"', - ); + expect(markup).not.toContain('aria-label="Create thread in worktree"'); }); }); diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx index 496804a07a..e720e6fcf8 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx @@ -337,14 +337,14 @@ export function EnvironmentRow({ - Create new thread in this worktree + Create thread in worktree
) : null} diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx index 090b9c85fe..eac47e0d43 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx @@ -1,4 +1,5 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import type { DiffPresentation } from "@/components/code/code-rendering"; import type { WorkspaceDiffTarget } from "@bb/domain"; import type { MarkdownLinkRouting } from "@/components/ui/markdown-link-routing.js"; @@ -14,8 +15,24 @@ import { useThreadStorageFilePreview, } from "@/hooks/queries/thread-queries"; import { useHostFilePreview } from "@/hooks/queries/host-file-preview-query"; +import { loadFilePreview } from "@/lib/api"; +import { resolveFileInteraction } from "@/lib/file-resolver"; +import { useAppNavigationHost } from "@/lib/app-navigation-host"; import { + getAbsoluteDirname, + resolveRootRelativeFilePath, +} from "@/lib/absolute-file-path"; +import { fileNameFromPath } from "@bb/thread-view"; +import type { ExperimentalFileIdentity } from "@get-bb/plugin-sdk"; +import { + buildHostFileDownloadUrl, + buildEnvironmentFileDownloadUrl, + buildEnvironmentFilePreviewUrl, + buildProjectFileDownloadUrl, buildRawFilesystemHtmlContentUrl, + buildThreadHostFileDownloadUrl, + buildThreadStorageDownloadUrl, + buildThreadWorktreeDownloadUrl, buildThreadWorktreeRawContentUrl, } from "@/lib/file-content-urls"; import type { @@ -32,6 +49,7 @@ import { SecondaryPanelFilePreview, ThreadStorageFilePreview, } from "./ThreadStorageFilePreview"; +import { buildMarkdownFilePreviewRouting } from "./markdown-file-preview-routing"; const GIT_DIFF_SKELETON_FILE_COUNT = 3; const PANEL_SCROLL_SLOT_CLASS = @@ -72,6 +90,7 @@ interface ProjectFilePreviewTabContentProps { environmentId: string | null; hostId: string | null; lineRange: FilePreviewLineRange | null; + markdownLinkRouting?: MarkdownLinkRouting; onSelectionAddToChat?: (text: string) => void; onOpenInEditor?: (path: string) => void; projectId: string; @@ -108,6 +127,13 @@ interface ThreadStorageFilePreviewTabContentProps { threadId: string; } +interface ByteFilePreviewTabContentProps { + identity: ExperimentalFileIdentity; + isPanelOpen: boolean; + markdownLinkRouting?: MarkdownLinkRouting; + onSelectionAddToChat?: (text: string) => void; +} + function ThreadDiffSkeleton() { return (
@@ -323,12 +349,21 @@ export function WorkspaceFilePreviewTabContent({ void refetchProjectFilePreview()} @@ -408,6 +452,7 @@ export function HostFilePreviewTabContent({ ); } + +export function ByteFilePreviewTabContent({ + identity, + isPanelOpen, + markdownLinkRouting, + onSelectionAddToChat, +}: ByteFilePreviewTabContentProps) { + const navigation = useAppNavigationHost(); + const interaction = resolveFileInteraction(identity); + const previewUrl = interaction.previewUrl; + const previewQuery = useQuery({ + queryKey: ["byte-file-preview", previewUrl, identity.displayName], + queryFn: ({ signal }) => { + if (previewUrl === null) { + throw new Error("File preview context is missing."); + } + return loadFilePreview( + { + name: identity.displayName, + path: identity.displayName, + url: previewUrl, + }, + signal, + ); + }, + enabled: isPanelOpen && previewUrl !== null, + }); + const projectAttachmentMarkdownLinkRouting = useMemo(() => { + if (identity.source.store !== "project-attachment") return undefined; + const attachmentPath = identity.source.path.replace(/^\/+/, ""); + const source = identity.source; + return buildMarkdownFilePreviewRouting({ + baseDir: getAbsoluteDirname({ path: `/${attachmentPath}` }), + contentSource: { + kind: "project-attachment", + projectId: source.ownerId, + }, + onOpenLink: ({ href }) => navigation.openUrl({ url: href }), + onOpenLocalFileLink: (link) => { + const path = resolveRootRelativeFilePath({ + path: link.path, + rootPath: "/", + }); + if (path === null) return false; + return navigation.openFilePreview({ + identity: { + source: { ...source, path }, + displayName: fileNameFromPath(path), + mimeType: null, + sizeBytes: null, + location: + link.lineRange === null + ? null + : { + kind: "range", + startLine: link.lineRange.startLineNumber, + endLine: link.lineRange.endLineNumber, + }, + }, + }); + }, + rootPath: "/", + }); + }, [identity.source, navigation]); + + return ( + void previewQuery.refetch()} + statusLabel={null} + /> + ); +} diff --git a/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.tsx b/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.tsx index afd347f6d5..3a3c5fa4b6 100644 --- a/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.tsx +++ b/apps/app/src/components/secondary-panel/ThreadStorageFilePreview.tsx @@ -24,6 +24,7 @@ const GENERIC_HTML_IFRAME_SANDBOX = "allow-scripts"; interface FilePreviewBaseProps { activePath: string; copyPath?: string | null; + downloadUrl?: string | null; error?: Error | null; filePreview: FilePreview | undefined; isLoading: boolean; @@ -89,6 +90,7 @@ function getTextPreviewKind( export function SecondaryPanelFilePreview({ activePath, copyPath = null, + downloadUrl = null, error, filePreview, htmlPreviewUrl = null, @@ -103,6 +105,15 @@ export function SecondaryPanelFilePreview({ }: SecondaryPanelFilePreviewProps) { if (error) { const isNotFound = error instanceof HttpError && error.status === 404; + const message = + error instanceof HttpError && + (error.status === 401 || error.status === 403) + ? "You do not have access to this file." + : error instanceof HttpError && error.status === 409 + ? "The file source is disconnected or unavailable." + : error instanceof HttpError && error.status === 413 + ? "This file exceeds the preview size limit." + : undefined; return ( ); } @@ -138,6 +153,7 @@ export function SecondaryPanelFilePreview({ ); @@ -201,6 +219,7 @@ export function SecondaryPanelFilePreview({ isRefreshing={isRefreshing} markdownLinkRouting={markdownLinkRouting} statusLabel={statusLabel} + downloadUrl={downloadUrl} state={{ kind: "ready", lineRange, @@ -221,6 +240,7 @@ export function SecondaryPanelFilePreview({ onRefresh={onRefresh} isRefreshing={isRefreshing} statusLabel={statusLabel} + downloadUrl={downloadUrl} state={{ kind: "image", url: filePreview.url }} /> ); @@ -236,11 +256,49 @@ export function SecondaryPanelFilePreview({ onRefresh={onRefresh} isRefreshing={isRefreshing} statusLabel={statusLabel} + downloadUrl={downloadUrl} state={{ kind: "video", url: filePreview.url }} /> ); } + if (filePreview.kind === "audio") { + return ( + + ); + } + + if (filePreview.kind === "document") { + return ( + + ); + } + return ( }), ), ); +const ByteFilePreviewTabContentChunk = lazy(() => + import("./ThreadSecondaryPanelTabContent").then( + ({ ByteFilePreviewTabContent }) => ({ + default: ByteFilePreviewTabContent, + }), + ), +); function SecondaryPanelContentSkeleton() { return ( @@ -283,3 +290,15 @@ export function LazyThreadStorageFilePreviewTabContent( ); } + +export function LazyByteFilePreviewTabContent( + props: ComponentProps< + ThreadSecondaryPanelTabContentModule["ByteFilePreviewTabContent"] + >, +) { + return ( + }> + + + ); +} diff --git a/apps/app/src/components/secondary-panel/markdown-file-preview-routing.test.ts b/apps/app/src/components/secondary-panel/markdown-file-preview-routing.test.ts new file mode 100644 index 0000000000..a787ca68d8 --- /dev/null +++ b/apps/app/src/components/secondary-panel/markdown-file-preview-routing.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildProjectFilePreviewUrl, + buildProjectAttachmentPreviewUrl, + buildThreadHostFilePreviewUrl, + buildThreadStoragePreviewUrl, + buildThreadWorktreeRawContentUrl, +} from "@/lib/file-content-urls"; +import { + buildMarkdownFilePreviewRouting, + resolveMarkdownFilePreviewRootPath, + type MarkdownFilePreviewContentSource, +} from "./markdown-file-preview-routing"; + +const rootPath = "/work space/资料%"; +const baseDir = `${rootPath}/docs`; +const resourcePath = `${baseDir}/asset space-图%.png`; +const onOpenLink = vi.fn(() => true); +const onOpenLocalFileLink = vi.fn(() => true); + +function buildRouting(contentSource: MarkdownFilePreviewContentSource) { + return buildMarkdownFilePreviewRouting({ + baseDir, + contentSource, + onOpenLink, + onOpenLocalFileLink, + rootPath, + }); +} + +function resolveResource( + contentSource: MarkdownFilePreviewContentSource, + target: + | { fragment?: string; lineRange: null } + | { + fragment?: undefined; + lineRange: { endLineNumber: number; startLineNumber: number }; + } = { lineRange: null }, +) { + return buildRouting(contentSource).localImage?.resolveSrc({ + ...target, + path: resourcePath, + }); +} + +describe("buildMarkdownFilePreviewRouting", () => { + it("routes working-tree resources through the authenticated thread route", () => { + expect( + resolveResource( + { + environmentId: "env-1", + fileSource: { kind: "working-tree" }, + kind: "workspace", + projectId: "project-1", + threadId: "thread-1", + }, + { fragment: "#section%20one", lineRange: null }, + ), + ).toBe( + `${buildThreadWorktreeRawContentUrl( + "thread-1", + "docs/asset space-图%.png", + )}#section%20one`, + ); + }); + + it("uses the project route for a root workspace without thread context", () => { + expect( + resolveResource({ + environmentId: "env-1", + fileSource: { kind: "working-tree" }, + kind: "workspace", + projectId: "project-1", + threadId: null, + }), + ).toBe( + buildProjectFilePreviewUrl("project-1", "docs/asset space-图%.png", { + environmentId: "env-1", + }), + ); + }); + + it("omits resource routing for historical files and missing workspace context", () => { + for (const contentSource of [ + { + environmentId: "env-1", + fileSource: { kind: "head" }, + kind: "workspace", + projectId: "project-1", + threadId: "thread-1", + }, + { + environmentId: null, + fileSource: { kind: "working-tree" }, + kind: "workspace", + projectId: null, + threadId: null, + }, + ] satisfies MarkdownFilePreviewContentSource[]) { + const routing = buildRouting(contentSource); + expect(routing.localImage).toBeUndefined(); + expect(routing.localFile?.relativeLinks).toEqual({ baseDir, rootPath }); + } + }); + + it("routes project, host, and storage resources through their source routes", () => { + const projectSource = { + environmentId: null, + hostId: "host-1", + kind: "project", + projectId: "project-1", + } satisfies MarkdownFilePreviewContentSource; + expect(resolveResource(projectSource)).toBe( + buildProjectFilePreviewUrl("project-1", "docs/asset space-图%.png", { + hostId: "host-1", + }), + ); + expect( + resolveResource( + { kind: "host", threadId: "thread-1" }, + { + lineRange: { endLineNumber: 14, startLineNumber: 12 }, + }, + ), + ).toBe( + `${buildThreadHostFilePreviewUrl("thread-1", resourcePath)}#L12-L14`, + ); + expect( + resolveResource({ kind: "thread-storage", threadId: "thread-1" }), + ).toBe( + buildThreadStoragePreviewUrl("thread-1", "docs/asset space-图%.png"), + ); + }); + + it("resolves project attachment resources beside the Markdown attachment", () => { + const routing = buildMarkdownFilePreviewRouting({ + baseDir: "/uploads/01ABC", + contentSource: { + kind: "project-attachment", + projectId: "project-1", + }, + onOpenLink, + onOpenLocalFileLink, + rootPath: "/", + }); + expect( + routing.localImage?.resolveSrc({ + lineRange: null, + path: "/uploads/01ABC/asset space-图%23.png", + }), + ).toBe( + buildProjectAttachmentPreviewUrl( + "project-1", + "uploads/01ABC/asset space-图%23.png", + ), + ); + }); + + it("keeps controls inactive when root context is missing", () => { + const routing = buildMarkdownFilePreviewRouting({ + baseDir, + contentSource: { kind: "host", threadId: "thread-1" }, + onOpenLink, + onOpenLocalFileLink, + rootPath: null, + }); + + expect(routing).toEqual({ onOpenLink }); + }); +}); + +describe("resolveMarkdownFilePreviewRootPath", () => { + it("selects the first containing root when roots overlap", () => { + expect( + resolveMarkdownFilePreviewRootPath({ + filePath: "/workspace/project/docs/readme.md", + rootPaths: ["/workspace/project", "/workspace"], + }), + ).toBe("/workspace/project"); + }); + + it("selects the containing root and rejects traversal or missing roots", () => { + expect( + resolveMarkdownFilePreviewRootPath({ + filePath: "/workspace/project/docs/readme.md", + rootPaths: ["/workspace/project", "/storage/thread-1"], + }), + ).toBe("/workspace/project"); + expect( + resolveMarkdownFilePreviewRootPath({ + filePath: "/storage/thread-1/current/../readme.md", + rootPaths: ["/workspace/project", "/storage/thread-1"], + }), + ).toBe("/storage/thread-1"); + expect( + resolveMarkdownFilePreviewRootPath({ + filePath: "/workspace/project/../../secret.md", + rootPaths: ["/workspace/project", null, undefined], + }), + ).toBeNull(); + }); +}); diff --git a/apps/app/src/components/secondary-panel/markdown-file-preview-routing.ts b/apps/app/src/components/secondary-panel/markdown-file-preview-routing.ts new file mode 100644 index 0000000000..8658974eee --- /dev/null +++ b/apps/app/src/components/secondary-panel/markdown-file-preview-routing.ts @@ -0,0 +1,203 @@ +import type { EnvironmentFilePreviewSource } from "@bb/client-core"; +import { + buildProjectFilePreviewUrl, + buildProjectAttachmentPreviewUrl, + buildThreadHostFilePreviewUrl, + buildThreadStoragePreviewUrl, + buildThreadWorktreeRawContentUrl, +} from "@/lib/file-content-urls"; +import { + getAbsoluteDirname, + isAbsoluteFilePathWithinRoot, + resolveRootRelativeFilePath, +} from "@/lib/absolute-file-path"; +import type { MarkdownPreviewLinkHandler } from "@/components/ui/markdown-link"; +import type { + MarkdownPreviewLocalFileLink, + MarkdownPreviewLocalFileLinkHandler, +} from "@/components/ui/markdown-local-file-link"; +import type { + MarkdownLinkRouting, + MarkdownLocalImageRouting, +} from "@/components/ui/markdown-link-routing"; + +export type MarkdownFilePreviewContentSource = + | { + environmentId: string | null; + fileSource: EnvironmentFilePreviewSource; + kind: "workspace"; + projectId: string | null; + threadId: string | null; + } + | { + environmentId: string | null; + hostId: string | null; + kind: "project"; + projectId: string; + } + | { kind: "project-attachment"; projectId: string } + | { kind: "host"; threadId: string } + | { kind: "thread-storage"; threadId: string }; + +interface BuildMarkdownFilePreviewRoutingArgs { + baseDir: string | undefined; + contentSource: MarkdownFilePreviewContentSource; + onOpenLink: MarkdownPreviewLinkHandler; + onOpenLocalFileLink: MarkdownPreviewLocalFileLinkHandler; + rootPath: string | null | undefined; +} + +interface ResolveMarkdownFilePreviewRootPathArgs { + filePath: string; + rootPaths: readonly (string | null | undefined)[]; +} + +function buildLineRangeFragment(link: MarkdownPreviewLocalFileLink): string { + if (link.fragment !== undefined) { + return link.fragment; + } + if (link.lineRange === null) { + return ""; + } + if (link.lineRange.startLineNumber === link.lineRange.endLineNumber) { + return `#L${link.lineRange.startLineNumber}`; + } + return `#L${link.lineRange.startLineNumber}-L${link.lineRange.endLineNumber}`; +} + +function buildContentUrl( + contentSource: MarkdownFilePreviewContentSource, + link: MarkdownPreviewLocalFileLink, + rootPath: string, +): string | null { + if (contentSource.kind === "host") { + return `${buildThreadHostFilePreviewUrl( + contentSource.threadId, + link.path, + )}${buildLineRangeFragment(link)}`; + } + + const relativePath = resolveRootRelativeFilePath({ + path: link.path, + rootPath, + }); + if (relativePath === null) { + return null; + } + + let contentUrl: string; + switch (contentSource.kind) { + case "workspace": { + if (contentSource.fileSource.kind !== "working-tree") { + return null; + } + if (contentSource.threadId !== null) { + contentUrl = buildThreadWorktreeRawContentUrl( + contentSource.threadId, + relativePath, + ); + break; + } + if ( + contentSource.projectId === null || + contentSource.environmentId === null + ) { + return null; + } + contentUrl = buildProjectFilePreviewUrl( + contentSource.projectId, + relativePath, + { environmentId: contentSource.environmentId }, + ); + break; + } + case "project": + contentUrl = buildProjectFilePreviewUrl( + contentSource.projectId, + relativePath, + { + ...(contentSource.environmentId !== null + ? { environmentId: contentSource.environmentId } + : contentSource.hostId !== null + ? { hostId: contentSource.hostId } + : {}), + }, + ); + break; + case "project-attachment": + contentUrl = buildProjectAttachmentPreviewUrl( + contentSource.projectId, + relativePath, + ); + break; + case "thread-storage": + contentUrl = buildThreadStoragePreviewUrl( + contentSource.threadId, + relativePath, + ); + break; + } + + return `${contentUrl}${buildLineRangeFragment(link)}`; +} + +export function buildMarkdownFilePreviewRouting({ + baseDir, + contentSource, + onOpenLink, + onOpenLocalFileLink, + rootPath, +}: BuildMarkdownFilePreviewRoutingArgs): MarkdownLinkRouting { + if (rootPath === null || rootPath === undefined) { + return { onOpenLink }; + } + + const absolutePaths = { + kind: "contained", + rootPath, + } as const; + const relativePaths = + baseDir === undefined ? undefined : { baseDir, rootPath }; + const supportsLocalResources = + contentSource.kind !== "workspace" || + (contentSource.fileSource.kind === "working-tree" && + (contentSource.threadId !== null || + (contentSource.projectId !== null && + contentSource.environmentId !== null))); + const localImage: MarkdownLocalImageRouting | undefined = + supportsLocalResources + ? { + absolutePaths, + resolveSrc: (link) => + buildContentUrl(contentSource, link, rootPath) ?? "data:;base64,", + ...(relativePaths === undefined ? {} : { relativePaths }), + } + : undefined; + + return { + localFile: { + absoluteLinks: absolutePaths, + onOpenLink: onOpenLocalFileLink, + ...(relativePaths === undefined ? {} : { relativeLinks: relativePaths }), + }, + ...(localImage === undefined ? {} : { localImage }), + onOpenLink, + }; +} + +export function resolveMarkdownFilePreviewRootPath({ + filePath, + rootPaths, +}: ResolveMarkdownFilePreviewRootPathArgs): string | null { + const baseDir = getAbsoluteDirname({ path: filePath }); + for (const rootPath of rootPaths) { + if ( + rootPath !== null && + rootPath !== undefined && + isAbsoluteFilePathWithinRoot({ candidatePath: baseDir, rootPath }) + ) { + return rootPath; + } + } + return null; +} diff --git a/apps/app/src/components/secondary-panel/sidebarSplitLayout.test.ts b/apps/app/src/components/secondary-panel/sidebarSplitLayout.test.ts index ad23efe953..0e253e06d1 100644 --- a/apps/app/src/components/secondary-panel/sidebarSplitLayout.test.ts +++ b/apps/app/src/components/secondary-panel/sidebarSplitLayout.test.ts @@ -10,6 +10,7 @@ import { SIDEBAR_FIXED_DIFF_TAB_ID, SIDEBAR_FIXED_INFO_TAB_ID, createSidebarSplitState, + getSidebarTabPlacement, focusSidebarPane, getSidebarGroupForPane, isCanonicalSidebarSplitState, @@ -20,6 +21,7 @@ import { reconcileSidebarSplitState, removeSidebarSplit, reorderSidebarTab, + restoreSidebarTabPlacement, replaceSidebarTab, resizeSidebarSplit, selectSidebarTab, @@ -333,6 +335,53 @@ describe("sidebar split layout", () => { ).toContain("terminal-a"); }); + it("restores closed tabs to their prior visible order", () => { + const firstTabId = "browser:first"; + const secondTabId = "browser:second"; + let state = createSidebarSplitState( + [SIDEBAR_FIXED_INFO_TAB_ID, firstTabId, secondTabId], + secondTabId, + ); + const firstPlacement = getSidebarTabPlacement(state, firstTabId); + if (firstPlacement === null) throw new Error("Missing first tab placement"); + + state = reconcileSidebarSplitState( + state, + [SIDEBAR_FIXED_INFO_TAB_ID, secondTabId], + secondTabId, + ); + const secondPlacement = getSidebarTabPlacement(state, secondTabId); + if (secondPlacement === null) + throw new Error("Missing second tab placement"); + state = reconcileSidebarSplitState( + state, + [SIDEBAR_FIXED_INFO_TAB_ID], + SIDEBAR_FIXED_INFO_TAB_ID, + ); + state = restoreSidebarTabPlacement( + reconcileSidebarSplitState( + state, + [SIDEBAR_FIXED_INFO_TAB_ID, secondTabId], + secondTabId, + ), + secondTabId, + secondPlacement, + ); + state = restoreSidebarTabPlacement( + reconcileSidebarSplitState( + state, + [SIDEBAR_FIXED_INFO_TAB_ID, secondTabId, firstTabId], + firstTabId, + ), + firstTabId, + firstPlacement, + ); + + expect( + getSidebarGroupForPane(state, state.layout.focusedPaneId)?.tabIds, + ).toEqual([SIDEBAR_FIXED_INFO_TAB_ID, firstTabId, secondTabId]); + }); + it("keeps a New Tab replacement in its existing split pane", () => { const newTabId = "new-tab:launcher"; const terminalTabId = "terminal:term-a:none"; diff --git a/apps/app/src/components/secondary-panel/sidebarSplitLayout.ts b/apps/app/src/components/secondary-panel/sidebarSplitLayout.ts index 8b353b57db..8ccfa4f7cd 100644 --- a/apps/app/src/components/secondary-panel/sidebarSplitLayout.ts +++ b/apps/app/src/components/secondary-panel/sidebarSplitLayout.ts @@ -53,6 +53,13 @@ export interface SidebarSplitState { maximizedPaneId: string | null; } +export interface SidebarTabPlacement { + followingTabId: string | null; + groupId: string; + index: number; + precedingTabId: string | null; +} + interface SidebarSplitIds { groupId: string; paneId: string; @@ -171,6 +178,104 @@ function preserveSidebarSplitStateIdentity( return areSidebarSplitStatesEqual(current, next) ? current : next; } +export function getSidebarTabPlacement( + state: SidebarSplitState, + tabId: string, +): SidebarTabPlacement | null { + const group = Object.values(state.groups).find((candidate) => + candidate.tabIds.includes(tabId), + ); + if (group === undefined) return null; + const index = group.tabIds.indexOf(tabId); + return { + followingTabId: group.tabIds[index + 1] ?? null, + groupId: group.id, + index, + precedingTabId: group.tabIds[index - 1] ?? null, + }; +} + +export function restoreSidebarTabPlacement( + state: SidebarSplitState, + tabId: string, + placement: SidebarTabPlacement, +): SidebarSplitState { + const currentGroup = Object.values(state.groups).find((group) => + group.tabIds.includes(tabId), + ); + if (currentGroup === undefined) return state; + const placedGroup = state.groups[placement.groupId]; + const targetGroup = + placedGroup !== undefined && + (placedGroup.id === currentGroup.id || currentGroup.tabIds.length > 1) + ? placedGroup + : currentGroup; + const groups = Object.fromEntries( + Object.entries(state.groups).map(([groupId, group]) => { + const tabIds = group.tabIds.filter((candidate) => candidate !== tabId); + return [ + groupId, + { + ...group, + tabIds, + activeTabId: + group.activeTabId === tabId + ? (tabIds[0] ?? targetGroup.activeTabId) + : group.activeTabId, + }, + ]; + }), + ); + const nextTargetGroup = groups[targetGroup.id]; + if (nextTargetGroup === undefined) return state; + const followingIndex = + placement.followingTabId === null + ? -1 + : nextTargetGroup.tabIds.indexOf(placement.followingTabId); + const precedingIndex = + placement.precedingTabId === null + ? -1 + : nextTargetGroup.tabIds.indexOf(placement.precedingTabId); + const insertAt = + followingIndex >= 0 + ? followingIndex + : precedingIndex >= 0 + ? precedingIndex + 1 + : Math.min(placement.index, nextTargetGroup.tabIds.length); + const tabIds = [...nextTargetGroup.tabIds]; + tabIds.splice(insertAt, 0, tabId); + groups[targetGroup.id] = { ...nextTargetGroup, tabIds }; + return { ...state, groups }; +} + +function insertMissingTabsInAvailableOrder( + tabIds: readonly string[], + missingTabIds: readonly string[], + availableTabIds: readonly string[], +): string[] { + const next = [...tabIds]; + for (const missingTabId of missingTabIds) { + const availableIndex = availableTabIds.indexOf(missingTabId); + const followingTabId = availableTabIds + .slice(availableIndex + 1) + .find((tabId) => next.includes(tabId)); + if (followingTabId !== undefined) { + next.splice(next.indexOf(followingTabId), 0, missingTabId); + continue; + } + const precedingTabId = availableTabIds + .slice(0, availableIndex) + .reverse() + .find((tabId) => next.includes(tabId)); + const insertAt = + precedingTabId === undefined + ? next.length + : next.indexOf(precedingTabId) + 1; + next.splice(insertAt, 0, missingTabId); + } + return next; +} + export function isCanonicalSidebarSplitState( state: SidebarSplitState, availableTabIds: readonly string[], @@ -610,7 +715,11 @@ export function reconcileSidebarSplitState( ...next.groups, [focusedGroup.id]: { ...focusedGroup, - tabIds: [...focusedGroup.tabIds, ...missing], + tabIds: insertMissingTabsInAvailableOrder( + focusedGroup.tabIds, + missing, + available, + ), activeTabId: focusedGroup.tabIds.length === 0 ? activeTabId diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts index 11eb6a8cf9..a3bf820d68 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.test.ts @@ -16,7 +16,10 @@ import { FIXED_PANEL_TABS_STATE_STORAGE_VERSION, } from "@/lib/fixed-panel-tabs-state"; import { buildFileOpenerPanelTab } from "@/components/plugin/file-opener-tabs"; -import { useThreadFileTabs } from "./useThreadFileTabs"; +import { + resetRecentlyClosedPanelTabsForTest, + useThreadFileTabs, +} from "./useThreadFileTabs"; import { resetPluginSlotStoreForTest, setPluginSlotRegistrations, @@ -58,6 +61,14 @@ function renderThreadHook(hook: () => Result) { return renderHook(hook, { wrapper: QueryWrapper }); } +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + function terminalSession(overrides: TerminalSessionOverrides): TerminalSession { return { id: "term_1", @@ -82,12 +93,472 @@ afterEach(() => { cleanup(); queryClient.clear(); window.localStorage.clear(); + resetRecentlyClosedPanelTabsForTest(); resetPluginSlotStoreForTest(); syncMocks.scheduleLocalThreadTabsMigration.mockClear(); syncMocks.scheduleThreadTabsPersistence.mockClear(); syncMocks.useThreadTabs.mockClear(); }); +describe("useThreadFileTabs recently closed tabs", () => { + it("reopens closed tabs in reverse close order and restores their positions", () => { + const { result } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "recently-closed", + syncThreadId: null, + environmentId: "env_1", + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + + let firstTabId = ""; + let secondTabId = ""; + act(() => { + firstTabId = + result.current.openTab({ + kind: "browser", + url: "https://first.example", + })?.id ?? ""; + secondTabId = + result.current.openTab({ + kind: "browser", + url: "https://second.example", + })?.id ?? ""; + }); + act(() => { + result.current.closeTab(firstTabId); + result.current.closeTab(secondTabId); + }); + + expect(result.current.orderedSecondaryFileTabs).toHaveLength(0); + let didReopen = false; + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(true); + expect(result.current.activeBrowserTab?.id).toBe(secondTabId); + + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(true); + expect(result.current.activeBrowserTab?.id).toBe(firstTabId); + expect( + result.current.orderedSecondaryFileTabs.map((tab) => tab.id), + ).toEqual([firstTabId, secondTabId]); + + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(false); + }); + + it("does not reopen a launcher tab or a file reopened another way", () => { + const { result } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "recently-closed-launcher", + syncThreadId: null, + environmentId: "env_1", + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + const fileRequest = { + kind: "workspace-file-preview" as const, + tab: { + lineRange: null, + path: "src/index.ts", + source: { kind: "working-tree" as const }, + statusLabel: null, + }, + }; + + act(() => { + const launcher = result.current.openTab({ kind: "new-tab" }); + result.current.closeTab(launcher?.id ?? ""); + }); + expect(result.current.reopenClosedTab()).toBe(false); + + let fileTabId = ""; + act(() => { + fileTabId = result.current.openTab(fileRequest)?.id ?? ""; + }); + act(() => result.current.closeTab(fileTabId)); + act(() => { + result.current.openTab(fileRequest); + }); + expect(result.current.reopenClosedTab()).toBe(false); + }); + + it("skips storage history with a deleted path or different owner", () => { + let storageFiles = { + files: [ + { name: "available.md", path: "available.md" }, + { name: "deleted.md", path: "deleted.md" }, + ], + truncated: false, + }; + const { result, rerender } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "recently-closed-storage", + syncThreadId: "thr_current", + environmentId: "env_1", + storageFiles, + terminalSessions: undefined, + }), + ); + + let availableTabId = ""; + let foreignTabId = ""; + let deletedTabId = ""; + act(() => { + availableTabId = + result.current.openTab({ + kind: "thread-storage-file-preview", + tab: { lineRange: null, path: "available.md" }, + })?.id ?? ""; + foreignTabId = + result.current.openTab({ + kind: "thread-storage-file-preview", + tab: { lineRange: null, path: "foreign.md" }, + threadId: "thr_foreign", + })?.id ?? ""; + deletedTabId = + result.current.openTab({ + kind: "thread-storage-file-preview", + tab: { lineRange: null, path: "deleted.md" }, + })?.id ?? ""; + }); + act(() => { + result.current.closeTab(availableTabId); + result.current.closeTab(foreignTabId); + result.current.closeTab(deletedTabId); + }); + act(() => { + storageFiles = { + files: [{ name: "available.md", path: "available.md" }], + truncated: false, + }; + rerender(); + }); + + let didReopen = false; + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(true); + expect(result.current.activeStorageFilePath).toBe("available.md"); + expect(result.current.activeStorageFileThreadId).toBe("thr_current"); + + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(false); + }); + + it("does not consume or transiently restore storage history before exact validation", async () => { + const validation = createDeferred(); + const storageFileExists = vi.fn(() => validation.promise); + const { result } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "recently-closed-storage-loading", + syncThreadId: "thr_current", + environmentId: "env_1", + storageFileExists, + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + + let storageTabId = ""; + act(() => { + storageTabId = + result.current.openTab({ + kind: "thread-storage-file-preview", + tab: { lineRange: null, path: "still-here.md" }, + })?.id ?? ""; + }); + act(() => result.current.closeTab(storageTabId)); + + let didHandle = false; + act(() => { + didHandle = result.current.reopenClosedTab(); + }); + expect(didHandle).toBe(true); + expect(result.current.orderedSecondaryFileTabs).toHaveLength(0); + expect(storageFileExists).toHaveBeenCalledWith("still-here.md"); + + await act(async () => { + validation.resolve(true); + await validation.promise; + await Promise.resolve(); + }); + expect(result.current.activeStorageFilePath).toBe("still-here.md"); + }); + + it("checks a path omitted from a truncated inventory and skips it when deleted", async () => { + const storageFileExists = vi.fn(async () => false); + const { result } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "recently-closed-storage-truncated", + syncThreadId: "thr_current", + environmentId: "env_1", + storageFileExists, + storageFiles: { files: [], truncated: true }, + terminalSessions: undefined, + }), + ); + + let browserTabId = ""; + let storageTabId = ""; + act(() => { + browserTabId = + result.current.openTab({ + kind: "browser", + url: "https://fallback.example", + })?.id ?? ""; + storageTabId = + result.current.openTab({ + kind: "thread-storage-file-preview", + tab: { lineRange: null, path: "deleted-after-close.md" }, + })?.id ?? ""; + }); + act(() => { + result.current.closeTab(browserTabId); + result.current.closeTab(storageTabId); + }); + act(() => { + result.current.reopenClosedTab(); + }); + + await waitFor(() => { + expect(result.current.activeBrowserTab?.id).toBe(browserTabId); + }); + expect(storageFileExists).toHaveBeenCalledWith("deleted-after-close.md"); + expect(result.current.activeStorageFilePath).toBeNull(); + }); + + it("restores a valid path omitted from a truncated inventory", async () => { + const storageFileExists = vi.fn(async () => true); + const { result } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "recently-closed-storage-truncated-valid", + syncThreadId: "thr_current", + environmentId: "env_1", + storageFileExists, + storageFiles: { files: [], truncated: true }, + terminalSessions: undefined, + }), + ); + + let storageTabId = ""; + act(() => { + storageTabId = + result.current.openTab({ + kind: "thread-storage-file-preview", + tab: { lineRange: null, path: "after-page-one.md" }, + })?.id ?? ""; + }); + act(() => result.current.closeTab(storageTabId)); + act(() => { + result.current.reopenClosedTab(); + }); + + await waitFor(() => { + expect(result.current.activeStorageFilePath).toBe("after-page-one.md"); + }); + expect(storageFileExists).toHaveBeenCalledWith("after-page-one.md"); + }); + + it("keeps an open storage tab when the inventory is truncated", () => { + const threadId = "storage-truncated-open-tab"; + const storageTab = createThreadStorageFilePreviewFixedPanelTab({ + environmentId: "env_1", + isPinned: false, + tab: { lineRange: null, path: "after-page-one.md" }, + threadId, + }); + const state = createEmptyFixedPanelTabsState({ + secondary: { + activeTabId: storageTab.id, + isOpen: true, + tabs: [storageTab], + }, + lastUsedAt: Date.now(), + }); + window.localStorage.setItem( + getFixedPanelTabsStateStorageKey({ threadId }), + serializeFixedPanelTabsState({ state }), + ); + + const { result } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: threadId, + syncThreadId: threadId, + environmentId: "env_1", + storageFiles: { files: [], truncated: true }, + terminalSessions: undefined, + }), + ); + + expect(result.current.activeStorageFilePath).toBe("after-page-one.md"); + }); + + it.each([ + { + changedContext: { + environmentId: "env_2", + fileOwnerThreadId: "thr_1", + projectHostId: "host_1", + projectId: "proj_1", + }, + dimension: "environment", + }, + { + changedContext: { + environmentId: "env_1", + fileOwnerThreadId: "thr_1", + projectHostId: "host_1", + projectId: "proj_2", + }, + dimension: "project", + }, + { + changedContext: { + environmentId: "env_1", + fileOwnerThreadId: "thr_2", + projectHostId: "host_1", + projectId: "proj_1", + }, + dimension: "file owner", + }, + { + changedContext: { + environmentId: "env_1", + fileOwnerThreadId: "thr_1", + projectHostId: "host_2", + projectId: "proj_1", + }, + dimension: "project host", + }, + ])( + "skips workspace history from a different $dimension", + ({ changedContext, dimension }) => { + let context = { + environmentId: "env_1", + fileOwnerThreadId: "thr_1", + projectHostId: "host_1", + projectId: "proj_1", + }; + const { result, rerender } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: `recently-closed-${dimension}`, + syncThreadId: null, + environmentId: context.environmentId, + fileOwnerThreadId: context.fileOwnerThreadId, + projectHostId: context.projectHostId, + projectId: context.projectId, + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + + let workspaceTabId = ""; + act(() => { + workspaceTabId = + result.current.openTab({ + kind: "workspace-file-preview", + tab: { + lineRange: null, + path: "src/index.ts", + source: { kind: "working-tree" }, + statusLabel: null, + }, + })?.id ?? ""; + }); + act(() => result.current.closeTab(workspaceTabId)); + act(() => { + context = changedContext; + rerender(); + }); + + let didReopen = false; + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(false); + expect(result.current.activeWorkspaceFilePath).toBeNull(); + }, + ); + + it("restores the nearest history entry owned by the current context", () => { + let environmentId = "env_1"; + const { result, rerender } = renderThreadHook(() => + useThreadFileTabs({ + panelStateId: "recently-closed-context-order", + syncThreadId: null, + environmentId, + fileOwnerThreadId: "thr_1", + projectHostId: "host_1", + projectId: "proj_1", + storageFiles: undefined, + terminalSessions: undefined, + }), + ); + + const openAndCloseWorkspaceFile = (path: string) => { + let tabId = ""; + act(() => { + tabId = + result.current.openTab({ + kind: "workspace-file-preview", + tab: { + lineRange: null, + path, + source: { kind: "working-tree" }, + statusLabel: null, + }, + })?.id ?? ""; + }); + act(() => result.current.closeTab(tabId)); + }; + + openAndCloseWorkspaceFile("src/env-one.ts"); + act(() => { + environmentId = "env_2"; + rerender(); + }); + openAndCloseWorkspaceFile("src/env-two.ts"); + act(() => { + environmentId = "env_1"; + rerender(); + }); + + let didReopen = false; + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(true); + expect(result.current.activeWorkspaceFilePath).toBe("src/env-one.ts"); + + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(false); + + act(() => { + environmentId = "env_2"; + rerender(); + }); + act(() => { + didReopen = result.current.reopenClosedTab(); + }); + expect(didReopen).toBe(true); + expect(result.current.activeWorkspaceFilePath).toBe("src/env-two.ts"); + }); +}); + describe("useThreadFileTabs terminal pruning", () => { it("keeps root-compose file tabs local", () => { const { result } = renderThreadHook(() => @@ -734,7 +1205,10 @@ describe("useThreadFileTabs file opener diversion", () => { panelStateId: "opener-storage-search", syncThreadId: "thr_storage_search", environmentId: "env_1", - storageFiles: [{ path: "artifacts/notes.md" }], + storageFiles: { + files: [{ name: "notes.md", path: "artifacts/notes.md" }], + truncated: false, + }, terminalSessions: undefined, }), ); diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts index ffb4c254f9..402e836506 100644 --- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts +++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts @@ -1,18 +1,30 @@ -import { useCallback, useEffect, useMemo } from "react"; -import type { TerminalSession } from "@bb/server-contract"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, +} from "react"; +import type { + TerminalSession, + ThreadStorageFileListResponse, +} from "@bb/server-contract"; import { useFixedPanelTabsState, useUpdateFixedPanelTabsState, } from "@/lib/fixed-panel-tabs"; import { createBrowserFixedPanelTab, + createByteFilePreviewFixedPanelTab, createHostFilePreviewFixedPanelTab, createNewTabFixedPanelTab, createPluginPanelFixedPanelTab, createThreadStorageFilePreviewFixedPanelTab, createWorkspaceFilePreviewFixedPanelTab, type BrowserFixedPanelTab, + type ByteFilePreviewFixedPanelTab, type FixedPanelTab, + type FixedPanelTabsState, type HostFilePreviewFixedPanelTab, type NewTabFixedPanelTab, type PluginPanelFixedPanelTab, @@ -22,6 +34,7 @@ import { import { usePluginSlots } from "@/lib/plugin-slots"; import { useFileOpenerPreferenceValue } from "@/lib/file-opener-preference"; import { + createFileOpenerOriginalTab, createFileOpenerTabForRequest, fileOpenerIdFromActionId, parseFileOpenerParams, @@ -30,6 +43,7 @@ import type { FileOpenerOverride } from "@/lib/plugin-slot-resolvers"; import type { OpenPluginPanelArgs } from "@/components/plugin/PluginPanelActions"; import type { HostFileTabState, + ByteFileTabState, ThreadStorageFileTabState, WorkspaceFileTabState, } from "@bb/client-core"; @@ -66,14 +80,13 @@ interface UseThreadFileTabsParams { projectHostId?: string | null; projectId?: string | null; retainedTerminalId?: string | null; - storageFiles: readonly ThreadStorageFileListItem[] | undefined; + storageFileExists?: (path: string) => Promise; + storageFiles: + | Pick + | undefined; terminalSessions: readonly TerminalSession[] | undefined; } -interface ThreadStorageFileListItem { - path: string; -} - interface FileSearchWorkspaceSelection { source: "workspace"; path: string; @@ -95,6 +108,10 @@ export interface UpdateBrowserTabArgs { } export type OpenSecondaryPanelTabRequest = + | { + kind: "byte-file-preview"; + tab: ByteFileTabState; + } | { kind: "workspace-file-preview"; tab: WorkspaceFileTabState; @@ -126,6 +143,7 @@ interface PruneSecondaryTabsArgs { } type SecondaryPanelTab = + | ByteFilePreviewFixedPanelTab | WorkspaceFilePreviewFixedPanelTab | HostFilePreviewFixedPanelTab | ThreadStorageFilePreviewFixedPanelTab @@ -133,8 +151,197 @@ type SecondaryPanelTab = | NewTabFixedPanelTab | PluginPanelFixedPanelTab; +type ReopenableSecondaryPanelTab = Exclude< + SecondaryPanelTab, + NewTabFixedPanelTab +>; + +interface RecentlyClosedPanelTab { + index: number; + tab: ReopenableSecondaryPanelTab; +} + +interface RecentlyClosedPanelContext { + environmentId: string | null | undefined; + fileOwnerThreadId: string | null; + panelStateId: string; + projectHostId: string | null; + projectId: string | null; +} + +interface IsReopenablePanelTabOwnedByContextArgs { + context: RecentlyClosedPanelContext; + tab: ReopenableSecondaryPanelTab; +} + +interface StorageFileInventory { + knownPaths: ReadonlySet; + truncated: boolean; +} + +type RecentlyClosedPanelTabAvailability = + | "available" + | "missing" + | "unresolved"; + +type TakeClosedPanelTabResult = + | { kind: "available"; entry: RecentlyClosedPanelTab } + | { kind: "unresolved"; entry: RecentlyClosedPanelTab } + | { kind: "empty" }; + +type RecentlyClosedPanelContextKey = string; type OpenResolvedTabBehavior = "open" | "replace-new-tab"; +const MAX_RECENTLY_CLOSED_PANEL_TABS = 25; +const recentlyClosedPanelTabs = new Map< + RecentlyClosedPanelContextKey, + RecentlyClosedPanelTab[] +>(); + +function isReopenableSecondaryPanelTab( + tab: FixedPanelTab, +): tab is ReopenableSecondaryPanelTab { + switch (tab.kind) { + case "workspace-file-preview": + case "byte-file-preview": + case "host-file-preview": + case "thread-storage-file-preview": + case "browser": + case "plugin-panel": + return true; + case "thread-info": + case "git-diff": + case "plugin-page-fixed": + case "new-tab": + case "terminal": + return false; + } +} + +function rememberClosedPanelTab( + contextKey: RecentlyClosedPanelContextKey, + entry: RecentlyClosedPanelTab, +): void { + const stack = recentlyClosedPanelTabs.get(contextKey) ?? []; + stack.push(entry); + if (stack.length > MAX_RECENTLY_CLOSED_PANEL_TABS) { + stack.splice(0, stack.length - MAX_RECENTLY_CLOSED_PANEL_TABS); + } + recentlyClosedPanelTabs.set(contextKey, stack); +} + +function forgetClosedPanelTab( + contextKey: RecentlyClosedPanelContextKey, + tabId: string, +): boolean { + const stack = recentlyClosedPanelTabs.get(contextKey); + if (stack === undefined) return false; + const wasTop = stack.at(-1)?.tab.id === tabId; + const next = stack.filter((entry) => entry.tab.id !== tabId); + if (next.length === 0) { + recentlyClosedPanelTabs.delete(contextKey); + return wasTop; + } + recentlyClosedPanelTabs.set(contextKey, next); + return wasTop; +} + +function buildRecentlyClosedPanelContextKey( + context: RecentlyClosedPanelContext, +): RecentlyClosedPanelContextKey { + return JSON.stringify(context); +} + +function isReopenablePanelTabOwnedByContext({ + context, + tab: reopenableTab, +}: IsReopenablePanelTabOwnedByContextArgs): boolean { + const originalTab = + reopenableTab.kind === "plugin-panel" + ? createFileOpenerOriginalTab(reopenableTab) + : null; + const tab = originalTab ?? reopenableTab; + switch (tab.kind) { + case "byte-file-preview": + return ( + tab.source === "tasks-attachment" || tab.ownerId === context.projectId + ); + case "workspace-file-preview": + return ( + tab.environmentId === context.environmentId && + tab.projectId === + (context.environmentId === null ? context.projectId : null) + ); + case "host-file-preview": + return ( + tab.hostId !== null || + (tab.environmentId === context.environmentId && + tab.threadId === context.fileOwnerThreadId) + ); + case "thread-storage-file-preview": + return tab.threadId === context.fileOwnerThreadId; + case "browser": + return tab.environmentId === context.environmentId; + case "plugin-panel": + return true; + } +} + +function storagePathForRecentlyClosedPanelTab( + tab: ReopenableSecondaryPanelTab, +): string | null { + const originalTab = + tab.kind === "plugin-panel" ? createFileOpenerOriginalTab(tab) : null; + const resourceTab = originalTab ?? tab; + return resourceTab.kind === "thread-storage-file-preview" + ? resourceTab.path + : null; +} + +function recentlyClosedPanelTabAvailability( + tab: ReopenableSecondaryPanelTab, + storageInventory: StorageFileInventory | null, +): RecentlyClosedPanelTabAvailability { + const storagePath = storagePathForRecentlyClosedPanelTab(tab); + if (storagePath === null) return "available"; + if (storageInventory === null) return "unresolved"; + if (storageInventory.knownPaths.has(storagePath)) return "available"; + return storageInventory.truncated ? "unresolved" : "missing"; +} + +function takeClosedPanelTab( + contextKey: RecentlyClosedPanelContextKey, + openTabIds: ReadonlySet, + availability: ( + entry: RecentlyClosedPanelTab, + ) => RecentlyClosedPanelTabAvailability, +): TakeClosedPanelTabResult { + const stack = recentlyClosedPanelTabs.get(contextKey); + if (stack === undefined) return { kind: "empty" }; + while (stack.length > 0) { + const entry = stack.at(-1); + if (entry === undefined) break; + if (openTabIds.has(entry.tab.id)) { + stack.pop(); + continue; + } + const entryAvailability = availability(entry); + if (entryAvailability === "unresolved") { + return { kind: "unresolved", entry }; + } + stack.pop(); + if (entryAvailability === "missing") continue; + if (stack.length === 0) recentlyClosedPanelTabs.delete(contextKey); + return { kind: "available", entry }; + } + recentlyClosedPanelTabs.delete(contextKey); + return { kind: "empty" }; +} + +export function resetRecentlyClosedPanelTabsForTest(): void { + recentlyClosedPanelTabs.clear(); +} + function createStorageTab( environmentId: string | null, tab: ThreadStorageFileTabState, @@ -155,6 +362,8 @@ function createTabForOpenRequest({ threadId, }: CreateTabForOpenRequestArgs): SecondaryPanelTab | null { switch (request.kind) { + case "byte-file-preview": + return createByteFilePreviewFixedPanelTab({ tab: request.tab }); case "workspace-file-preview": if ( request.environmentId === undefined && @@ -248,6 +457,7 @@ export function useThreadFileTabs({ projectHostId = null, projectId = null, retainedTerminalId = null, + storageFileExists, storageFiles, terminalSessions, }: UseThreadFileTabsParams) { @@ -260,8 +470,11 @@ export function useThreadFileTabs({ syncThreadId, ); const recordRecentItem = useRecordThreadRecentItem(panelStateId); - const isPanelStateResolved = - panelStateId !== null && panelStateId !== undefined; + const resolvedPanelStateId = + typeof panelStateId === "string" && panelStateId.length > 0 + ? panelStateId + : null; + const isPanelStateResolved = resolvedPanelStateId !== null; const resolvedFileOwnerThreadId = fileOwnerThreadId !== undefined ? fileOwnerThreadId @@ -269,6 +482,59 @@ export function useThreadFileTabs({ const resolvedEnvironmentId = isPanelStateResolved ? environmentId : undefined; + const storageInventory = useMemo( + () => + storageFiles === undefined + ? null + : { + knownPaths: new Set(storageFiles.files.map((file) => file.path)), + truncated: storageFiles.truncated, + }, + [storageFiles], + ); + const recentlyClosedPanelContext = useMemo( + () => + resolvedPanelStateId === null + ? null + : { + environmentId: resolvedEnvironmentId, + fileOwnerThreadId: resolvedFileOwnerThreadId, + panelStateId: resolvedPanelStateId, + projectHostId, + projectId, + }, + [ + projectHostId, + projectId, + resolvedEnvironmentId, + resolvedFileOwnerThreadId, + resolvedPanelStateId, + ], + ); + const recentlyClosedPanelContextKey = useMemo( + () => + recentlyClosedPanelContext === null + ? null + : buildRecentlyClosedPanelContextKey(recentlyClosedPanelContext), + [recentlyClosedPanelContext], + ); + const recentlyClosedPanelContextKeyRef = useRef( + recentlyClosedPanelContextKey, + ); + const pendingStorageValidationRef = useRef(null); + const isMountedRef = useRef(true); + + useLayoutEffect(() => { + recentlyClosedPanelContextKeyRef.current = recentlyClosedPanelContextKey; + pendingStorageValidationRef.current = null; + }, [recentlyClosedPanelContextKey]); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); useEffect(() => { if (!resolvedFileOwnerThreadId) return; @@ -363,13 +629,18 @@ export function useThreadFileTabs({ ]); useEffect(() => { - if (!isPanelStateResolved || !storageFiles) return; + if ( + !isPanelStateResolved || + storageInventory === null || + storageInventory.truncated + ) { + return; + } updateFixedPanelTabsState((state) => { - const knownPaths = new Set(storageFiles.map((file) => file.path)); const pruned = setPrunedSecondaryTabs({ activeTabId: state.secondary.activeTabId, tabs: pruneStorageTabs({ - knownPaths, + knownPaths: storageInventory.knownPaths, tabs: state.secondary.tabs, threadId: resolvedFileOwnerThreadId, }), @@ -384,7 +655,7 @@ export function useThreadFileTabs({ }, [ isPanelStateResolved, resolvedFileOwnerThreadId, - storageFiles, + storageInventory, updateFixedPanelTabsState, ]); @@ -422,16 +693,19 @@ export function useThreadFileTabs({ behavior: OpenResolvedTabBehavior, viewer?: FileOpenerOverride, ): SecondaryPanelTab | null => { - const openerTab = createFileOpenerTabForRequest({ - fileOpeners, - preference: fileOpenerPreference, - projectHostId, - projectId, - request, - resolvedEnvironmentId, - threadId: resolvedFileOwnerThreadId, - ...(viewer !== undefined ? { viewer } : {}), - }); + const openerTab = + request.kind === "byte-file-preview" + ? null + : createFileOpenerTabForRequest({ + fileOpeners, + preference: fileOpenerPreference, + projectHostId, + projectId, + request, + resolvedEnvironmentId, + threadId: resolvedFileOwnerThreadId, + ...(viewer !== undefined ? { viewer } : {}), + }); const tab = openerTab ?? createTabForOpenRequest({ @@ -442,6 +716,10 @@ export function useThreadFileTabs({ }); if (tab === null) return null; + if (recentlyClosedPanelContextKey !== null) { + forgetClosedPanelTab(recentlyClosedPanelContextKey, tab.id); + } + if ( request.kind === "workspace-file-preview" && request.tab.source.kind === "working-tree" @@ -468,6 +746,7 @@ export function useThreadFileTabs({ projectId, resolvedEnvironmentId, resolvedFileOwnerThreadId, + recentlyClosedPanelContextKey, updateFixedPanelTabsState, ], ); @@ -497,13 +776,132 @@ export function useThreadFileTabs({ const closeTab = useCallback( (tabId: string) => { - updateFixedPanelTabsState((state) => - closeSecondaryPanelTabInState(state, tabId), - ); + updateFixedPanelTabsState((state) => { + const tabIndex = state.secondary.tabs.findIndex( + (tab) => tab.id === tabId, + ); + const tab = state.secondary.tabs[tabIndex]; + const next = closeSecondaryPanelTabInState(state, tabId); + if ( + next !== state && + recentlyClosedPanelContext !== null && + recentlyClosedPanelContextKey !== null && + tab !== undefined && + isReopenableSecondaryPanelTab(tab) && + isReopenablePanelTabOwnedByContext({ + context: recentlyClosedPanelContext, + tab, + }) + ) { + rememberClosedPanelTab(recentlyClosedPanelContextKey, { + index: tabIndex, + tab, + }); + } + return next; + }); }, - [updateFixedPanelTabsState], + [ + recentlyClosedPanelContext, + recentlyClosedPanelContextKey, + updateFixedPanelTabsState, + ], ); + const reopenClosedTab = useCallback((): boolean => { + if (recentlyClosedPanelContextKey === null) return false; + const contextKey = recentlyClosedPanelContextKey; + + const restoreEntry = ( + state: FixedPanelTabsState, + entry: RecentlyClosedPanelTab, + ) => { + const index = Math.max( + 0, + Math.min(entry.index, state.secondary.tabs.length), + ); + const tabs = [...state.secondary.tabs]; + tabs.splice(index, 0, entry.tab); + return setSecondaryPanelTabsInState({ + activeTabId: entry.tab.id, + isOpen: true, + state, + tabs, + }); + }; + + const attemptReopen = (): boolean => { + let didReopen = false; + const unresolvedEntries: RecentlyClosedPanelTab[] = []; + updateFixedPanelTabsState((state) => { + const result = takeClosedPanelTab( + contextKey, + new Set(state.secondary.tabs.map((tab) => tab.id)), + (entry) => + recentlyClosedPanelTabAvailability(entry.tab, storageInventory), + ); + if (result.kind === "empty") return state; + if (result.kind === "unresolved") { + unresolvedEntries.push(result.entry); + return state; + } + didReopen = true; + return restoreEntry(state, result.entry); + }); + if (didReopen) return true; + const entry = unresolvedEntries.at(0); + if (entry === undefined || storageFileExists === undefined) { + return false; + } + + const storagePath = storagePathForRecentlyClosedPanelTab(entry.tab); + if (storagePath === null) return false; + const validationKey = `${contextKey}:${entry.tab.id}`; + if (pendingStorageValidationRef.current === validationKey) return true; + pendingStorageValidationRef.current = validationKey; + void storageFileExists(storagePath) + .then((exists) => { + if ( + !isMountedRef.current || + recentlyClosedPanelContextKeyRef.current !== contextKey || + pendingStorageValidationRef.current !== validationKey + ) { + return; + } + pendingStorageValidationRef.current = null; + if (!exists) { + const wasTop = forgetClosedPanelTab(contextKey, entry.tab.id); + if (wasTop) attemptReopen(); + return; + } + updateFixedPanelTabsState((state) => { + const result = takeClosedPanelTab( + contextKey, + new Set(state.secondary.tabs.map((tab) => tab.id)), + (candidate) => + candidate.tab.id === entry.tab.id ? "available" : "unresolved", + ); + return result.kind === "available" + ? restoreEntry(state, result.entry) + : state; + }); + }) + .catch(() => { + if (pendingStorageValidationRef.current === validationKey) { + pendingStorageValidationRef.current = null; + } + }); + return true; + }; + + return attemptReopen(); + }, [ + recentlyClosedPanelContextKey, + storageFileExists, + storageInventory, + updateFixedPanelTabsState, + ]); + const openPluginPanel = useCallback( ({ pluginId, actionId, title, paramsJson }: OpenPluginPanelArgs) => { const tab = createPluginPanelFixedPanelTab({ @@ -512,6 +910,9 @@ export function useThreadFileTabs({ pluginId, title, }); + if (recentlyClosedPanelContextKey !== null) { + forgetClosedPanelTab(recentlyClosedPanelContextKey, tab.id); + } updateFixedPanelTabsState((state) => { const existing = findSecondaryPanelTab(state.secondary.tabs, tab.id); if (existing !== null && existing.kind === "plugin-panel") { @@ -527,7 +928,7 @@ export function useThreadFileTabs({ return replaceNewTabWithSecondaryPanelTabInState({ state, tab }); }); }, - [updateFixedPanelTabsState], + [recentlyClosedPanelContextKey, updateFixedPanelTabsState], ); const selectFileSearchResult = useCallback( @@ -680,6 +1081,7 @@ export function useThreadFileTabs({ openPluginPanel, openTab, orderedSecondaryFileTabs, + reopenClosedTab, reorderTab, selectFileSearchResult, updateBrowserTab, diff --git a/apps/app/src/components/secondary-panel/useThreadStorageViewer.ts b/apps/app/src/components/secondary-panel/useThreadStorageViewer.ts index dfd2538668..aadec29e0b 100644 --- a/apps/app/src/components/secondary-panel/useThreadStorageViewer.ts +++ b/apps/app/src/components/secondary-panel/useThreadStorageViewer.ts @@ -1,4 +1,6 @@ +import { useCallback } from "react"; import type { FixedPanelTab } from "@/lib/fixed-panel-tabs-state"; +import { sdk } from "@/lib/sdk"; import { DEFAULT_THREAD_STORAGE_FILE_LIST_OPTIONS } from "@/lib/thread-storage-files"; import { useThreadStorageFiles } from "../../hooks/queries/thread-queries"; @@ -24,8 +26,21 @@ export function useThreadStorageViewer({ enabled: hasThread && fileListEnabled, }, ); + const checkThreadStorageFileExists = useCallback( + async (path: string): Promise => { + if (!threadId) return false; + const result = await sdk.threads.storageFiles({ + limit: "1", + query: path, + threadId, + }); + return result.files.some((file) => file.path === path); + }, + [threadId], + ); return { + checkThreadStorageFileExists, isThreadStorageFilesLoading, threadStorageFilesError, threadStorageFiles, diff --git a/apps/app/src/components/settings/UpdatesSettingsSection.stories.tsx b/apps/app/src/components/settings/UpdatesSettingsSection.stories.tsx index a8d2d64bb1..d2b58f7d18 100644 --- a/apps/app/src/components/settings/UpdatesSettingsSection.stories.tsx +++ b/apps/app/src/components/settings/UpdatesSettingsSection.stories.tsx @@ -551,7 +551,6 @@ export function MultiMachine() { label="Update all 3 CLI tools" tooltipLabel="Update all" icon={UPDATE_ACTION_ICON} - iconPosition="end" visibleLabel="Update all" variant="default" onClick={noop} diff --git a/apps/app/src/components/settings/UpdatesSettingsSection.test.tsx b/apps/app/src/components/settings/UpdatesSettingsSection.test.tsx index bb45717216..a8098bf5f5 100644 --- a/apps/app/src/components/settings/UpdatesSettingsSection.test.tsx +++ b/apps/app/src/components/settings/UpdatesSettingsSection.test.tsx @@ -370,7 +370,7 @@ describe("UpdatesSettingsSection", () => { expect(updateAll?.className).toContain("bg-foreground"); expect(updateAll?.className).toContain("text-background"); expect(updateAll?.textContent).toBe("Update all"); - expect(updateAll?.lastElementChild?.getAttribute("data-icon")).toBe( + expect(updateAll?.firstElementChild?.getAttribute("data-icon")).toBe( "Download", ); const workstationHeading = screen.getByRole("heading", { @@ -1480,7 +1480,9 @@ The canonical release summary. name: "Update all 2 CLI tools", }); expect(updateAll.textContent).toBe("Update all"); - expect(updateAll.querySelector('[data-icon="Download"]')).not.toBeNull(); + expect(updateAll.firstElementChild?.getAttribute("data-icon")).toBe( + "Download", + ); fireEvent.click(updateAll); expect(startInstallMock).toHaveBeenCalledTimes(2); diff --git a/apps/app/src/components/settings/UpdatesSettingsSection.tsx b/apps/app/src/components/settings/UpdatesSettingsSection.tsx index 8dc545503d..6f75653878 100644 --- a/apps/app/src/components/settings/UpdatesSettingsSection.tsx +++ b/apps/app/src/components/settings/UpdatesSettingsSection.tsx @@ -1329,7 +1329,6 @@ export function UpdatesSettingsSection({ label={`Update all ${actionableIssues.length} CLI tools`} tooltipLabel="Update all" icon={UPDATE_ACTION_ICON} - iconPosition="end" visibleLabel="Update all" variant="default" onClick={() => { diff --git a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx index 09e37a21c0..081302fc5d 100644 --- a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx @@ -518,13 +518,18 @@ describe("ProjectRow interactions", () => { screen.getByRole("button", { name: "Worktree actions" }), { button: 0 }, ); - fireEvent.click(await screen.findByRole("menuitem", { name: "Rename" })); + fireEvent.click( + await screen.findByRole("menuitem", { name: "Rename worktree" }), + ); expect( - await screen.findByRole("dialog", { name: "Rename environment" }), + await screen.findByRole("dialog", { name: "Rename worktree" }), ).not.toBeNull(); + expect(screen.getByText("feat/menu-close")).not.toBeNull(); await waitFor(() => { - expect(screen.queryByRole("menuitem", { name: "Rename" })).toBeNull(); + expect( + screen.queryByRole("menuitem", { name: "Rename worktree" }), + ).toBeNull(); }); }); }); diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index a3026af04d..a81ad5949b 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -871,7 +871,7 @@ function EnvironmentThreadGroupHeaderActions({ }} >
) : null} - {filePaths.length > 0 ? ( + {fileItems.length > 0 ? (
- {filePaths.map((path) => { + {fileItems.map((fileItem) => { + const { path } = fileItem; + const identity = fileItem.identity; const className = cn( "inline-flex max-w-full items-center rounded-full border px-2 py-0.5 text-xs text-muted-foreground", align === "end" @@ -172,22 +250,28 @@ export function ConversationAttachments({ const label = ( {fileNameFromPath(path)} ); - const attachmentHref = projectAttachmentHref({ path, projectId }); - - if (attachmentHref) { + if (identity !== null) { return ( - + + {fileItem.downloadUrl === null ? null : ( + + + )} - > - {label} - + ); } @@ -218,6 +302,8 @@ export function ConversationAttachments({ title="Attached image preview" imageSrc={currentImageItem?.src ?? null} imageAlt={currentImageItem?.alt ?? "Attached image"} + downloadUrl={currentImageItem?.downloadUrl ?? null} + downloadName={currentImageItem?.alt} hasMultipleImages={hasMultipleImages} onPrevious={() => { setExpandedImageIndex( diff --git a/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx b/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx index 236beb09f8..da92c0850c 100644 --- a/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx +++ b/apps/app/src/components/thread/timeline/ConversationMessageContent.test.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { ThreadListEntry } from "@bb/domain"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter } from "react-router-dom"; import { ThreadTitleMentionResourcesProvider } from "@/components/thread/ThreadTitleMentions"; import { RouteNavigationProvider } from "@/components/ui/app-route-anchor"; @@ -91,6 +91,57 @@ describe("ConversationMessageContent assistant images", () => { }); }); +describe("ConversationMessageContent user file references", () => { + it("routes links, inline references, and images through local file handling", () => { + const onOpenLocalFileLink = vi.fn(() => true); + + render( + + + + + , + ); + + fireEvent.click(screen.getByRole("link", { name: /Space file/u })); + fireEvent.click(screen.getByRole("link", { name: "data/table.csv:3" })); + + expect(onOpenLocalFileLink).toHaveBeenNthCalledWith(1, { + lineRange: { endLineNumber: 4, startLineNumber: 2 }, + path: "/workspace/space name.md", + }); + expect(onOpenLocalFileLink).toHaveBeenNthCalledWith(2, { + lineRange: { endLineNumber: 3, startLineNumber: 3 }, + path: "/workspace/data/table.csv", + }); + expect(screen.getByRole("img", { name: "Plot" }).getAttribute("src")).toBe( + "/api/v1/threads/thr_user/host-files/content?path=%2Fworkspace%2Fassets%2Fplot.png", + ); + }); +}); + describe("ConversationMessageContent assistant thread mentions", () => { it("renders an agent-authored thread token with the referenced thread title", () => { const mentionedThread = threadListEntry({ diff --git a/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx b/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx index f1dd3976a7..db8ed31f58 100644 --- a/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx +++ b/apps/app/src/components/thread/timeline/ConversationMessageContent.tsx @@ -98,7 +98,9 @@ interface ConversationMessageContentUserProps extends ConversationMessageContent senderIsPluginSideChat: boolean; systemMessageKind: TimelineUserConversationRow["systemMessageKind"]; systemMessageSubject: TimelineUserConversationRow["systemMessageSubject"]; + threadId?: string; turnRequest: TimelineUserConversationRow["turnRequest"]; + workspaceRootPath?: string; } type AssistantMessageRowIdentity = Pick< @@ -164,7 +166,9 @@ interface UserConversationMessageProps { systemMessageKind: TimelineUserConversationRow["systemMessageKind"]; systemMessageSubject: TimelineUserConversationRow["systemMessageSubject"]; text: string; + threadId?: string; turnRequest: TimelineUserConversationRow["turnRequest"]; + workspaceRootPath?: string; } interface AssistantConversationMessageProps extends AssistantMessageRowIdentity { @@ -188,19 +192,19 @@ interface AssistantConversationMessageProps extends AssistantMessageRowIdentity } interface CollapsibleMessageTextProps { + linkRouting?: MarkdownLinkRouting; mentions: readonly PromptTextMention[]; resolveMentionLink?: PromptMentionLinkResolver; resolveSegmentLinkHref?: TimelineTitleLinkResolver; - onOpenLink?: ThreadTimelineLinkHandler; text: string; mutePrefixLength?: number; } function CollapsibleMessageText({ + linkRouting, mentions, resolveMentionLink, resolveSegmentLinkHref, - onOpenLink, text, mutePrefixLength, }: CollapsibleMessageTextProps) { @@ -244,11 +248,6 @@ function CollapsibleMessageText({ }), [body.mentions], ); - const linkRouting = useMemo( - () => (onOpenLink ? { onOpenLink } : undefined), - [onOpenLink], - ); - const isOverflowing = useIsOverflowing({ elementRef: bodyRef, enabled: !isExpanded, @@ -301,6 +300,48 @@ function CollapsibleMessageText({ ); } +function buildConversationMarkdownLinkRouting({ + onOpenLink, + onOpenLocalFileLink, + threadId, + workspaceRootPath, +}: { + onOpenLink: ThreadTimelineLinkHandler | undefined; + onOpenLocalFileLink: ThreadTimelineLocalFileLinkHandler | undefined; + threadId: string | undefined; + workspaceRootPath: string | undefined; +}): MarkdownLinkRouting | undefined { + const routing: MarkdownLinkRouting = {}; + if (onOpenLink !== undefined) { + routing.onOpenLink = onOpenLink; + } + if (onOpenLocalFileLink !== undefined) { + routing.localFile = { + absoluteLinks: { kind: "trusted-host" }, + onOpenLink: onOpenLocalFileLink, + }; + if (workspaceRootPath !== undefined) { + routing.localFile.relativeLinks = { + baseDir: workspaceRootPath, + rootPath: workspaceRootPath, + }; + } + } + if (threadId !== undefined) { + routing.localImage = { + absolutePaths: { kind: "trusted-host" }, + resolveSrc: ({ path }) => buildThreadHostFileContentUrl(threadId, path), + }; + if (workspaceRootPath !== undefined) { + routing.localImage.relativePaths = { + baseDir: workspaceRootPath, + rootPath: workspaceRootPath, + }; + } + } + return Object.keys(routing).length === 0 ? undefined : routing; +} + function buildAddToChatAttachments( attachments: TimelineConversationAttachments | null, ): PromptDraftAttachment[] { @@ -347,8 +388,20 @@ function UserConversationMessage({ systemMessageKind, systemMessageSubject, text, + threadId, turnRequest, + workspaceRootPath, }: UserConversationMessageProps) { + const linkRouting = useMemo( + () => + buildConversationMarkdownLinkRouting({ + onOpenLink, + onOpenLocalFileLink, + threadId, + workspaceRootPath, + }), + [onOpenLink, onOpenLocalFileLink, threadId, workspaceRootPath], + ); if (initiator === "agent" && senderThreadId !== null) { const body = generatedConversationBodySlice({ initiator, text }); const bodyMentions = shiftMentionsToTextRange({ @@ -359,9 +412,9 @@ function UserConversationMessage({ return ( {messageText ? ( @@ -445,10 +498,9 @@ function UserConversationMessage({ )}
{} @@ -615,10 +667,9 @@ function AssistantConversationMessage({ )} {showActions ? ( @@ -654,8 +706,9 @@ export function ConversationMessageContent( attachments, projectId, resolveUserAttachmentImageSrc, + threadId, }), - [attachments, projectId, resolveUserAttachmentImageSrc], + [attachments, projectId, resolveUserAttachmentImageSrc, threadId], ); const addToChatAttachments = useMemo( () => buildAddToChatAttachments(attachments), @@ -687,7 +740,9 @@ export function ConversationMessageContent( systemMessageKind={props.systemMessageKind} systemMessageSubject={props.systemMessageSubject} text={text} + threadId={threadId} turnRequest={props.turnRequest} + workspaceRootPath={props.workspaceRootPath} /> ); } diff --git a/apps/app/src/components/thread/timeline/GeneratedConversationMessage.tsx b/apps/app/src/components/thread/timeline/GeneratedConversationMessage.tsx index e625897c79..de9a09bb9b 100644 --- a/apps/app/src/components/thread/timeline/GeneratedConversationMessage.tsx +++ b/apps/app/src/components/thread/timeline/GeneratedConversationMessage.tsx @@ -27,10 +27,7 @@ import type { TimelineTitleActionResolver, TimelineTitleLinkResolver, } from "./TimelineTitleView.js"; -import type { - ThreadTimelineLinkHandler, - ThreadTimelineLocalFileLinkHandler, -} from "./types.js"; +import type { ThreadTimelineLocalFileLinkHandler } from "./types.js"; import { turnRequestLabel } from "@bb/client-core"; import { TurnRequestLabel } from "./TurnRequestLabel.js"; import { useOverflowMeasurement } from "./conversation-message-overflow.js"; @@ -46,9 +43,9 @@ import { interface GeneratedConversationMessageProps { attachmentItems: ConversationAttachmentItems; + linkRouting?: MarkdownLinkRouting; originKind: ThreadOriginKind | null; mentions: readonly PromptTextMention[]; - onOpenLink?: ThreadTimelineLinkHandler; onOpenLocalFileLink?: ThreadTimelineLocalFileLinkHandler; projectId?: string; resolveMentionLink?: PromptMentionLinkResolver; @@ -431,9 +428,9 @@ const COLLAPSED_MARKDOWN_PREVIEW_CLASS = cn( export const GeneratedConversationMessage = memo( function GeneratedConversationMessage({ attachmentItems, + linkRouting, originKind, mentions, - onOpenLink, onOpenLocalFileLink, projectId, resolveMentionLink, @@ -461,9 +458,6 @@ export const GeneratedConversationMessage = memo( [mentions, messageText.length, trimStartLength], ); const requestLabel = turnRequestLabel(turnRequest); - const linkRouting = useMemo(() => { - return onOpenLink === undefined ? undefined : { onOpenLink }; - }, [onOpenLink]); const title = useMemo( () => generatedConversationTitle({ @@ -504,7 +498,7 @@ export const GeneratedConversationMessage = memo( ); const titleOnly = systemMessageIsTitleOnly(sourceKind, systemMessageKind); const hasExpandedOnlyContent = - attachmentItems.filePaths.length > 0 || + attachmentItems.fileItems.length > 0 || attachmentItems.imageItems.length > 0 || requestLabel !== null; const collapsedPreviewSource = @@ -615,10 +609,9 @@ export const GeneratedConversationMessage = memo( )} {requestLabel ? (
@@ -629,13 +622,12 @@ export const GeneratedConversationMessage = memo(
), [ - attachmentItems.filePaths, + attachmentItems.fileItems, attachmentItems.imageItems, linkRouting, messageText, messageMentions, onOpenLocalFileLink, - projectId, resolveSegmentLinkHref, resolveMentionLink, sourceKind, diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx index ee36ff8b46..5990706da2 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx @@ -1023,7 +1023,9 @@ const ConversationRowContent = memo(function ConversationRowContent({ systemMessageSubject={row.systemMessageSubject} pluginActions={rowPluginActions} text={row.text} + threadId={row.threadId} turnRequest={row.turnRequest} + workspaceRootPath={workspaceRootPath} /> ); } diff --git a/apps/app/src/components/thread/timeline/rows/Tool.stories.tsx b/apps/app/src/components/thread/timeline/rows/Tool.stories.tsx index 3ddb7a4c62..3ac271f986 100644 --- a/apps/app/src/components/thread/timeline/rows/Tool.stories.tsx +++ b/apps/app/src/components/thread/timeline/rows/Tool.stories.tsx @@ -36,6 +36,26 @@ const toolSearchTool: TimelineRow = toolRow({ durationMs: 105, }); +const nativeSkillTool: TimelineRow = toolRow({ + id: "thr_skill_native:tool:toolu_skill_native", + threadId: "thr_skill_native", + turnId: "turn_skill_native_1", + sourceSeqStart: 1, + sourceSeqEnd: 2, + status: "completed", + callId: "toolu_skill_native", + toolName: "Skill", + toolArgs: { skill: "visual-qa-loop" }, + output: "Skill loaded", + approvalStatus: null, + durationMs: 120, + presentation: { + label: { pending: "Loading skill", completed: "Loaded skill" }, + icon: { glyph: "Zap" }, + title: "visual-qa-loop", + }, +}); + const longOutputTool: TimelineRow = toolRow({ id: "thr_tool_long_output:tool:toolu_long_output", threadId: "thr_tool_long_output", @@ -435,3 +455,18 @@ export function SkillReads() { ); } + +export function NativeSkillCall() { + return ( + + + + + + + + ); +} diff --git a/apps/app/src/components/ui/image-lightbox.tsx b/apps/app/src/components/ui/image-lightbox.tsx index 9cc2618f0f..74f8828b3c 100644 --- a/apps/app/src/components/ui/image-lightbox.tsx +++ b/apps/app/src/components/ui/image-lightbox.tsx @@ -36,6 +36,8 @@ interface WrappedImageIndexInput { } interface ImageLightboxProps { + downloadName?: string; + downloadUrl?: string | null; hasMultipleImages?: boolean; imageAlt: string; imageSrc: string | null; @@ -92,6 +94,8 @@ export function getWrappedImageIndex({ } export function ImageLightbox({ + downloadName, + downloadUrl = null, hasMultipleImages = false, imageAlt, imageSrc, @@ -166,6 +170,19 @@ export function ImageLightbox({ className="max-h-[82vh] max-w-[90vw] rounded object-contain" /> + {downloadUrl === null ? null : ( + + )} + {hasNavigation ? ( <> + )} + + + + ); } @@ -147,11 +168,19 @@ function ImageAttachmentFigure({ onClick={() => onOpenImage(attachment)} > {attachment.fileName} + + + {}
({ + openFilePreview: vi.fn(() => true), +})); + +vi.mock("@get-bb/plugin-sdk/app", () => ({ + useBbNavigate: () => ({ + experimental_openFilePreview: openFilePreview, + }), +})); + afterEach(() => { cleanup(); + openFilePreview.mockClear(); }); function imageAttachment(overrides: Partial = {}): Attachment { @@ -45,6 +56,43 @@ describe("AttachmentsGrid layout", () => { Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); }); + + it("provides canonical Open and Download actions for files and images", () => { + const file = imageAttachment({ + id: "01JFILE00000000000000000A1", + fileName: "report.pdf", + mime: "application/octet-stream", + isImage: false, + }); + const image = imageAttachment(); + const screen = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open report.pdf" })); + expect(openFilePreview).toHaveBeenCalledWith({ + identity: { + source: { + store: "tasks-attachment", + ownerId: file.taskId, + attachmentId: file.id, + }, + displayName: file.fileName, + mimeType: "application/pdf", + sizeBytes: file.sizeBytes, + location: null, + }, + }); + expect( + screen + .getByRole("link", { name: "Download report.pdf" }) + .getAttribute("href"), + ).toContain("/attachments/download?attachmentId="); + expect(screen.getByAltText("diagram.png").getAttribute("src")).toContain( + "/attachments/preview?attachmentId=", + ); + expect( + screen.getByRole("link", { name: "Download diagram.png" }), + ).not.toBeNull(); + }); }); describe("AttachmentsGrid removal", () => { diff --git a/plugins/tasks/views/detail/attachments.tsx b/plugins/tasks/views/detail/attachments.tsx index 8aa3d4881a..7996ad3ef1 100644 --- a/plugins/tasks/views/detail/attachments.tsx +++ b/plugins/tasks/views/detail/attachments.tsx @@ -3,11 +3,36 @@ import type { Attachment } from "../../shared/contract.js"; import { formatFileSize } from "../activity/time.js"; import { ConfirmDialog } from "../../components/confirm-dialog.js"; import { Icon } from "@bb/shared-ui/icon"; +import { useBbNavigate } from "@get-bb/plugin-sdk/app"; +import type { ExperimentalFileIdentity } from "@get-bb/plugin-sdk"; +import { canonicalAttachmentMime } from "../../shared/attachment-mime.js"; + +export function attachmentPreviewUrl(attachmentId: string): string { + return `/api/v1/plugins/tasks/http/attachments/preview?attachmentId=${encodeURIComponent(attachmentId)}`; +} export function attachmentDownloadUrl(attachmentId: string): string { return `/api/v1/plugins/tasks/http/attachments/download?attachmentId=${encodeURIComponent(attachmentId)}`; } +export function attachmentIdentity( + attachment: Attachment, +): ExperimentalFileIdentity | null { + const ownerId = attachment.taskId ?? attachment.commentId; + if (ownerId === null) return null; + return { + source: { + store: "tasks-attachment", + ownerId, + attachmentId: attachment.id, + }, + displayName: attachment.fileName, + mimeType: canonicalAttachmentMime(attachment.mime, attachment.fileName), + sizeBytes: attachment.sizeBytes, + location: null, + }; +} + let tokenPromise: Promise | null = null; function pluginToken(): Promise { @@ -91,7 +116,7 @@ export function Lightbox({ onClick={onClose} > {attachment.fileName} event.stopPropagation()} @@ -102,6 +127,16 @@ export function Lightbox({ {formatFileSize(attachment.sizeBytes)}
+ event.stopPropagation()} + > + + Download + + )} + + + + {removeButton(attachment, "file")} + + ); + }; const imageTile = (attachment: Attachment) => (
setLightbox(attachment)} > {attachment.fileName} @@ -236,6 +297,14 @@ export function AttachmentsGrid({ {attachment.fileName} + + + {removeButton(attachment, "image")}
); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 487767e196..8ec85c0bf6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3627,6 +3627,9 @@ importers: plugins/tasks: dependencies: + '@bb/server-contract': + specifier: workspace:* + version: link:../../packages/server-contract '@bb/shared-ui': specifier: workspace:* version: link:../../packages/shared-ui