From 3f7e732c3a1e8adc7f0671009badadb9b129d762 Mon Sep 17 00:00:00 2001
From: brsbl
Date: Sat, 29 Aug 2026 01:18:06 -0700
Subject: [PATCH 01/19] Use Zap icon for native Claude Skill calls (#2609)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Human comments
## What was wrong
Native Claude `Skill` calls used the generic plugin Puzzle glyph instead
of bb’s established skill glyph.
## What changed
Maps native Claude `Skill` calls to the Zap glyph and adds focused
provider and story coverage.
### Before — parent `main` at `93643a53`
Native Claude Skill calls use the generic Puzzle glyph.

### After — PR head `d8a06e53`
The same row uses the established Zap skill glyph.

## How you verified
- Exact parent/head Ladle states rendered in Chrome for Testing
149.0.7827.55.
- The focused provider presentation test and app story coverage pass in
remote CI.
- All required CI checks are green.
## Fixes
No linked GitHub issue; addresses the reported icon regression.
BB-Thread-ID: thr_fdabesxhdr
> AGENT GENERATED
---
.../thread/timeline/rows/Tool.stories.tsx | 35 +++++++++++++++++++
.../src/presentation.test.ts | 13 +++++++
.../provider-claude-code/src/presentation.ts | 2 +-
3 files changed, 49 insertions(+), 1 deletion(-)
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/plugins/provider-claude-code/src/presentation.test.ts b/plugins/provider-claude-code/src/presentation.test.ts
index c54abb0838..51232ab902 100644
--- a/plugins/provider-claude-code/src/presentation.test.ts
+++ b/plugins/provider-claude-code/src/presentation.test.ts
@@ -371,6 +371,19 @@ describe("claude item presentation", () => {
});
});
+ it("uses the established skill glyph for native Skill calls", () => {
+ const harness = createClaudeDeltaHarness();
+ const events = harness.translate(
+ toolUse("skill-1", "Skill", { skill: "debugging" }),
+ );
+
+ expect(presentationOf(startedItems(events)[0])).toEqual({
+ label: { pending: "Loading skill", completed: "Loaded skill" },
+ icon: { glyph: "Zap" },
+ title: "debugging",
+ });
+ });
+
it("maps MultiEdit and NotebookEdit to file changes with their own verbs", () => {
const harness = createClaudeDeltaHarness();
const events = harness.translate({
diff --git a/plugins/provider-claude-code/src/presentation.ts b/plugins/provider-claude-code/src/presentation.ts
index 3db8588d98..bb9fac0776 100644
--- a/plugins/provider-claude-code/src/presentation.ts
+++ b/plugins/provider-claude-code/src/presentation.ts
@@ -167,7 +167,7 @@ const BUILTIN_TOOL_PRESENTATIONS: Readonly<
},
Skill: {
label: { pending: "Loading skill", completed: "Loaded skill" },
- glyph: "Puzzle",
+ glyph: "Zap",
titleField: "skill",
},
StructuredOutput: {
From 4af2920f8d048e47707e224a899a53c9b31726e1 Mon Sep 17 00:00:00 2001
From: brsbl
Date: Sat, 29 Aug 2026 01:18:07 -0700
Subject: [PATCH 02/19] Keep composer metadata stable on hover (#2611)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Human comments
## What was wrong
Muted project, environment, and model controls changed foreground
treatment on hover, creating a subtle visual jump in the composer
metadata row.
## What changed
Keeps the muted foreground token stable across hover while preserving
the existing interactive background treatment.
### Before hover
The composer metadata row is settled in its resting geometry.

### After hover
Hovering the model control preserves the row geometry while exposing its
normal interactive treatment.

## How you verified
- Chrome for Testing 149.0.7827.55 exercised project, environment, and
model controls in the real composer.
- Element bounds remained unchanged across rest and hover; only the
intended interactive background changed.
- All required CI checks are green.
## Fixes
No linked GitHub issue; addresses the reported hover regression.
BB-Thread-ID: thr_fdabesxhdr
> AGENT GENERATED
---
packages/shared-ui/src/components/ui/option-display.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/shared-ui/src/components/ui/option-display.tsx b/packages/shared-ui/src/components/ui/option-display.tsx
index 05e2da20ac..e95ba0f4d2 100644
--- a/packages/shared-ui/src/components/ui/option-display.tsx
+++ b/packages/shared-ui/src/components/ui/option-display.tsx
@@ -9,7 +9,7 @@ export const OPTION_CONTENT_CLASS_NAME = "flex min-w-0 items-center gap-1.5";
export const OPTION_TRIGGER_CONTENT_CLASS_NAME = "contents";
export const OPTION_MENU_CONTENT_CLASS_NAME = "w-max min-w-0 max-w-96";
export const OPTION_MUTED_CLASS_NAME =
- "text-muted-foreground hover:text-foreground";
+ "text-muted-foreground hover:text-muted-foreground";
export interface OptionDisplayProps {
label: string;
From fd8248acaa74e95e3641f9234afbe8135367ec43 Mon Sep 17 00:00:00 2001
From: brsbl
Date: Sat, 29 Aug 2026 01:18:07 -0700
Subject: [PATCH 03/19] Shorten worktree thread action copy (#2612)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Human comments
## What was wrong
The worktree action tooltip said “Create new thread in this worktree,”
which was longer than needed and inconsistent across the composer and
metadata panel.
## What changed
Uses “Create thread in worktree” for the tooltip and accessible label in
both surfaces.
### Before — parent head `a1b2d5c8`

### After — PR head `738b5606`

## How you verified
- The same isolated worktree thread and 1440×900 viewport were rendered
against the exact parent and PR head in Chrome for Testing
149.0.7827.55.
- The composer action exposed the expected visible tooltip and matching
accessible label on each revision.
- All required CI checks are green.
## Fixes
No linked GitHub issue; addresses the reported tooltip copy regression.
BB-Thread-ID: thr_fdabesxhdr
> AGENT GENERATED
---
.../promptbox/ThreadEnvironmentSummary.test.tsx | 4 ++--
.../promptbox/ThreadEnvironmentSummary.tsx | 4 ++--
.../secondary-panel/ThreadMetadataContent.test.tsx | 14 +++++---------
.../secondary-panel/ThreadMetadataContent.tsx | 4 ++--
4 files changed, 11 insertions(+), 15 deletions(-)
diff --git a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx
index 98f28956f0..ad6f750c7e 100644
--- a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx
+++ b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx
@@ -34,12 +34,12 @@ describe("ThreadEnvironmentSummary", () => {
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",
);
});
});
diff --git a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx
index fdcbdd52b7..cadc8b4851 100644
--- a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx
+++ b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx
@@ -90,14 +90,14 @@ export const ThreadEnvironmentSummary = memo(function ThreadEnvironmentSummary({
- Create new thread in this worktree
+ Create thread in worktree
) : null}
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}
From 0db384c6136b6165c065c4a3e02734b74346c531 Mon Sep 17 00:00:00 2001
From: brsbl
Date: Sat, 29 Aug 2026 01:18:08 -0700
Subject: [PATCH 04/19] Clarify thread environment identity (#2472)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Human comments
## What was wrong
The composer reduced an unnamed worktree to the generic “Worktree”
label, so users could see the checkout but not which host owned the
environment. Environment type was also implicit, worktree rename copy
used the broader “environment” concept, and provisioning could be
presented as a workspace type instead of a lifecycle state.
## What changed
The environment summary now preserves environment identity and checkout
metadata as separate dimensions:
- Direct environments and unnamed worktrees show their host name; named
worktrees show their custom name.
- The branch remains separately visible with the branch icon and copies
on click, even if it matches a custom worktree name.
- The environment icon tooltip distinguishes Local worktree, Remote
worktree, Local, and Remote.
- Provisioning takes precedence, uses the active loading spinner, and
keeps the lifecycle label “Provisioning.”
- Worktree actions and the rename dialog use worktree-specific language.
A custom-named worktree shows its branch beneath the field, and “Clear
custom name” restores the host as the environment identity.
### Composer identity
**Before — the unnamed worktree is reduced to the generic “Worktree”
label.**

**After — the host identifies the environment while the branch remains
visible as checkout metadata.**

### Rename flow
**Before — generic environment terminology and no worktree context.**

**After — worktree-specific terminology for an unnamed worktree.**

**Custom-name state — the branch remains visible and clearing the custom
name restores host identity.**

## How you verified
- Exact parent head `409ef81e9c36ef927e4af0a2bbff89c5001ed5c7` and exact
PR head `78b589967f86c575066b2e8ef1d339c97f8c0b3e` were rendered in the
branch web app with the same project, thread, environment, route, light
theme, and 1440×900 viewport.
- Chrome for Testing 150 exercised hard reload, host and branch
rendering, worktree rename and clear, the Local worktree tooltip, and
the Create thread in worktree tooltip. All passed with no runtime
errors.
- Remote CI covers the environment-state matrix, icon selection, branch
presentation, rename copy, and component integration.
## Fixes
No linked GitHub issue; addresses the reported environment-summary and
worktree-naming regressions.
BB-Thread-ID: thr_fdabesxhdr
> AGENT GENERATED
---
.../EnvironmentRenameDialog.stories.tsx | 30 ++-
.../dialogs/EnvironmentRenameDialog.tsx | 15 +-
.../src/components/dialogs/RenameDialog.tsx | 3 +
.../plugin/PluginThreadChat.test.tsx | 1 +
.../components/plugin/PluginThreadChat.tsx | 34 ++-
.../promptbox/FollowUpPromptBox.stories.tsx | 224 +++++++++++++++++-
.../ThreadEnvironmentSummary.test.tsx | 100 +++++++-
.../promptbox/ThreadEnvironmentSummary.tsx | 102 +++++---
.../sidebar/ProjectRow.interactions.test.tsx | 11 +-
.../app/src/components/sidebar/ProjectRow.tsx | 2 +-
.../lib/environment-workspace-display.test.ts | 87 +++++++
.../src/lib/environment-workspace-display.ts | 69 ++++++
.../thread-detail/ThreadDetailPromptArea.tsx | 5 +
.../views/thread-detail/ThreadDetailView.tsx | 38 +--
packages/core-ui/src/environment-display.ts | 9 +
.../core-ui/test/environment-display.test.ts | 5 +
.../src/components/ui/option-display.tsx | 21 +-
17 files changed, 663 insertions(+), 93 deletions(-)
create mode 100644 apps/app/src/lib/environment-workspace-display.test.ts
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/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/FollowUpPromptBox.stories.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx
index 31f6cd8ae9..9b575e44f7 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,9 @@ 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 +50,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 +58,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 +178,8 @@ const readOnlyPermission: ExecutionPermissionConfig = {
interface EnvironmentSummaryArgs {
environment: Environment;
host: EnvironmentDisplayHostContext;
+ projectName?: string;
+ machineName?: string;
branchName?: string;
environmentCheckout?: WorkspaceCheckoutDisplay;
onCreateNewThreadInWorktree?: () => void;
@@ -176,6 +188,8 @@ interface EnvironmentSummaryArgs {
function makeEnvironmentSummary({
environment,
host,
+ projectName,
+ machineName,
branchName,
environmentCheckout,
onCreateNewThreadInWorktree,
@@ -184,6 +198,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 +218,11 @@ function makeEnvironmentSummary({
: undefined);
return (
@@ -226,6 +247,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 +272,7 @@ const remoteEnvironmentSummary: ReactNode = makeEnvironmentSummary({
status: "ready",
}),
host: remoteEnvironmentDisplayHost,
+ machineName: "Build Mac mini",
branchName: STORY_BRANCH_NAME,
});
@@ -247,6 +283,31 @@ 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 namedWorktreeEnvironmentSummary: ReactNode = makeEnvironmentSummary({
+ environment: makeEnvironment({
+ name: "Design system polish",
+ isWorktree: true,
+ workspaceProvisionType: "managed-worktree",
+ status: "ready",
+ }),
+ host: localEnvironmentDisplayHost,
branchName: STORY_BRANCH_NAME,
onCreateNewThreadInWorktree: noop,
});
@@ -969,6 +1030,33 @@ export function Overview() {
environmentSummary={worktreeEnvironmentSummary}
/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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/ThreadEnvironmentSummary.test.tsx b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx
index ad6f750c7e..32d1db1dc2 100644
--- a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx
+++ b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx
@@ -1,27 +1,109 @@
// @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("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 () => {
render(
diff --git a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx
index cadc8b4851..4f40641ace 100644
--- a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx
+++ b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx
@@ -3,6 +3,8 @@ 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 type { EnvironmentWorkspaceTypeLabel } from "@/lib/environment-workspace-display";
import type { WorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display";
const CHECKOUT_CHIP_BASE_CLASS_NAME =
@@ -14,6 +16,7 @@ interface ThreadEnvironmentSummaryProps {
environmentLabel?: string;
environmentCompactLabel?: string;
environmentIcon?: IconName;
+ environmentTypeLabel?: EnvironmentWorkspaceTypeLabel;
environmentCheckout?: WorkspaceCheckoutDisplay;
onCreateNewThreadInWorktree?: () => void;
}
@@ -23,10 +26,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 +48,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 ? (
{
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({
}}
>
- Rename
+ Rename worktree
{
+ it.each([
+ ["managed-worktree", "local", "Local worktree"],
+ ["unmanaged-worktree", "remote", "Remote worktree"],
+ ["other", "local", "Local"],
+ ["other", "remote", "Remote"],
+ ] as const)("maps %s on a %s host to %s", (kind, locality, expected) => {
+ expect(getEnvironmentWorkspaceTypeLabel(kind, locality)).toBe(expected);
+ });
+});
+
+describe("getEnvironmentWorkspaceSummaryDisplay", () => {
+ it("keeps provisioning ahead of host and worktree classification", () => {
+ expect(
+ getEnvironmentWorkspaceSummaryDisplay({
+ display: {
+ modeLabel: "Provisioning",
+ compactModeLabel: "Provisioning",
+ lifecycle: "provisioning",
+ id: "env_test",
+ mode: "direct",
+ workspaceDisplayKind: "managed-worktree",
+ },
+ environmentName: null,
+ locality: "remote",
+ hostName: "Remote builder",
+ }),
+ ).toEqual({
+ label: "Provisioning",
+ compactLabel: "Provisioning",
+ icon: "Loading",
+ typeLabel: undefined,
+ });
+ });
+
+ it("uses the host for a worktree without a custom name", () => {
+ expect(
+ getEnvironmentWorkspaceSummaryDisplay({
+ display: {
+ modeLabel: "Worktree",
+ compactModeLabel: "Worktree",
+ lifecycle: null,
+ id: "env_test",
+ mode: "worktree",
+ workspaceDisplayKind: "managed-worktree",
+ },
+ environmentName: null,
+ locality: "remote",
+ hostName: "Build Mac mini",
+ machinePrefix: "Build Mac mini · ",
+ }),
+ ).toMatchObject({
+ label: "Build Mac mini",
+ compactLabel: "Build Mac mini",
+ icon: "FolderGit",
+ typeLabel: "Remote worktree",
+ });
+ });
+
+ it("preserves a real custom worktree name", () => {
+ expect(
+ getEnvironmentWorkspaceSummaryDisplay({
+ display: {
+ modeLabel: "Design system polish",
+ compactModeLabel: "Design system polish",
+ lifecycle: null,
+ id: "env_test",
+ mode: "worktree",
+ workspaceDisplayKind: "managed-worktree",
+ },
+ environmentName: "Design system polish",
+ locality: "remote",
+ hostName: "Build Mac mini",
+ machinePrefix: "Build Mac mini · ",
+ }),
+ ).toMatchObject({
+ label: "Build Mac mini · Design system polish",
+ compactLabel: "Design system polish",
+ });
+ });
+});
diff --git a/apps/app/src/lib/environment-workspace-display.ts b/apps/app/src/lib/environment-workspace-display.ts
index b9f7ed02ee..0a2b3e7218 100644
--- a/apps/app/src/lib/environment-workspace-display.ts
+++ b/apps/app/src/lib/environment-workspace-display.ts
@@ -1,7 +1,76 @@
import type { EnvironmentWorkspaceDisplayKind } from "@bb/domain";
+import type { EnvironmentDisplayInfo } from "@bb/core-ui";
import type { IconName } from "@bb/shared-ui/icon";
import { PersistentHostIconName } from "@/lib/host-display";
+export type EnvironmentWorkspaceTypeLabel =
+ | "Local worktree"
+ | "Remote worktree"
+ | "Local"
+ | "Remote";
+
+interface EnvironmentWorkspaceSummaryDisplayArgs {
+ display: EnvironmentDisplayInfo;
+ environmentName: string | null;
+ locality: "local" | "remote";
+ hostName?: string;
+ machinePrefix?: string;
+}
+
+export interface EnvironmentWorkspaceSummaryDisplay {
+ label: string | undefined;
+ compactLabel: string | undefined;
+ icon: IconName;
+ typeLabel: EnvironmentWorkspaceTypeLabel | undefined;
+}
+
+export function getEnvironmentWorkspaceSummaryDisplay({
+ display,
+ environmentName,
+ locality,
+ hostName,
+ machinePrefix = "",
+}: EnvironmentWorkspaceSummaryDisplayArgs): EnvironmentWorkspaceSummaryDisplay {
+ if (display.lifecycle === "provisioning") {
+ return {
+ label: "Provisioning",
+ compactLabel: "Provisioning",
+ icon: "Loading",
+ typeLabel: undefined,
+ };
+ }
+
+ return {
+ label:
+ display.mode === "direct"
+ ? hostName
+ : environmentName === null
+ ? hostName
+ : `${machinePrefix}${display.modeLabel}`,
+ compactLabel:
+ display.mode === "direct"
+ ? hostName
+ : environmentName === null
+ ? hostName
+ : display.compactModeLabel,
+ icon: getEnvironmentWorkspaceLabelIconName(display.workspaceDisplayKind),
+ typeLabel: getEnvironmentWorkspaceTypeLabel(
+ display.workspaceDisplayKind,
+ locality,
+ ),
+ };
+}
+
+export function getEnvironmentWorkspaceTypeLabel(
+ kind: EnvironmentWorkspaceDisplayKind,
+ locality: "local" | "remote",
+): EnvironmentWorkspaceTypeLabel {
+ if (kind === "other") {
+ return locality === "local" ? "Local" : "Remote";
+ }
+ return locality === "local" ? "Local worktree" : "Remote worktree";
+}
+
export function getEnvironmentWorkspaceDisplayIconName(
kind: EnvironmentWorkspaceDisplayKind,
): IconName | null {
diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
index 25d88fed3c..96fdfb4928 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx
@@ -63,6 +63,7 @@ import {
type QueuedMessageInlineEditor,
} from "@/components/promptbox/banner/QueuedMessagesList";
import { ThreadEnvironmentSummary } from "@/components/promptbox/ThreadEnvironmentSummary";
+import type { EnvironmentWorkspaceTypeLabel } from "@/lib/environment-workspace-display";
import type { WorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display";
import { useComposerTextEffects } from "@/lib/composer-text-effects";
import { useLatestRef } from "@/hooks/useLatestRef";
@@ -153,6 +154,7 @@ interface ThreadDetailPromptAreaProps {
environmentHostId?: string;
environmentIcon?: IconName;
environmentLabel?: string;
+ environmentTypeLabel?: EnvironmentWorkspaceTypeLabel;
onCreateNewThreadInWorktree?: () => void;
onPullRequestDraft?: () => void;
onPullRequestMerge?: (method: PullRequestMergeMethod) => void;
@@ -344,6 +346,7 @@ export function ThreadDetailPromptArea({
environmentHostId,
environmentIcon,
environmentLabel,
+ environmentTypeLabel,
onCreateNewThreadInWorktree,
onPullRequestDraft,
onPullRequestMerge,
@@ -1174,6 +1177,7 @@ export function ThreadDetailPromptArea({
environmentLabel={environmentLabel}
environmentCompactLabel={environmentCompactLabel}
environmentIcon={environmentIcon}
+ environmentTypeLabel={environmentTypeLabel}
environmentCheckout={environmentCheckout}
onCreateNewThreadInWorktree={onCreateNewThreadInWorktree}
/>
@@ -1183,6 +1187,7 @@ export function ThreadDetailPromptArea({
environmentCompactLabel,
environmentIcon,
environmentLabel,
+ environmentTypeLabel,
onCreateNewThreadInWorktree,
projectName,
],
diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
index 6cc5443e97..4da2acb8ae 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
@@ -105,7 +105,9 @@ import {
useCreateThreadTerminal,
useThreadTerminals,
} from "@/hooks/queries/thread-terminal-queries";
-import { getEnvironmentWorkspaceLabelIconName } from "@/lib/environment-workspace-display";
+import {
+ getEnvironmentWorkspaceSummaryDisplay,
+} from "@/lib/environment-workspace-display";
import { formatWorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display";
import {
getAbsoluteDirname,
@@ -916,13 +918,14 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
),
[hostsQuery.data],
);
- const threadEnvironmentHost = useMemo(() => {
+ const resolvedThreadEnvironmentHost = useMemo(() => {
const hosts = hostsQuery.data ?? [];
- if (hosts.length <= 1) return null;
const environmentHostId = environment?.hostId;
if (!environmentHostId) return null;
return hosts.find((host) => host.id === environmentHostId) ?? null;
}, [environment?.hostId, hostsQuery.data]);
+ const threadEnvironmentHost =
+ (hostsQuery.data?.length ?? 0) > 1 ? resolvedThreadEnvironmentHost : null;
const hostConnectionNotice = useMemo(
() =>
thread
@@ -2341,11 +2344,15 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
: undefined;
const environmentMachinePrefix =
threadEnvironmentHost !== null ? `${threadEnvironmentHost.name} · ` : "";
- const threadEnvironmentIcon = threadEnvironmentDisplay
- ? getEnvironmentWorkspaceLabelIconName(
- threadEnvironmentDisplay.workspaceDisplayKind,
- )
- : null;
+ const composerEnvironmentSummary = threadEnvironmentDisplay
+ ? getEnvironmentWorkspaceSummaryDisplay({
+ display: threadEnvironmentDisplay,
+ environmentName: environment?.name ?? null,
+ locality: environmentDisplayHostContext.locality,
+ hostName: resolvedThreadEnvironmentHost?.name,
+ machinePrefix: environmentMachinePrefix,
+ })
+ : undefined;
const isThreadOnProvisionedWorktreeEnvironment =
environment !== undefined &&
environment.status === "ready" &&
@@ -2472,17 +2479,10 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
canUseGitUi={canUseGitUi}
contextWindowUsage={contextWindowUsage}
environmentCheckout={threadCheckoutDisplay}
- environmentCompactLabel={
- threadEnvironmentDisplay
- ? threadEnvironmentDisplay.compactModeLabel
- : undefined
- }
- environmentIcon={threadEnvironmentIcon ?? undefined}
- environmentLabel={
- threadEnvironmentDisplay
- ? `${environmentMachinePrefix}${threadEnvironmentDisplay.modeLabel}`
- : undefined
- }
+ environmentCompactLabel={composerEnvironmentSummary?.compactLabel}
+ environmentIcon={composerEnvironmentSummary?.icon}
+ environmentLabel={composerEnvironmentSummary?.label}
+ environmentTypeLabel={composerEnvironmentSummary?.typeLabel}
environmentGoneStatus={threadEnvironmentGoneStatus}
environmentHostId={environment?.hostId}
isEnvironmentActionPending={requestEnvironmentAction.isPending}
diff --git a/packages/core-ui/src/environment-display.ts b/packages/core-ui/src/environment-display.ts
index 2ef30cf317..f9e960f962 100644
--- a/packages/core-ui/src/environment-display.ts
+++ b/packages/core-ui/src/environment-display.ts
@@ -16,6 +16,7 @@ export interface EnvironmentDisplayHostContext {
export interface EnvironmentDisplayInfo {
modeLabel: string;
compactModeLabel: string;
+ lifecycle: "provisioning" | "destroying" | "destroyed" | null;
id: string;
mode: "direct" | "worktree";
workspaceDisplayKind: EnvironmentWorkspaceDisplayKind;
@@ -71,10 +72,18 @@ export function formatEnvironmentDisplay({
: directCompactModeLabel;
const modeLabel = environment.name ?? generatedModeLabel;
const compactModeLabel = environment.name ?? generatedCompactModeLabel;
+ const lifecycle = isProvisioningDisplay
+ ? "provisioning"
+ : environment.status === "destroying"
+ ? "destroying"
+ : environment.status === "destroyed"
+ ? "destroyed"
+ : null;
return {
modeLabel,
compactModeLabel,
+ lifecycle,
id: environment.id,
mode,
workspaceDisplayKind,
diff --git a/packages/core-ui/test/environment-display.test.ts b/packages/core-ui/test/environment-display.test.ts
index 2e59ab2480..8c9096ce1c 100644
--- a/packages/core-ui/test/environment-display.test.ts
+++ b/packages/core-ui/test/environment-display.test.ts
@@ -47,6 +47,7 @@ describe("formatEnvironmentDisplay", () => {
expect(result).toEqual({
modeLabel: "Working locally",
compactModeLabel: "Local",
+ lifecycle: null,
id: "env_test",
mode: "direct",
workspaceDisplayKind: "other",
@@ -61,6 +62,7 @@ describe("formatEnvironmentDisplay", () => {
expect(result).toEqual({
modeLabel: "Working remotely",
compactModeLabel: "Remote",
+ lifecycle: null,
id: "env_test",
mode: "direct",
workspaceDisplayKind: "other",
@@ -78,6 +80,7 @@ describe("formatEnvironmentDisplay", () => {
expect(result).toEqual({
modeLabel: "Worktree",
compactModeLabel: "Worktree",
+ lifecycle: null,
id: "env_test",
mode: "worktree",
workspaceDisplayKind: "managed-worktree",
@@ -175,6 +178,7 @@ describe("formatEnvironmentDisplay", () => {
expect(result).toEqual({
modeLabel: "Provisioning",
compactModeLabel: "Provisioning",
+ lifecycle: "provisioning",
id: "env_test",
mode: "direct",
workspaceDisplayKind: "managed-worktree",
@@ -198,6 +202,7 @@ describe("formatEnvironmentDisplay", () => {
expect(result).toEqual({
modeLabel: "Provisioning",
compactModeLabel: "Provisioning",
+ lifecycle: "provisioning",
id: "env_test",
mode: "direct",
workspaceDisplayKind: "other",
diff --git a/packages/shared-ui/src/components/ui/option-display.tsx b/packages/shared-ui/src/components/ui/option-display.tsx
index e95ba0f4d2..93eab47a39 100644
--- a/packages/shared-ui/src/components/ui/option-display.tsx
+++ b/packages/shared-ui/src/components/ui/option-display.tsx
@@ -1,5 +1,6 @@
import type { ComponentType, ReactNode } from "react";
import { cn } from "../../lib/utils";
+import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
export const OPTION_BASE_CLASS_NAME =
"h-8 w-fit max-w-full min-w-0 items-center justify-start gap-1 px-1 text-xs leading-tight";
@@ -21,6 +22,7 @@ export interface OptionDisplayProps {
compactValueHiddenWhenTiny?: boolean;
className?: string;
title?: string;
+ tooltip?: ReactNode;
muted?: boolean;
}
@@ -34,18 +36,22 @@ export function OptionDisplay({
compactValueHiddenWhenTiny,
className,
title,
+ tooltip,
muted,
}: OptionDisplayProps) {
const defaultTitle =
typeof value === "string" ? `${label}: ${value}` : undefined;
- return (
+ const display = (
);
+
+ if (!tooltip) {
+ return display;
+ }
+
+ return (
+
+ {display}
+ {tooltip}
+
+ );
}
From 6e8891f16af2def57a07cf924d09c5aac905937c Mon Sep 17 00:00:00 2001
From: brsbl
Date: Sat, 29 Aug 2026 01:18:08 -0700
Subject: [PATCH 05/19] Fix misleading host disconnect stop reason (#2514)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What was wrong
The server's daemon-disconnect grace callback classified every
unrecovered host/session socket loss as `host-daemon-restarted`, even
when no replacement daemon instance had connected. That persisted reason
flowed unchanged through the thread events API and timeline projection,
so a transient tunnel/session failure rendered as “Stopped — host daemon
restarted.” Confirmed restarts already have a separate stable signal:
`handleHostSessionOpened` observes a changed daemon `instanceId`.
## What changed
- Added the additive persisted interruption reason
`host-connection-lost` and use it only when the daemon/session
disconnect grace expires without a confirmed replacement instance.
- Preserved `host-daemon-restarted` for the existing
changed-`instanceId` restart path.
- Added lifecycle failure copy and the timeline title “Stopped —
connection to host was lost.”
- Added regressions at the server lifecycle and thread-view parser
boundaries proving lost connections and confirmed restarts remain
distinguishable.
- Refreshed the Plugin Guide's generated SDK public-API inventory for
the additive declaration change. The inherited SDK version remains
`0.4.25` and its release guard passes.
- No host-daemon wire message changed, so `HOST_DAEMON_PROTOCOL_VERSION`
is unchanged. There are no CLI changes.
## How you verified
- Red proof on the parent behavior: the new server lifecycle assertion
failed 1/11 because the disconnect-grace event was
`host-daemon-restarted` instead of `host-connection-lost`.
- Green proof on the child behavior: the focused server file passed
11/11 and the thread-view parser file passed 9/9, including distinct
assertions for confirmed restart and lost connection.
- Regenerated the exhaustive declaration inventory with `pnpm exec turbo
run update:sdk-inventory --filter=@bb/plugin-api-map`; only the two
declaration hashes affected by the additive reason changed.
- Exact rebased head `38bec6236a6b58ca75542bb3e5cff98182cd3b71` passes
every required GitHub check, including app, server, packages,
integration, Linux package smoke, and macOS package smoke.
- Chrome for Testing 152.0.7977.64 rendered the real branch web app at
1440×900 with the same deterministic thread fixture. The untouched
parent rendered two restart rows; the exact child rendered one confirmed
restart and one lost connection, with no runtime exceptions. Safari is
not required for this non-marketing bb UI change.
### Before — untouched PR #2472 head
`93d1dca06ab44ae8365a60cd33d996e97d834a8d`
Both the confirmed restart and unconfirmed disconnect render as a daemon
restart.

### After — child head `38bec6236a6b58ca75542bb3e5cff98182cd3b71`
The confirmed restart remains unchanged; the unconfirmed disconnect is
labeled as a lost host connection.

BB-Thread-ID: thr_sjdd7gudiq
> AGENT GENERATED
---
.../internal/session-owner-side-effects.ts | 1 +
.../src/services/threads/thread-lifecycle.ts | 35 +++++++++++++------
.../background-task-reconciliation.test.ts | 12 ++++---
packages/domain/src/thread-events.ts | 1 +
.../domain/test/stored-thread-event.test.ts | 22 ++++++++++++
packages/plugin-api-map/sdk-public-api.json | 4 +--
.../src/parse-operation-message.ts | 10 ++++--
.../test/parse-operation-message.test.ts | 10 +++++-
.../thread-view/test/timeline-test-harness.ts | 2 ++
9 files changed, 78 insertions(+), 19 deletions(-)
diff --git a/apps/server/src/internal/session-owner-side-effects.ts b/apps/server/src/internal/session-owner-side-effects.ts
index 4dccf48374..e885e78f7d 100644
--- a/apps/server/src/internal/session-owner-side-effects.ts
+++ b/apps/server/src/internal/session-owner-side-effects.ts
@@ -231,6 +231,7 @@ function completeDaemonActiveWorkDisconnectGrace(
interruptActiveThreadsForHost(deps, {
hostId: args.hostId,
reason: "host-daemon-restarted",
+ cause: "host-connection-lost",
});
}
diff --git a/apps/server/src/services/threads/thread-lifecycle.ts b/apps/server/src/services/threads/thread-lifecycle.ts
index 2e7028bc77..f446556292 100644
--- a/apps/server/src/services/threads/thread-lifecycle.ts
+++ b/apps/server/src/services/threads/thread-lifecycle.ts
@@ -244,11 +244,13 @@ interface InterruptActiveThreadArgs {
}
interface InterruptActiveThreadsArgs {
+ cause?: "host-connection-lost";
reason: SystemThreadInterruptedReason;
threads: readonly InterruptActiveThreadArgs[];
}
interface InterruptActiveThreadsForHostArgs {
+ cause?: "host-connection-lost";
hostId: string;
reason: SystemThreadInterruptedReason;
}
@@ -312,13 +314,18 @@ interface SettleThreadPlanCancelCommandResultArgs {
report: ThreadPlanCancelCommandResultReport;
}
+type RuntimeThreadInterruptionReason =
+ | SystemThreadInterruptedReason
+ | "host-connection-lost";
+
function lifecycleEventForInterruptedThread(
- reason: SystemThreadInterruptedReason,
+ reason: RuntimeThreadInterruptionReason,
): ThreadLifecycleEvent {
switch (reason) {
case "manual-stop":
return { type: "stop.settled" };
case "host-daemon-restarted":
+ case "host-connection-lost":
return { type: "run.failed" };
case "provider-turn-idle":
return { type: "run.failed" };
@@ -328,13 +335,15 @@ function lifecycleEventForInterruptedThread(
}
function pendingInteractionStopReason(
- reason: SystemThreadInterruptedReason,
+ reason: RuntimeThreadInterruptionReason,
): string {
switch (reason) {
case "manual-stop":
return "Thread stopped by user request";
case "host-daemon-restarted":
return "Host daemon restarted while awaiting user interaction";
+ case "host-connection-lost":
+ return "Connection to host was lost while awaiting user interaction";
case "provider-turn-idle":
return "Thread stopped after the provider stopped sending progress";
default:
@@ -343,13 +352,15 @@ function pendingInteractionStopReason(
}
function threadCommandFailureMessageForInterruption(
- reason: SystemThreadInterruptedReason,
+ reason: RuntimeThreadInterruptionReason,
): string | null {
switch (reason) {
case "manual-stop":
return null;
case "host-daemon-restarted":
return "Thread interrupted because the host daemon disconnected";
+ case "host-connection-lost":
+ return "Thread interrupted because the connection to the host was lost";
case "provider-turn-idle":
return "Live runtime work failed because the provider stopped sending progress";
default:
@@ -358,12 +369,13 @@ function threadCommandFailureMessageForInterruption(
}
function threadCommandFailureDetailForInterruption(
- reason: SystemThreadInterruptedReason,
+ reason: RuntimeThreadInterruptionReason,
): string {
switch (reason) {
case "manual-stop":
return "Thread stopped by user request";
case "host-daemon-restarted":
+ case "host-connection-lost":
return "Please retry the thread to continue.";
case "provider-turn-idle":
return "Provider stopped sending progress while the thread was running";
@@ -1483,7 +1495,8 @@ function interruptActiveThreads(
const results: InterruptedActiveThreadResult[] = [];
const threadIds = args.threads.map((thread) => thread.threadId);
- const lifecycleEvent = lifecycleEventForInterruptedThread(args.reason);
+ const effectiveReason = args.cause ?? args.reason;
+ const lifecycleEvent = lifecycleEventForInterruptedThread(effectiveReason);
deps.db.transaction(
(tx) => {
@@ -1498,9 +1511,8 @@ function interruptActiveThreads(
const state = stateByThreadId.get(thread.threadId);
const activeTurnId = state?.activeTurnId ?? null;
const providerThreadId = state?.latestProviderThreadId ?? null;
- const failureMessage = threadCommandFailureMessageForInterruption(
- args.reason,
- );
+ const failureMessage =
+ threadCommandFailureMessageForInterruption(effectiveReason);
if (activeTurnId !== null) {
eventArgs.push({
@@ -1527,7 +1539,8 @@ function interruptActiveThreads(
data: buildSystemErrorEventData({
code: "thread_command_failed",
message: failureMessage,
- detail: threadCommandFailureDetailForInterruption(args.reason),
+ detail:
+ threadCommandFailureDetailForInterruption(effectiveReason),
}),
});
}
@@ -1538,6 +1551,7 @@ function interruptActiveThreads(
scope: threadScope(),
data: {
reason: args.reason,
+ ...(args.cause ? { cause: args.cause } : {}),
},
});
results.push({
@@ -1560,7 +1574,7 @@ function interruptActiveThreads(
deps.pendingInteractions.interruptPendingInteractionsForThreadIds({
threadIds: results.map((result) => result.threadId),
- reason: pendingInteractionStopReason(args.reason),
+ reason: pendingInteractionStopReason(effectiveReason),
});
for (const result of results) {
@@ -1611,6 +1625,7 @@ export function interruptActiveThreadsForHost(
return interruptActiveThreads(deps, {
threads: activeThreads,
reason: args.reason,
+ ...(args.cause ? { cause: args.cause } : {}),
});
}
diff --git a/apps/server/test/internal/background-task-reconciliation.test.ts b/apps/server/test/internal/background-task-reconciliation.test.ts
index 77c900195c..5632866e8c 100644
--- a/apps/server/test/internal/background-task-reconciliation.test.ts
+++ b/apps/server/test/internal/background-task-reconciliation.test.ts
@@ -492,7 +492,7 @@ describe("active thread disconnect reconciliation triggers", () => {
});
});
- it("interrupts active turns when a different daemon instance registers", async () => {
+ it("records a confirmed daemon restart when a different daemon instance registers", async () => {
await withTestHarness(async (harness) => {
const { host, session, thread } = seedActiveTurnThread(harness);
@@ -542,7 +542,7 @@ describe("active thread disconnect reconciliation triggers", () => {
});
});
- it("interrupts active turns after the live event window elapses without a reconnect", async () => {
+ it("records a lost host connection after the live event window elapses without a reconnect", async () => {
await withTestHarness(async (harness) => {
const { session, thread } = seedActiveTurnThread(harness);
@@ -575,13 +575,17 @@ describe("active thread disconnect reconciliation triggers", () => {
expect.objectContaining({
data: expect.objectContaining({
code: "thread_command_failed",
- message: "Thread interrupted because the host daemon disconnected",
+ message:
+ "Thread interrupted because the connection to the host was lost",
detail: "Please retry the thread to continue.",
}),
type: "system/error",
}),
expect.objectContaining({
- data: { reason: "host-daemon-restarted" },
+ data: {
+ reason: "host-daemon-restarted",
+ cause: "host-connection-lost",
+ },
type: "system/thread/interrupted",
}),
]);
diff --git a/packages/domain/src/thread-events.ts b/packages/domain/src/thread-events.ts
index e8551a0fc0..794ee9d345 100644
--- a/packages/domain/src/thread-events.ts
+++ b/packages/domain/src/thread-events.ts
@@ -222,6 +222,7 @@ export type SystemThreadInterruptedReason = z.infer<
export const systemThreadInterruptedEventDataSchema = z.object({
reason: systemThreadInterruptedReasonSchema,
+ cause: z.literal("host-connection-lost").optional(),
});
export const provisioningTranscriptEntrySchema = z.object({
diff --git a/packages/domain/test/stored-thread-event.test.ts b/packages/domain/test/stored-thread-event.test.ts
index 4aa10696ec..dcad6267e5 100644
--- a/packages/domain/test/stored-thread-event.test.ts
+++ b/packages/domain/test/stored-thread-event.test.ts
@@ -1,9 +1,11 @@
import { describe, expect, it } from "vitest";
+import { z } from "zod";
import {
parseStoredThreadEvent,
parseThreadEventRow,
} from "../src/stored-thread-event.js";
import { threadScope, turnScope } from "../src/thread-event-scope.js";
+import { systemThreadInterruptedEventDataSchema } from "../src/thread-events.js";
describe("parseStoredThreadEvent", () => {
it("rejects assistant deltas without an itemId", () => {
@@ -117,4 +119,24 @@ describe("parseStoredThreadEvent", () => {
});
expect(event).not.toHaveProperty("turnId");
});
+
+ it("keeps host connection loss compatible with legacy interruption readers", () => {
+ const data = {
+ reason: "host-daemon-restarted",
+ cause: "host-connection-lost",
+ } as const;
+
+ expect(systemThreadInterruptedEventDataSchema.parse(data)).toEqual(data);
+
+ const legacySchema = z.object({
+ reason: z.enum([
+ "manual-stop",
+ "host-daemon-restarted",
+ "provider-turn-idle",
+ ]),
+ });
+ expect(legacySchema.parse(data)).toEqual({
+ reason: "host-daemon-restarted",
+ });
+ });
});
diff --git a/packages/plugin-api-map/sdk-public-api.json b/packages/plugin-api-map/sdk-public-api.json
index 098cbc0ed2..7244f556c9 100644
--- a/packages/plugin-api-map/sdk-public-api.json
+++ b/packages/plugin-api-map/sdk-public-api.json
@@ -3,7 +3,7 @@
"entries": {
".": {
"types": "bundled-types/bb-plugin-sdk.d.ts",
- "sha256": "70077a510684588d29816ecb221ca99476640c7972c31730025064904265535e"
+ "sha256": "76c415f99e2336c4a2a17b4a7760e60004a38e279dcfa28025ae0c0fda763545"
},
"./ai-services": {
"types": "bundled-types/bb-plugin-sdk-ai-services.d.ts",
@@ -27,7 +27,7 @@
},
"./provider-bridge/testing": {
"types": "bundled-types/bb-plugin-sdk-provider-bridge-testing.d.ts",
- "sha256": "0eec301dc51c73ae3201abfe4bf6d1afe1fab7a79214d37311d20c861cc4533f"
+ "sha256": "511c1586fac2ec1ca648b1ddfc5882821750ff33d3c19dd7749ac2328d4d2dd1"
},
"./testing": {
"types": "bundled-types/bb-plugin-sdk-testing.d.ts",
diff --git a/packages/thread-view/src/parse-operation-message.ts b/packages/thread-view/src/parse-operation-message.ts
index 155bb75232..2a296f7adc 100644
--- a/packages/thread-view/src/parse-operation-message.ts
+++ b/packages/thread-view/src/parse-operation-message.ts
@@ -109,7 +109,13 @@ function createThreadOperationMetadata(
};
}
-function threadInterruptedTitle(reason: SystemThreadInterruptedReason): string {
+function threadInterruptedTitle(
+ reason: SystemThreadInterruptedReason,
+ cause?: "host-connection-lost",
+): string {
+ if (cause === "host-connection-lost") {
+ return "Stopped — connection to host was lost";
+ }
switch (reason) {
case "manual-stop":
return "Stopped manually";
@@ -490,7 +496,7 @@ export function parseOperationMessage(
if (decoded.type === "system/thread/interrupted") {
return op(decoded, meta, "thread-interrupted", {
opType: "thread-interrupted",
- title: threadInterruptedTitle(decoded.reason),
+ title: threadInterruptedTitle(decoded.reason, decoded.cause),
status: "interrupted",
});
}
diff --git a/packages/thread-view/test/parse-operation-message.test.ts b/packages/thread-view/test/parse-operation-message.test.ts
index 1d46aab71c..635d9d0b2d 100644
--- a/packages/thread-view/test/parse-operation-message.test.ts
+++ b/packages/thread-view/test/parse-operation-message.test.ts
@@ -41,8 +41,9 @@ function provisioningTitle(
function interruptedTitle(
reason: SystemThreadInterruptedReason,
threadName: string,
+ cause?: "host-connection-lost",
): string {
- const row = factory().systemThreadInterrupted({ reason });
+ const row = factory().systemThreadInterrupted({ reason, cause });
return operationTitleFor(row, threadName);
}
@@ -137,6 +138,13 @@ describe("parseOperationMessage operation titles", () => {
expect(interruptedTitle("host-daemon-restarted", THREAD_NAME)).toBe(
"Stopped — host daemon restarted",
);
+ expect(
+ interruptedTitle(
+ "host-daemon-restarted",
+ THREAD_NAME,
+ "host-connection-lost",
+ ),
+ ).toBe("Stopped — connection to host was lost");
});
});
diff --git a/packages/thread-view/test/timeline-test-harness.ts b/packages/thread-view/test/timeline-test-harness.ts
index 19d03e1116..d0968a923c 100644
--- a/packages/thread-view/test/timeline-test-harness.ts
+++ b/packages/thread-view/test/timeline-test-harness.ts
@@ -283,6 +283,7 @@ interface SystemOperationArgs extends EventFactoryRowOptions {
}
interface SystemThreadInterruptedArgs extends EventFactoryRowOptions {
+ cause?: "host-connection-lost";
reason?: SystemThreadInterruptedReason;
}
@@ -924,6 +925,7 @@ export function createTimelineEventFactory(
type: "system/thread/interrupted",
data: {
reason: args.reason ?? "manual-stop",
+ ...(args.cause ? { cause: args.cause } : {}),
},
};
},
From 1ba060f9c272fa7831c33e78ee39eb4bf5a8a7b9 Mon Sep 17 00:00:00 2001
From: brsbl
Date: Sat, 29 Aug 2026 01:18:09 -0700
Subject: [PATCH 06/19] Lower follow-up composer control emphasis (#2529)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What was wrong
Secondary follow-up composer affordances used three different foreground
tiers: the plus action inherited the primary foreground, the model caret
used the muted foreground, and the collapse caret used the subtle
foreground. That inconsistency gave low-priority chrome more visual
weight than its function warranted.
## What changed
- Moves the composer collapse caret and plus action to the established
shared subtle chrome-button treatment.
- Moves the model-picker caret to the same `text-subtle-foreground/75`
tier only in its muted composer presentation; non-composer styling is
unchanged.
- Preserves hover, focus, menu, collapse, and responsive behavior.
- Adds focused regression assertions for all three foreground treatments
and a focused Ladle state.
- No daemon wire, SDK, API, CLI, or persisted contract changes.
## How you verified
- Red proof on the parent behavior: computed light-theme colors resolved
to three different tiers—`foreground` for plus, `muted-foreground` for
the model caret, and `subtle-foreground` for collapse.
- Green proof on the child behavior: the focused class assertions pass
with all three resolving to `subtle-foreground/75`; the relevant app
test files passed 144/144.
- Exact rebased head `699352c7e7576ee937d68d3e9f43f02f570aecfd` passes
every required GitHub check, including all app shards, packages, server,
integration, and Linux/macOS package smoke.
- Chrome for Testing 152.0.7977.64 rendered the same `Stacked cards with
pills` fixture at 1440×900 on exact parent and child heads. The
deliberate interaction pass covered plus-menu, model-menu, collapse,
focus re-expansion, light/dark rendering, responsive widths, and runtime
exceptions. The later rebase changed only ancestor SDK inventory
metadata, not this layer's app tree. Safari is not required for this
non-marketing bb UI change.
### Before — parent head `38bec6236a6b58ca75542bb3e5cff98182cd3b71`
The plus action, model caret, and collapse caret use stronger,
inconsistent foreground tiers.

### After — child head `699352c7e7576ee937d68d3e9f43f02f570aecfd`
All three affordances use the same lower-emphasis chrome tier while
model text and primary actions retain their hierarchy.

BB-Thread-ID: thr_sjdd7gudiq
> AGENT GENERATED
---
.../pickers/ModelReasoningPicker.test.tsx | 14 ++++++++++++++
.../components/pickers/ModelReasoningPicker.tsx | 5 ++++-
.../promptbox/FollowUpPromptBox.stories.tsx | 12 +++++++++---
.../promptbox/PromptBoxActionsMenu.test.tsx | 7 +++----
.../components/promptbox/PromptBoxActionsMenu.tsx | 2 ++
.../promptbox/PromptBoxInternal.test.tsx | 8 +++++---
.../src/components/promptbox/PromptBoxInternal.tsx | 3 ++-
7 files changed, 39 insertions(+), 12 deletions(-)
diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx
index 0722fd60b9..12951966b2 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,17 @@ 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");
+ });
+
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..e6ae8a0dfc 100644
--- a/apps/app/src/components/pickers/ModelReasoningPicker.tsx
+++ b/apps/app/src/components/pickers/ModelReasoningPicker.tsx
@@ -855,7 +855,10 @@ export function ModelReasoningPicker({
{disabled ? null : (
)}
+
+
+ );
+}
+
export function Overview() {
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..90bf8b10d0 100644
--- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
+++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
@@ -1648,9 +1648,11 @@ 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");
+ 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..daef24a565 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,
@@ -3030,7 +3031,7 @@ export function PromptBoxInternal({
onClick={collapsePromptBox}
aria-label="Collapse prompt box"
className={cn(
- "text-subtle-foreground hover:text-muted-foreground",
+ CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS,
COARSE_POINTER_PROMPT_ICON_ACTION_BUTTON_CLASS,
)}
>
From e828fd0480ac9ef54899995dbb8ab986d76a5843 Mon Sep 17 00:00:00 2001
From: brsbl
Date: Sat, 29 Aug 2026 01:18:09 -0700
Subject: [PATCH 07/19] Improve follow-up environment story coverage (#2530)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What was wrong
The follow-up composer story catalog defaulted every row without an
explicit environment fixture to the same local-direct summary, so most
functional states repeated one environment treatment. The environment
matrix also omitted unmanaged, named, and detached worktree shapes, and
its detached fixture lacked the machine label needed to render the
worktree icon.
## What changed
- Rotates the functional overview through a balanced mix of local/remote
and direct/worktree summaries.
- Expands `Environment matrix` with unmanaged, named, and detached
worktree rows.
- Supplies the detached fixture's machine label so the production
`FolderGit` environment icon renders.
- Keeps provisioning on bb’s active-work `Loading03` indicator because
discovery has not confirmed the eventual workspace type.
- Changes only Ladle fixtures; there are no product, daemon-wire, SDK,
API, CLI, or persistence changes.
## How you verified
- Exact rebased head `f4707e0ad2ea6622fa4c314e943c6735a1d21f34` passes
every required GitHub check, including all app shards, packages, server,
integration, and Linux/macOS package smoke.
- Ladle metadata registers both the native-skill-call and
environment-matrix stories; neither renders a missing-story state.
- Chrome for Testing 152.0.7977.64 rendered the exact parent and child
matrix at the same 1440×900 viewport and scroll position. The child
visibly adds unmanaged, named, and detached worktree rows with the
production worktree icon; provisioning uses bb’s active-work `Loading03`
indicator. The deliberate QA pass also verified all environment
accessible labels, a balanced overview mix, no horizontal overflow, and
no runtime exceptions.
- Safari is not required for this non-marketing Ladle change.
### Before — parent head `699352c7e7576ee937d68d3e9f43f02f570aecfd`
The matrix ends after managed local/remote worktrees.

### After — child head `f4707e0ad2ea6622fa4c314e943c6735a1d21f34`
The same fixture now exposes unmanaged, named, and detached worktree
label/icon shapes.

BB-Thread-ID: thr_sjdd7gudiq
> AGENT GENERATED
---
.../promptbox/FollowUpPromptBox.stories.tsx | 68 +++++++++++++++++--
1 file changed, 64 insertions(+), 4 deletions(-)
diff --git a/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx
index b00fc08336..2131552d11 100644
--- a/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx
+++ b/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx
@@ -298,6 +298,20 @@ const remoteWorktreeEnvironmentSummary: ReactNode = makeEnvironmentSummary({
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",
@@ -317,6 +331,7 @@ const detachedWorktreeEnvironmentSummary: ReactNode = makeEnvironmentSummary({
status: "ready",
}),
host: localEnvironmentDisplayHost,
+ machineName: "Bersabel's MacBook Pro",
environmentCheckout: formatWorkspaceCheckoutDisplay({
checkout: {
kind: "detached",
@@ -842,6 +857,7 @@ function StackedCardsWithPillsRow() {
stack={contextBannerElement}
queuedMessages={queuedMessages}
contextWindowUsage={usage}
+ environmentSummary={remoteEnvironmentSummary}
/>
);
}
@@ -868,6 +884,7 @@ export function Overview() {
submitMode={{ kind: "queue", onStop: noop }}
threadRuntimeDisplayStatus="active"
contextWindowUsage={usage}
+ environmentSummary={worktreeEnvironmentSummary}
/>
-
+
@@ -960,6 +984,7 @@ export function Overview() {
loadError: null,
},
}}
+ environmentSummary={detachedWorktreeEnvironmentSummary}
/>
-
+
@@ -1130,7 +1163,7 @@ export function EnvironmentMatrix() {
+
+
+
+
+
+
+
+
+
Date: Sat, 29 Aug 2026 01:18:10 -0700
Subject: [PATCH 08/19] Align follow-up composer stack carets (#2585)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What was wrong
The three right-edge disclosure controls in the follow-up composer stack
did not share a horizontal geometry contract. The branch and
queued-message controls centered their 16px chevrons in a 24px edge
column, while the composer collapse control used a 32px icon button
against the same right edge. That put the composer caret four pixels
left of the carets above it.
## What changed
- Adds one shared 24px horizontal edge-caret class for the follow-up
prompt stack.
- Applies it to the branch banner, queued-message header, and wide
composer collapse control while preserving their existing vertical hit
targets.
- Adds a regression assertion at the stable composer behavior boundary
so the collapse control cannot drift back to a wider horizontal box.
- No wire, persistence, SDK, CLI, or daemon protocol behavior changed.
## How you verified
- Red proof: with the production composer class absent, the new
assertion fails because the collapse control does not contain `w-6`.
- Green proof: the focused composer assertion passes, and neighboring
queued-message coverage passes 36/36.
- Exact rebased head `400f7c6674cbeb5a1df9c2f331f97b020865c3ba` passes
every required GitHub check, including all app shards, packages, server,
integration, and Linux/macOS package smoke.
- Chrome for Testing 152.0.7977.64 rendered the same `Stacked cards with
pills` fixture at 1440×900 on exact parent and child heads. On the
child, branch, queue, and composer caret centers all measured x=1019;
the deliberate real-app pass also covered expanded/collapsed behavior,
plus/model menus, focus re-expansion, responsive overflow, and runtime
exceptions. The later rebase changed only ancestor SDK inventory
metadata, not this layer's app tree. Safari is not required for this
non-marketing bb UI change.
### Before — parent head `f4707e0ad2ea6622fa4c314e943c6735a1d21f34`
The composer caret sits four pixels left of the banner and queue carets.

### After — child head `400f7c6674cbeb5a1df9c2f331f97b020865c3ba`
All three carets share the same horizontal center.

BB-Thread-ID: thr_sjdd7gudiq
> AGENT GENERATED
---
.../components/promptbox/PromptBoxInternal.test.tsx | 2 ++
.../app/src/components/promptbox/PromptBoxInternal.tsx | 2 ++
.../components/promptbox/banner/PromptStackCard.tsx | 1 +
.../components/promptbox/banner/QueuedMessagesList.tsx | 10 ++++++++--
4 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
index 90bf8b10d0..425053e131 100644
--- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
+++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
@@ -1652,6 +1652,8 @@ describe("PromptBoxInternal size controls", () => {
name: "Collapse prompt box",
});
expect(collapseButton.classList).toContain("text-subtle-foreground/75");
+ expect(collapseButton.classList).toContain("w-6");
+ expect(collapseButton.classList).toContain("px-0");
fireEvent.click(collapseButton);
expect(onCollapse).toHaveBeenCalledOnce();
diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx
index daef24a565..892a7b561f 100644
--- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx
+++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx
@@ -87,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 {
@@ -3033,6 +3034,7 @@ export function PromptBoxInternal({
className={cn(
CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS,
COARSE_POINTER_PROMPT_ICON_ACTION_BUTTON_CLASS,
+ PROMPT_STACK_EDGE_CARET_BUTTON_WIDTH_CLASS,
)}
>
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.tsx b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx
index 35ae8cdd51..da8064d65d 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";
@@ -1522,7 +1525,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"}
From 3414fdaea7e9928cdaada0d9c1b75ee4b22bb7cf Mon Sep 17 00:00:00 2001
From: brsbl
Date: Sat, 29 Aug 2026 01:18:10 -0700
Subject: [PATCH 09/19] Unify prompt box affordance colors (#2633)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Human comments
## What was wrong
The prompt-box footer applied semantic warning and hover colors to
secondary affordances. Full Access correctly made the permission label
amber, but it also made the dropdown caret amber; hovering Create thread
in worktree promoted its icon to foreground. Both controls therefore
stood out from the prompt box's other neutral affordances.
## What changed
- Moves the permission caret to the prompt box’s
`text-subtle-foreground/75` chrome tier while the selected permission
label retains its warning tone.
- Moves the Create thread in worktree icon to the same low-emphasis
chrome tier, with the established muted hover treatment.
- Uses normal weight for the model selector so its long label and
provider mark do not dominate the composer.
- Aligns the top-right collapse caret exactly with the permission caret
while preserving its hit target.
- Expands the existing Control emphasis story to show Full Access and
the worktree-create action together.
- Adds focused regression assertions for the color, weight, and
caret-alignment contracts.
- No wire, persistence, SDK, CLI, or daemon protocol behavior changed.
## How you verified
- Chrome for Testing 149.0.7827.55 rendered matched DPR 2 crops of the
same Control emphasis fixture at 1440×900. Per product review, the
Before image combines the target controls’ parent behavior
(`68a9aa715db41ede83ae66ccf72cf385aabab28c`) with main’s
plus/model/composer-caret styling
(`ec8f4ef04105c2cb5a59f7a9a9328bf2b16b39ce`); After is the exact child
head `cb254b273da66575afcc307fefd466602650f02d`.
- Before: the permission label and caret both resolved to the amber
warning color; the worktree-create icon sat at the stronger muted tier.
- After: the permission label remains amber, while the permission caret,
worktree-create icon, model caret, composer caret, and plus icon all
resolve to the exact same `text-subtle-foreground/75` color in light and
dark themes.
- The same neutral equality was checked in dark theme. On the final
light-theme capture, the model selector resolves to weight 400; the
collapse and permission carets both render at 16px with center x=894 and
the same color. Local CI-equivalent checks were not run per repository
policy; GitHub CI owns test and typecheck execution.
### Before — main styling for the other prompt-box controls
The plus, model caret, and composer caret use their main styling; the
permission caret remains amber and the worktree-create icon remains
darker.

### After — child head `cb254b273da66575afcc307fefd466602650f02d`
The warning remains on the Full Access label; the model returns to
normal weight, and the two right-edge carets align exactly.

BB-Thread-ID: thr_fdabesxhdr
> AGENT GENERATED
---
.../pickers/ModelReasoningPicker.test.tsx | 1 +
.../pickers/ModelReasoningPicker.tsx | 1 +
.../src/components/pickers/OptionPicker.tsx | 9 ++++-----
.../pickers/PermissionModePicker.test.tsx | 16 ++++++++++++++++
.../pickers/PermissionModePicker.tsx | 1 +
.../promptbox/FollowUpPromptBox.stories.tsx | 6 +++++-
.../promptbox/PromptBoxInternal.test.tsx | 4 ++++
.../components/promptbox/PromptBoxInternal.tsx | 4 ++--
.../ThreadEnvironmentSummary.test.tsx | 18 +++++++++++++-----
.../promptbox/ThreadEnvironmentSummary.tsx | 6 +++++-
10 files changed, 52 insertions(+), 14 deletions(-)
diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx
index 12951966b2..b60acad123 100644
--- a/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx
+++ b/apps/app/src/components/pickers/ModelReasoningPicker.test.tsx
@@ -260,6 +260,7 @@ describe("ModelReasoningPicker", () => {
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", () => {
diff --git a/apps/app/src/components/pickers/ModelReasoningPicker.tsx b/apps/app/src/components/pickers/ModelReasoningPicker.tsx
index e6ae8a0dfc..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,
)}
diff --git a/apps/app/src/components/pickers/OptionPicker.tsx b/apps/app/src/components/pickers/OptionPicker.tsx
index 1fb470b504..8c16fe161b 100644
--- a/apps/app/src/components/pickers/OptionPicker.tsx
+++ b/apps/app/src/components/pickers/OptionPicker.tsx
@@ -22,7 +22,6 @@ import {
const OPTION_WARNING_TEXT_CLASS_NAME = "text-warning-text";
const OPTION_WARNING_INTERACTIVE_CLASS_NAME =
"hover:text-warning-text data-[state=open]:text-warning-text";
-const OPTION_WARNING_ICON_CLASS_NAME = "text-warning-text";
export interface PickerOption {
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/promptbox/FollowUpPromptBox.stories.tsx b/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx
index 2131552d11..7fafbfd488 100644
--- a/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx
+++ b/apps/app/src/components/promptbox/FollowUpPromptBox.stories.tsx
@@ -865,7 +865,11 @@ function StackedCardsWithPillsRow() {
export function ControlEmphasis() {
return (
-
+
);
}
diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
index 425053e131..18737695b8 100644
--- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
+++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
@@ -1654,6 +1654,10 @@ describe("PromptBoxInternal size controls", () => {
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();
diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx
index 892a7b561f..57c22ea8b1 100644
--- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx
+++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx
@@ -3020,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 32d1db1dc2..fe31dd83f7 100644
--- a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx
+++ b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.test.tsx
@@ -105,7 +105,7 @@ describe("ThreadEnvironmentSummary", () => {
);
it("explains the create-thread action in a tooltip", async () => {
- render(
+ const { container } = render(
{
,
);
- fireEvent.focus(
- screen.getByRole("button", {
- name: "Create thread in 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 thread in worktree",
diff --git a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx
index 4f40641ace..76091aee8a 100644
--- a/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx
+++ b/apps/app/src/components/promptbox/ThreadEnvironmentSummary.tsx
@@ -4,6 +4,7 @@ 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";
@@ -128,7 +129,10 @@ export const ThreadEnvironmentSummary = memo(function ThreadEnvironmentSummary({
type="button"
aria-label="Create thread in worktree"
onClick={onCreateNewThreadInWorktree}
- className="-ml-1 inline-flex cursor-pointer shrink-0 items-center justify-center rounded-md px-1 py-0.5 text-muted-foreground transition-colors hover:bg-state-hover hover:text-foreground"
+ className={cn(
+ "-ml-1 inline-flex cursor-pointer shrink-0 items-center justify-center rounded-md px-1 py-0.5 transition-colors hover:bg-state-hover",
+ CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS,
+ )}
>
From 02abe33adcc3920b256497b12ce6916a28093d6d Mon Sep 17 00:00:00 2001
From: brsbl
Date: Sat, 29 Aug 2026 11:15:02 -0700
Subject: [PATCH 10/19] Rename queued messages to follow-ups (#2695)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Human comments
## What was wrong
The queued-message stack used the internal state label “Queued” as its
visible heading and throughout its accessible names. That made the
surface sound implementation-led, gave secondary chrome too much visual
weight, and left sighted and assistive-technology terminology
inconsistent once the heading changed.
## What changed
- Rename the visible stack heading to “Follow-ups,” use normal weight,
and apply the subtle foreground token so it reads as tertiary chrome.
- Use follow-up terminology consistently across the section label,
expand/collapse and drag controls, row actions, action menu, and inline
editor.
- Update focused component and thread-detail assertions for the new
accessible names.
- Keep internal queued-message APIs, types, and persisted contracts
unchanged.
## How you verified
- `git diff --check`
- Chrome for Testing 151: final Ladle Overview at 1440×1000 in light and
dark themes, with desktop and 320px mobile fixtures rendered together;
label/count hierarchy, truncation, and layout remained stable.
- Chrome for Testing 151: matching queued-item fixture captured from
1440×1000 branch web apps, cropped to the identical 728×86 component
region on merge base `8d926c312` and PR head `d3757a814`.
- Local CI-equivalent tests were not run per repository policy; focused
assertions were updated for remote CI.
- Remote CI passed across checks, app, integration, server, package, and
macOS/Linux package-smoke jobs.
### Before — merge base

### After — PR head

BB-Thread-ID: thr_yyjgejjc8w
> AGENT GENERATED
---
.../banner/QueuedMessagesList.test.tsx | 57 ++++++++++---------
.../promptbox/banner/QueuedMessagesList.tsx | 30 +++++-----
2 files changed, 46 insertions(+), 41 deletions(-)
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 da8064d65d..f0def6d812 100644
--- a/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx
+++ b/apps/app/src/components/promptbox/banner/QueuedMessagesList.tsx
@@ -645,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}
>
@@ -716,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`}
>
@@ -740,7 +740,7 @@ const QueuedMessageRow = memo(function QueuedMessageRow({
queuedMessageIndex: index,
})
}
- aria-label={`Edit queued message ${index + 1}`}
+ aria-label={`Edit follow-up ${index + 1}`}
>
@@ -759,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}`}
>
@@ -784,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`}
>
@@ -908,8 +908,8 @@ function QueuedMessageInlineEditorSlot({
>
@@ -1457,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();
@@ -1474,7 +1474,7 @@ export function QueuedMessagesList({
return (
-
Queued
+
+ Follow-ups
+
{queuedMessages.length}
@@ -1506,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}
From 61f4e193d1d6d7a2c675e8d3d2d594aa058af842 Mon Sep 17 00:00:00 2001
From: brsbl
Date: Sat, 29 Aug 2026 12:17:21 -0700
Subject: [PATCH 11/19] Add reopen closed tab shortcut (#2686)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Human comments
## What was wrong
The secondary panel had no command or state for restoring a closed tab,
and the desktop-only `Cmd+Shift+T` binding was assigned to opening a
terminal instead of the platform-standard reopen gesture. The first
implementation also treated incomplete storage inventories as
authoritative, rebuilt restored tabs from canonical rather than visible
placement, and left browser `WebContentsView`s visible while the host
renderer reloaded.
## What changed
- Added the `panel.reopenClosedTab` app command, a desktop default of
`Cmd+Shift+T`, and a matching native File menu item.
- Added a 25-entry LIFO history for closed file, browser, and
plugin-panel tabs. Reopened tabs become active; launchers, fixed tabs,
and destroyed terminal sessions are excluded.
- Keyed history by one canonical panel-context identity—panel state,
environment, project, project host, and file-owner thread—so each
context has an independent stack. Ownership is enforced when recording;
restoration only pops the current stack and validates resource liveness.
- Preserved storage-inventory readiness and truncation. Restoration
waits for an authoritative inventory, performs targeted existence checks
when the inventory is truncated, skips deleted or foreign-owner entries,
and continues to the next valid entry.
- Preserved each tab's visible split-panel placement when it is
temporarily removed and restored, independent of canonical fixed-panel
ordering.
- Hid browser `WebContentsView`s before native or keyboard renderer
reloads so stale child views cannot cover or intercept the replacement
renderer; the fresh renderer reattaches and shows them after startup.
- Kept terminal opening on its existing cross-platform `Cmd+Shift+Enter`
binding and documented the shared panel command for plugin surfaces.
- Added focused regressions for history ordering and exclusions,
environment/project/project-host/file-owner isolation, storage readiness
and truncation, deleted and beyond-page storage files, visible tab
placement, default bindings, native accelerators, the menu action, and
desktop reload preparation.
- Refreshed the tracked public SDK inventory for the additive command-ID
value. No host-daemon wire contract changed, so
`HOST_DAEMON_PROTOCOL_VERSION` is unchanged.
### Before
The merge base has no reopen-closed-tab command.
Native View → Reload with an open browser view could leave the host
renderer blank.
### After
The exact PR branch exposes the desktop shortcut as `Cmd+Shift+T`.
Native View → Reload restores both the host renderer and embedded
browser view.
## Exact-head desktop smoke evidence
Two browser tabs open:
Both browser tabs closed:
First `Cmd+Shift+T` restores the most recently closed tab and its URL:
Second `Cmd+Shift+T` restores the remaining tab:
A third `Cmd+Shift+T` is a no-op, and the original visible order remains
`Browser · Example Domain`:
A normally provisioned, environment-backed thread also restores a real
file from the current worktree. After closing `README.md`, the literal
`Cmd+Shift+T` gesture restores the active tab with its rendered
repository content—not a skeleton:
## How you verified
- Exact candidate: `e82f32cb5d961299d69be7f2f1bfde80b49a1270` in the
branch desktop app, Electron 41.7.0.
- **Pass:** opened two browser tabs, closed both, and pressed the real
`Cmd+Shift+T` gesture twice. The tabs returned in reverse close order,
the URL was retained, the original visible placement was restored, and a
third press was a no-op.
- **Pass:** opened the exact Electron app's native File menu and
confirmed its accessibility tree exposes **Reopen Closed Tab**. The
OS-level menu capture was discarded because an unrelated system prompt
contaminated the frame; accessibility evidence is used instead of
presenting misleading visual evidence.
- **Pass:** invoked native View → Reload with a live browser view. The
replacement host renderer loaded, the child browser view reattached, and
both remained interactive after settling.
- **Pass:** regression coverage switches across environments and also
distinguishes project, project host, panel state, and file-owner thread
before restoring only the nearest entry owned by the active context.
- **Pass:** storage regressions cover unresolved inventories, truncated
inventories, valid paths beyond the first page, deleted files, foreign
owners, and open storage tabs that must not be pruned from an incomplete
inventory.
- **Pass:** provisioned a fresh thread through the official CLI against
the branch dev server, confirmed its environment resolves to the exact
current worktree, opened `README.md` through real file search, closed
the tab, and pressed the literal `Cmd+Shift+T` gesture. The tab returned
active with fully rendered file content and no persistent skeleton or
409 response.
- Reviewed every retained screenshot for clipping, layout shift, stale
frames, and incorrect tab state. No visual defect remains in the
captured flows.
- Per repository policy, tests, typechecks, and lint were not run
locally. Pull-request CI is fully green: checks,
app/server/package/integration tests, Linux/macOS package smoke, and
version checks all passed.
## Final review and triage
One deliberate architecture and implementation review was run for this
PR. Its findings were validated, then all confirmed product defects from
the review and smoke pass were fixed:
- **P1 — fixed:** native desktop reload could leave a live browser child
view covering and intercepting the replacement host renderer.
- **P1 — fixed:** closed-tab history could cross environment/checkout
boundaries without using the complete panel, environment, project,
project-host, and file-owner identity.
- **P2 — fixed:** unresolved or truncated storage inventories could
transiently restore a deleted file or permanently discard a valid entry
beyond the inventory page.
- **P2 — fixed:** the second restored tab could be appended instead of
returning to its original visible split-panel placement.
- **P2 — fixed:** deleted or foreign-owner thread-storage entries are
skipped while restoration continues to the next valid history entry.
No known product defect remains from the review, smoke test, or
screenshot inspection. The earlier convenience fixture that populated
nine tabs bypassed normal provisioning and produced an environment-less
thread. It was discarded and is not used as merge evidence; the final
file-tab pass used the normally provisioned environment-backed thread
shown above. One QA-infrastructure observation remains outside this
feature: on a cold desktop-dev launch, the launcher's fixed readiness
window can expire while branch packaging is still completing, producing
a temporary blank window. Logs and the later automatic load established
that this was service readiness timing rather than a renderer crash.
BB-Thread-ID: thr_ufp89hj9ea
> AGENT GENERATED
---
.../plugin/PluginPanelRightPanelHost.tsx | 6 +
.../secondary-panel/SidebarSplitContainer.tsx | 25 +-
.../sidebarSplitLayout.test.ts | 49 ++
.../secondary-panel/sidebarSplitLayout.ts | 111 +++-
.../secondary-panel/useThreadFileTabs.test.ts | 478 +++++++++++++++++-
.../secondary-panel/useThreadFileTabs.ts | 420 ++++++++++++++-
.../secondary-panel/useThreadStorageViewer.ts | 15 +
apps/app/src/lib/app-command-metadata.ts | 5 +
apps/app/src/views/RootComposeView.tsx | 30 +-
.../views/thread-detail/ThreadDetailView.tsx | 10 +-
apps/desktop/src/desktop-browser-view.ts | 12 +
apps/desktop/src/desktop-menu-shortcuts.ts | 6 +
apps/desktop/src/main.ts | 15 +
apps/desktop/src/menu.ts | 9 +
.../test/desktop-browser-main-ipc.test.ts | 2 +
.../test/desktop-browser-view-manager.test.ts | 44 ++
.../test/desktop-menu-shortcuts.test.ts | 3 +
apps/desktop/test/menu.test.ts | 26 +
.../references/frontend-core-slots.md | 3 +-
.../src/services/system/app-keybindings.ts | 18 +-
.../test/system/app-keybindings.test.ts | 21 +-
packages/domain/src/app-keybindings.ts | 1 +
packages/plugin-api-map/sdk-public-api.json | 2 +-
23 files changed, 1264 insertions(+), 47 deletions(-)
diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx
index 17d24ad199..74b0db7c5a 100644
--- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx
+++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx
@@ -278,6 +278,7 @@ export function PluginPanelRightPanelHost({
closeTab,
openTab,
orderedSecondaryFileTabs,
+ reopenClosedTab,
reorderTab,
updateBrowserTab,
} = useThreadFileTabs({
@@ -487,6 +488,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);
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/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..ada31526db 100644
--- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts
+++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts
@@ -1,5 +1,14 @@
-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,
@@ -13,6 +22,7 @@ import {
createWorkspaceFilePreviewFixedPanelTab,
type BrowserFixedPanelTab,
type FixedPanelTab,
+ type FixedPanelTabsState,
type HostFilePreviewFixedPanelTab,
type NewTabFixedPanelTab,
type PluginPanelFixedPanelTab,
@@ -22,6 +32,7 @@ import {
import { usePluginSlots } from "@/lib/plugin-slots";
import { useFileOpenerPreferenceValue } from "@/lib/file-opener-preference";
import {
+ createFileOpenerOriginalTab,
createFileOpenerTabForRequest,
fileOpenerIdFromActionId,
parseFileOpenerParams,
@@ -66,14 +77,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;
@@ -133,8 +143,192 @@ 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 "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 "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,
@@ -248,6 +442,7 @@ export function useThreadFileTabs({
projectHostId = null,
projectId = null,
retainedTerminalId = null,
+ storageFileExists,
storageFiles,
terminalSessions,
}: UseThreadFileTabsParams) {
@@ -260,8 +455,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 +467,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 +614,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 +640,7 @@ export function useThreadFileTabs({
}, [
isPanelStateResolved,
resolvedFileOwnerThreadId,
- storageFiles,
+ storageInventory,
updateFixedPanelTabsState,
]);
@@ -442,6 +698,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 +728,7 @@ export function useThreadFileTabs({
projectId,
resolvedEnvironmentId,
resolvedFileOwnerThreadId,
+ recentlyClosedPanelContextKey,
updateFixedPanelTabsState,
],
);
@@ -497,13 +758,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 +892,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 +910,7 @@ export function useThreadFileTabs({
return replaceNewTabWithSecondaryPanelTabInState({ state, tab });
});
},
- [updateFixedPanelTabsState],
+ [recentlyClosedPanelContextKey, updateFixedPanelTabsState],
);
const selectFileSearchResult = useCallback(
@@ -680,6 +1063,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/lib/app-command-metadata.ts b/apps/app/src/lib/app-command-metadata.ts
index 1fa5e5f80d..fcff375a79 100644
--- a/apps/app/src/lib/app-command-metadata.ts
+++ b/apps/app/src/lib/app-command-metadata.ts
@@ -97,6 +97,11 @@ export const APP_COMMAND_GROUPS: readonly AppCommandGroup[] = [
"New panel tab",
"Open a tab in the secondary panel.",
),
+ command(
+ "panel.reopenClosedTab",
+ "Reopen closed panel tab",
+ "Reopen the most recently closed secondary panel tab.",
+ ),
command(
"panel.close",
"Close panel tab",
diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx
index 7393cfcbc3..f86d415bc5 100644
--- a/apps/app/src/views/RootComposeView.tsx
+++ b/apps/app/src/views/RootComposeView.tsx
@@ -950,16 +950,17 @@ function RootComposeSurface({
: rootPanelHostPathTerminalTarget,
[rootPanelEnvironmentId, rootPanelHostPathTerminalTarget],
);
- const { threadStorageFiles: rootThreadStorageFiles } = useThreadStorageViewer(
- {
- fileListEnabled: shouldLoadThreadStorageFileList({
- hasThread: rootPanelThreadId !== null,
- isSecondaryPanelOpen,
- secondaryTabs: fixedPanelTabsState.secondary.tabs,
- }),
- threadId: rootPanelThreadId ?? undefined,
- },
- );
+ const {
+ checkThreadStorageFileExists: checkRootThreadStorageFileExists,
+ threadStorageFiles: rootThreadStorageFiles,
+ } = useThreadStorageViewer({
+ fileListEnabled: shouldLoadThreadStorageFileList({
+ hasThread: rootPanelThreadId !== null,
+ isSecondaryPanelOpen,
+ secondaryTabs: fixedPanelTabsState.secondary.tabs,
+ }),
+ threadId: rootPanelThreadId ?? undefined,
+ });
const environmentTerminalsListQuery = useEnvironmentTerminals(
rootPanelEnvironmentId ?? "",
{
@@ -1018,6 +1019,7 @@ function RootComposeSurface({
openPluginPanel,
openTab,
orderedSecondaryFileTabs,
+ reopenClosedTab,
reorderTab,
selectFileSearchResult,
updateBrowserTab,
@@ -1030,7 +1032,8 @@ function RootComposeSurface({
projectHostId: rootProjectHostId,
projectId: isProjectless ? null : projectId,
retainedTerminalId,
- storageFiles: rootThreadStorageFiles?.files,
+ storageFileExists: checkRootThreadStorageFileExists,
+ storageFiles: rootThreadStorageFiles,
terminalSessions: loadedTerminalSessions,
});
const rootPluginPanelActions = usePluginNewThreadPanelActions({
@@ -1358,6 +1361,11 @@ function RootComposeSurface({
handleOpenNewTab();
return true;
});
+ useAppCommandHandler("panel.reopenClosedTab", () => {
+ if (!isFocusedPane || !reopenClosedTab()) return false;
+ openCompactDrawer();
+ return true;
+ });
useAppCommandHandler("file.quickOpen", () => {
if (!isFocusedPane) return false;
handleOpenNewTab();
diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
index 4da2acb8ae..2d901d3f62 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
@@ -658,6 +658,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
secondaryTabs: fixedPanelTabsState.secondary.tabs,
});
const {
+ checkThreadStorageFileExists,
isThreadStorageFilesLoading,
refetchThreadStorageFiles,
threadStorageFiles,
@@ -683,6 +684,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
openTab,
openPluginPanel,
orderedSecondaryFileTabs,
+ reopenClosedTab,
reorderTab,
selectFileSearchResult,
updateBrowserTab,
@@ -691,7 +693,8 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
syncThreadId: threadId,
environmentId: thread?.environmentId,
retainedTerminalId,
- storageFiles: threadStorageFiles?.files,
+ storageFileExists: checkThreadStorageFileExists,
+ storageFiles: threadStorageFiles,
terminalSessions: terminalsListQuery.data?.sessions,
});
const pluginPanelActions = usePluginPanelActions({
@@ -1557,6 +1560,11 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
handleOpenNewTab();
return true;
});
+ useAppCommandHandler("panel.reopenClosedTab", () => {
+ if (!isFocused || !reopenClosedTab()) return false;
+ openCompactDrawer();
+ return true;
+ });
useAppCommandHandler("file.quickOpen", () => {
if (!isFocused) return false;
handleOpenNewTab();
diff --git a/apps/desktop/src/desktop-browser-view.ts b/apps/desktop/src/desktop-browser-view.ts
index a9b1f8059a..637a8c57d1 100644
--- a/apps/desktop/src/desktop-browser-view.ts
+++ b/apps/desktop/src/desktop-browser-view.ts
@@ -157,6 +157,7 @@ export interface DesktopBrowserViewManager {
): void;
beginWindowResize(hostWindow: DesktopBrowserHostWindow): void;
endWindowResize(hostWindow: DesktopBrowserHostWindow): void;
+ prepareWindowReload(hostWindow: DesktopBrowserHostWindow): void;
releaseWindow(hostWebContentsId: number): void;
destroyAll(): void;
}
@@ -805,6 +806,17 @@ export function createDesktopBrowserViewManager(
});
}
},
+ prepareWindowReload(hostWindow) {
+ resizingHostIds.delete(hostWindow.webContents.id);
+ const prefix = `${hostWindow.webContents.id}:`;
+ for (const [key, entry] of entries.entries()) {
+ if (!key.startsWith(prefix) || entry.view.webContents.isDestroyed()) {
+ continue;
+ }
+ entry.visible = false;
+ applyEntryVisibility(entry, hostWindow);
+ }
+ },
releaseWindow(hostWebContentsId) {
resizingHostIds.delete(hostWebContentsId);
const prefix = `${hostWebContentsId}:`;
diff --git a/apps/desktop/src/desktop-menu-shortcuts.ts b/apps/desktop/src/desktop-menu-shortcuts.ts
index 2041a9e1c9..c9d1216df6 100644
--- a/apps/desktop/src/desktop-menu-shortcuts.ts
+++ b/apps/desktop/src/desktop-menu-shortcuts.ts
@@ -10,6 +10,7 @@ export interface ApplicationMenuAccelerators {
openNewTab: string | undefined;
openNewThread: string | undefined;
openSettings: string | undefined;
+ reopenClosedTab: string | undefined;
}
export const DEFAULT_APPLICATION_MENU_ACCELERATORS: ApplicationMenuAccelerators =
@@ -19,6 +20,7 @@ export const DEFAULT_APPLICATION_MENU_ACCELERATORS: ApplicationMenuAccelerators
openNewTab: "CommandOrControl+T",
openNewThread: "CommandOrControl+N",
openSettings: "CommandOrControl+,",
+ reopenClosedTab: "CommandOrControl+Shift+T",
};
const ELECTRON_KEY_NAMES: Readonly> = {
@@ -92,5 +94,9 @@ export function resolveApplicationMenuAccelerators(
openNewTab: acceleratorForCommand(keybindings, "panel.newTab"),
openNewThread: acceleratorForCommand(keybindings, "thread.new"),
openSettings: acceleratorForCommand(keybindings, "settings.open"),
+ reopenClosedTab: acceleratorForCommand(
+ keybindings,
+ "panel.reopenClosedTab",
+ ),
};
}
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
index b005970cd3..cae2675e5a 100644
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -475,6 +475,10 @@ function registerApplicationRendererReloadShortcut(
return;
}
event.preventDefault();
+ const browserWindow = resolveApplicationWindow(webContents);
+ if (browserWindow !== null) {
+ desktopBrowserViewManager?.prepareWindowReload(browserWindow);
+ }
if (shortcut === "force-reload") {
webContents.reloadIgnoringCache();
} else {
@@ -741,6 +745,16 @@ function installCurrentApplicationMenu(): void {
);
}
},
+ reopenClosedTab() {
+ const browserWindow = getFocusedApplicationWindow();
+ if (browserWindow !== null) {
+ sendToApplicationRenderer(
+ browserWindow,
+ BB_DESKTOP_APP_COMMAND_CHANNEL,
+ "panel.reopenClosedTab",
+ );
+ }
+ },
openSettings() {
const browserWindow = getFocusedApplicationWindow();
if (browserWindow !== null) {
@@ -755,6 +769,7 @@ function installCurrentApplicationMenu(): void {
if (!(browserWindow instanceof BrowserWindow)) {
return;
}
+ desktopBrowserViewManager?.prepareWindowReload(browserWindow);
if (ignoreCache) {
browserWindow.webContents.reloadIgnoringCache();
} else {
diff --git a/apps/desktop/src/menu.ts b/apps/desktop/src/menu.ts
index 995d34a61a..50a0e8588f 100644
--- a/apps/desktop/src/menu.ts
+++ b/apps/desktop/src/menu.ts
@@ -9,6 +9,7 @@ import type { ConnectServerSyncSkipReason } from "./connect-server-sync.js";
const SERVER_DAEMON_LOGS_MENU_LABEL = "Server & Daemon Logs";
const OPEN_NEW_TAB_MENU_LABEL = "New Tab";
+const REOPEN_CLOSED_TAB_MENU_LABEL = "Reopen Closed Tab";
const NEW_THREAD_MENU_LABEL = "New Thread";
const NEW_WINDOW_MENU_LABEL = "New Window";
const CLOSE_WINDOW_MENU_LABEL = "Close Window";
@@ -44,6 +45,7 @@ export interface InstallApplicationMenuArgs {
openNewTab(): void;
openNewThread(): void;
openSettings(): void;
+ reopenClosedTab(): void;
reloadWindow(
browserWindow: BaseWindow | undefined,
ignoreCache: boolean,
@@ -153,6 +155,13 @@ export function buildApplicationMenuTemplate(
},
label: OPEN_NEW_TAB_MENU_LABEL,
},
+ {
+ accelerator: args.accelerators.reopenClosedTab,
+ click() {
+ args.reopenClosedTab();
+ },
+ label: REOPEN_CLOSED_TAB_MENU_LABEL,
+ },
{
accelerator: args.accelerators.openNewThread,
click() {
diff --git a/apps/desktop/test/desktop-browser-main-ipc.test.ts b/apps/desktop/test/desktop-browser-main-ipc.test.ts
index 211164f83b..4e7fe9594b 100644
--- a/apps/desktop/test/desktop-browser-main-ipc.test.ts
+++ b/apps/desktop/test/desktop-browser-main-ipc.test.ts
@@ -125,6 +125,8 @@ class RecordingDesktopBrowserViewManager implements DesktopBrowserViewManager {
this.beginWindowResizeCalls.push(hostWindow);
}
+ prepareWindowReload(): void {}
+
destroyAll(): void {
this.destroyAllCalls.push("destroyAll");
}
diff --git a/apps/desktop/test/desktop-browser-view-manager.test.ts b/apps/desktop/test/desktop-browser-view-manager.test.ts
index 277a0abbc8..30880b290f 100644
--- a/apps/desktop/test/desktop-browser-view-manager.test.ts
+++ b/apps/desktop/test/desktop-browser-view-manager.test.ts
@@ -1597,6 +1597,50 @@ describe("DesktopBrowserViewManager", () => {
expect(focusedView.webContents.focusCalls).toBe(1);
});
+ it("hides only the reloading window's browser views until they reattach", () => {
+ const manager = createDesktopBrowserViewManager({
+ partition: "persist:test",
+ });
+ const reloadingWindow = new FakeHostWindow({
+ contentBounds: { width: 900, height: 600 },
+ webContentsId: 83,
+ });
+ const otherWindow = new FakeHostWindow({
+ contentBounds: { width: 900, height: 600 },
+ webContentsId: 84,
+ });
+ attachBrowserTab({
+ manager,
+ hostWindow: reloadingWindow,
+ tabId: "browser:reloading",
+ url: "https://example.com/reloading",
+ });
+ attachBrowserTab({
+ manager,
+ hostWindow: otherWindow,
+ tabId: "browser:other",
+ url: "https://example.com/other",
+ });
+ const reloadingView = requireFakeView(0);
+ const otherView = requireFakeView(1);
+
+ manager.prepareWindowReload(reloadingWindow);
+
+ expect(reloadingView.visible).toBe(false);
+ expect(otherView.visible).toBe(true);
+ manager.attach({
+ hostWindow: reloadingWindow,
+ request: {
+ tabId: "browser:reloading",
+ url: "https://example.com/reloading",
+ bounds: { x: 100, y: 50, width: 500, height: 350 },
+ visible: true,
+ },
+ });
+ expect(electronMock.fakeViews).toHaveLength(2);
+ expect(reloadingView.visible).toBe(true);
+ });
+
it("allows clipboard-sanitized-write but denies clipboard-read and device permissions", () => {
expect(isAllowedBrowserPermission("clipboard-sanitized-write")).toBe(true);
expect(isAllowedBrowserPermission("clipboard-read")).toBe(false);
diff --git a/apps/desktop/test/desktop-menu-shortcuts.test.ts b/apps/desktop/test/desktop-menu-shortcuts.test.ts
index 16ab95695b..ea796d6a61 100644
--- a/apps/desktop/test/desktop-menu-shortcuts.test.ts
+++ b/apps/desktop/test/desktop-menu-shortcuts.test.ts
@@ -34,6 +34,7 @@ describe("desktop menu shortcuts", () => {
openNewTab: "CommandOrControl+T",
openNewThread: "CommandOrControl+N",
openSettings: "CommandOrControl+,",
+ reopenClosedTab: "CommandOrControl+Shift+T",
});
});
@@ -55,9 +56,11 @@ describe("desktop menu shortcuts", () => {
binding("thread.new", "n", { mod: true }),
binding("thread.new", "u", { mod: true, shift: true }),
binding("settings.open", ",", { mod: true }),
+ binding("panel.reopenClosedTab", "t", { mod: true, shift: true }),
]);
expect(accelerators.openNewThread).toBe("CommandOrControl+Shift+U");
expect(accelerators.openSettings).toBe("CommandOrControl+,");
+ expect(accelerators.reopenClosedTab).toBe("CommandOrControl+Shift+T");
expect(accelerators.openNewTab).toBeUndefined();
});
});
diff --git a/apps/desktop/test/menu.test.ts b/apps/desktop/test/menu.test.ts
index 30c58bff5b..e94b80ef83 100644
--- a/apps/desktop/test/menu.test.ts
+++ b/apps/desktop/test/menu.test.ts
@@ -26,6 +26,7 @@ function menuArgs(
openNewTab: undefined,
openNewThread: undefined,
openSettings: undefined,
+ reopenClosedTab: undefined,
},
closeWindowOrSideTab: () => {},
connectServersSkipReason: null,
@@ -36,6 +37,7 @@ function menuArgs(
openNewThread: () => {},
openServerDaemonLogs: () => {},
openSettings: () => {},
+ reopenClosedTab: () => {},
reloadWindow,
selectServer: () => {},
serverDaemonLogsMenuEnabled: false,
@@ -55,6 +57,30 @@ function findServerSubmenu(
}
describe("application menu", () => {
+ it("reopens the last closed tab from the File menu", () => {
+ const reopenClosedTab = vi.fn();
+ const template = buildApplicationMenuTemplate(
+ menuArgs(() => {}, {
+ accelerators: {
+ closeWindowOrSideTab: undefined,
+ createNewWindow: undefined,
+ openNewTab: undefined,
+ openNewThread: undefined,
+ openSettings: undefined,
+ reopenClosedTab: "CommandOrControl+Shift+T",
+ },
+ reopenClosedTab,
+ }),
+ );
+ const fileMenu = template.find((item) => item.label === "File");
+ const submenu = fileMenu?.submenu as MenuItemConstructorOptions[];
+ const reopen = submenu.find((item) => item.label === "Reopen Closed Tab");
+
+ expect(reopen?.accelerator).toBe("CommandOrControl+Shift+T");
+ reopen?.click?.({} as never, {} as BaseWindow, {} as never);
+ expect(reopenClosedTab).toHaveBeenCalledTimes(1);
+ });
+
it("closes a native panel when Electron omits its window", () => {
vi.mocked(Menu.sendActionToFirstResponder).mockClear();
const closeWindowOrSideTab = vi.fn();
diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-core-slots.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-core-slots.md
index 5402f90f6b..8d9bb4ae48 100644
--- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-core-slots.md
+++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-core-slots.md
@@ -87,7 +87,8 @@ Slot props contracts (versioned, additive-only):
main body; it must not mount a second panel layout or register Browser and
Terminal itself. BB owns the desktop split, compact drawer, header/panel
toggle, resizing, tab strip, persistence, and the shared `panel.toggle`,
- `panel.newTab`, and `terminal.open` keyboard commands.
+ `panel.newTab`, `panel.reopenClosedTab`, and `terminal.open` keyboard
+ commands.
New tab is a transient host launcher. On a plugin page it offers Browser
(when the desktop browser is available) and Terminal; it does not offer
diff --git a/apps/server/src/services/system/app-keybindings.ts b/apps/server/src/services/system/app-keybindings.ts
index 4cdc0707a0..d5be8caa8e 100644
--- a/apps/server/src/services/system/app-keybindings.ts
+++ b/apps/server/src/services/system/app-keybindings.ts
@@ -190,6 +190,15 @@ export const DEFAULT_APP_KEYBINDINGS: AppDefaultKeybindings = [
),
binding("pane.close", "x", { mod: true, shift: true }, splitWithoutModal),
binding("panel.newTab", "t", { mod: true }, mainWithoutModal),
+ binding(
+ "panel.reopenClosedTab",
+ "t",
+ { mod: true, shift: true },
+ {
+ ...mainWithoutModal,
+ desktopOnly: true,
+ },
+ ),
binding("panel.close", "w", { mod: true }, mainWithoutModal),
binding("panel.toggle", "j", { mod: true }, mainWithoutModal),
binding("file.quickOpen", "p", { mod: true }, mainWithoutModal),
@@ -208,15 +217,6 @@ export const DEFAULT_APP_KEYBINDINGS: AppDefaultKeybindings = [
{ mod: true, shift: true },
mainWithoutModal,
),
- binding(
- "terminal.open",
- "t",
- { mod: true, shift: true },
- {
- ...mainWithoutModal,
- desktopOnly: true,
- },
- ),
binding(
"composer.focus",
"c",
diff --git a/apps/server/test/system/app-keybindings.test.ts b/apps/server/test/system/app-keybindings.test.ts
index 7626b45e9d..0f58d13d19 100644
--- a/apps/server/test/system/app-keybindings.test.ts
+++ b/apps/server/test/system/app-keybindings.test.ts
@@ -151,6 +151,24 @@ describe("app keybindings", () => {
desktopOnly: true,
shortcut: { key: "n", mod: true, shift: true },
});
+ expect(
+ config.keybindings.find(
+ (binding) => binding.command === "panel.reopenClosedTab",
+ ),
+ ).toMatchObject({
+ desktopOnly: true,
+ shortcut: { key: "t", mod: true, shift: true },
+ });
+ expect(
+ config.keybindings.filter(
+ (binding) => binding.command === "terminal.open",
+ ),
+ ).toMatchObject([
+ {
+ desktopOnly: false,
+ shortcut: { key: "Enter", mod: true, shift: true },
+ },
+ ]);
expect(
assignedDefaultKeybindings
.filter((binding) => binding.command === "thread.previous")
@@ -276,7 +294,6 @@ describe("app keybindings", () => {
})),
).toEqual([
{ desktopOnly: false, key: "Enter" },
- { desktopOnly: true, key: "t" },
]);
expect(
assignedDefaultKeybindings.find(
@@ -448,7 +465,7 @@ describe("app keybindings", () => {
"thread.next",
...THREAD_JUMP_APP_COMMAND_IDS,
...PANE_FOCUS_APP_COMMAND_IDS,
- "terminal.open",
+ "panel.reopenClosedTab",
"browser.focusLocation",
"browser.reload",
"browser.find",
diff --git a/packages/domain/src/app-keybindings.ts b/packages/domain/src/app-keybindings.ts
index b44513db0e..caea53c63e 100644
--- a/packages/domain/src/app-keybindings.ts
+++ b/packages/domain/src/app-keybindings.ts
@@ -54,6 +54,7 @@ export const APP_COMMAND_IDS = [
"settings.openServers",
"sidebar.toggle",
"panel.newTab",
+ "panel.reopenClosedTab",
"panel.close",
"panel.toggle",
"file.quickOpen",
diff --git a/packages/plugin-api-map/sdk-public-api.json b/packages/plugin-api-map/sdk-public-api.json
index 7244f556c9..6991f03c82 100644
--- a/packages/plugin-api-map/sdk-public-api.json
+++ b/packages/plugin-api-map/sdk-public-api.json
@@ -3,7 +3,7 @@
"entries": {
".": {
"types": "bundled-types/bb-plugin-sdk.d.ts",
- "sha256": "76c415f99e2336c4a2a17b4a7760e60004a38e279dcfa28025ae0c0fda763545"
+ "sha256": "fa4442250761eb37dc2cff041754f44277fbfae68afa35eb6743a8b252675720"
},
"./ai-services": {
"types": "bundled-types/bb-plugin-sdk-ai-services.d.ts",
From 9c170fdbbd129921ac92c8f48a0bddbf03fba0c3 Mon Sep 17 00:00:00 2001
From: brsbl
Date: Sat, 29 Aug 2026 12:25:46 -0700
Subject: [PATCH 12/19] Bump plugin SDK after reopen tab command (#2701)
## Human comments
## What was wrong
PR #2686 added `panel.reopenClosedTab` to the public Plugin SDK
declaration while the package remained at `0.4.28`. That version is
already published with different bundled types, so the post-merge npm
version guard correctly failed on `main`.
## What changed
Bumped `@get-bb/plugin-sdk` and the shared domain version constant
together from `0.4.28` to `0.4.29`. No host-daemon wire contract
changed.
## How you verified
- Used the repository-provided `node scripts/bump-plugin-sdk.mjs
--patch` command.
- Per repository policy, CI-equivalent checks were not run locally;
pull-request CI is the verification gate.
Follow-up to #2686
BB-Thread-ID: thr_ufp89hj9ea
> AGENT GENERATED
---
packages/domain/src/plugin-sdk-version.ts | 2 +-
packages/plugin-sdk/package.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts
index b7d8c0af47..677e5edb7f 100644
--- a/packages/domain/src/plugin-sdk-version.ts
+++ b/packages/domain/src/plugin-sdk-version.ts
@@ -1,3 +1,3 @@
-export const PLUGIN_SDK_VERSION = "0.4.28";
+export const PLUGIN_SDK_VERSION = "0.4.29";
export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]);
diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json
index 99a6aa612e..4b7ceb16e2 100644
--- a/packages/plugin-sdk/package.json
+++ b/packages/plugin-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@get-bb/plugin-sdk",
- "version": "0.4.28",
+ "version": "0.4.29",
"homepage": "https://github.com/get-bb/bb#readme",
"bugs": {
"url": "https://github.com/get-bb/bb/issues"
From 720b3163d70fa28fa2ec4114a8e97797b533db7f Mon Sep 17 00:00:00 2001
From: brsbl
Date: Sat, 29 Aug 2026 16:18:37 -0700
Subject: [PATCH 13/19] Improve Updates download controls (#2707)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Human comments
## What was wrong
The Updates header explicitly positioned the Update all download glyph
after its label, while the sidebar update badge only reflected
provider-version status and never observed an active CLI install. The
Updates story fixture also copied provider metadata without seeding the
real logo assets, so installed-provider marks could render without their
provider icon in Ladle.
## What changed
- Keep the existing Update all control intact while using its standard
icon-first order.
- Subscribe the sidebar update badge to the existing install runner and
replace its download glyph with the existing loading glyph only while an
installed provider is actively downloading; restore the download glyph
after success or failure.
- Seed provider-logo assets in the existing Updates story fixture and
add focused available/downloading sidebar stories.
- Preserve the existing actions, styles, spacing, tooltips, and
accessible names.
## How you verified
- Chrome for Testing, exact branch web app: compared merge base
`9c170fdbb` with head `56bafa392` using the same provider-status
fixture, route, and 1440×900 viewport.
- Confirmed the Update all glyph is first at 1440×900 and 767×900 and
retains `aria-label="Update all 2 CLI tools"`.
- Exercised Update all and the individual Codex update action.
- During a controlled install failure, confirmed the sidebar shows the
animated loading glyph with both Codex and Claude provider marks, then
restores the download glyph after the install settles.
- Remote CI: 13 required checks passed; 2 not-applicable checks skipped.
## Screenshots
### Update all icon order
Before — merge base, 1440×900:

After — PR head, 1440×900:

After — PR head, 767×900:

### Sidebar active install state
Before — merge base during the controlled install, 1440×900:

After — PR head during the same controlled install, 1440×900:

After — PR head after the controlled failure settles, 1440×900:

BB-Thread-ID: thr_m57exaav4m
> AGENT GENERATED
---
apps/app/.ladle/settings-story-fixtures.tsx | 27 +++++-
.../UpdatesSettingsSection.stories.tsx | 1 -
.../settings/UpdatesSettingsSection.test.tsx | 6 +-
.../settings/UpdatesSettingsSection.tsx | 1 -
.../sidebar/SidebarUpdatesBadge.stories.tsx | 87 +++++++++++++++++++
.../sidebar/SidebarUpdatesBadge.test.tsx | 40 ++++++++-
.../sidebar/SidebarUpdatesBadge.tsx | 18 +++-
7 files changed, 170 insertions(+), 10 deletions(-)
create mode 100644 apps/app/src/components/sidebar/SidebarUpdatesBadge.stories.tsx
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/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/SidebarUpdatesBadge.stories.tsx b/apps/app/src/components/sidebar/SidebarUpdatesBadge.stories.tsx
new file mode 100644
index 0000000000..321e2ed8a1
--- /dev/null
+++ b/apps/app/src/components/sidebar/SidebarUpdatesBadge.stories.tsx
@@ -0,0 +1,87 @@
+import { useEffect, type ReactNode } from "react";
+import type { ProviderCliInstallAction } from "@bb/host-daemon-contract";
+import { SettingsStoryFixtures } from "../../../.ladle/settings-story-fixtures";
+import {
+ HOST_IDS,
+ makeProviderCliStatus,
+} from "../../../.ladle/story-fixtures";
+import type { ProviderCliActionableIssue } from "@/components/provider-cli/provider-cli-install";
+import { startProviderCliInstall } from "@/components/provider-cli/provider-cli-install-store";
+import { SidebarMenu } from "@/components/ui/sidebar";
+import { sdk } from "@/lib/sdk";
+import { SidebarUpdatesBadge } from "./SidebarUpdatesBadge";
+
+export default { title: "sidebar/Updates badge" };
+
+const action: ProviderCliInstallAction = {
+ kind: "update",
+ label: "Update",
+ command: "codex update",
+};
+const status = makeProviderCliStatus("codex", {
+ currentVersion: "0.150.0",
+ latestVersion: "0.151.0",
+ needsUpdate: true,
+ installAction: action,
+});
+const issue: ProviderCliActionableIssue = {
+ provider: "codex",
+ status,
+ action,
+ title: "Codex update available",
+ description: "0.150.0 -> 0.151.0",
+ fingerprint: "codex:story",
+};
+
+function BadgeStage({ children }: { children?: ReactNode }) {
+ return (
+
+
+ {children}
+
+
+
+
+
+ );
+}
+
+function ActiveProviderUpdate() {
+ useEffect(() => {
+ const originalInstallProviderCli = sdk.hosts.installProviderCli;
+ let finishInstall: (() => void) | null = null;
+ const startTimer = window.setTimeout(() => {
+ sdk.hosts.installProviderCli = () =>
+ new Promise((resolve) => {
+ finishInstall = () => {
+ resolve([
+ {
+ type: "completed",
+ provider: "codex",
+ exitCode: 0,
+ signal: null,
+ success: true,
+ },
+ ]);
+ };
+ });
+ startProviderCliInstall({ hostId: HOST_IDS.local, issue });
+ }, 0);
+
+ return () => {
+ window.clearTimeout(startTimer);
+ sdk.hosts.installProviderCli = originalInstallProviderCli;
+ finishInstall?.();
+ };
+ }, []);
+
+ return ;
+}
+
+export function ProviderUpdateAvailable() {
+ return ;
+}
+
+export function ProviderUpdateDownloading() {
+ return ;
+}
diff --git a/apps/app/src/components/sidebar/SidebarUpdatesBadge.test.tsx b/apps/app/src/components/sidebar/SidebarUpdatesBadge.test.tsx
index 83fa794f7f..9482ad8016 100644
--- a/apps/app/src/components/sidebar/SidebarUpdatesBadge.test.tsx
+++ b/apps/app/src/components/sidebar/SidebarUpdatesBadge.test.tsx
@@ -15,11 +15,20 @@ import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import { SidebarUpdatesBadge } from "./SidebarUpdatesBadge";
const useUpdateInventoryMock = vi.hoisted(() => vi.fn());
+const providerCliInstallRunnerState = vi.hoisted(() => ({
+ runningJobKey: null as string | null,
+}));
vi.mock("@/hooks/useUpdateInventory", () => ({
useUpdateInventory: useUpdateInventoryMock,
}));
+vi.mock("@/components/provider-cli/provider-cli-install", () => ({
+ useProviderCliInstallRunner: () => ({
+ runningJobKey: providerCliInstallRunnerState.runningJobKey,
+ }),
+}));
+
vi.mock("@/lib/sdk", async () => {
const { makeProviderInfo: provider } =
await import("@/test/provider-info-fixture");
@@ -41,6 +50,7 @@ vi.mock("@/lib/ws", () => ({
afterEach(() => {
cleanup();
+ providerCliInstallRunnerState.runningJobKey = null;
useUpdateInventoryMock.mockReset();
});
@@ -143,13 +153,16 @@ function renderBadge(inventory: Partial) {
...inventory,
});
const { wrapper } = createQueryClientTestHarness();
- return render(
+ return render(, { wrapper });
+}
+
+function BadgeHarness() {
+ return (
- ,
- { wrapper },
+
);
}
@@ -188,6 +201,27 @@ describe("SidebarUpdatesBadge", () => {
).toBe("Claude Code update available");
});
+ it("shows loading while a provider update runs and restores the download icon when it settles", () => {
+ providerCliInstallRunnerState.runningJobKey = "host-1:claude-code";
+ const result = renderBadge({
+ machines: [
+ machine({ issues: [providerIssue("claude-code", "Claude Code")] }),
+ ],
+ });
+
+ const providerChip = screen.getByTestId("sidebar-updates-badge-providers");
+ const loadingIcon = providerChip.querySelector('[data-icon="Loading"]');
+ expect(loadingIcon).toBeTruthy();
+ expect(loadingIcon?.classList.contains("animate-spin")).toBe(true);
+ expect(providerChip.querySelector('[data-icon="Download"]')).toBeNull();
+
+ providerCliInstallRunnerState.runningJobKey = null;
+ result.rerender();
+
+ expect(providerChip.querySelector('[data-icon="Loading"]')).toBeNull();
+ expect(providerChip.querySelector('[data-icon="Download"]')).toBeTruthy();
+ });
+
it("renders no provider chip when a CLI is not installed", () => {
renderBadge({
machines: [
diff --git a/apps/app/src/components/sidebar/SidebarUpdatesBadge.tsx b/apps/app/src/components/sidebar/SidebarUpdatesBadge.tsx
index 1b71e903fd..3c05850a14 100644
--- a/apps/app/src/components/sidebar/SidebarUpdatesBadge.tsx
+++ b/apps/app/src/components/sidebar/SidebarUpdatesBadge.tsx
@@ -3,6 +3,8 @@ import type { ProviderCliKey } from "@bb/host-daemon-contract";
import { Icon } from "@bb/shared-ui/icon";
import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip";
import { cn } from "@bb/shared-ui/lib/utils";
+import { useProviderCliInstallRunner } from "@/components/provider-cli/provider-cli-install";
+import { providerCliJobKey } from "@/components/provider-cli/provider-cli-install-store";
import { SidebarMenuItem } from "@/components/ui/sidebar.js";
import { useSystemProviders } from "@/hooks/queries/system-queries";
import { useUpdateInventory } from "@/hooks/useUpdateInventory";
@@ -34,6 +36,7 @@ interface StaleProvider {
export function SidebarUpdatesBadge({ onNavigate }: SidebarUpdatesBadgeProps) {
const inventory = useUpdateInventory();
const providers = useSystemProviders().data;
+ const { runningJobKey } = useProviderCliInstallRunner();
const stuckDaemonCount = inventory.machines.filter(
(machine) => machine.canRetryDaemonUpdate,
@@ -58,6 +61,13 @@ export function SidebarUpdatesBadge({ onNavigate }: SidebarUpdatesBadgeProps) {
}
}
const staleProviders = [...staleProvidersByKey.values()];
+ const providerUpdateRunning = inventory.machines.some((machine) =>
+ machine.issues.some(
+ (issue) =>
+ issue.status.installed &&
+ runningJobKey === providerCliJobKey(machine.host.id, issue.provider),
+ ),
+ );
if (bbUpdateCount === 0 && staleProviders.length === 0) {
return null;
@@ -99,7 +109,13 @@ export function SidebarUpdatesBadge({ onNavigate }: SidebarUpdatesBadgeProps) {
data-testid="sidebar-updates-badge-providers"
className={CHIP_CLASS}
>
-
+
{staleProviders.map((stale) => {
const providerId = stale.provider;
From f4bbc2fe81a9b7639ff9a7396e172bddd89109e4 Mon Sep 17 00:00:00 2001
From: brsbl
Date: Sat, 29 Aug 2026 19:16:56 -0700
Subject: [PATCH 14/19] Add copy thread link action (#2708)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Human comments
None.
## What was wrong
Thread action menus did not provide a reliable way to copy the current
thread URL. The first implementation wired the URL only from the thread
header, which allowed the header and sidebar `…` menus to drift.
## What changed
- Adds **Copy thread link** to the shared thread-action item set used by
the header, sidebar dropdown, desktop context menu, and compact
long-press menu.
- Derives the link from the active app origin and canonical
project/thread route inside the shared menu, so every instance follows
the current environment while excluding transient panel, split, and
query state.
- Adds focused coverage that the shared menu copies its canonical thread
URL with success and failure feedback.
## Screenshots
The comparison uses the same seeded thread, route, open-menu state, and
1440×1000 viewport, cropped identically to the header action area. The
after evidence is from branch commit `8f0eef326`, which contains the
final production code; final head `1b911180c` changes only the test
interaction event.
| Before — merge base `720b3163d` | After — final production code
`8f0eef326` |
| --- | --- |
| 
| 
|
Sidebar parity on the same production-code revision:

## How you verified
- Chrome for Testing 151.0.7922.71 against the branch web app at
1440×1000.
- Hard reloaded the seeded thread, opened both header and sidebar `…`
menus, copied from each, verified the exact clipboard URL, and observed
success feedback.
- Confirmed no console errors during the changed flow.
- CI: all required build, typecheck, lint, app, server, integration,
package, and packaging smoke checks pass on final head `1b911180c`.
- `git diff --check origin/main...HEAD`
- Local CI-equivalent checks were not run per repository policy; PR CI
is the validation path.
BB-Thread-ID: thr_dwsizgdy3q
> AGENT GENERATED
---
.../thread/ThreadActionsMenu.test.tsx | 59 +++++++++++++++++++
.../components/thread/ThreadActionsMenu.tsx | 19 +++++-
2 files changed, 77 insertions(+), 1 deletion(-)
create mode 100644 apps/app/src/components/thread/ThreadActionsMenu.test.tsx
diff --git a/apps/app/src/components/thread/ThreadActionsMenu.test.tsx b/apps/app/src/components/thread/ThreadActionsMenu.test.tsx
new file mode 100644
index 0000000000..58efbc7078
--- /dev/null
+++ b/apps/app/src/components/thread/ThreadActionsMenu.test.tsx
@@ -0,0 +1,59 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import type { Thread } from "@bb/domain";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ThreadActionsMenu } from "./ThreadActionsMenu";
+
+const mocks = vi.hoisted(() => ({
+ copyToClipboardWithToast: vi.fn(),
+}));
+
+vi.mock("@/lib/clipboard", () => ({
+ copyToClipboardWithToast: mocks.copyToClipboardWithToast,
+}));
+
+vi.mock("./ThreadActionsProvider", () => ({
+ useThreadActions: () => ({
+ archiveThreadAndChildren: vi.fn(),
+ requestRename: vi.fn(),
+ requestDelete: vi.fn(),
+ togglePin: vi.fn(),
+ toggleRead: vi.fn(),
+ unarchiveThread: vi.fn(),
+ }),
+}));
+
+function createThread(): Thread {
+ return {
+ id: "thr_test",
+ projectId: "proj_test",
+ archivedAt: null,
+ pinnedAt: null,
+ } as Thread;
+}
+
+afterEach(() => {
+ cleanup();
+ mocks.copyToClipboardWithToast.mockReset();
+});
+
+describe("ThreadActionsMenu", () => {
+ it("copies the canonical thread URL from every menu instance", () => {
+ render();
+
+ fireEvent.pointerDown(
+ screen.getByRole("button", { name: "Thread actions" }),
+ { button: 0 },
+ );
+ fireEvent.click(screen.getByRole("menuitem", { name: "Copy thread link" }));
+
+ expect(mocks.copyToClipboardWithToast).toHaveBeenCalledWith(
+ `${window.location.origin}/projects/proj_test/threads/thr_test`,
+ {
+ successMessage: "Thread link copied",
+ errorMessage: "Failed to copy thread link",
+ },
+ );
+ });
+});
diff --git a/apps/app/src/components/thread/ThreadActionsMenu.tsx b/apps/app/src/components/thread/ThreadActionsMenu.tsx
index ef2baae580..0f287f42e3 100644
--- a/apps/app/src/components/thread/ThreadActionsMenu.tsx
+++ b/apps/app/src/components/thread/ThreadActionsMenu.tsx
@@ -22,6 +22,8 @@ import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport";
import { cn } from "@bb/shared-ui/lib/utils";
import { CompactLongPressMenu } from "@/components/ui/compact-long-press-menu";
import { isThreadRead } from "@bb/client-core";
+import { copyToClipboardWithToast } from "@/lib/clipboard";
+import { getThreadRoutePath } from "@/lib/route-paths";
import { useThreadActions } from "./ThreadActionsProvider";
interface ThreadActionsMenuBaseProps {
@@ -135,6 +137,10 @@ function ThreadActionsMenuItems({
const isRead = isThreadRead(thread);
const isArchived = thread.archivedAt != null;
const isPinned = thread.pinnedAt !== null;
+ const threadUrl = new URL(
+ getThreadRoutePath({ projectId: thread.projectId, threadId: thread.id }),
+ window.location.origin,
+ ).toString();
return (
<>
@@ -173,7 +179,18 @@ function ThreadActionsMenuItems({
) : null}
>
) : null}
- {}
+ {
+ void copyToClipboardWithToast(threadUrl, {
+ successMessage: "Thread link copied",
+ errorMessage: "Failed to copy thread link",
+ });
+ }}
+ >
+ Copy thread link
+
Date: Sat, 29 Aug 2026 14:40:50 +0000
Subject: [PATCH 15/19] Fix Markdown file preview routing
---
.../ThreadSecondaryPanelTabContent.tsx | 3 +
.../markdown-file-preview-routing.test.ts | 168 ++++++++++++++
.../markdown-file-preview-routing.ts | 195 ++++++++++++++++
.../ui/markdown-local-file-link.test.ts | 38 +++-
.../components/ui/markdown-local-file-link.ts | 23 +-
.../components/ui/markdown-preview.test.tsx | 18 +-
apps/app/src/lib/absolute-file-path.test.ts | 27 +++
apps/app/src/lib/absolute-file-path.ts | 28 +++
.../views/RootComposePanelTabContent.test.tsx | 213 ++++++++++++++++--
.../src/views/RootComposePanelTabContent.tsx | 71 +++++-
apps/app/src/views/RootComposeView.tsx | 63 ++++++
.../views/thread-detail/ThreadDetailView.tsx | 129 +++--------
...adDetailMarkdownFilePreviewRouting.test.ts | 98 ++++++++
.../threadDetailMarkdownFilePreviewRouting.ts | 77 +++++++
14 files changed, 1021 insertions(+), 130 deletions(-)
create mode 100644 apps/app/src/components/secondary-panel/markdown-file-preview-routing.test.ts
create mode 100644 apps/app/src/components/secondary-panel/markdown-file-preview-routing.ts
create mode 100644 apps/app/src/views/thread-detail/threadDetailMarkdownFilePreviewRouting.test.ts
create mode 100644 apps/app/src/views/thread-detail/threadDetailMarkdownFilePreviewRouting.ts
diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx
index 090b9c85fe..005183066f 100644
--- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx
@@ -72,6 +72,7 @@ interface ProjectFilePreviewTabContentProps {
environmentId: string | null;
hostId: string | null;
lineRange: FilePreviewLineRange | null;
+ markdownLinkRouting?: MarkdownLinkRouting;
onSelectionAddToChat?: (text: string) => void;
onOpenInEditor?: (path: string) => void;
projectId: string;
@@ -349,6 +350,7 @@ export function ProjectFilePreviewTabContent({
hostId,
isPanelOpen,
lineRange,
+ markdownLinkRouting,
onSelectionAddToChat,
onOpenInEditor,
projectId,
@@ -375,6 +377,7 @@ export function ProjectFilePreviewTabContent({
isLoading={isProjectFilePreviewLoading}
isRefreshing={isProjectFilePreviewFetching}
lineRange={lineRange}
+ markdownLinkRouting={markdownLinkRouting}
onSelectionAddToChat={onSelectionAddToChat}
onOpenInEditor={onOpenInEditor}
onRefresh={() => void refetchProjectFilePreview()}
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..c0e1e74782
--- /dev/null
+++ b/apps/app/src/components/secondary-panel/markdown-file-preview-routing.test.ts
@@ -0,0 +1,168 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ buildProjectFileContentUrl,
+ buildThreadHostFileContentUrl,
+ buildThreadStorageContentUrl,
+ 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(
+ buildProjectFileContentUrl("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(
+ buildProjectFileContentUrl("project-1", "docs/asset space-图%.png", {
+ hostId: "host-1",
+ }),
+ );
+ expect(
+ resolveResource(
+ { kind: "host", threadId: "thread-1" },
+ {
+ lineRange: { endLineNumber: 14, startLineNumber: 12 },
+ },
+ ),
+ ).toBe(
+ `${buildThreadHostFileContentUrl("thread-1", resourcePath)}#L12-L14`,
+ );
+ expect(
+ resolveResource({ kind: "thread-storage", threadId: "thread-1" }),
+ ).toBe(
+ buildThreadStorageContentUrl("thread-1", "docs/asset space-图%.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 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..d78b739000
--- /dev/null
+++ b/apps/app/src/components/secondary-panel/markdown-file-preview-routing.ts
@@ -0,0 +1,195 @@
+import type { EnvironmentFilePreviewSource } from "@bb/client-core";
+import {
+ buildProjectFileContentUrl,
+ buildThreadHostFileContentUrl,
+ buildThreadStorageContentUrl,
+ 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: "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 `${buildThreadHostFileContentUrl(
+ 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 = buildProjectFileContentUrl(
+ contentSource.projectId,
+ relativePath,
+ { environmentId: contentSource.environmentId },
+ );
+ break;
+ }
+ case "project":
+ contentUrl = buildProjectFileContentUrl(
+ contentSource.projectId,
+ relativePath,
+ {
+ ...(contentSource.environmentId !== null
+ ? { environmentId: contentSource.environmentId }
+ : contentSource.hostId !== null
+ ? { hostId: contentSource.hostId }
+ : {}),
+ },
+ );
+ break;
+ case "thread-storage":
+ contentUrl = buildThreadStorageContentUrl(
+ 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/ui/markdown-local-file-link.test.ts b/apps/app/src/components/ui/markdown-local-file-link.test.ts
index 10a1ab4371..f9f6bcde91 100644
--- a/apps/app/src/components/ui/markdown-local-file-link.test.ts
+++ b/apps/app/src/components/ui/markdown-local-file-link.test.ts
@@ -156,16 +156,27 @@ describe("parseLocalFileHref", () => {
}
});
- it("parses local file links with non-line fragments as the file path", () => {
+ it("preserves non-line fragments separately from the file path", () => {
expect(
parseLocalFileHref({
absoluteLinks: TRUSTED_HOST_ABSOLUTE_LINKS,
href: "/workspace/app.ts#section",
}),
).toEqual({
+ fragment: "#section",
path: "/workspace/app.ts",
lineRange: null,
});
+ expect(
+ parseLocalFileHref({
+ absoluteLinks: TRUSTED_HOST_ABSOLUTE_LINKS,
+ href: "/work%20space/%E8%B5%84%E6%96%99%25.md#section%20one",
+ }),
+ ).toEqual({
+ fragment: "#section%20one",
+ lineRange: null,
+ path: "/work space/资料%.md",
+ });
for (const href of [
"/workspace/with#hash/app.ts:12",
"/workspace/app.ts#bad/fragment",
@@ -209,10 +220,24 @@ describe("buildLocalFileAnchorHref", () => {
).toBe("file:///workspace/README.md");
expect(
buildLocalFileAnchorHref(
- { path: "/workspace/README.md", lineRange: null },
+ {
+ fragment: "#intro",
+ path: "/workspace/README.md",
+ lineRange: null,
+ },
"/workspace/README.md#intro",
),
- ).toBe("file:///workspace/README.md");
+ ).toBe("file:///workspace/README.md#intro");
+ expect(
+ buildLocalFileAnchorHref(
+ {
+ fragment: "#section%20one",
+ path: "/work space/资料%.md",
+ lineRange: null,
+ },
+ "/work%20space/%E8%B5%84%E6%96%99%25.md#section%20one",
+ ),
+ ).toBe("file:///work%20space/%E8%B5%84%E6%96%99%25.md#section%20one");
expect(
buildLocalFileAnchorHref(
{
@@ -252,6 +277,13 @@ describe("resolveRelativeLocalFileHref", () => {
rootPath: "/storage/thr_1",
}),
).toBe("/storage/thr_1/current/summary.md#L7");
+ expect(
+ resolveRelativeLocalFileHref({
+ baseDir: "/work space/资料%/docs",
+ href: "../child%20file.md#section%20one",
+ rootPath: "/work space/资料%",
+ }),
+ ).toBe("/work space/资料%/child file.md#section%20one");
});
it("parses file line suffixes before checking URI schemes", () => {
diff --git a/apps/app/src/components/ui/markdown-local-file-link.ts b/apps/app/src/components/ui/markdown-local-file-link.ts
index 7a2ec7d613..6eb425325e 100644
--- a/apps/app/src/components/ui/markdown-local-file-link.ts
+++ b/apps/app/src/components/ui/markdown-local-file-link.ts
@@ -8,6 +8,7 @@ import {
} from "@bb/client-core";
export interface MarkdownPreviewLocalFileLink {
+ fragment?: string;
lineRange: FilePreviewLineRange | null;
path: string;
}
@@ -35,6 +36,7 @@ export interface MarkdownRelativeLocalFileLinkRouting {
}
interface LocalFileHrefParts {
+ fragment?: string;
lineRange: FilePreviewLineRange | null;
path: string;
}
@@ -131,6 +133,7 @@ function parseLineSuffix(value: string): LocalFileHrefParts | null {
}
return {
+ fragment: `#${fragment}`,
lineRange: null,
path: value.slice(0, hashIndex),
};
@@ -249,7 +252,13 @@ function parseAbsoluteLocalFileHref(
return null;
}
- return parsed;
+ if (parsed.fragment === undefined) {
+ return parsed;
+ }
+ const fragmentIndex = href.indexOf("#");
+ return fragmentIndex === -1
+ ? parsed
+ : { ...parsed, fragment: href.slice(fragmentIndex) };
}
const URI_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/u;
@@ -313,7 +322,11 @@ export function resolveRelativeLocalFileHref({
return null;
}
- return `${normalizedHrefPath}${decodedHref.slice(parsedHref.path.length)}`;
+ const suffix =
+ parsedHref.fragment === undefined
+ ? decodedHref.slice(parsedHref.path.length)
+ : href.slice(href.indexOf("#"));
+ return `${normalizedHrefPath}${suffix}`;
}
function isLinkContainedInRoot({
@@ -405,7 +418,7 @@ export function buildLocalFileAnchorHref(
return originalHref;
}
- return `file://${encodeFileUrlPath(link.path)}${buildLineRangeAnchorFragment(
- link.lineRange,
- )}`;
+ const targetFragment =
+ link.fragment ?? buildLineRangeAnchorFragment(link.lineRange);
+ return `file://${encodeFileUrlPath(link.path)}${targetFragment}`;
}
diff --git a/apps/app/src/components/ui/markdown-preview.test.tsx b/apps/app/src/components/ui/markdown-preview.test.tsx
index 15bfd7a4e5..e157bcb7c7 100644
--- a/apps/app/src/components/ui/markdown-preview.test.tsx
+++ b/apps/app/src/components/ui/markdown-preview.test.tsx
@@ -373,13 +373,16 @@ describe("MarkdownPreview", () => {
content={[
"",
"",
+ "",
"",
+ "",
+ "",
].join("\n\n")}
linkRouting={{
localImage: {
absolutePaths: { kind: "trusted-host" },
relativePaths: {
- baseDir: "/workspace",
+ baseDir: "/workspace/docs",
rootPath: "/workspace",
},
resolveSrc,
@@ -393,11 +396,20 @@ describe("MarkdownPreview", () => {
).toBe("/api/files/content?path=%2Fworkspace%2Fgenerated.png");
expect(
container.querySelector('img[alt="relative"]')?.getAttribute("src"),
- ).toBe("/api/files/content?path=%2Fworkspace%2Fart%2Fchart.png");
+ ).toBe("/api/files/content?path=%2Fworkspace%2Fdocs%2Fart%2Fchart.png");
+ expect(
+ container.querySelector('img[alt="parent"]')?.getAttribute("src"),
+ ).toBe("/api/files/content?path=%2Fworkspace%2Fshared.png");
expect(
container.querySelector('img[alt="remote"]')?.getAttribute("src"),
).toBe("https://example.com/image.png");
- expect(resolveSrc).toHaveBeenCalledTimes(2);
+ expect(
+ container.querySelector('img[alt="data"]')?.hasAttribute("src") ?? false,
+ ).toBe(false);
+ expect(
+ container.querySelector('img[alt="escape"]')?.getAttribute("src"),
+ ).toBe("../../secret.png");
+ expect(resolveSrc).toHaveBeenCalledTimes(3);
});
it("keeps absolute app-origin URLs on the app-route path", () => {
diff --git a/apps/app/src/lib/absolute-file-path.test.ts b/apps/app/src/lib/absolute-file-path.test.ts
index 45f451f521..b4a45cf75a 100644
--- a/apps/app/src/lib/absolute-file-path.test.ts
+++ b/apps/app/src/lib/absolute-file-path.test.ts
@@ -3,6 +3,7 @@ import {
getAbsoluteDirname,
isAbsoluteFilePathWithinRoot,
normalizeAbsoluteFilePath,
+ resolveRootRelativeFilePath,
} from "./absolute-file-path";
describe("getAbsoluteDirname", () => {
@@ -57,3 +58,29 @@ describe("isAbsoluteFilePathWithinRoot", () => {
).toBe(false);
});
});
+
+describe("resolveRootRelativeFilePath", () => {
+ it("preserves spaces, Unicode, and percent characters inside the root", () => {
+ expect(
+ resolveRootRelativeFilePath({
+ path: "/work space/资料%/docs/../asset %.png",
+ rootPath: "/work space/资料%/",
+ }),
+ ).toBe("asset %.png");
+ });
+
+ it("rejects the root itself and paths outside the root", () => {
+ expect(
+ resolveRootRelativeFilePath({
+ path: "/workspace/project",
+ rootPath: "/workspace/project",
+ }),
+ ).toBeNull();
+ expect(
+ resolveRootRelativeFilePath({
+ path: "/workspace/project/../../secret.txt",
+ rootPath: "/workspace/project",
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/apps/app/src/lib/absolute-file-path.ts b/apps/app/src/lib/absolute-file-path.ts
index 364ca2d544..6ff227e13d 100644
--- a/apps/app/src/lib/absolute-file-path.ts
+++ b/apps/app/src/lib/absolute-file-path.ts
@@ -3,6 +3,11 @@ interface ResolveAbsoluteFilePathArgs {
rootPath: string | null | undefined;
}
+interface ResolveRootRelativeFilePathArgs {
+ path: string;
+ rootPath: string;
+}
+
interface BuildAbsoluteFilePathArgs {
path: string;
rootPath: string;
@@ -113,6 +118,29 @@ export function resolveAbsoluteFilePath({
return buildAbsoluteFilePath({ path, rootPath });
}
+export function resolveRootRelativeFilePath({
+ path,
+ rootPath,
+}: ResolveRootRelativeFilePathArgs): string | null {
+ const normalizedPath = normalizeAbsoluteFilePath({ path });
+ const normalizedRootPath = normalizeAbsoluteFilePath({ path: rootPath });
+ if (
+ normalizedPath === null ||
+ normalizedRootPath === null ||
+ normalizedPath === normalizedRootPath ||
+ !isAbsoluteFilePathWithinRoot({
+ candidatePath: normalizedPath,
+ rootPath: normalizedRootPath,
+ })
+ ) {
+ return null;
+ }
+
+ return normalizedRootPath === "/"
+ ? normalizedPath.slice(1)
+ : normalizedPath.slice(normalizedRootPath.length + 1);
+}
+
export function getAbsoluteDirname({ path }: GetAbsoluteDirnameArgs): string {
const trimmed = trimTrailingSlash(path);
const lastSlashIndex = trimmed.lastIndexOf("/");
diff --git a/apps/app/src/views/RootComposePanelTabContent.test.tsx b/apps/app/src/views/RootComposePanelTabContent.test.tsx
index f44f4ee18a..27f6996800 100644
--- a/apps/app/src/views/RootComposePanelTabContent.test.tsx
+++ b/apps/app/src/views/RootComposePanelTabContent.test.tsx
@@ -4,49 +4,126 @@ import type { ComponentProps, ReactNode } from "react";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
+ createHostFilePreviewFixedPanelTab,
createTerminalFixedPanelTab,
+ createThreadStorageFilePreviewFixedPanelTab,
createWorkspaceFilePreviewFixedPanelTab,
} from "@/lib/fixed-panel-tabs-state";
+import type { MarkdownLinkRouting } from "@/components/ui/markdown-link-routing";
import { buildFileOpenerPanelTab } from "@/components/plugin/file-opener-tabs";
import { RootComposePanelTabContent } from "./RootComposePanelTabContent";
+function resolveSiblingResource(
+ markdownLinkRouting: MarkdownLinkRouting | undefined,
+): string | null {
+ const localImage = markdownLinkRouting?.localImage;
+ const baseDir = localImage?.relativePaths?.baseDir;
+ if (localImage === undefined || baseDir === undefined) {
+ return null;
+ }
+ return localImage.resolveSrc({
+ lineRange: null,
+ path: `${baseDir}/asset space-资料%.png`,
+ });
+}
+
+function PreviewRoutingProbe({
+ activePath,
+ markdownLinkRouting,
+ prefix,
+}: {
+ activePath: string;
+ markdownLinkRouting?: MarkdownLinkRouting;
+ prefix: string;
+}) {
+ return (
+
+ );
+}
+
vi.mock("@/components/secondary-panel/lazySecondaryPanelComponents", () => ({
LazyFilePreview: () => null,
- LazyHostFilePreviewTabContent: () => null,
+ LazyHostFilePreviewTabContent: ({
+ activePath,
+ markdownLinkRouting,
+ }: {
+ activePath: string;
+ markdownLinkRouting?: MarkdownLinkRouting;
+ }) => (
+
+ ),
LazyNewTabPage: () => null,
LazyProjectFilePreviewTabContent: ({
activePath,
environmentId,
hostId,
+ markdownLinkRouting,
projectId,
}: {
activePath: string;
environmentId: string | null;
hostId: string | null;
+ markdownLinkRouting?: MarkdownLinkRouting;
projectId: string;
}) => (
-
+ ),
+ LazyThreadStorageFilePreviewTabContent: ({
+ activePath,
+ markdownLinkRouting,
+ }: {
+ activePath: string;
+ markdownLinkRouting?: MarkdownLinkRouting;
+ }) => (
+
),
- LazyThreadStorageFilePreviewTabContent: () => null,
LazyThreadTerminalPanel: ({ terminalId }: { terminalId?: string }) => (
),
LazyWorkspaceFilePreviewTabContent: ({
activePath,
environmentId,
+ markdownLinkRouting,
}: {
activePath: string;
environmentId: string;
+ markdownLinkRouting?: MarkdownLinkRouting;
}) => (
-
+
),
}));
@@ -71,7 +148,10 @@ vi.mock("@/hooks/queries/environment-queries", () => ({
}));
vi.mock("@/components/secondary-panel/useThreadStorageViewer", () => ({
- useThreadStorageViewer: () => ({ threadStorageRootPath: null }),
+ useThreadStorageViewer: ({ threadId }: { threadId?: string }) => ({
+ threadStorageRootPath:
+ threadId === undefined ? null : `/storage/${threadId}`,
+ }),
}));
vi.mock("@/hooks/useHostDaemon", () => ({
@@ -103,6 +183,7 @@ const baseProps = {
onAutoFocusNewTabHandled: noop,
onAutoFocusTerminalHandled: noop,
onOpenBrowser: noop,
+ onOpenLocalFileLink: () => true,
onOpenPanelLink: () => false,
onSelectFileSearchResult: noop,
onSelectionAddToChat: noop,
@@ -165,12 +246,12 @@ describe("RootComposePanelTabContent", () => {
expect(
screen
- .getByTestId("workspace-src/first.ts")
+ .getByTestId("workspace-context-src/first.ts")
.getAttribute("data-environment-id"),
).toBe("env-first");
expect(
screen
- .getByTestId("workspace-src/second.ts")
+ .getByTestId("workspace-context-src/second.ts")
.getAttribute("data-environment-id"),
).toBe("env-second");
});
@@ -236,9 +317,111 @@ describe("RootComposePanelTabContent", () => {
/>,
);
- const preview = screen.getByTestId("project-persisted/readme.md");
+ const preview = screen.getByTestId("project-context-persisted/readme.md");
expect(preview.getAttribute("data-environment-id")).toBeNull();
expect(preview.getAttribute("data-host-id")).toBe("host-opened");
expect(preview.getAttribute("data-project-id")).toBe("project-opened");
});
+
+ it("routes Markdown links and sibling resources for every root compose file source", () => {
+ const workspaceTab = createWorkspaceFilePreviewFixedPanelTab({
+ environmentId: "env-current",
+ projectId: "project-current",
+ tab: {
+ lineRange: null,
+ path: "docs/workspace.md",
+ source: { kind: "working-tree" },
+ statusLabel: null,
+ },
+ });
+ const projectTab = createWorkspaceFilePreviewFixedPanelTab({
+ environmentId: null,
+ projectId: "project-current",
+ tab: {
+ lineRange: null,
+ path: "docs/project.md",
+ source: { kind: "working-tree" },
+ statusLabel: null,
+ },
+ });
+ const hostTab = createHostFilePreviewFixedPanelTab({
+ environmentId: "env-current",
+ tab: {
+ lineRange: null,
+ path: "/workspace/env-current/docs/host.md",
+ },
+ threadId: "thread-current",
+ });
+ const storageTab = createThreadStorageFilePreviewFixedPanelTab({
+ environmentId: "env-current",
+ isPinned: false,
+ tab: { lineRange: null, path: "docs/storage.md" },
+ threadId: "thread-current",
+ });
+ const projectSources: PanelContentProps["projectSources"] = [
+ {
+ createdAt: 1,
+ hostId: "host-current",
+ id: "source-current",
+ isDefault: true,
+ path: "/project root/资料%",
+ projectId: "project-current",
+ type: "local_path",
+ updatedAt: 1,
+ },
+ ];
+ const sourceProps = {
+ ...baseProps,
+ projectSources,
+ };
+
+ render(
+ <>
+ {[workspaceTab, projectTab, hostTab, storageTab].map((tab) => (
+
+ ))}
+ >,
+ );
+
+ const cases = [
+ {
+ expectedRoot: "/workspace/env-current",
+ expectedUrl:
+ "/api/v1/threads/thread-current/worktree/files/docs/asset%20space-%E8%B5%84%E6%96%99%25.png",
+ testId: "workspace-docs/workspace.md",
+ },
+ {
+ expectedRoot: "/workspace/env-current",
+ expectedUrl:
+ "/api/v1/projects/project-current/files/content?path=docs%2Fasset+space-%E8%B5%84%E6%96%99%25.png&environmentId=env-current",
+ testId: "project-docs/project.md",
+ },
+ {
+ expectedRoot: "/workspace/env-current",
+ expectedUrl:
+ "/api/v1/threads/thread-current/host-files/content?path=%2Fworkspace%2Fenv-current%2Fdocs%2Fasset+space-%E8%B5%84%E6%96%99%25.png",
+ testId: "host-/workspace/env-current/docs/host.md",
+ },
+ {
+ expectedRoot: "/storage/thread-current",
+ expectedUrl:
+ "/api/v1/threads/thread-current/thread-storage/content?path=docs%2Fasset+space-%E8%B5%84%E6%96%99%25.png",
+ testId: "storage-docs/storage.md",
+ },
+ ];
+ for (const testCase of cases) {
+ const preview = screen.getByTestId(testCase.testId);
+ expect(preview.getAttribute("data-root-path")).toBe(
+ testCase.expectedRoot,
+ );
+ expect(preview.getAttribute("data-resource-url")).toBe(
+ testCase.expectedUrl,
+ );
+ }
+ });
});
diff --git a/apps/app/src/views/RootComposePanelTabContent.tsx b/apps/app/src/views/RootComposePanelTabContent.tsx
index 8d7fc3530e..5568631826 100644
--- a/apps/app/src/views/RootComposePanelTabContent.tsx
+++ b/apps/app/src/views/RootComposePanelTabContent.tsx
@@ -36,9 +36,17 @@ import {
resolveEnvironmentOpenContext,
} from "./thread-detail/threadWorkspaceOpenPath";
import { getFilePreviewLineRangeStart } from "@bb/client-core";
-import { resolveAbsoluteFilePath } from "@/lib/absolute-file-path";
+import {
+ getAbsoluteDirname,
+ resolveAbsoluteFilePath,
+} from "@/lib/absolute-file-path";
import { useAppCommandHandler } from "@/components/commands/AppCommandProvider";
import type { MarkdownPreviewLinkHandler } from "@/components/ui/markdown-link";
+import type { MarkdownPreviewLocalFileLinkHandler } from "@/components/ui/markdown-local-file-link";
+import {
+ buildMarkdownFilePreviewRouting,
+ resolveMarkdownFilePreviewRootPath,
+} from "@/components/secondary-panel/markdown-file-preview-routing";
export const ROOT_COMPOSE_FIXED_PANEL_STATE_ID = "root-compose";
@@ -57,6 +65,7 @@ interface RootComposePanelTabContentProps {
onAutoFocusNewTabHandled: () => void;
onAutoFocusTerminalHandled: () => void;
onOpenBrowser: () => void;
+ onOpenLocalFileLink: MarkdownPreviewLocalFileLinkHandler;
onOpenPanelLink: MarkdownPreviewLinkHandler;
onSelectFileSearchResult: (selection: FileSearchSelection) => void;
onSelectionAddToChat: (text: string) => void;
@@ -81,6 +90,8 @@ interface RootComposeFilePreviewTabContentProps {
isPanelOpen: boolean;
isProjectless: boolean;
fileOpenerSource: PluginFileOpenerSource | null;
+ onOpenLocalFileLink: MarkdownPreviewLocalFileLinkHandler;
+ onOpenPanelLink: MarkdownPreviewLinkHandler;
onSelectionAddToChat: (text: string) => void;
pluginPanelTab?: PluginPanelFixedPanelTab;
primaryHostId: string | null;
@@ -142,6 +153,7 @@ export function RootComposePanelTabContent({
onAutoFocusNewTabHandled,
onAutoFocusTerminalHandled,
onOpenBrowser,
+ onOpenLocalFileLink,
onOpenPanelLink,
onSelectFileSearchResult,
onSelectionAddToChat,
@@ -226,6 +238,8 @@ export function RootComposePanelTabContent({
isFocused={pane.isFocused}
isPanelOpen={isPanelOpen}
isProjectless={isProjectless}
+ onOpenLocalFileLink={onOpenLocalFileLink}
+ onOpenPanelLink={onOpenPanelLink}
onSelectionAddToChat={onSelectionAddToChat}
primaryHostId={primaryHostId}
projectSources={projectSources}
@@ -257,6 +271,8 @@ export function RootComposePanelTabContent({
isFocused={pane.isFocused}
isPanelOpen={isPanelOpen}
isProjectless={isProjectless}
+ onOpenLocalFileLink={onOpenLocalFileLink}
+ onOpenPanelLink={onOpenPanelLink}
onSelectionAddToChat={onSelectionAddToChat}
pluginPanelTab={tab}
primaryHostId={primaryHostId}
@@ -279,6 +295,8 @@ function RootComposeFilePreviewTabContent({
isPanelOpen,
isProjectless,
onSelectionAddToChat,
+ onOpenLocalFileLink,
+ onOpenPanelLink,
pluginPanelTab,
primaryHostId,
projectSources,
@@ -432,6 +450,21 @@ function RootComposeFilePreviewTabContent({
environmentId={tab.environmentId}
isPanelOpen={isPanelOpen}
lineRange={tab.lineRange}
+ markdownLinkRouting={buildMarkdownFilePreviewRouting({
+ baseDir: copyPath
+ ? getAbsoluteDirname({ path: copyPath })
+ : undefined,
+ contentSource: {
+ environmentId: tab.environmentId,
+ fileSource: tab.source,
+ kind: "workspace",
+ projectId: currentProjectId,
+ threadId: rootPanelThreadId,
+ },
+ onOpenLink: onOpenPanelLink,
+ onOpenLocalFileLink,
+ rootPath: workspaceRootPath,
+ })}
onOpenInEditor={onOpenInEditor}
onSelectionAddToChat={onSelectionAddToChat}
source={tab.source}
@@ -446,6 +479,20 @@ function RootComposeFilePreviewTabContent({
hostId={projectFilePreviewRouting.hostId}
isPanelOpen={isPanelOpen}
lineRange={tab.lineRange}
+ markdownLinkRouting={buildMarkdownFilePreviewRouting({
+ baseDir: copyPath
+ ? getAbsoluteDirname({ path: copyPath })
+ : undefined,
+ contentSource: {
+ environmentId: projectFilePreviewRouting.environmentId,
+ hostId: projectFilePreviewRouting.hostId,
+ kind: "project",
+ projectId: projectPreviewId,
+ },
+ onOpenLink: onOpenPanelLink,
+ onOpenLocalFileLink,
+ rootPath: projectPreviewRootPath,
+ })}
onOpenInEditor={onOpenInEditor}
onSelectionAddToChat={onSelectionAddToChat}
projectId={projectPreviewId}
@@ -473,6 +520,16 @@ function RootComposeFilePreviewTabContent({
environmentId={environmentId}
isPanelOpen={isPanelOpen}
lineRange={tab.lineRange}
+ markdownLinkRouting={buildMarkdownFilePreviewRouting({
+ baseDir: getAbsoluteDirname({ path: tab.path }),
+ contentSource: { kind: "host", threadId },
+ onOpenLink: onOpenPanelLink,
+ onOpenLocalFileLink,
+ rootPath: resolveMarkdownFilePreviewRootPath({
+ filePath: tab.path,
+ rootPaths: [workspaceRootPath, threadStorageRootPath],
+ }),
+ })}
onOpenInEditor={onOpenInEditor}
onSelectionAddToChat={onSelectionAddToChat}
threadId={threadId}
@@ -498,6 +555,18 @@ function RootComposeFilePreviewTabContent({
copyPath={copyPath}
isPanelOpen={isPanelOpen}
lineRange={tab.lineRange}
+ markdownLinkRouting={buildMarkdownFilePreviewRouting({
+ baseDir: copyPath
+ ? getAbsoluteDirname({ path: copyPath })
+ : undefined,
+ contentSource: {
+ kind: "thread-storage",
+ threadId: storageThreadId,
+ },
+ onOpenLink: onOpenPanelLink,
+ onOpenLocalFileLink,
+ rootPath: threadStorageRootPath,
+ })}
onOpenInEditor={onOpenInEditor}
onSelectionAddToChat={onSelectionAddToChat}
threadId={storageThreadId}
diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx
index f86d415bc5..67f2f370ca 100644
--- a/apps/app/src/views/RootComposeView.tsx
+++ b/apps/app/src/views/RootComposeView.tsx
@@ -119,6 +119,8 @@ import {
useOpenLinksInAppBrowserPreference,
} from "@/lib/in-app-browser-link-preference";
import type { MarkdownPreviewLinkHandler } from "@/components/ui/markdown-link";
+import type { MarkdownPreviewLocalFileLinkHandler } from "@/components/ui/markdown-local-file-link";
+import { appToast } from "@/components/ui/app-toast";
import { UrlOpenRoutingProvider } from "@/lib/url-open-routing";
import {
AppNavigationHostProvider,
@@ -180,6 +182,7 @@ import {
RootComposePanelTabContent,
type RootComposeTerminalTarget,
} from "./RootComposePanelTabContent";
+import { resolveThreadLocalFileLink } from "@/lib/thread-local-file-links";
const ROOT_COMPOSE_SIDEBAR_ACTION_ALIGNED_TOP_PADDING_CLASS = "pt-14";
@@ -961,6 +964,13 @@ function RootComposeSurface({
}),
threadId: rootPanelThreadId ?? undefined,
});
+ const rootPanelWorkspaceRootPath =
+ rootPanelEnvironment?.path ??
+ (rootPanelHostPathTerminalTarget?.kind === "host_path"
+ ? rootPanelHostPathTerminalTarget.cwd
+ : null);
+ const rootPanelThreadStorageRootPath =
+ rootThreadStorageFiles?.storageRootPath ?? null;
const environmentTerminalsListQuery = useEnvironmentTerminals(
rootPanelEnvironmentId ?? "",
{
@@ -1129,6 +1139,57 @@ function RootComposeSurface({
openPersistedWorkspaceFile,
togglePersistedPanel: toggleRootPersistedSecondaryPanel,
});
+ const handleOpenRootPanelLocalFileLink =
+ useCallback(
+ (link) => {
+ const resolution = resolveThreadLocalFileLink({
+ hostFileLinksAvailable:
+ rootPanelThreadId !== null && rootPanelEnvironmentId !== null,
+ link,
+ threadStorageRootPath: rootPanelThreadStorageRootPath,
+ workspaceRootPath: rootPanelWorkspaceRootPath,
+ });
+ if (resolution.kind === "app-route") {
+ return false;
+ }
+ if (resolution.kind === "error") {
+ appToast.error("Failed to open file locally", {
+ description: resolution.description,
+ });
+ return true;
+ }
+ if (resolution.kind === "open-workspace-path") {
+ openWorkspaceFile({
+ lineRange: resolution.request.lineRange,
+ path: resolution.request.relativePath,
+ source: { kind: "working-tree" },
+ statusLabel: null,
+ });
+ return true;
+ }
+ if (resolution.kind === "open-thread-storage-path") {
+ openStorageFile({
+ lineRange: resolution.request.lineRange,
+ path: resolution.request.relativePath,
+ });
+ return true;
+ }
+ openHostFile({
+ lineRange: resolution.request.lineRange,
+ path: resolution.request.path,
+ });
+ return true;
+ },
+ [
+ openHostFile,
+ openStorageFile,
+ openWorkspaceFile,
+ rootPanelEnvironmentId,
+ rootPanelThreadId,
+ rootPanelThreadStorageRootPath,
+ rootPanelWorkspaceRootPath,
+ ],
+ );
const handleOpenLiveFilePreview = useCallback(
(intent: AppFilePreviewIntent): boolean => {
const normalized = normalizeExperimentalFileOpenOptions(intent);
@@ -1540,6 +1601,7 @@ function RootComposeSurface({
onAutoFocusNewTabHandled={handleNewTabAutoFocusHandled}
onAutoFocusTerminalHandled={handleTerminalAutoFocusHandled}
onOpenBrowser={openBrowserTabAndReveal}
+ onOpenLocalFileLink={handleOpenRootPanelLocalFileLink}
onOpenPanelLink={handleOpenPanelLink}
onSelectFileSearchResult={handleSelectFileSearchResult}
onSelectionAddToChat={handleRootPanelSelectionAddToChat}
@@ -1564,6 +1626,7 @@ function RootComposeSurface({
canCreateRootTerminal,
handleNewTabAutoFocusHandled,
handleOpenPanelLink,
+ handleOpenRootPanelLocalFileLink,
handleRootPanelSelectionAddToChat,
handleSelectFileSearchResult,
handleStartTerminal,
diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
index 2d901d3f62..26eb1d2c69 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
@@ -20,7 +20,6 @@ import {
type ThreadTimelineSendToMainMessageHandler,
type ThreadTimelineLinkHandler,
type ThreadTimelineLocalFileLink,
- type ThreadTimelineLocalFileLinkHandler,
type ThreadTimelineOpenPluginPanelHandler,
type TimelineTitleActionResolver,
useThreadTimelineController,
@@ -109,11 +108,7 @@ import {
getEnvironmentWorkspaceSummaryDisplay,
} from "@/lib/environment-workspace-display";
import { formatWorkspaceCheckoutDisplay } from "@/lib/workspace-checkout-display";
-import {
- getAbsoluteDirname,
- isAbsoluteFilePathWithinRoot,
- resolveAbsoluteFilePath,
-} from "@/lib/absolute-file-path";
+import { resolveAbsoluteFilePath } from "@/lib/absolute-file-path";
import { getGitStatusDisplay } from "@/components/workspace/workspace-status";
import {
selectWorkspaceChangedFilesSection,
@@ -259,10 +254,9 @@ import {
} from "@/lib/thread-local-file-links";
import {
MarkdownLocalFileContextMenuContext,
- type MarkdownLinkRouting,
type MarkdownLocalFileContextMenuItem,
- type MarkdownLocalFileLinkRouting,
} from "@/components/ui/markdown-link-routing";
+import { buildThreadDetailMarkdownFilePreviewRouting } from "./threadDetailMarkdownFilePreviewRouting";
import {
useFixedPanelTabsStorageMaintenance,
useReconciledFixedPanelTabsState,
@@ -373,19 +367,6 @@ type ThreadDetailViewProps =
| ThreadDetailViewPageProps
| ThreadDetailViewPaneProps;
-interface BuildMarkdownPreviewLinkRoutingArgs {
- baseDir: string | undefined;
- onOpenLink: ThreadTimelineLinkHandler;
- onOpenLocalFileLink: ThreadTimelineLocalFileLinkHandler;
- rootPath: string | null | undefined;
-}
-
-interface ResolveHostFilePreviewLinkRootPathArgs {
- baseDir: string | undefined;
- threadStorageRootPath: string | null;
- workspaceRootPath: string | null;
-}
-
function buildHostConnectionNotice(
thread: ThreadWithRuntime,
hostName: string | null,
@@ -408,38 +389,6 @@ function buildHostConnectionNotice(
};
}
-function buildMarkdownPreviewLinkRouting({
- baseDir,
- onOpenLink,
- onOpenLocalFileLink,
- rootPath,
-}: BuildMarkdownPreviewLinkRoutingArgs): MarkdownLinkRouting {
- if (rootPath === null || rootPath === undefined) {
- return {
- onOpenLink,
- };
- }
-
- const localFileRouting: MarkdownLocalFileLinkRouting = {
- absoluteLinks: {
- kind: "contained",
- rootPath,
- },
- onOpenLink: onOpenLocalFileLink,
- };
- if (baseDir !== undefined) {
- localFileRouting.relativeLinks = {
- baseDir,
- rootPath,
- };
- }
-
- return {
- localFile: localFileRouting,
- onOpenLink,
- };
-}
-
function getLocalFileBasename(path: string): string {
const normalizedPath = path.replace(/[\\/]+$/u, "");
return normalizedPath.split(/[\\/]/u).at(-1) ?? path;
@@ -449,38 +398,6 @@ function buildOpenTargetMenuItemLabel(target: WorkspaceOpenTarget): string {
return `Open in ${target.label}`;
}
-function resolveHostFilePreviewLinkRootPath({
- baseDir,
- threadStorageRootPath,
- workspaceRootPath,
-}: ResolveHostFilePreviewLinkRootPathArgs): string | null {
- if (baseDir === undefined) {
- return null;
- }
-
- if (
- workspaceRootPath !== null &&
- isAbsoluteFilePathWithinRoot({
- candidatePath: baseDir,
- rootPath: workspaceRootPath,
- })
- ) {
- return workspaceRootPath;
- }
-
- if (
- threadStorageRootPath !== null &&
- isAbsoluteFilePathWithinRoot({
- candidatePath: baseDir,
- rootPath: threadStorageRootPath,
- })
- ) {
- return threadStorageRootPath;
- }
-
- return null;
-}
-
function ThreadDetailNotFound() {
return (
@@ -2605,13 +2522,18 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
environmentId={tab.environmentId}
isPanelOpen={isSecondaryPanelOpen}
lineRange={tab.lineRange}
- markdownLinkRouting={buildMarkdownPreviewLinkRouting({
- baseDir: copyPath
- ? getAbsoluteDirname({ path: copyPath })
- : undefined,
+ markdownLinkRouting={buildThreadDetailMarkdownFilePreviewRouting({
onOpenLink: handleOpenTimelineLink,
onOpenLocalFileLink: handleOpenTimelineLocalFileLink,
- rootPath: workspacePreviewRootPath,
+ source: {
+ absolutePath: copyPath ?? undefined,
+ environmentId: tab.environmentId,
+ fileSource: tab.source,
+ kind: "workspace",
+ projectId,
+ rootPath: workspacePreviewRootPath,
+ threadId: thread.id,
+ },
})}
onOpenInEditor={handleOpenFileInEditor}
onSelectionAddToChat={handleSelectionAddToChat}
@@ -2622,7 +2544,6 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
);
}
case "host-file-preview": {
- const baseDir = getAbsoluteDirname({ path: tab.path });
return (
true);
+const onOpenLocalFileLink = vi.fn(() => true);
+
+function resolveSibling(
+ routing: ReturnType,
+) {
+ const baseDir = routing.localImage?.relativePaths?.baseDir;
+ return baseDir === undefined
+ ? undefined
+ : routing.localImage?.resolveSrc({
+ lineRange: null,
+ path: `${baseDir}/asset space-资料%.png`,
+ });
+}
+
+describe("buildThreadDetailMarkdownFilePreviewRouting", () => {
+ it("routes workspace Markdown links and resources beside the preview file", () => {
+ const routing = buildThreadDetailMarkdownFilePreviewRouting({
+ onOpenLink,
+ onOpenLocalFileLink,
+ source: {
+ absolutePath: "/workspace/项目%/docs/readme.md",
+ environmentId: "env-1",
+ fileSource: { kind: "working-tree" },
+ kind: "workspace",
+ projectId: "project-1",
+ rootPath: "/workspace/项目%",
+ threadId: "thread-1",
+ },
+ });
+
+ expect(routing.localFile?.relativeLinks).toEqual({
+ baseDir: "/workspace/项目%/docs",
+ rootPath: "/workspace/项目%",
+ });
+ expect(resolveSibling(routing)).toBe(
+ buildThreadWorktreeRawContentUrl(
+ "thread-1",
+ "docs/asset space-资料%.png",
+ ),
+ );
+ });
+
+ it("selects the containing root for host Markdown resources", () => {
+ const routing = buildThreadDetailMarkdownFilePreviewRouting({
+ onOpenLink,
+ onOpenLocalFileLink,
+ source: {
+ filePath: "/storage/thread-1/current/docs/readme.md",
+ kind: "host",
+ rootPaths: ["/workspace/project", "/storage/thread-1"],
+ threadId: "thread-1",
+ },
+ });
+
+ expect(routing.localFile?.relativeLinks?.rootPath).toBe(
+ "/storage/thread-1",
+ );
+ expect(resolveSibling(routing)).toBe(
+ buildThreadHostFileContentUrl(
+ "thread-1",
+ "/storage/thread-1/current/docs/asset space-资料%.png",
+ ),
+ );
+ });
+
+ it("routes storage Markdown links and resources beside the preview file", () => {
+ const routing = buildThreadDetailMarkdownFilePreviewRouting({
+ onOpenLink,
+ onOpenLocalFileLink,
+ source: {
+ absolutePath: "/storage/thread-1/current/docs/readme.md",
+ kind: "thread-storage",
+ rootPath: "/storage/thread-1",
+ threadId: "thread-1",
+ },
+ });
+
+ expect(routing.localFile?.relativeLinks).toEqual({
+ baseDir: "/storage/thread-1/current/docs",
+ rootPath: "/storage/thread-1",
+ });
+ expect(resolveSibling(routing)).toBe(
+ buildThreadStorageContentUrl(
+ "thread-1",
+ "current/docs/asset space-资料%.png",
+ ),
+ );
+ });
+});
diff --git a/apps/app/src/views/thread-detail/threadDetailMarkdownFilePreviewRouting.ts b/apps/app/src/views/thread-detail/threadDetailMarkdownFilePreviewRouting.ts
new file mode 100644
index 0000000000..606671c1b7
--- /dev/null
+++ b/apps/app/src/views/thread-detail/threadDetailMarkdownFilePreviewRouting.ts
@@ -0,0 +1,77 @@
+import type { EnvironmentFilePreviewSource } from "@bb/client-core";
+import type { MarkdownPreviewLinkHandler } from "@/components/ui/markdown-link";
+import type { MarkdownPreviewLocalFileLinkHandler } from "@/components/ui/markdown-local-file-link";
+import {
+ buildMarkdownFilePreviewRouting,
+ resolveMarkdownFilePreviewRootPath,
+} from "@/components/secondary-panel/markdown-file-preview-routing";
+import { getAbsoluteDirname } from "@/lib/absolute-file-path";
+
+type ThreadDetailMarkdownFilePreviewSource =
+ | {
+ absolutePath: string | undefined;
+ environmentId: string | null;
+ fileSource: EnvironmentFilePreviewSource;
+ kind: "workspace";
+ projectId: string | null;
+ rootPath: string | null;
+ threadId: string;
+ }
+ | {
+ filePath: string;
+ kind: "host";
+ rootPaths: readonly (string | null | undefined)[];
+ threadId: string;
+ }
+ | {
+ absolutePath: string | undefined;
+ kind: "thread-storage";
+ rootPath: string | null;
+ threadId: string;
+ };
+
+interface BuildThreadDetailMarkdownFilePreviewRoutingArgs {
+ onOpenLink: MarkdownPreviewLinkHandler;
+ onOpenLocalFileLink: MarkdownPreviewLocalFileLinkHandler;
+ source: ThreadDetailMarkdownFilePreviewSource;
+}
+
+export function buildThreadDetailMarkdownFilePreviewRouting({
+ onOpenLink,
+ onOpenLocalFileLink,
+ source,
+}: BuildThreadDetailMarkdownFilePreviewRoutingArgs) {
+ if (source.kind === "host") {
+ return buildMarkdownFilePreviewRouting({
+ baseDir: getAbsoluteDirname({ path: source.filePath }),
+ contentSource: { kind: "host", threadId: source.threadId },
+ onOpenLink,
+ onOpenLocalFileLink,
+ rootPath: resolveMarkdownFilePreviewRootPath({
+ filePath: source.filePath,
+ rootPaths: source.rootPaths,
+ }),
+ });
+ }
+
+ const baseDir =
+ source.absolutePath === undefined
+ ? undefined
+ : getAbsoluteDirname({ path: source.absolutePath });
+ return buildMarkdownFilePreviewRouting({
+ baseDir,
+ contentSource:
+ source.kind === "workspace"
+ ? {
+ environmentId: source.environmentId,
+ fileSource: source.fileSource,
+ kind: "workspace",
+ projectId: source.projectId,
+ threadId: source.threadId,
+ }
+ : { kind: "thread-storage", threadId: source.threadId },
+ onOpenLink,
+ onOpenLocalFileLink,
+ rootPath: source.rootPath,
+ });
+}
From 1f06e4cc729000c26e1be7e1e4794b04e865e8b8 Mon Sep 17 00:00:00 2001
From: Jonathan Lee
Date: Sun, 30 Aug 2026 12:54:03 +0000
Subject: [PATCH 16/19] Fix encoded Markdown preview paths
---
.../markdown-file-preview-routing.test.ts | 9 +++++++++
.../src/components/ui/markdown-local-file-link.test.ts | 10 ++++++++++
apps/app/src/components/ui/markdown-local-file-link.ts | 5 +++--
3 files changed, 22 insertions(+), 2 deletions(-)
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
index c0e1e74782..f6da08cf6d 100644
--- 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
@@ -145,6 +145,15 @@ describe("buildMarkdownFilePreviewRouting", () => {
});
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({
diff --git a/apps/app/src/components/ui/markdown-local-file-link.test.ts b/apps/app/src/components/ui/markdown-local-file-link.test.ts
index f9f6bcde91..11476f202e 100644
--- a/apps/app/src/components/ui/markdown-local-file-link.test.ts
+++ b/apps/app/src/components/ui/markdown-local-file-link.test.ts
@@ -286,6 +286,16 @@ describe("resolveRelativeLocalFileHref", () => {
).toBe("/work space/资料%/child file.md#section%20one");
});
+ it("preserves an encoded hash in a relative file path", () => {
+ expect(
+ resolveRelativeLocalFileHref({
+ baseDir: "/root/docs",
+ href: "notes%231.md",
+ rootPath: "/root",
+ }),
+ ).toBe("/root/docs/notes#1.md");
+ });
+
it("parses file line suffixes before checking URI schemes", () => {
expect(
resolveRelativeLocalFileHref({
diff --git a/apps/app/src/components/ui/markdown-local-file-link.ts b/apps/app/src/components/ui/markdown-local-file-link.ts
index 6eb425325e..1a7f6e3804 100644
--- a/apps/app/src/components/ui/markdown-local-file-link.ts
+++ b/apps/app/src/components/ui/markdown-local-file-link.ts
@@ -322,10 +322,11 @@ export function resolveRelativeLocalFileHref({
return null;
}
+ const fragmentIndex = href.indexOf("#");
const suffix =
- parsedHref.fragment === undefined
+ parsedHref.fragment === undefined || fragmentIndex === -1
? decodedHref.slice(parsedHref.path.length)
- : href.slice(href.indexOf("#"));
+ : href.slice(fragmentIndex);
return `${normalizedHrefPath}${suffix}`;
}
From 5d3f8d468cfd3d1030f27779af089ee841ba8ec0 Mon Sep 17 00:00:00 2001
From: Jonathan Lee
Date: Mon, 31 Aug 2026 03:10:19 +0000
Subject: [PATCH 17/19] Unify file preview and download behavior
---
.../AppFileExternalNavigationDispatcher.tsx | 22 +-
.../AppFileExternalNavigationHost.test.tsx | 45 +++--
.../plugin/AppFileExternalNavigationHost.tsx | 6 +-
.../plugin/ExperimentalFileLink.test.tsx | 24 ++-
.../plugin/ExperimentalFileLink.tsx | 22 +-
.../plugin/ExperimentalFileLinkMenu.tsx | 20 +-
.../plugin/PluginPanelRightPanelHost.test.tsx | 45 +++--
.../plugin/PluginPanelRightPanelHost.tsx | 64 ++++--
.../secondary-panel/FilePreview.test.tsx | 74 +++++--
.../secondary-panel/FilePreview.tsx | 68 ++++++-
.../ThreadSecondaryPanelTabContent.tsx | 150 +++++++++++++-
.../ThreadStorageFilePreview.tsx | 63 +++++-
.../lazySecondaryPanelComponents.tsx | 19 ++
.../markdown-file-preview-routing.test.ts | 39 +++-
.../markdown-file-preview-routing.ts | 22 +-
.../secondary-panel/useThreadFileTabs.ts | 38 +++-
.../timeline/ConversationAttachments.test.tsx | 104 ++++++++++
.../timeline/ConversationAttachments.tsx | 187 ++++++++++++-----
.../ConversationMessageContent.test.tsx | 53 ++++-
.../timeline/ConversationMessageContent.tsx | 85 ++++++--
.../timeline/GeneratedConversationMessage.tsx | 19 +-
.../thread/timeline/ThreadTimelineRows.tsx | 2 +
apps/app/src/components/ui/image-lightbox.tsx | 17 ++
.../components/ui/markdown-preview.test.tsx | 45 ++++-
.../src/components/ui/markdown-preview.tsx | 51 ++++-
apps/app/src/lib/api.ts | 25 ++-
apps/app/src/lib/app-navigation-host.tsx | 9 +-
apps/app/src/lib/file-content-urls.ts | 157 ++++++++++++++
apps/app/src/lib/file-resolver.test.ts | 80 ++++++++
apps/app/src/lib/file-resolver.ts | 151 ++++++++++++++
apps/app/src/lib/fixed-panel-tabs-state.ts | 2 +
apps/app/src/lib/live-file-navigation.test.ts | 85 ++++++++
apps/app/src/lib/live-file-navigation.ts | 35 +++-
apps/app/src/lib/plugin-sdk-app-impl.test.tsx | 15 +-
apps/app/src/lib/user-attachment-images.ts | 9 +-
.../views/RootComposePanelTabContent.test.tsx | 6 +-
.../src/views/RootComposePanelTabContent.tsx | 10 +
apps/app/src/views/RootComposeView.tsx | 48 ++++-
.../views/thread-detail/ThreadDetailView.tsx | 65 +++++-
apps/desktop/src/desktop-window-factory.ts | 38 +++-
.../test/desktop-window-factory.test.ts | 66 ++++++
apps/mobile/src/lib/shell/index.ts | 2 +
apps/mobile/src/lib/shell/shell-url.test.ts | 27 +++
apps/mobile/src/lib/shell/shell-url.ts | 14 ++
.../screens/webview/ProfileWebViewScreen.tsx | 15 ++
apps/server/src/routes/environments.ts | 55 +++++
apps/server/src/routes/files.ts | 68 +++++--
apps/server/src/routes/projects.ts | 164 +++++++++++----
apps/server/src/routes/threads/data.ts | 191 ++++++++++++------
.../services/files/file-response-policy.ts | 116 +++++++++++
.../src/services/projects/attachments.ts | 16 +-
.../test/files/file-response-policy.test.ts | 78 +++++++
.../test/files/host-file-routes.test.ts | 96 ++++++++-
.../public/public-project-attachments.test.ts | 50 +++++
.../test/public/public-thread-data.test.ts | 29 +++
.../test/public/public-thread-tabs.test.ts | 11 +
docs/api_to_audit.md | 15 +-
packages/client-core/src/file-preview.ts | 36 +++-
.../src/panel/fixed-panel-tabs-state.ts | 70 +++++++
.../src/panel/secondaryPanelTabState.ts | 2 +
.../client-core/test/file-preview.test.ts | 15 ++
packages/plugin-api-map/sdk-public-api.json | 4 +-
packages/plugin-api-map/src/surfaces.ts | 13 +-
packages/plugin-sdk/src/app-contract.ts | 53 ++++-
.../internal/file-navigation-validation.ts | 159 ++++++++++++++-
.../testing/__tests__/app-harness.test.tsx | 30 ++-
packages/plugin-sdk/src/testing/app.tsx | 20 +-
.../server-contract/src/api/environments.ts | 7 +
.../server-contract/src/api/thread-tabs.ts | 13 ++
packages/server-contract/src/public-api.ts | 104 ++++++++++
.../server-contract/test/thread-tabs.test.ts | 18 ++
plugins/tasks/attachments/attachments.test.ts | 103 +++++++++-
plugins/tasks/attachments/index.ts | 73 +++++--
plugins/tasks/cli/cli.test.ts | 4 +-
plugins/tasks/shared/attachment-mime.ts | 43 ++++
.../views/activity/task-activity.test.tsx | 27 ++-
.../tasks/views/activity/task-activity.tsx | 57 ++++--
.../tasks/views/detail/attachments.test.tsx | 48 +++++
plugins/tasks/views/detail/attachments.tsx | 115 ++++++++---
79 files changed, 3556 insertions(+), 490 deletions(-)
create mode 100644 apps/app/src/components/thread/timeline/ConversationAttachments.test.tsx
create mode 100644 apps/app/src/lib/file-resolver.test.ts
create mode 100644 apps/app/src/lib/file-resolver.ts
create mode 100644 apps/server/src/services/files/file-response-policy.ts
create mode 100644 apps/server/test/files/file-response-policy.test.ts
create mode 100644 plugins/tasks/shared/attachment-mime.ts
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 74b0db7c5a..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,
@@ -414,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;
@@ -743,6 +762,13 @@ export function PluginPanelRightPanelHost({
threadId={tab.threadId}
/>
);
+ case "byte-file-preview":
+ return (
+
+ );
case "plugin-panel": {
const originalTab = createFileOpenerOriginalTab(tab);
return (
@@ -826,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/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/ThreadSecondaryPanelTabContent.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx
index 005183066f..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 =
@@ -109,6 +127,13 @@ interface ThreadStorageFilePreviewTabContentProps {
threadId: string;
}
+interface ByteFilePreviewTabContentProps {
+ identity: ExperimentalFileIdentity;
+ isPanelOpen: boolean;
+ markdownLinkRouting?: MarkdownLinkRouting;
+ onSelectionAddToChat?: (text: string) => void;
+}
+
function ThreadDiffSkeleton() {
return (
@@ -324,12 +349,21 @@ export function WorkspaceFilePreviewTabContent({
);
}
+
+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
index f6da08cf6d..a787ca68d8 100644
--- 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
@@ -1,8 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import {
- buildProjectFileContentUrl,
- buildThreadHostFileContentUrl,
- buildThreadStorageContentUrl,
+ buildProjectFilePreviewUrl,
+ buildProjectAttachmentPreviewUrl,
+ buildThreadHostFilePreviewUrl,
+ buildThreadStoragePreviewUrl,
buildThreadWorktreeRawContentUrl,
} from "@/lib/file-content-urls";
import {
@@ -73,7 +74,7 @@ describe("buildMarkdownFilePreviewRouting", () => {
threadId: null,
}),
).toBe(
- buildProjectFileContentUrl("project-1", "docs/asset space-图%.png", {
+ buildProjectFilePreviewUrl("project-1", "docs/asset space-图%.png", {
environmentId: "env-1",
}),
);
@@ -110,7 +111,7 @@ describe("buildMarkdownFilePreviewRouting", () => {
projectId: "project-1",
} satisfies MarkdownFilePreviewContentSource;
expect(resolveResource(projectSource)).toBe(
- buildProjectFileContentUrl("project-1", "docs/asset space-图%.png", {
+ buildProjectFilePreviewUrl("project-1", "docs/asset space-图%.png", {
hostId: "host-1",
}),
);
@@ -122,12 +123,36 @@ describe("buildMarkdownFilePreviewRouting", () => {
},
),
).toBe(
- `${buildThreadHostFileContentUrl("thread-1", resourcePath)}#L12-L14`,
+ `${buildThreadHostFilePreviewUrl("thread-1", resourcePath)}#L12-L14`,
);
expect(
resolveResource({ kind: "thread-storage", threadId: "thread-1" }),
).toBe(
- buildThreadStorageContentUrl("thread-1", "docs/asset space-图%.png"),
+ 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",
+ ),
);
});
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
index d78b739000..8658974eee 100644
--- a/apps/app/src/components/secondary-panel/markdown-file-preview-routing.ts
+++ b/apps/app/src/components/secondary-panel/markdown-file-preview-routing.ts
@@ -1,8 +1,9 @@
import type { EnvironmentFilePreviewSource } from "@bb/client-core";
import {
- buildProjectFileContentUrl,
- buildThreadHostFileContentUrl,
- buildThreadStorageContentUrl,
+ buildProjectFilePreviewUrl,
+ buildProjectAttachmentPreviewUrl,
+ buildThreadHostFilePreviewUrl,
+ buildThreadStoragePreviewUrl,
buildThreadWorktreeRawContentUrl,
} from "@/lib/file-content-urls";
import {
@@ -34,6 +35,7 @@ export type MarkdownFilePreviewContentSource =
kind: "project";
projectId: string;
}
+ | { kind: "project-attachment"; projectId: string }
| { kind: "host"; threadId: string }
| { kind: "thread-storage"; threadId: string };
@@ -69,7 +71,7 @@ function buildContentUrl(
rootPath: string,
): string | null {
if (contentSource.kind === "host") {
- return `${buildThreadHostFileContentUrl(
+ return `${buildThreadHostFilePreviewUrl(
contentSource.threadId,
link.path,
)}${buildLineRangeFragment(link)}`;
@@ -102,7 +104,7 @@ function buildContentUrl(
) {
return null;
}
- contentUrl = buildProjectFileContentUrl(
+ contentUrl = buildProjectFilePreviewUrl(
contentSource.projectId,
relativePath,
{ environmentId: contentSource.environmentId },
@@ -110,7 +112,7 @@ function buildContentUrl(
break;
}
case "project":
- contentUrl = buildProjectFileContentUrl(
+ contentUrl = buildProjectFilePreviewUrl(
contentSource.projectId,
relativePath,
{
@@ -122,8 +124,14 @@ function buildContentUrl(
},
);
break;
+ case "project-attachment":
+ contentUrl = buildProjectAttachmentPreviewUrl(
+ contentSource.projectId,
+ relativePath,
+ );
+ break;
case "thread-storage":
- contentUrl = buildThreadStorageContentUrl(
+ contentUrl = buildThreadStoragePreviewUrl(
contentSource.threadId,
relativePath,
);
diff --git a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts
index ada31526db..402e836506 100644
--- a/apps/app/src/components/secondary-panel/useThreadFileTabs.ts
+++ b/apps/app/src/components/secondary-panel/useThreadFileTabs.ts
@@ -15,12 +15,14 @@ import {
} from "@/lib/fixed-panel-tabs";
import {
createBrowserFixedPanelTab,
+ createByteFilePreviewFixedPanelTab,
createHostFilePreviewFixedPanelTab,
createNewTabFixedPanelTab,
createPluginPanelFixedPanelTab,
createThreadStorageFilePreviewFixedPanelTab,
createWorkspaceFilePreviewFixedPanelTab,
type BrowserFixedPanelTab,
+ type ByteFilePreviewFixedPanelTab,
type FixedPanelTab,
type FixedPanelTabsState,
type HostFilePreviewFixedPanelTab,
@@ -41,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";
@@ -105,6 +108,10 @@ export interface UpdateBrowserTabArgs {
}
export type OpenSecondaryPanelTabRequest =
+ | {
+ kind: "byte-file-preview";
+ tab: ByteFileTabState;
+ }
| {
kind: "workspace-file-preview";
tab: WorkspaceFileTabState;
@@ -136,6 +143,7 @@ interface PruneSecondaryTabsArgs {
}
type SecondaryPanelTab =
+ | ByteFilePreviewFixedPanelTab
| WorkspaceFilePreviewFixedPanelTab
| HostFilePreviewFixedPanelTab
| ThreadStorageFilePreviewFixedPanelTab
@@ -195,6 +203,7 @@ function isReopenableSecondaryPanelTab(
): 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":
@@ -253,6 +262,10 @@ function isReopenablePanelTabOwnedByContext({
: 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 &&
@@ -349,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 &&
@@ -678,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({
diff --git a/apps/app/src/components/thread/timeline/ConversationAttachments.test.tsx b/apps/app/src/components/thread/timeline/ConversationAttachments.test.tsx
new file mode 100644
index 0000000000..5783c0dd06
--- /dev/null
+++ b/apps/app/src/components/thread/timeline/ConversationAttachments.test.tsx
@@ -0,0 +1,104 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { AppNavigationHostProvider } from "@/lib/app-navigation-host";
+import {
+ ConversationAttachments,
+ buildAttachmentItems,
+} from "./ConversationAttachments";
+
+afterEach(cleanup);
+
+describe("conversation attachment contract", () => {
+ it("classifies project attachments and absolute host files without ambiguity", () => {
+ const items = buildAttachmentItems({
+ attachments: {
+ webImages: 0,
+ localImages: 2,
+ localFiles: 2,
+ imageUrls: [],
+ localImagePaths: ["/workspace/图 100%.png", "stored/image.png"],
+ localFilePaths: [
+ "/workspace/report 100%.pdf",
+ "stored/report 100%.pdf",
+ ],
+ },
+ projectId: "proj_1",
+ threadId: "thr_1",
+ });
+
+ expect(items.fileItems.map((item) => item.identity?.source)).toEqual([
+ {
+ store: "thread-host",
+ ownerId: "thr_1",
+ path: "/workspace/report 100%.pdf",
+ },
+ {
+ store: "project-attachment",
+ ownerId: "proj_1",
+ path: "stored/report 100%.pdf",
+ },
+ ]);
+ expect(items.imageItems[0]?.src).toContain(
+ "/threads/thr_1/host-files/preview?",
+ );
+ expect(items.imageItems[1]?.src).toContain(
+ "/projects/proj_1/attachments/preview?",
+ );
+ expect(items.imageItems.every((item) => item.downloadUrl !== null)).toBe(
+ true,
+ );
+ });
+
+ it("renders active Open and Download controls in the shared web renderer", () => {
+ const openFilePreview = vi.fn(() => true);
+ const items = buildAttachmentItems({
+ attachments: {
+ webImages: 0,
+ localImages: 0,
+ localFiles: 1,
+ imageUrls: [],
+ localImagePaths: [],
+ localFilePaths: ["stored/资料 100%.pdf"],
+ },
+ projectId: "proj_1",
+ threadId: "thr_1",
+ });
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Open 资料 100%.pdf" }));
+ expect(openFilePreview).toHaveBeenCalledWith({
+ identity: expect.objectContaining({
+ source: expect.objectContaining({ store: "project-attachment" }),
+ }),
+ });
+ const download = screen.getByRole("link", {
+ name: "Download 资料 100%.pdf",
+ });
+ expect(download.getAttribute("href")).toContain("/attachments/download?");
+ expect(download.getAttribute("download")).toBe("资料 100%.pdf");
+ });
+
+ it("leaves unresolved controls inert and visible", () => {
+ const items = buildAttachmentItems({
+ attachments: {
+ webImages: 0,
+ localImages: 0,
+ localFiles: 1,
+ imageUrls: [],
+ localImagePaths: [],
+ localFilePaths: ["relative-without-project.bin"],
+ },
+ });
+ render();
+
+ expect(screen.getByText("relative-without-project.bin")).not.toBeNull();
+ expect(screen.queryByRole("button")).toBeNull();
+ expect(screen.queryByRole("link")).toBeNull();
+ });
+});
diff --git a/apps/app/src/components/thread/timeline/ConversationAttachments.tsx b/apps/app/src/components/thread/timeline/ConversationAttachments.tsx
index 123398f411..6b3ba3aae1 100644
--- a/apps/app/src/components/thread/timeline/ConversationAttachments.tsx
+++ b/apps/app/src/components/thread/timeline/ConversationAttachments.tsx
@@ -6,7 +6,10 @@ import {
getWrappedImageIndex,
} from "../../ui/image-lightbox.js";
import { cn } from "@bb/shared-ui/lib/utils";
-import { buildProjectAttachmentContentUrl } from "@/lib/file-content-urls";
+import { Icon } from "@bb/shared-ui/icon";
+import type { ExperimentalFileIdentity } from "@get-bb/plugin-sdk";
+import { useAppNavigationHost } from "@/lib/app-navigation-host";
+import { resolveFileInteraction } from "@/lib/file-resolver";
import type {
ThreadTimelineLocalFileLinkHandler,
UserAttachmentImageSrcResolver,
@@ -14,29 +17,37 @@ import type {
interface ConversationImageItem {
alt: string;
+ downloadUrl: string | null;
src: string;
}
+interface ConversationFileItem {
+ downloadUrl: string | null;
+ identity: ExperimentalFileIdentity | null;
+ path: string;
+}
+
export interface ConversationAttachmentItems {
- filePaths: string[];
+ fileItems: ConversationFileItem[];
imageItems: ConversationImageItem[];
}
interface ConversationAttachmentsProps extends ConversationAttachmentItems {
align?: "start" | "end";
onOpenLocalFileLink?: ThreadTimelineLocalFileLinkHandler;
- projectId?: string;
}
interface BuildAttachmentItemsArgs {
attachments: TimelineConversationAttachments | null;
projectId?: string;
resolveUserAttachmentImageSrc?: UserAttachmentImageSrcResolver;
+ threadId?: string;
}
-interface ProjectAttachmentHrefArgs {
+interface FileIdentityArgs {
path: string;
projectId: string | undefined;
+ threadId: string | undefined;
}
interface PathClassificationArgs {
@@ -59,25 +70,52 @@ function isProjectAttachmentPath({ path }: PathClassificationArgs): boolean {
);
}
-function projectAttachmentHref({
+function fileIdentity({
path,
projectId,
-}: ProjectAttachmentHrefArgs): string | null {
- if (!projectId || !isProjectAttachmentPath({ path })) {
- return null;
+ threadId,
+}: FileIdentityArgs): ExperimentalFileIdentity | null {
+ const displayName = fileNameFromPath(path);
+ if (projectId && isProjectAttachmentPath({ path })) {
+ return {
+ source: { store: "project-attachment", ownerId: projectId, path },
+ displayName,
+ mimeType: null,
+ sizeBytes: null,
+ location: null,
+ };
}
-
- return buildProjectAttachmentContentUrl(projectId, path);
+ if (threadId && isAbsoluteLocalPath({ path })) {
+ return {
+ source: { store: "thread-host", ownerId: threadId, path },
+ displayName,
+ mimeType: null,
+ sizeBytes: null,
+ location: null,
+ };
+ }
+ if (/^https?:/iu.test(path)) {
+ const url = new URL(path);
+ return {
+ source: { store: "remote", ownerId: url.origin, url: path },
+ displayName,
+ mimeType: null,
+ sizeBytes: null,
+ location: null,
+ };
+ }
+ return null;
}
export function buildAttachmentItems({
attachments,
projectId,
resolveUserAttachmentImageSrc,
+ threadId,
}: BuildAttachmentItemsArgs): ConversationAttachmentItems {
if (!attachments) {
return {
- filePaths: [],
+ fileItems: [],
imageItems: [],
};
}
@@ -85,29 +123,48 @@ export function buildAttachmentItems({
const imageItems: ConversationImageItem[] = [
...attachments.imageUrls.map((url) => ({
alt: fileNameFromPath(url),
+ downloadUrl: null,
src: url,
})),
- ...attachments.localImagePaths.map((path) => ({
- alt: fileNameFromPath(path),
- src: resolveUserAttachmentImageSrc
- ? resolveUserAttachmentImageSrc(path, projectId)
- : path,
- })),
+ ...attachments.localImagePaths.map((path) => {
+ const identity = fileIdentity({ path, projectId, threadId });
+ const interaction =
+ identity === null ? null : resolveFileInteraction(identity);
+ return {
+ alt: fileNameFromPath(path),
+ downloadUrl: interaction?.downloadUrl ?? null,
+ src:
+ interaction?.previewUrl ??
+ (resolveUserAttachmentImageSrc
+ ? resolveUserAttachmentImageSrc(path, projectId)
+ : path),
+ };
+ }),
];
return {
- filePaths: attachments.localFilePaths,
+ fileItems: attachments.localFilePaths.map((path) => {
+ const identity = fileIdentity({ path, projectId, threadId });
+ return {
+ downloadUrl:
+ identity === null
+ ? null
+ : resolveFileInteraction(identity).downloadUrl,
+ identity,
+ path,
+ };
+ }),
imageItems,
};
}
export function ConversationAttachments({
align = "start",
- filePaths,
+ fileItems,
imageItems,
onOpenLocalFileLink,
- projectId,
}: ConversationAttachmentsProps) {
+ const navigation = useAppNavigationHost();
const [expandedImageIndex, setExpandedImageIndex] = useState(
null,
);
@@ -125,7 +182,7 @@ export function ConversationAttachments({
setExpandedImageIndex(null);
}, [expandedImageIndex, imageItems.length]);
- if (filePaths.length === 0 && imageItems.length === 0) {
+ if (fileItems.length === 0 && imageItems.length === 0) {
return null;
}
@@ -134,35 +191,51 @@ export function ConversationAttachments({
{imageItems.length > 0 ? (
{imageItems.map((imageItem, index) => (
-
+
+ {imageItem.downloadUrl === null ? null : (
+
+
+
+ )}
+
))}
) : 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 +245,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 +297,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..157b91c2d7 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,7 +622,7 @@ export const GeneratedConversationMessage = memo(
),
[
- attachmentItems.filePaths,
+ attachmentItems.fileItems,
attachmentItems.imageItems,
linkRouting,
messageText,
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/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)}
>
+
+
+
{}
({
+ 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}
>
event.stopPropagation()}
@@ -102,6 +127,16 @@ export function Lightbox({
{formatFileSize(attachment.sizeBytes)}
+ event.stopPropagation()}
+ >
+
+ Download
+
(null);
const [confirm, setConfirm] = useState(null);
const [pending, setPending] = useState>(new Set());
+ const navigate = useBbNavigate();
const removable = onRemove !== undefined;
const requestRemove = (attachment: Attachment) => setConfirm(attachment);
@@ -186,29 +222,54 @@ export function AttachmentsGrid({
);
};
- const fileCard = (attachment: Attachment) => (
-
+ );
+ };
const imageTile = (attachment: Attachment) => (
setLightbox(attachment)}
>
})
@@ -236,6 +297,14 @@ export function AttachmentsGrid({
{attachment.fileName}
+
+
+
{removeButton(attachment, "image")}
);
From a9ff890dba1fb12de665c52e99e9b596f56d6d32 Mon Sep 17 00:00:00 2001
From: Jonathan Lee
Date: Mon, 31 Aug 2026 04:55:19 +0000
Subject: [PATCH 18/19] fix(files): close BM-41 preview review findings
---
.../timeline/ConversationAttachments.test.tsx | 42 ++++++++
.../timeline/ConversationAttachments.tsx | 7 +-
.../components/ui/markdown-preview.test.tsx | 15 +++
.../src/components/ui/markdown-preview.tsx | 19 +++-
apps/app/src/lib/file-resolver.test.ts | 61 +++++++++++-
.../services/files/file-response-policy.ts | 16 +--
.../test/files/file-response-policy.test.ts | 30 ++++++
.../public/public-project-attachments.test.ts | 31 ++++++
.../public-project-workspace-routing.test.ts | 51 ++++++++++
.../test/fixed-panel-tabs-state.test.ts | 45 +++++++++
.../src/file-response-policy.ts | 18 ++++
packages/server-contract/src/index.ts | 1 +
.../test/file-response-policy.test.ts | 34 +++++++
plugins/tasks/attachments/attachments.test.ts | 97 ++++++++++++++++++-
plugins/tasks/attachments/index.ts | 11 +--
plugins/tasks/package.json | 1 +
pnpm-lock.yaml | 3 +
17 files changed, 460 insertions(+), 22 deletions(-)
create mode 100644 packages/client-core/test/fixed-panel-tabs-state.test.ts
create mode 100644 packages/server-contract/src/file-response-policy.ts
create mode 100644 packages/server-contract/test/file-response-policy.test.ts
diff --git a/apps/app/src/components/thread/timeline/ConversationAttachments.test.tsx b/apps/app/src/components/thread/timeline/ConversationAttachments.test.tsx
index 5783c0dd06..d0a8a146bf 100644
--- a/apps/app/src/components/thread/timeline/ConversationAttachments.test.tsx
+++ b/apps/app/src/components/thread/timeline/ConversationAttachments.test.tsx
@@ -84,6 +84,48 @@ describe("conversation attachment contract", () => {
expect(download.getAttribute("download")).toBe("资料 100%.pdf");
});
+ it("renders Download for byte-backed images", () => {
+ const items = buildAttachmentItems({
+ attachments: {
+ webImages: 0,
+ localImages: 1,
+ localFiles: 0,
+ imageUrls: [],
+ localImagePaths: ["stored/diagram.png"],
+ localFilePaths: [],
+ },
+ projectId: "proj_1",
+ threadId: "thr_1",
+ });
+ render();
+
+ const download = screen.getByRole("link", {
+ name: "Download diagram.png",
+ });
+ expect(download.getAttribute("href")).toContain("/attachments/download?");
+ expect(download.getAttribute("download")).toBe("diagram.png");
+ });
+
+ it("renders malformed URL-like paths as inert controls", () => {
+ const items = buildAttachmentItems({
+ attachments: {
+ webImages: 0,
+ localImages: 0,
+ localFiles: 1,
+ imageUrls: [],
+ localImagePaths: [],
+ localFilePaths: ["https:"],
+ },
+ projectId: "proj_1",
+ threadId: "thr_1",
+ });
+ render();
+
+ expect(screen.getByText("https:")).not.toBeNull();
+ expect(screen.queryByRole("button")).toBeNull();
+ expect(screen.queryByRole("link")).toBeNull();
+ });
+
it("leaves unresolved controls inert and visible", () => {
const items = buildAttachmentItems({
attachments: {
diff --git a/apps/app/src/components/thread/timeline/ConversationAttachments.tsx b/apps/app/src/components/thread/timeline/ConversationAttachments.tsx
index 6b3ba3aae1..beee9bfba4 100644
--- a/apps/app/src/components/thread/timeline/ConversationAttachments.tsx
+++ b/apps/app/src/components/thread/timeline/ConversationAttachments.tsx
@@ -95,7 +95,12 @@ function fileIdentity({
};
}
if (/^https?:/iu.test(path)) {
- const url = new URL(path);
+ let url: URL;
+ try {
+ url = new URL(path);
+ } catch {
+ return null;
+ }
return {
source: { store: "remote", ownerId: url.origin, url: path },
displayName,
diff --git a/apps/app/src/components/ui/markdown-preview.test.tsx b/apps/app/src/components/ui/markdown-preview.test.tsx
index fcfbbbb817..d1210635ea 100644
--- a/apps/app/src/components/ui/markdown-preview.test.tsx
+++ b/apps/app/src/components/ui/markdown-preview.test.tsx
@@ -291,6 +291,21 @@ describe("MarkdownPreview", () => {
).toBe("file:///workspace/src/app.ts");
});
+ it("keeps common dotted code tokens inert", () => {
+ const tokens = ["Array.from", "user.name", "os.path", "self.value"];
+ render(
+ `\`${token}\``).join(" ")}
+ linkRouting={workspaceLinkRouting}
+ />,
+ );
+
+ for (const token of tokens) {
+ expect(screen.queryByRole("link", { name: token })).toBeNull();
+ expect(screen.getByText(token).tagName).toBe("CODE");
+ }
+ });
+
it("shows a context menu on local file links when the context provides items", () => {
const openBuiltin = vi.fn();
const openFinder = vi.fn();
diff --git a/apps/app/src/components/ui/markdown-preview.tsx b/apps/app/src/components/ui/markdown-preview.tsx
index fe8f08c3f0..6cb228d83f 100644
--- a/apps/app/src/components/ui/markdown-preview.tsx
+++ b/apps/app/src/components/ui/markdown-preview.tsx
@@ -563,6 +563,18 @@ function hasFileExtension(path: string): boolean {
return /\.[A-Za-z0-9][A-Za-z0-9+_-]{0,31}$/u.test(path);
}
+function isLikelyFileReferencePath(
+ reference: string,
+ resolvedPath: string,
+): boolean {
+ return (
+ hasFileExtension(resolvedPath) &&
+ (reference.includes("/") ||
+ reference.includes("\\") ||
+ /\.(?:md|markdown)$/iu.test(resolvedPath))
+ );
+}
+
function resolveInlineCodeMarkdownFileHref({
codeText,
localFileRouting,
@@ -585,7 +597,9 @@ function resolveInlineCodeMarkdownFileHref({
href: codeText,
});
if (absoluteLink !== null) {
- return hasFileExtension(absoluteLink.path) ? codeText : null;
+ return isLikelyFileReferencePath(codeText, absoluteLink.path)
+ ? codeText
+ : null;
}
if (localFileRouting.relativeLinks === undefined) {
@@ -604,7 +618,8 @@ function resolveInlineCodeMarkdownFileHref({
absoluteLinks: localFileRouting.absoluteLinks,
href: resolvedHref,
});
- return resolvedLink !== null && hasFileExtension(resolvedLink.path)
+ return resolvedLink !== null &&
+ isLikelyFileReferencePath(codeText, resolvedLink.path)
? resolvedHref
: null;
}
diff --git a/apps/app/src/lib/file-resolver.test.ts b/apps/app/src/lib/file-resolver.test.ts
index 5d972cb0d4..a56fd0f28e 100644
--- a/apps/app/src/lib/file-resolver.test.ts
+++ b/apps/app/src/lib/file-resolver.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import type { ExperimentalFileIdentity } from "@get-bb/plugin-sdk";
-import { resolveFileInteraction } from "./file-resolver";
+import {
+ byteFileTabFromIdentity,
+ resolveFileInteraction,
+} from "./file-resolver";
function identity(
source: ExperimentalFileIdentity["source"],
@@ -77,4 +80,60 @@ describe("resolveFileInteraction", () => {
"/api/v1/environments/environment-1/files/preview?path=src%2Fapp.ts",
});
});
+
+ it("maps thread-host files to thread routes", () => {
+ expect(
+ resolveFileInteraction(
+ identity({
+ store: "thread-host",
+ ownerId: "thread-1",
+ path: "/tmp/report.md",
+ }),
+ ),
+ ).toEqual({
+ downloadUrl:
+ "/api/v1/threads/thread-1/host-files/download?path=%2Ftmp%2Freport.md",
+ openAction: "preview",
+ previewUrl:
+ "/api/v1/threads/thread-1/host-files/preview?path=%2Ftmp%2Freport.md",
+ });
+ });
+
+ it("uses distinct Tasks preview and download routes", () => {
+ expect(
+ resolveFileInteraction(
+ identity({
+ store: "tasks-attachment",
+ ownerId: "task-1",
+ attachmentId: "attachment % # 资料",
+ }),
+ ),
+ ).toEqual({
+ downloadUrl:
+ "/api/v1/plugins/tasks/http/attachments/download?attachmentId=attachment%20%25%20%23%20%E8%B5%84%E6%96%99",
+ openAction: "preview",
+ previewUrl:
+ "/api/v1/plugins/tasks/http/attachments/preview?attachmentId=attachment%20%25%20%23%20%E8%B5%84%E6%96%99",
+ });
+ });
+
+ it("creates a Tasks byte tab without losing identity or line range", () => {
+ expect(
+ byteFileTabFromIdentity(
+ identity({
+ store: "tasks-attachment",
+ ownerId: "task-1",
+ attachmentId: "attachment-1",
+ }),
+ ),
+ ).toEqual({
+ displayName: "资料 100%#final.md",
+ lineRange: { startLineNumber: 2, endLineNumber: 7 },
+ mimeType: "text/markdown",
+ ownerId: "task-1",
+ resourceId: "attachment-1",
+ sizeBytes: 42,
+ source: "tasks-attachment",
+ });
+ });
});
diff --git a/apps/server/src/services/files/file-response-policy.ts b/apps/server/src/services/files/file-response-policy.ts
index 25e999c5b1..65cb62cd0d 100644
--- a/apps/server/src/services/files/file-response-policy.ts
+++ b/apps/server/src/services/files/file-response-policy.ts
@@ -1,12 +1,11 @@
import { Buffer } from "node:buffer";
+import { filePreviewContentSecurityPolicy } from "@bb/server-contract";
const BIDI_CONTROL_PATTERN = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
const MIME_TYPE_PATTERN =
/^(?:application|audio|font|image|message|model|multipart|text|video)\/[!#$&^_.+\-A-Za-z0-9]+$/u;
const OCTET_STREAM_MIME_TYPE = "application/octet-stream";
const HTML_MIME_TYPE = "text/html";
-const SVG_MIME_TYPE = "image/svg+xml";
-const ACTIVE_CONTENT_CSP = "sandbox allow-scripts";
type FileResponseDisposition = "attachment" | "inline";
@@ -79,6 +78,7 @@ function safeInlineMimeType(mimeType: string | null | undefined): string {
normalized.startsWith("video/") ||
normalized === "application/pdf" ||
normalized === "application/json" ||
+ normalized === "application/xml" ||
normalized.endsWith("+json") ||
normalized.endsWith("+xml")
) {
@@ -105,12 +105,12 @@ export function buildFileResponseHeaders({
if (cacheControl !== undefined) {
headers.set("cache-control", cacheControl);
}
- if (
- disposition === "inline" &&
- (contentType.startsWith(`${HTML_MIME_TYPE};`) ||
- contentType === SVG_MIME_TYPE)
- ) {
- headers.set("content-security-policy", ACTIVE_CONTENT_CSP);
+ const contentSecurityPolicy =
+ disposition === "inline"
+ ? filePreviewContentSecurityPolicy(contentType)
+ : null;
+ if (contentSecurityPolicy !== null) {
+ headers.set("content-security-policy", contentSecurityPolicy);
}
return headers;
}
diff --git a/apps/server/test/files/file-response-policy.test.ts b/apps/server/test/files/file-response-policy.test.ts
index 9e2769397b..2dc13b90fe 100644
--- a/apps/server/test/files/file-response-policy.test.ts
+++ b/apps/server/test/files/file-response-policy.test.ts
@@ -49,6 +49,36 @@ describe("file response policy", () => {
expect(html.get("content-security-policy")).toBe("sandbox allow-scripts");
});
+ it.each([
+ ["text/html", "text/html; charset=utf-8"],
+ ["application/xhtml+xml", "application/xhtml+xml"],
+ ["application/rdf+xml", "application/rdf+xml"],
+ ["application/xml", "application/xml"],
+ ["text/xml", "text/xml"],
+ ["text/mathml", "text/mathml"],
+ ["image/svg+xml", "image/svg+xml"],
+ ])(
+ "sandboxes %s previews without changing their safe MIME type",
+ (mimeType, expectedContentType) => {
+ const preview = buildFileResponseHeaders({
+ disposition: "inline",
+ fileName: "active-content.xml",
+ mimeType,
+ });
+ const download = buildFileResponseHeaders({
+ disposition: "attachment",
+ fileName: "active-content.xml",
+ mimeType,
+ });
+
+ expect(preview.get("content-type")).toBe(expectedContentType);
+ expect(preview.get("content-security-policy")).toBe(
+ "sandbox allow-scripts",
+ );
+ expect(download.get("content-security-policy")).toBeNull();
+ },
+ );
+
it("forces unknown preview types to octet-stream and preserves download MIME", () => {
expect(
buildFileResponseHeaders({
diff --git a/apps/server/test/public/public-project-attachments.test.ts b/apps/server/test/public/public-project-attachments.test.ts
index 9df03d508c..d091eb8e61 100644
--- a/apps/server/test/public/public-project-attachments.test.ts
+++ b/apps/server/test/public/public-project-attachments.test.ts
@@ -1,3 +1,5 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { uploadedPromptAttachmentSchema } from "@bb/server-contract";
import { readJson } from "../helpers/json.js";
@@ -187,6 +189,35 @@ describe("public project attachments", () => {
});
});
+ it("rejects encoded and decoded paths outside the project attachment root", async () => {
+ await withTestHarness(async (harness) => {
+ const { host } = seedHostSession(harness.deps, {
+ id: "host-project-attachment-containment",
+ });
+ const { project } = seedProjectWithSource(harness.deps, {
+ hostId: host.id,
+ });
+ const attachmentRoot = join(harness.deps.config.dataDir, "attachments");
+ await mkdir(attachmentRoot, { recursive: true });
+ await writeFile(join(attachmentRoot, "outside.txt"), "outside");
+
+ for (const path of [
+ "../outside.txt",
+ "..%2Foutside.txt",
+ "%2e%2e%2foutside.txt",
+ ]) {
+ const response = await harness.app.request(
+ `/api/v1/projects/${project.id}/attachments/preview?path=${path}`,
+ );
+ expect(response.status, path).toBe(400);
+ await expect(readJson(response)).resolves.toEqual({
+ code: "invalid_request",
+ message: "Attachment path escapes project directory",
+ });
+ }
+ });
+ });
+
it("rejects HEIC/HEIF image uploads instead of storing an image nothing can render", async () => {
await withTestHarness(async (harness) => {
const { host } = seedHostSession(harness.deps, {
diff --git a/apps/server/test/public/public-project-workspace-routing.test.ts b/apps/server/test/public/public-project-workspace-routing.test.ts
index a4a2fcc8a6..aee69788e6 100644
--- a/apps/server/test/public/public-project-workspace-routing.test.ts
+++ b/apps/server/test/public/public-project-workspace-routing.test.ts
@@ -430,4 +430,55 @@ describe("public project workspace routing", () => {
);
});
});
+
+ it("serves project file previews inline and downloads as attachments", async () => {
+ await withTestHarness(async (harness) => {
+ const { host, session } = seedHostSession(harness.deps, {
+ id: "host-project-file-disposition",
+ });
+ seedPrimaryHost(harness.deps, host.id);
+ const { project } = seedProjectWithSource(harness.deps, {
+ hostId: host.id,
+ path: "/project/disposition",
+ });
+ registerHostRpcResponder(harness, {
+ hostId: host.id,
+ sessionId: session.id,
+ handle: (request) => {
+ if (request.command.type !== "host.read_file") {
+ throw new Error(
+ `Unexpected project file RPC ${request.command.type}`,
+ );
+ }
+ return {
+ ok: true,
+ result: {
+ path: request.command.path,
+ content: "# Guide\n",
+ contentEncoding: "utf8",
+ mimeType: "text/markdown",
+ sizeBytes: 8,
+ sha256: "2".repeat(64),
+ },
+ };
+ },
+ });
+
+ const preview = await harness.app.request(
+ `/api/v1/projects/${project.id}/files/preview?path=docs%2Fguide.md`,
+ );
+ const download = await harness.app.request(
+ `/api/v1/projects/${project.id}/files/download?path=docs%2Fguide.md`,
+ );
+
+ expect(preview.status).toBe(200);
+ expect(preview.headers.get("content-type")).toBe("text/markdown");
+ expect(preview.headers.get("content-disposition")).toContain("inline;");
+ expect(download.status).toBe(200);
+ expect(download.headers.get("content-type")).toBe("text/markdown");
+ expect(download.headers.get("content-disposition")).toContain(
+ 'attachment; filename="guide.md";',
+ );
+ });
+ });
});
diff --git a/packages/client-core/test/fixed-panel-tabs-state.test.ts b/packages/client-core/test/fixed-panel-tabs-state.test.ts
new file mode 100644
index 0000000000..7c8534023a
--- /dev/null
+++ b/packages/client-core/test/fixed-panel-tabs-state.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from "vitest";
+import {
+ areFixedPanelTabsEquivalent,
+ createByteFilePreviewFixedPanelTab,
+} from "../src/panel/fixed-panel-tabs-state.js";
+
+describe("byte file preview tab equivalence", () => {
+ it("compares all byte identity and preview fields", () => {
+ const first = createByteFilePreviewFixedPanelTab({
+ tab: {
+ displayName: "report.pdf",
+ lineRange: { startLineNumber: 2, endLineNumber: 7 },
+ mimeType: "application/pdf",
+ ownerId: "task-1",
+ resourceId: "attachment-1",
+ sizeBytes: 42,
+ source: "tasks-attachment",
+ },
+ });
+ const second = createByteFilePreviewFixedPanelTab({
+ tab: {
+ displayName: "report.pdf",
+ lineRange: { startLineNumber: 2, endLineNumber: 7 },
+ mimeType: "application/pdf",
+ ownerId: "task-1",
+ resourceId: "attachment-1",
+ sizeBytes: 42,
+ source: "tasks-attachment",
+ },
+ });
+
+ expect(areFixedPanelTabsEquivalent(first, second)).toBe(true);
+ for (const changed of [
+ { ...second, displayName: "other.pdf" },
+ { ...second, mimeType: "application/octet-stream" },
+ { ...second, sizeBytes: 43 },
+ {
+ ...second,
+ lineRange: { startLineNumber: 3, endLineNumber: 7 },
+ },
+ ]) {
+ expect(areFixedPanelTabsEquivalent(first, changed)).toBe(false);
+ }
+ });
+});
diff --git a/packages/server-contract/src/file-response-policy.ts b/packages/server-contract/src/file-response-policy.ts
new file mode 100644
index 0000000000..8f16c55632
--- /dev/null
+++ b/packages/server-contract/src/file-response-policy.ts
@@ -0,0 +1,18 @@
+export const FILE_PREVIEW_ACTIVE_CONTENT_SECURITY_POLICY =
+ "sandbox allow-scripts";
+
+export function filePreviewContentSecurityPolicy(
+ mimeType: string,
+): string | null {
+ const normalized = mimeType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
+ if (
+ normalized === "text/html" ||
+ normalized === "application/xml" ||
+ normalized === "text/xml" ||
+ normalized === "text/mathml" ||
+ normalized.endsWith("+xml")
+ ) {
+ return FILE_PREVIEW_ACTIVE_CONTENT_SECURITY_POLICY;
+ }
+ return null;
+}
diff --git a/packages/server-contract/src/index.ts b/packages/server-contract/src/index.ts
index 5759ed739f..03aeb84762 100644
--- a/packages/server-contract/src/index.ts
+++ b/packages/server-contract/src/index.ts
@@ -3,6 +3,7 @@ export * from "./api-types.js";
export * from "./api/thread-tabs.js";
export * from "./common.js";
export * from "./errors.js";
+export * from "./file-response-policy.js";
export * from "./public-api.js";
export * from "./thread-timeline.js";
diff --git a/packages/server-contract/test/file-response-policy.test.ts b/packages/server-contract/test/file-response-policy.test.ts
new file mode 100644
index 0000000000..fd6a4ceb54
--- /dev/null
+++ b/packages/server-contract/test/file-response-policy.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import {
+ FILE_PREVIEW_ACTIVE_CONTENT_SECURITY_POLICY,
+ filePreviewContentSecurityPolicy,
+} from "../src/file-response-policy.js";
+
+describe("file preview response policy", () => {
+ it.each([
+ "text/html",
+ "text/html; charset=utf-8",
+ "application/xhtml+xml",
+ "application/rdf+xml",
+ "application/xml",
+ "text/xml",
+ "text/mathml",
+ "image/svg+xml",
+ ])("sandboxes active content for %s", (mimeType) => {
+ expect(filePreviewContentSecurityPolicy(mimeType)).toBe(
+ FILE_PREVIEW_ACTIVE_CONTENT_SECURITY_POLICY,
+ );
+ });
+
+ it.each([
+ "text/plain",
+ "text/markdown",
+ "application/pdf",
+ "image/png",
+ "audio/ogg",
+ "video/mp4",
+ "application/octet-stream",
+ ])("does not sandbox passive content for %s", (mimeType) => {
+ expect(filePreviewContentSecurityPolicy(mimeType)).toBeNull();
+ });
+});
diff --git a/plugins/tasks/attachments/attachments.test.ts b/plugins/tasks/attachments/attachments.test.ts
index f4ee223edc..23f22fabaa 100644
--- a/plugins/tasks/attachments/attachments.test.ts
+++ b/plugins/tasks/attachments/attachments.test.ts
@@ -1,4 +1,4 @@
-import { readFile, stat } from "node:fs/promises";
+import { readFile, rm, stat, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { createFakePluginHost } from "@get-bb/plugin-sdk/testing";
import { describe, expect, it } from "vitest";
@@ -8,7 +8,9 @@ import {
buildAttachmentUrl,
deleteAttachmentById,
MAX_ATTACHMENT_SIZE_BYTES,
+ readAttachmentContent,
registerAttachments,
+ saveAttachmentFromBytes,
} from ".";
function setup(options?: Parameters[2]) {
@@ -30,7 +32,7 @@ function setup(options?: Parameters[2]) {
.all()
.find((entry) => entry.name === "main");
if (!database) throw new Error("test database path is missing");
- return { bb, harness, store, task, root: dirname(database.file) };
+ return { bb, db, harness, store, task, root: dirname(database.file) };
}
async function upload(
@@ -253,6 +255,97 @@ describe("task attachments", () => {
}
});
+ it.each([
+ ["unsafe.html", "text/html"],
+ ["unsafe.xhtml", "application/xhtml+xml"],
+ ["unsafe.rdf", "application/rdf+xml"],
+ ["unsafe.xml", "application/xml"],
+ ["unsafe.txt", "text/xml"],
+ ["unsafe.mathml", "text/mathml"],
+ ["unsafe.svg", "image/svg+xml"],
+ ])("applies the shared sandbox policy to %s", async (fileName, mime) => {
+ const { harness, task } = setup();
+ try {
+ const uploaded = await upload(
+ harness,
+ task.id,
+ new TextEncoder().encode(""),
+ fileName,
+ mime,
+ );
+ const { attachmentId } = (await uploaded.json()) as {
+ attachmentId: string;
+ };
+ const preview = await harness.fetchHttp(
+ "GET",
+ `/attachments/preview?attachmentId=${attachmentId}`,
+ );
+ const download = await harness.fetchHttp(
+ "GET",
+ `/attachments/download?attachmentId=${attachmentId}`,
+ );
+
+ expect(preview.headers.get("content-type")).toBe(mime);
+ expect(preview.headers.get("content-security-policy")).toBe(
+ "sandbox allow-scripts",
+ );
+ expect(download.headers.get("content-security-policy")).toBeNull();
+ } finally {
+ await harness.dispose();
+ }
+ });
+
+ it("sanitizes filenames supplied through direct byte saves", async () => {
+ const { harness, store, task } = setup();
+ try {
+ const attachment = await saveAttachmentFromBytes(
+ store,
+ new TextEncoder().encode("unsafe name"),
+ {
+ taskId: task.id,
+ fileName: "../../unsafe?.txt",
+ mime: "text/plain",
+ },
+ );
+
+ expect(attachment.fileName).toBe("unsafe_.txt");
+ expect(attachment.blobPath).toBe(
+ join("blobs", attachment.id, "unsafe_.txt"),
+ );
+ } finally {
+ await harness.dispose();
+ }
+ });
+
+ it("refuses stored blob paths outside the plugin data directory", async () => {
+ const { db, harness, root, store, task } = setup();
+ const outsideName = `outside-${task.id}.txt`;
+ const outsidePath = join(root, "..", outsideName);
+ try {
+ const attachment = await saveAttachmentFromBytes(
+ store,
+ new TextEncoder().encode("inside"),
+ {
+ taskId: task.id,
+ fileName: "inside.txt",
+ mime: "text/plain",
+ },
+ );
+ await writeFile(outsidePath, "outside");
+ db.prepare<[string, string]>(
+ "UPDATE attachments SET blob_path = ? WHERE id = ?",
+ ).run(join("..", outsideName), attachment.id);
+
+ await expect(readAttachmentContent(store, attachment.id)).rejects.toThrow(
+ "Attachment blob path escapes the plugin data directory",
+ );
+ await expect(readFile(outsidePath, "utf8")).resolves.toBe("outside");
+ } finally {
+ await rm(outsidePath, { force: true });
+ await harness.dispose();
+ }
+ });
+
it("downloads a non-Latin-1 file name with an ASCII fallback in filename= (issue #1621)", async () => {
const { harness, store, task } = setup();
try {
diff --git a/plugins/tasks/attachments/index.ts b/plugins/tasks/attachments/index.ts
index 36632a930e..c20076ce17 100644
--- a/plugins/tasks/attachments/index.ts
+++ b/plugins/tasks/attachments/index.ts
@@ -1,5 +1,6 @@
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { dirname, join, resolve, sep } from "node:path";
+import { filePreviewContentSecurityPolicy } from "@bb/server-contract";
import type { BbPluginApi } from "@get-bb/plugin-sdk";
import type { Attachment, TasksStore } from "../db";
import {
@@ -211,12 +212,6 @@ function isInlineRasterMime(mime: string): boolean {
);
}
-function previewContentSecurityPolicy(mime: string): string | undefined {
- return mime === "text/html" || mime === "image/svg+xml"
- ? "sandbox; default-src 'none'; img-src data:; media-src data:; style-src 'unsafe-inline'"
- : undefined;
-}
-
function bytesMatch(
bytes: Uint8Array,
offset: number,
@@ -560,7 +555,7 @@ export function registerAttachments(
}
const mime = canonicalAttachmentMime(attachment.mime, attachment.fileName);
const contentSecurityPolicy =
- disposition === "inline" ? previewContentSecurityPolicy(mime) : undefined;
+ disposition === "inline" ? filePreviewContentSecurityPolicy(mime) : null;
return new Response(new Uint8Array(await readFile(absolutePath)), {
headers: {
"Content-Type": mime,
@@ -570,7 +565,7 @@ export function registerAttachments(
attachment.fileName,
),
"X-Content-Type-Options": "nosniff",
- ...(contentSecurityPolicy === undefined
+ ...(contentSecurityPolicy === null
? {}
: { "Content-Security-Policy": contentSecurityPolicy }),
},
diff --git a/plugins/tasks/package.json b/plugins/tasks/package.json
index d71b0906d1..76198fbe10 100644
--- a/plugins/tasks/package.json
+++ b/plugins/tasks/package.json
@@ -59,6 +59,7 @@
"vitest": "^4.1.1"
},
"dependencies": {
+ "@bb/server-contract": "workspace:*",
"@bb/shared-ui": "workspace:*",
"@hugeicons/core-free-icons": "^4.1.3",
"@hugeicons/react": "^1.1.6",
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
From c991bfd97995ba65f31feb16dab77ae07ad7e2a6 Mon Sep 17 00:00:00 2001
From: Jonathan Lee
Date: Mon, 31 Aug 2026 07:19:28 +0000
Subject: [PATCH 19/19] fix(files): complete BM-41 review follow-up
---
.../src/components/promptbox/AttachmentPreview.test.tsx | 2 +-
.../thread/timeline/GeneratedConversationMessage.tsx | 1 -
.../threadDetailMarkdownFilePreviewRouting.test.ts | 8 ++++----
apps/server/src/services/files/file-response-policy.ts | 1 +
.../bb-plugin-authoring/references/frontend-api-index.md | 3 +++
packages/client-core/test/fixed-panel-tabs-state.test.ts | 2 ++
packages/domain/src/plugin-sdk-version.ts | 2 +-
packages/plugin-sdk/package.json | 2 +-
.../server-contract/test/file-response-policy.test.ts | 7 ++-----
9 files changed, 15 insertions(+), 13 deletions(-)
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/thread/timeline/GeneratedConversationMessage.tsx b/apps/app/src/components/thread/timeline/GeneratedConversationMessage.tsx
index 157b91c2d7..de9a09bb9b 100644
--- a/apps/app/src/components/thread/timeline/GeneratedConversationMessage.tsx
+++ b/apps/app/src/components/thread/timeline/GeneratedConversationMessage.tsx
@@ -628,7 +628,6 @@ export const GeneratedConversationMessage = memo(
messageText,
messageMentions,
onOpenLocalFileLink,
- projectId,
resolveSegmentLinkHref,
resolveMentionLink,
sourceKind,
diff --git a/apps/app/src/views/thread-detail/threadDetailMarkdownFilePreviewRouting.test.ts b/apps/app/src/views/thread-detail/threadDetailMarkdownFilePreviewRouting.test.ts
index b186e703e1..23e24ee175 100644
--- a/apps/app/src/views/thread-detail/threadDetailMarkdownFilePreviewRouting.test.ts
+++ b/apps/app/src/views/thread-detail/threadDetailMarkdownFilePreviewRouting.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import {
- buildThreadHostFileContentUrl,
- buildThreadStorageContentUrl,
+ buildThreadHostFilePreviewUrl,
+ buildThreadStoragePreviewUrl,
buildThreadWorktreeRawContentUrl,
} from "@/lib/file-content-urls";
import { buildThreadDetailMarkdownFilePreviewRouting } from "./threadDetailMarkdownFilePreviewRouting";
@@ -65,7 +65,7 @@ describe("buildThreadDetailMarkdownFilePreviewRouting", () => {
"/storage/thread-1",
);
expect(resolveSibling(routing)).toBe(
- buildThreadHostFileContentUrl(
+ buildThreadHostFilePreviewUrl(
"thread-1",
"/storage/thread-1/current/docs/asset space-资料%.png",
),
@@ -89,7 +89,7 @@ describe("buildThreadDetailMarkdownFilePreviewRouting", () => {
rootPath: "/storage/thread-1",
});
expect(resolveSibling(routing)).toBe(
- buildThreadStorageContentUrl(
+ buildThreadStoragePreviewUrl(
"thread-1",
"current/docs/asset space-资料%.png",
),
diff --git a/apps/server/src/services/files/file-response-policy.ts b/apps/server/src/services/files/file-response-policy.ts
index 65cb62cd0d..c696d6b54c 100644
--- a/apps/server/src/services/files/file-response-policy.ts
+++ b/apps/server/src/services/files/file-response-policy.ts
@@ -78,6 +78,7 @@ function safeInlineMimeType(mimeType: string | null | undefined): string {
normalized.startsWith("video/") ||
normalized === "application/pdf" ||
normalized === "application/json" ||
+ normalized === "application/typescript" ||
normalized === "application/xml" ||
normalized.endsWith("+json") ||
normalized.endsWith("+xml")
diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-api-index.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-api-index.md
index dca55fbef8..229b17262a 100644
--- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-api-index.md
+++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-api-index.md
@@ -140,8 +140,11 @@ Read the installed SDK declarations for the exact current signatures.
- `MarkdownProps`
- `UrlLinkProps`
- `ExperimentalLiveFileTarget`
+- `ExperimentalFileSource`
- `ExperimentalFileLocation`
+- `ExperimentalFileIdentity`
- `ExperimentalFileOpenOptions`
+- `ExperimentalResolvedFileOpenOptions`
- `ExperimentalFileLinkProps`
- `ExperimentalAppPanelSurface`
- `ExperimentalFixedTabTargetState`
diff --git a/packages/client-core/test/fixed-panel-tabs-state.test.ts b/packages/client-core/test/fixed-panel-tabs-state.test.ts
index 7c8534023a..9a780b5529 100644
--- a/packages/client-core/test/fixed-panel-tabs-state.test.ts
+++ b/packages/client-core/test/fixed-panel-tabs-state.test.ts
@@ -33,6 +33,8 @@ describe("byte file preview tab equivalence", () => {
for (const changed of [
{ ...second, displayName: "other.pdf" },
{ ...second, mimeType: "application/octet-stream" },
+ { ...second, ownerId: "task-2" },
+ { ...second, resourceId: "attachment-2" },
{ ...second, sizeBytes: 43 },
{
...second,
diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts
index 677e5edb7f..92a764c2a3 100644
--- a/packages/domain/src/plugin-sdk-version.ts
+++ b/packages/domain/src/plugin-sdk-version.ts
@@ -1,3 +1,3 @@
-export const PLUGIN_SDK_VERSION = "0.4.29";
+export const PLUGIN_SDK_VERSION = "0.4.30";
export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]);
diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json
index 4b7ceb16e2..7661c6d09e 100644
--- a/packages/plugin-sdk/package.json
+++ b/packages/plugin-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@get-bb/plugin-sdk",
- "version": "0.4.29",
+ "version": "0.4.30",
"homepage": "https://github.com/get-bb/bb#readme",
"bugs": {
"url": "https://github.com/get-bb/bb/issues"
diff --git a/packages/server-contract/test/file-response-policy.test.ts b/packages/server-contract/test/file-response-policy.test.ts
index fd6a4ceb54..f2d1c6cac6 100644
--- a/packages/server-contract/test/file-response-policy.test.ts
+++ b/packages/server-contract/test/file-response-policy.test.ts
@@ -1,8 +1,5 @@
import { describe, expect, it } from "vitest";
-import {
- FILE_PREVIEW_ACTIVE_CONTENT_SECURITY_POLICY,
- filePreviewContentSecurityPolicy,
-} from "../src/file-response-policy.js";
+import { filePreviewContentSecurityPolicy } from "../src/file-response-policy.js";
describe("file preview response policy", () => {
it.each([
@@ -16,7 +13,7 @@ describe("file preview response policy", () => {
"image/svg+xml",
])("sandboxes active content for %s", (mimeType) => {
expect(filePreviewContentSecurityPolicy(mimeType)).toBe(
- FILE_PREVIEW_ACTIVE_CONTENT_SECURITY_POLICY,
+ "sandbox allow-scripts",
);
});