diff --git a/CLAUDE.md b/CLAUDE.md
index e1ac653338..7076471ff9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -54,7 +54,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
-| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and routing preferences |
+| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar, side-pane, and compact workspace sheet host contracts, lifecycle, placement, routing preferences |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md
index d699d8519a..f501538270 100644
--- a/docs/agent-lifecycle.md
+++ b/docs/agent-lifecycle.md
@@ -212,7 +212,7 @@ parentAgentId === thisAgent.id AND !archivedAt
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
-Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
+Clicking either kind opens a workspace tab. On compact layouts it opens in the [workspace sheet](explorer-sidebar.md#workspace-sheet) instead; the sheet hides the composer, and a Paseo subagent's sheet offers **Open as tab** to get it back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md
index f1e27ac548..abd95fc35f 100644
--- a/docs/explorer-sidebar.md
+++ b/docs/explorer-sidebar.md
@@ -7,6 +7,7 @@ contracts.
| ---------------- | ---------------------------- | ------------------------------------------ |
| Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock |
| Side pane | Ordinary workspace content | Created and closed like any workspace pane |
+| Workspace sheet | Compact look-ups | Holds one tab; closes or becomes a tab |
## Panel host contract
@@ -22,6 +23,24 @@ can move between Explorer and main panes.
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
+## Workspace sheet
+
+On compact layouts, files and subagents open in a closable sheet over the workspace, so the tab
+switcher keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest
+(`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between
+sheet and tab, so callers open targets the same way on every layout. The sheet holds one ordinary
+`WorkspaceTab`, which is not persisted.
+
+The sheet is modal and covers the whole compact column, header included. It renders in the
+workspace tree rather than through `IsolatedBottomSheetModal`: its panel needs the navigator and
+every workspace provider an ordinary pane has, and a portal drops them
+([floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site)).
+
+A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
+Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the
+mounted view, so the move would drop it. Widening the layout is the known exception: it still moves
+the sheet's tab into a pane, and edits the autosave has not started writing are lost.
+
## Explorer sidebar
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
@@ -62,7 +81,9 @@ lifecycle.
`packages/app/src/workspace-tabs/open-beside.ts` owns content opened beside the user's work. The
layout store remembers one ordinary pane per workspace. The first side open creates a full-height
-right split around the workspace root; later side opens reuse it.
+right split around the workspace root; later side opens reuse it. Only the `split-panes` layout
+mode opens beside. Compact and single-pane layouts (touch tablets) open the same target in the main
+pane, so callers never check whether splits render.
Removing a pane clears its remembered id; a later side open creates a new pane. The last visible
ordinary pane stays when its final tab closes and shows the New launcher. An empty workspace does
diff --git a/docs/floating-panels.md b/docs/floating-panels.md
index 0d3983c343..73df983a50 100644
--- a/docs/floating-panels.md
+++ b/docs/floating-panels.md
@@ -304,7 +304,9 @@ track phase.
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This
includes native centered dialogs: separate React Native Modals cannot present a root-owned
-sibling dialog over an existing iOS presenter.
+sibling dialog over an existing iOS presenter. The one exception is the
+[workspace sheet](explorer-sidebar.md#workspace-sheet), an in-tree Gorhom `BottomSheet` that hosts a
+whole workspace panel.
The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary
but stores it in the modal queue and later reads `.current`; provider dismissal, replacement,
diff --git a/docs/glossary.md b/docs/glossary.md
index 3ed97d1fc3..ac988e8d2b 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -21,6 +21,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
+- **Workspace sheet** — The compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `sheetByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `WorkspaceSheetHost` (`packages/app/src/screens/workspace/workspace-sheet-host.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#workspace-sheet).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
diff --git a/packages/app/e2e/browser/compact-workspace-sheet.spec.ts b/packages/app/e2e/browser/compact-workspace-sheet.spec.ts
new file mode 100644
index 0000000000..474a498353
--- /dev/null
+++ b/packages/app/e2e/browser/compact-workspace-sheet.spec.ts
@@ -0,0 +1,106 @@
+import { readFile, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { expect, test } from "../support/fixtures";
+import { expectComposerVisible } from "../support/helpers/composer";
+import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
+import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
+import { openSubagentFromCompactTrack, seedSubagent } from "../support/helpers/subagents";
+import {
+ closeWorkspaceSheet,
+ expectOpenTabCount,
+ openWorkspaceSheetAsTab,
+ expectDeclinedDiscardPrompt,
+ expectWorkspaceSheetAtSnap,
+ replaceWorkspaceSheetFileText,
+ selectTabFromSwitcher,
+ tapWorkspaceSheetBackdrop,
+ workspaceSheet,
+} from "../support/helpers/workspace-sheet";
+
+const FILE_LINK = "linked.ts:3";
+
+function promptMentioning(target: string): string {
+ return [
+ "Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
+ "Return JSON only with fields 'title' and 'branch'.",
+ "",
+ "",
+ `Open \`${target}\` now`,
+ "",
+ ].join("\n");
+}
+
+test("compact opens files and subagents in a sheet, leaving the tabs for agents", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ const workspace = await seedMockAgentWorkspace({
+ repoPrefix: "compact-workspace-sheet-",
+ title: "Sheet parent",
+ initialPrompt: promptMentioning(FILE_LINK),
+ });
+ try {
+ const gate = await installDaemonWebSocketGate(page);
+ const linkedPath = path.join(workspace.cwd, "linked.ts");
+ await writeFile(linkedPath, "one\ntwo\nthree\n", "utf8");
+ const child = await seedSubagent(
+ { client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
+ { parentId: workspace.agentId, title: "Sheet child" },
+ );
+ await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
+ await expectOpenTabCount(page, 1);
+
+ await test.step("a chat file link opens the file in a sheet", async () => {
+ const fileLink = page.getByText(FILE_LINK, { exact: true });
+ await expect(fileLink).toBeVisible({ timeout: 15_000 });
+ await fileLink.click();
+
+ const sheet = workspaceSheet(page, "linked.ts");
+ await expect(sheet).toBeVisible();
+ await expect(sheet.getByText("three")).toBeVisible();
+ await expectWorkspaceSheetAtSnap(page, "linked.ts");
+ await expect(sheet.getByRole("button", { name: "Open as tab" })).toHaveCount(0);
+ await expectOpenTabCount(page, 1);
+ await closeWorkspaceSheet(page, "linked.ts");
+ });
+
+ await test.step("a subagent opens in a sheet without its composer", async () => {
+ await openSubagentFromCompactTrack(page, child.id);
+
+ const sheet = workspaceSheet(page, child.title);
+ await expect(sheet).toBeVisible();
+ await expectWorkspaceSheetAtSnap(page, child.title);
+ await expect(sheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
+ await expectOpenTabCount(page, 1);
+ });
+
+ await test.step("Open as tab promotes the subagent to a full tab", async () => {
+ await openWorkspaceSheetAsTab(page, child.title);
+ await expectOpenTabCount(page, 2);
+ await expectComposerVisible(page);
+ });
+
+ await test.step("a sheet with unsaved edits closes only through the guarded close", async () => {
+ await selectTabFromSwitcher(page, "Sheet parent");
+ await page.getByText(FILE_LINK, { exact: true }).click();
+ const sheet = workspaceSheet(page, "linked.ts");
+ await expect(sheet).toBeVisible();
+ // Hold the autosave so the edit stays unsaved.
+ gate.holdNextClientRequest("fs.file.write.request");
+ await replaceWorkspaceSheetFileText(page, "linked.ts", "edited in the sheet");
+ await gate.waitForHeldClientRequest();
+
+ await expectDeclinedDiscardPrompt(page, () => tapWorkspaceSheetBackdrop(page));
+ await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet");
+ await expectDeclinedDiscardPrompt(page, () =>
+ sheet.getByRole("button", { name: "Close" }).click(),
+ );
+ await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet");
+
+ gate.releaseHeldClientRequest();
+ await expect.poll(() => readFile(linkedPath, "utf8")).toBe("edited in the sheet");
+ });
+ } finally {
+ await workspace.cleanup();
+ }
+});
diff --git a/packages/app/e2e/support/helpers/subagents.ts b/packages/app/e2e/support/helpers/subagents.ts
index d9c59e702e..f3a5ca7c71 100644
--- a/packages/app/e2e/support/helpers/subagents.ts
+++ b/packages/app/e2e/support/helpers/subagents.ts
@@ -49,31 +49,37 @@ export async function seedParentWithSubagent(
modeId: "load-test",
model: "ten-second-stream",
});
- const child = await workspace.client.createAgent({
- provider: "mock",
- cwd: workspace.repoPath,
- workspaceId: workspace.workspaceId,
- title: input.childTitle,
- modeId: "load-test",
- model: "ten-second-stream",
- labels: {
- [PARENT_AGENT_ID_LABEL]: parent.id,
- },
- });
+ const child = await seedSubagent(workspace, { parentId: parent.id, title: input.childTitle });
return {
parent: {
id: parent.id,
title: input.parentTitle,
},
- child: {
- id: child.id,
- title: input.childTitle,
- },
+ child,
workspaceId: workspace.workspaceId,
};
}
+/** Seeds an idle Paseo subagent of an existing agent in the same workspace. */
+export async function seedSubagent(
+ workspace: Pick,
+ input: { parentId: string; title: string },
+): Promise<{ id: string; title: string }> {
+ const child = await workspace.client.createAgent({
+ provider: "mock",
+ cwd: workspace.repoPath,
+ workspaceId: workspace.workspaceId,
+ title: input.title,
+ modeId: "load-test",
+ model: "ten-second-stream",
+ labels: {
+ [PARENT_AGENT_ID_LABEL]: input.parentId,
+ },
+ });
+ return { id: child.id, title: input.title };
+}
+
export async function seedParentWithCrossWorkspaceSubagent(
workspace: Pick,
input: { parentTitle: string; childTitle: string },
@@ -141,6 +147,12 @@ export async function openSubagentsTrack(page: Page): Promise {
await expect(panel).toBeVisible({ timeout: 30_000 });
}
+/** On compact the track opens as a sheet, which closes once a row is chosen. */
+export async function openSubagentFromCompactTrack(page: Page, childId: string): Promise {
+ await page.getByTestId("subagents-track-header").click();
+ await page.getByTestId(`subagents-track-row-${childId}`).click();
+}
+
export async function expectSubagentRowVisible(page: Page, childId: string): Promise {
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toBeVisible({
timeout: 30_000,
diff --git a/packages/app/e2e/support/helpers/workspace-sheet.ts b/packages/app/e2e/support/helpers/workspace-sheet.ts
new file mode 100644
index 0000000000..8865073c81
--- /dev/null
+++ b/packages/app/e2e/support/helpers/workspace-sheet.ts
@@ -0,0 +1,94 @@
+import { expect, type Locator, type Page } from "@playwright/test";
+
+/** The compact workspace sheet, named after the tab it hosts. */
+export function workspaceSheet(page: Page, label: string): Locator {
+ return page.getByRole("dialog", { name: label });
+}
+
+/** Sheet height as a fraction of the viewport; mirrors the host's 92% snap point. */
+const SHEET_SNAP_FRACTION = 0.92;
+/** The dialog starts below the sheet's drag handle. */
+const SHEET_HANDLE_MAX_HEIGHT = 32;
+
+/**
+ * The sheet rests at its snap point, docked to the bottom edge. A sheet pushed up past it
+ * covers the workspace header; a sheet sunk below it hides content.
+ */
+export async function expectWorkspaceSheetAtSnap(page: Page, label: string): Promise {
+ const viewportHeight = page.viewportSize()?.height ?? 0;
+ const snapTop = Math.round(viewportHeight * (1 - SHEET_SNAP_FRACTION));
+ const sheet = workspaceSheet(page, label);
+ await expect
+ .poll(async () => {
+ const box = await sheet.boundingBox();
+ if (!box) return null;
+ const top = Math.round(box.y);
+ return {
+ dockedToBottom: Math.round(box.y + box.height) === viewportHeight,
+ topAtSnap: top >= snapTop && top <= snapTop + SHEET_HANDLE_MAX_HEIGHT,
+ };
+ })
+ .toEqual({ dockedToBottom: true, topAtSnap: true });
+}
+
+export async function expectOpenTabCount(page: Page, count: number): Promise {
+ await expect(
+ page.getByRole("button", { name: `Switch tabs (${count} open)` }).filter({ visible: true }),
+ ).toBeVisible();
+}
+
+export async function closeWorkspaceSheet(page: Page, label: string): Promise {
+ const sheet = workspaceSheet(page, label);
+ await sheet.getByRole("button", { name: "Close" }).click();
+ await expect(sheet).toHaveCount(0);
+}
+
+export async function openWorkspaceSheetAsTab(page: Page, label: string): Promise {
+ const sheet = workspaceSheet(page, label);
+ await sheet.getByRole("button", { name: "Open as tab" }).click();
+ await expect(sheet).toHaveCount(0);
+}
+
+export async function replaceWorkspaceSheetFileText(
+ page: Page,
+ label: string,
+ content: string,
+): Promise {
+ const editor = workspaceSheet(page, label)
+ .getByTestId("file-source-editor")
+ .locator(".cm-content");
+ await editor.click();
+ await editor.press("ControlOrMeta+A");
+ await editor.pressSequentially(content);
+}
+
+export async function selectTabFromSwitcher(page: Page, label: string): Promise {
+ await page
+ .getByRole("button", { name: /Switch tabs/ })
+ .filter({ visible: true })
+ .click();
+ const option = page.getByText(label, { exact: true }).filter({ visible: true }).last();
+ await expect(option).toBeVisible();
+ await option.click();
+ await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
+}
+
+export async function tapWorkspaceSheetBackdrop(page: Page): Promise {
+ await page
+ .getByRole("button", { name: "Bottom sheet backdrop" })
+ .click({ position: { x: 10, y: 10 } });
+}
+
+/** Runs a close that hits the unsaved-changes guard, and declines the discard. */
+export async function expectDeclinedDiscardPrompt(
+ page: Page,
+ close: () => Promise,
+): Promise {
+ const prompt = page.waitForEvent("dialog").then(async (dialog) => {
+ const message = dialog.message();
+ await dialog.dismiss();
+ return message;
+ });
+ await close();
+ expect(await prompt).toContain("discard the draft");
+}
diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index 869c93a0b2..72989bd83d 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -116,6 +116,7 @@ import { usePanelStore } from "@/stores/panel-store";
import { flushDraftPersistStorage } from "@/stores/draft-store";
import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme";
import { useSessionStore } from "@/stores/session-store";
+import { WorkspaceLayoutModePublisher } from "@/workspace-tabs/layout-mode-publisher";
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
import type { HostProfile } from "@/types/host-connection";
import {
@@ -549,6 +550,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
}
const workspaceChrome = (
+
{!isCompactLayout ? (
{themedSidebarChrome}
diff --git a/packages/app/src/file-pane/editor/view.web.tsx b/packages/app/src/file-pane/editor/view.web.tsx
index d396697c41..8b04590d28 100644
--- a/packages/app/src/file-pane/editor/view.web.tsx
+++ b/packages/app/src/file-pane/editor/view.web.tsx
@@ -165,4 +165,7 @@ const FRAME_STYLE = {
minHeight: 0,
minWidth: 0,
} as const;
-const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
+// CodeMirror's scrollIntoView scrolls every overflowing ancestor and stops at a fixed or sticky
+// one. Sticky (with no offsets it lays out like relative) keeps line reveals inside the editor, so
+// they don't shift the host around it, such as the compact workspace sheet.
+const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden", position: "sticky" } as const;
diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts
index ca402264f2..5f00d8f70c 100644
--- a/packages/app/src/i18n/resources/ar.ts
+++ b/packages/app/src/i18n/resources/ar.ts
@@ -613,6 +613,9 @@ export const ar: TranslationResources = {
updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.",
unableToSubscribe: "غير قادر على الاشتراك في المحطة",
},
+ sheet: {
+ openAsTab: "فتح كعلامة تبويب",
+ },
tabs: {
loading: "تحميل...",
modified: "تغييرات غير محفوظة",
diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts
index 92dbb3f78c..91e7e2c92f 100644
--- a/packages/app/src/i18n/resources/en.ts
+++ b/packages/app/src/i18n/resources/en.ts
@@ -610,6 +610,9 @@ export const en = {
updateHost: "Update the host to use the native terminal renderer.",
unableToSubscribe: "Unable to subscribe to terminal",
},
+ sheet: {
+ openAsTab: "Open as tab",
+ },
tabs: {
loading: "Loading...",
modified: "Unsaved changes",
diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts
index bc53c8b2aa..6aff72fdd7 100644
--- a/packages/app/src/i18n/resources/es.ts
+++ b/packages/app/src/i18n/resources/es.ts
@@ -619,6 +619,9 @@ export const es: TranslationResources = {
updateHost: "Actualiza el host para usar el renderizador de terminal nativo.",
unableToSubscribe: "No se puede suscribir al terminal",
},
+ sheet: {
+ openAsTab: "Abrir como pestaña",
+ },
tabs: {
loading: "Cargando...",
modified: "Cambios sin guardar",
diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts
index 78a9b2e82b..a4471f594c 100644
--- a/packages/app/src/i18n/resources/fr.ts
+++ b/packages/app/src/i18n/resources/fr.ts
@@ -618,6 +618,9 @@ export const fr: TranslationResources = {
updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.",
unableToSubscribe: "Impossible de s’abonner au terminal",
},
+ sheet: {
+ openAsTab: "Ouvrir dans un onglet",
+ },
tabs: {
loading: "Chargement…",
modified: "Modifications non enregistrées",
diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts
index 9899d0ff2b..9cbc630da2 100644
--- a/packages/app/src/i18n/resources/ja.ts
+++ b/packages/app/src/i18n/resources/ja.ts
@@ -619,6 +619,9 @@ export const ja: TranslationResources = {
updateHost: "ネイティブターミナルを使用するにはホストを更新してください。",
unableToSubscribe: "ターミナルに接続できません",
},
+ sheet: {
+ openAsTab: "タブで開く",
+ },
tabs: {
loading: "読み込み中...",
modified: "未保存の変更",
diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts
index c6a8f17b3c..98598d39dd 100644
--- a/packages/app/src/i18n/resources/ko.ts
+++ b/packages/app/src/i18n/resources/ko.ts
@@ -614,6 +614,9 @@ export const ko: TranslationResources = {
updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.",
unableToSubscribe: "터미널을 구독할 수 없습니다",
},
+ sheet: {
+ openAsTab: "탭으로 열기",
+ },
tabs: {
loading: "불러오는 중...",
modified: "저장되지 않은 변경사항",
diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts
index 66a25e9f03..c3ca4994d3 100644
--- a/packages/app/src/i18n/resources/pt-BR.ts
+++ b/packages/app/src/i18n/resources/pt-BR.ts
@@ -618,6 +618,9 @@ export const ptBR: TranslationResources = {
updateHost: "Atualize o host para usar o renderizador de terminal nativo.",
unableToSubscribe: "Não foi possível assinar o terminal",
},
+ sheet: {
+ openAsTab: "Abrir como aba",
+ },
tabs: {
loading: "Carregando...",
modified: "Alterações não salvas",
diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts
index c06e53eda0..57616a22a7 100644
--- a/packages/app/src/i18n/resources/ru.ts
+++ b/packages/app/src/i18n/resources/ru.ts
@@ -620,6 +620,9 @@ export const ru: TranslationResources = {
updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.",
unableToSubscribe: "Не удалось подключиться к терминалу",
},
+ sheet: {
+ openAsTab: "Открыть во вкладке",
+ },
tabs: {
loading: "Загрузка...",
modified: "Несохранённые изменения",
diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts
index 27a0d62ac1..99088e068a 100644
--- a/packages/app/src/i18n/resources/zh-CN.ts
+++ b/packages/app/src/i18n/resources/zh-CN.ts
@@ -613,6 +613,9 @@ export const zhCN: TranslationResources = {
updateHost: "请更新主机以使用原生终端渲染器。",
unableToSubscribe: "无法订阅 Terminal",
},
+ sheet: {
+ openAsTab: "作为标签页打开",
+ },
tabs: {
loading: "正在加载...",
modified: "未保存的更改",
diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx
index 2d8a605c3f..bb51832aa0 100644
--- a/packages/app/src/panels/agent-panel.tsx
+++ b/packages/app/src/panels/agent-panel.tsx
@@ -1153,6 +1153,8 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const { t } = useTranslation();
+ // The workspace sheet is for looking; Open as tab brings the agent back with its composer.
+ const showsComposer = usePaneContext().host !== "sheet";
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
state.sessions[serverId]?.agentTasks.get(agentId),
@@ -1216,7 +1218,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
composerState,
],
);
- const composerSection = (
+ const composerSection = showsComposer ? (
- );
+ ) : null;
const streamContent = (
diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx
index 962e095b89..3e1634c113 100644
--- a/packages/app/src/panels/agent-tracks.tsx
+++ b/packages/app/src/panels/agent-tracks.tsx
@@ -3,7 +3,7 @@ import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill";
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
import { AgentTaskList } from "@/composer/task-list";
import { ComposerTrackBar } from "@/composer/tracks";
-import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
+import { useIsCompactFormFactor } from "@/constants/layout";
import { usePaneContext } from "@/panels/pane-context";
import { useSettings } from "@/hooks/use-settings";
import { PluginComposerPills } from "@/plugins";
@@ -17,7 +17,7 @@ import {
import { SubagentsTrack } from "@/subagents/track";
import type { TodoEntry } from "@/types/stream";
import { navigateToAgent } from "@/utils/navigate-to-agent";
-import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
+import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model";
import { openPreferredWorkspaceTarget } from "@/workspace-tabs/open-beside";
import { openComposerChanges } from "@/workspace-tabs/open-supporting-view";
@@ -49,10 +49,9 @@ export const AgentTracks = memo(function AgentTracks({
onArchiveFinished: () => void;
hasPluginComposerPills: boolean;
}): ReactElement | null {
- const { tabId, openTab } = usePaneContext();
+ const { tabId } = usePaneContext();
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
const isCompact = useIsCompactFormFactor();
- const canSplit = supportsDesktopPaneSplits() && !isCompact;
const openInSidePane = useSettings((settings) => settings.openInSidePane);
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const canDetachSubagents = useSessionStore(
@@ -60,45 +59,36 @@ export const AgentTracks = memo(function AgentTracks({
);
const archiveSubagent = useArchiveSubagent({ serverId });
const detachSubagent = useDetachSubagent({ serverId });
+ const openSubagentTarget = useCallback(
+ (target: WorkspaceTabTarget) => {
+ openPreferredWorkspaceTarget({
+ workspaceKey,
+ target,
+ source: "subagents",
+ preferences: openInSidePane,
+ parentTabId: tabId,
+ });
+ },
+ [openInSidePane, tabId, workspaceKey],
+ );
const handleOpenSubagent = useCallback(
(subagentId: string) => {
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(subagentId) ?? session?.agentDetails.get(subagentId);
+ // A subagent in another workspace lives in that workspace's tabs.
if (agent?.workspaceId && agent.workspaceId !== workspaceId) {
navigateToAgent({ serverId, agentId: subagentId });
return;
}
- if (canSplit && workspaceKey) {
- openPreferredWorkspaceTarget({
- isCompact,
- workspaceKey,
- target: { kind: "agent", agentId: subagentId },
- source: "subagents",
- preferences: openInSidePane,
- parentTabId: tabId,
- });
- return;
- }
- navigateToAgent({ serverId, agentId: subagentId });
+ openSubagentTarget({ kind: "agent", agentId: subagentId });
},
- [canSplit, isCompact, openInSidePane, serverId, tabId, workspaceId, workspaceKey],
+ [openSubagentTarget, serverId, workspaceId],
);
const handleOpenProviderSubagent = useCallback(
(parentAgentId: string, subagentId: string) => {
- if (canSplit && workspaceKey) {
- openPreferredWorkspaceTarget({
- isCompact,
- workspaceKey,
- target: { kind: "provider_subagent", parentAgentId, subagentId },
- source: "subagents",
- preferences: openInSidePane,
- parentTabId: tabId,
- });
- return;
- }
- openTab({ kind: "provider_subagent", parentAgentId, subagentId });
+ openSubagentTarget({ kind: "provider_subagent", parentAgentId, subagentId });
},
- [canSplit, isCompact, openInSidePane, openTab, tabId, workspaceKey],
+ [openSubagentTarget],
);
const handleOpenChanges = useCallback(() => {
if (!workspaceKey) {
diff --git a/packages/app/src/panels/panel-instance-attributes.ts b/packages/app/src/panels/panel-instance-attributes.ts
index 6ef49b0e4e..6fb414943c 100644
--- a/packages/app/src/panels/panel-instance-attributes.ts
+++ b/packages/app/src/panels/panel-instance-attributes.ts
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import { usePaneContext } from "@/panels/pane-context";
+import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
export interface PanelInstanceIdentity {
serverId: string;
@@ -18,8 +19,20 @@ const listenersByPanel = new Map void>>();
const allListeners = new Set<() => void>();
let attributesRevision = 0;
+function buildWorkspacePanelKey(workspaceKey: string, tabId: string): string {
+ return `${workspaceKey}:${tabId}`;
+}
+
export function buildPanelInstanceKey(identity: PanelInstanceIdentity): string {
- return `${identity.serverId}:${identity.workspaceId}:${identity.tabId}`;
+ return buildWorkspacePanelKey(buildWorkspaceTabPersistenceKey(identity) ?? "", identity.tabId);
+}
+
+/** Looks a panel up by the workspace key the layout store uses. */
+export function getWorkspacePanelAttributes(
+ workspaceKey: string,
+ tabId: string,
+): PanelInstanceAttributes {
+ return attributesByPanel.get(buildWorkspacePanelKey(workspaceKey, tabId)) ?? DEFAULT_ATTRIBUTES;
}
export function getPanelInstanceAttributes(
diff --git a/packages/app/src/panels/panel-manifest.test.ts b/packages/app/src/panels/panel-manifest.test.ts
index c06a87864d..0766003f84 100644
--- a/packages/app/src/panels/panel-manifest.test.ts
+++ b/packages/app/src/panels/panel-manifest.test.ts
@@ -18,6 +18,14 @@ describe("panel manifest", () => {
expect(panelSupportsHost("setup", "explorer")).toBe(false);
});
+ it("supports the sheet exactly for kinds with a compact sheet rule", () => {
+ expect(panelSupportsHost("file", "sheet")).toBe(true);
+ expect(panelSupportsHost("agent", "sheet")).toBe(true);
+ expect(panelSupportsHost("provider_subagent", "sheet")).toBe(true);
+ expect(panelSupportsHost("terminal", "sheet")).toBe(false);
+ expect(panelSupportsHost("plugin", "sheet")).toBe(false);
+ });
+
it("lets navigation tabs hide their close button without losing their close action", () => {
expect(getPanelManifest("files").showCloseButton).toBe(false);
expect(getPanelManifest("changes_tree").showCloseButton).toBe(false);
diff --git a/packages/app/src/panels/panel-manifest.ts b/packages/app/src/panels/panel-manifest.ts
index 4122da80f1..90fdf1931e 100644
--- a/packages/app/src/panels/panel-manifest.ts
+++ b/packages/app/src/panels/panel-manifest.ts
@@ -1,12 +1,38 @@
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
-export type PaneHost = "main" | "explorer";
+/** Where a tab renders: a split pane, the Explorer dock, or the compact workspace sheet. */
+export type PaneHost = "main" | "explorer" | "sheet";
+
+/** How a target behaves while it is shown in the compact workspace sheet. */
+export interface CompactSheetPolicy {
+ /** The sheet offers Open as tab, which moves the same tab into the focused pane. */
+ openAsTab: boolean;
+}
+
+/** Facts about a target that live outside the layout, resolved lazily by the workspace. */
+export interface PanelTargetFacts {
+ isWorkspaceRootAgent(agentId: string): boolean;
+}
+
+type CompactSheetRule =
+ | CompactSheetPolicy
+ | null
+ | ((
+ target: Extract,
+ facts: PanelTargetFacts,
+ ) => CompactSheetPolicy | null);
export interface PanelManifest {
kind: K;
- supportedHosts: readonly PaneHost[];
+ /** The sheet is never listed: a kind renders there exactly when it has a `compactSheet` rule. */
+ supportedHosts: readonly Exclude[];
showCloseButton: boolean;
singleton: boolean;
+ /**
+ * On compact layouts, revealing this target opens it in the workspace sheet over the current
+ * tab instead of adding a tab. `null` keeps it in the tab strip.
+ */
+ compactSheet: CompactSheetRule;
resourceKey(target: Extract): string;
}
@@ -20,6 +46,7 @@ const manifests = {
supportedHosts: ["main", "explorer"],
showCloseButton: true,
singleton: false,
+ compactSheet: null,
resourceKey: () => "new_tab",
},
draft: {
@@ -27,6 +54,7 @@ const manifests = {
supportedHosts: ["main", "explorer"],
showCloseButton: true,
singleton: false,
+ compactSheet: null,
resourceKey: (target) => target.draftId,
},
agent: {
@@ -34,6 +62,8 @@ const manifests = {
supportedHosts: ["main", "explorer"],
showCloseButton: true,
singleton: false,
+ compactSheet: (target, facts) =>
+ facts.isWorkspaceRootAgent(target.agentId) ? null : { openAsTab: true },
resourceKey: (target) => target.agentId,
},
provider_subagent: {
@@ -41,6 +71,7 @@ const manifests = {
supportedHosts: ["main", "explorer"],
showCloseButton: true,
singleton: false,
+ compactSheet: { openAsTab: false },
resourceKey: (target) => `${target.parentAgentId}:${target.subagentId}`,
},
terminal: {
@@ -48,6 +79,7 @@ const manifests = {
supportedHosts: ["main", "explorer"],
showCloseButton: true,
singleton: false,
+ compactSheet: null,
resourceKey: (target) => target.terminalId,
},
browser: {
@@ -55,6 +87,7 @@ const manifests = {
supportedHosts: ["main"],
showCloseButton: true,
singleton: false,
+ compactSheet: null,
resourceKey: (target) => target.browserId,
},
changes_tree: {
@@ -62,6 +95,7 @@ const manifests = {
supportedHosts: ["explorer"],
showCloseButton: false,
singleton: true,
+ compactSheet: null,
resourceKey: () => "changes_tree",
},
files: {
@@ -69,6 +103,7 @@ const manifests = {
supportedHosts: ["explorer"],
showCloseButton: false,
singleton: true,
+ compactSheet: null,
resourceKey: () => "files",
},
pull_request: {
@@ -76,6 +111,7 @@ const manifests = {
supportedHosts: ["main", "explorer"],
showCloseButton: true,
singleton: false,
+ compactSheet: null,
resourceKey: () => "pull_request",
},
file: {
@@ -83,6 +119,7 @@ const manifests = {
supportedHosts: ["main", "explorer"],
showCloseButton: true,
singleton: false,
+ compactSheet: { openAsTab: false },
resourceKey: (target) => target.path,
},
working_diff: {
@@ -90,6 +127,7 @@ const manifests = {
supportedHosts: ["main", "explorer"],
showCloseButton: true,
singleton: false,
+ compactSheet: null,
resourceKey: () => "working_diff",
},
plugin: {
@@ -98,6 +136,7 @@ const manifests = {
supportedHosts: ["main", "explorer"],
showCloseButton: true,
singleton: false,
+ compactSheet: null,
resourceKey: (target) =>
target.context === "agent"
? `${target.pluginId}:${target.panelId}:agent:${target.agentId}`
@@ -108,6 +147,7 @@ const manifests = {
supportedHosts: ["main"],
showCloseButton: true,
singleton: false,
+ compactSheet: null,
resourceKey: (target) => target.workspaceId,
},
commit_diff: {
@@ -115,6 +155,7 @@ const manifests = {
supportedHosts: ["main", "explorer"],
showCloseButton: true,
singleton: false,
+ compactSheet: null,
resourceKey: (target) => target.sha,
},
} satisfies PanelManifestByKind;
@@ -124,7 +165,17 @@ export function getPanelManifest(kind: K):
}
export function panelSupportsHost(kind: WorkspaceTabTarget["kind"], host: PaneHost): boolean {
- return getPanelManifest(kind).supportedHosts.includes(host);
+ const manifest = getPanelManifest(kind);
+ if (host === "sheet") return manifest.compactSheet !== null;
+ return manifest.supportedHosts.includes(host);
+}
+
+export function panelCompactSheetPolicy(
+ target: WorkspaceTabTarget,
+ facts: PanelTargetFacts,
+): CompactSheetPolicy | null {
+ const rule = getPanelManifest(target.kind).compactSheet as CompactSheetRule;
+ return typeof rule === "function" ? rule(target as never, facts) : rule;
}
export function panelCanLaunchInPane(
diff --git a/packages/app/src/plugins/workspace-panels/locations.ts b/packages/app/src/plugins/workspace-panels/locations.ts
index 1c8cfbfb90..5800aecf72 100644
--- a/packages/app/src/plugins/workspace-panels/locations.ts
+++ b/packages/app/src/plugins/workspace-panels/locations.ts
@@ -50,7 +50,7 @@ export function panelTargetSupportsHost(
target: WorkspaceTabTarget,
host: PaneHost,
): boolean {
- if (target.kind !== "plugin") return panelSupportsHost(target.kind, host);
+ if (target.kind !== "plugin" || host === "sheet") return panelSupportsHost(target.kind, host);
const panel = findPluginPanel(serverId, target);
// Preserve a persisted unavailable panel where the user left it until its plugin loads again.
if (!panel) return true;
diff --git a/packages/app/src/screens/workspace/visible-agent-ids.test.ts b/packages/app/src/screens/workspace/visible-agent-ids.test.ts
index 6620889475..d7b6f33a72 100644
--- a/packages/app/src/screens/workspace/visible-agent-ids.test.ts
+++ b/packages/app/src/screens/workspace/visible-agent-ids.test.ts
@@ -110,3 +110,25 @@ test("pane retargeting replaces the viewed agent and duplicate panes collapse to
}),
}).toEqual({ duplicate: ["agent-a"], retargeted: ["agent-a", "agent-b"] });
});
+
+test("the agent shown in the workspace sheet is viewed alongside the pane under it", () => {
+ const layout: WorkspaceLayout = {
+ focusedPaneId: "main",
+ root: { kind: "pane", pane: { id: "main", tabIds: ["a"], focusedTabId: "a" } },
+ };
+ const tabs: WorkspaceTab[] = [
+ { tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
+ ];
+ const sheetTab: WorkspaceTab = {
+ tabId: "sheet",
+ target: { kind: "agent", agentId: "agent-child" },
+ createdAt: 2,
+ };
+
+ expect(
+ selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: true, focusedPaneOnly: true }),
+ ).toEqual(["agent-a", "agent-child"]);
+ expect(
+ selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: false, focusedPaneOnly: true }),
+ ).toEqual([]);
+});
diff --git a/packages/app/src/screens/workspace/visible-agent-ids.ts b/packages/app/src/screens/workspace/visible-agent-ids.ts
index 6fdd246a9b..324d4121c3 100644
--- a/packages/app/src/screens/workspace/visible-agent-ids.ts
+++ b/packages/app/src/screens/workspace/visible-agent-ids.ts
@@ -6,6 +6,8 @@ import { deriveWorkspacePaneState } from "./workspace-pane-state";
export function selectVisibleAgentIds(input: {
layout: WorkspaceLayout | null;
tabs: WorkspaceTab[];
+ /** The workspace sheet's tab, shown over the panes. */
+ sheetTab?: WorkspaceTab | null;
routeFocused: boolean;
focusedPaneOnly: boolean;
}): string[] {
@@ -16,20 +18,21 @@ export function selectVisibleAgentIds(input: {
? collectAllPanes(input.layout.root).filter((pane) => pane.id === input.layout?.focusedPaneId)
: collectAllPanes(input.layout.root);
+ const targets = panes.flatMap((pane) => {
+ const target = deriveWorkspacePaneState({ pane, tabs: input.tabs }).activeTab?.descriptor
+ .target;
+ return target ? [target] : [];
+ });
+ if (input.sheetTab) targets.push(input.sheetTab.target);
return [
- ...new Set(
- panes.flatMap((pane) => {
- const target = deriveWorkspacePaneState({ pane, tabs: input.tabs }).activeTab?.descriptor
- .target;
- return target?.kind === "agent" ? [target.agentId] : [];
- }),
- ),
+ ...new Set(targets.flatMap((target) => (target.kind === "agent" ? [target.agentId] : []))),
].sort();
}
export function useVisibleAgentIds(input: {
layout: WorkspaceLayout | null;
tabs: WorkspaceTab[];
+ sheetTab: WorkspaceTab | null;
routeFocused: boolean;
focusedPaneOnly: boolean;
}): string[] {
diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx
index 55a186cbab..62c9ee4fa6 100644
--- a/packages/app/src/screens/workspace/workspace-screen.tsx
+++ b/packages/app/src/screens/workspace/workspace-screen.tsx
@@ -73,6 +73,7 @@ import {
getFocusedBrowserId,
FOCUSED_PANE_PLACEMENT,
selectExplorerSidebarPaneId,
+ selectPaneFocusSuspended,
type WorkspaceLayout,
type WorkspaceTabPlacement,
useWorkspaceLayoutStore,
@@ -160,6 +161,8 @@ import {
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
+import { WorkspaceSheetHost } from "@/screens/workspace/workspace-sheet-host";
+import type { PaneHost } from "@/panels/panel-manifest";
import { WorkspaceFocusProvider } from "@/workspace/focus";
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
import type { NewTabSelection } from "@/workspace-tabs/new-tab";
@@ -751,22 +754,20 @@ function WorkspaceContentProviders({
);
}
-function WorkspacePanelContent({
+function WorkspacePanelProviders({
launcher,
- content,
+ children,
tabIds,
workspaceKey,
}: {
workspaceKey: string;
launcher: NewTabLauncher;
- content: ReactNode;
+ children: ReactNode;
tabIds: readonly string[];
}) {
return (
-
- {content}
-
+ {children}
);
}
@@ -1267,6 +1268,9 @@ function WorkspaceScreenContent({
const explorerSidebarPaneId = useWorkspaceLayoutStore((state) =>
persistenceKey ? selectExplorerSidebarPaneId(state, persistenceKey) : null,
);
+ const paneFocusSuspended = useWorkspaceLayoutStore((state) =>
+ persistenceKey ? selectPaneFocusSuspended(state, persistenceKey) : false,
+ );
const lastMainPaneRef = useLastMainPane({
workspaceKey: persistenceKey,
layout: workspaceLayout,
@@ -1305,6 +1309,9 @@ function WorkspaceScreenContent({
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
const selectWorkspaceTabInPane = useWorkspaceLayoutStore((state) => state.selectTabInPane);
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
+ const sheetTab = useWorkspaceLayoutStore((state) =>
+ persistenceKey ? (state.sheetByWorkspace[persistenceKey]?.tab ?? null) : null,
+ );
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState);
@@ -1377,6 +1384,7 @@ function WorkspaceScreenContent({
const visibleAgentIds = useVisibleAgentIds({
layout: workspaceLayout,
tabs: uiTabs,
+ sheetTab,
routeFocused: isRouteFocused,
focusedPaneOnly: syncFocusedPaneOnly,
});
@@ -1678,7 +1686,6 @@ function WorkspaceScreenContent({
}
const tabId = openPreferredWorkspaceTarget({
- isCompact: isMobile,
workspaceKey: persistenceKey,
target: createWorkspaceFileTabTarget(location),
source: "chatFiles",
@@ -2062,6 +2069,12 @@ function WorkspaceScreenContent({
const handleCloseTabById = useCallback(
async (tabId: string) => {
+ if (persistenceKey && sheetTab?.tabId === tabId) {
+ if (await confirmDiscardModifiedTab(tabId)) {
+ closeWorkspaceTab(persistenceKey, tabId);
+ }
+ return;
+ }
const tab = allTabDescriptorsById.get(tabId);
if (!tab) {
return;
@@ -2081,10 +2094,13 @@ function WorkspaceScreenContent({
},
[
allTabDescriptorsById,
+ closeWorkspaceTab,
confirmDiscardModifiedTab,
handleCloseAgentTab,
handleClosePassiveTab,
handleCloseTerminalTab,
+ persistenceKey,
+ sheetTab,
],
);
@@ -2918,17 +2934,19 @@ function WorkspaceScreenContent({
tab: WorkspaceTabDescriptor;
paneId?: string | null;
focusPaneBeforeOpen?: boolean;
+ host?: PaneHost;
}) =>
buildWorkspacePaneContentModel({
tab: input.tab,
normalizedServerId,
normalizedWorkspaceId,
host:
- canRenderDesktopPaneSplits &&
+ input.host ??
+ (canRenderDesktopPaneSplits &&
input.paneId !== null &&
input.paneId === explorerSidebarPaneId
? "explorer"
- : "main",
+ : "main"),
fileNavigationRevision: fileNavigationRevisionByTabId[input.tab.tabId] ?? 0,
onOpenTab: (target) => {
if (!persistenceKey) {
@@ -2947,7 +2965,6 @@ function WorkspaceScreenContent({
onOpenPreferredTarget: (target, source) => {
if (!persistenceKey) return;
const tabId = openPreferredWorkspacePreview({
- isCompact: isMobile,
workspaceKey: persistenceKey,
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
@@ -3009,7 +3026,6 @@ function WorkspaceScreenContent({
canRenderDesktopPaneSplits,
openImportSheet,
openInSidePane,
- isMobile,
requestFileNavigation,
revealWorkspaceChildTab,
persistenceKey,
@@ -3053,6 +3069,17 @@ function WorkspaceScreenContent({
},
[buildPaneContentModel],
);
+ const buildSheetPaneContentModel = useCallback(
+ function buildSheetPaneContentModel(tab: WorkspaceTabDescriptor) {
+ return buildPaneContentModel({
+ tab,
+ paneId: null,
+ focusPaneBeforeOpen: false,
+ host: "sheet",
+ });
+ },
+ [buildPaneContentModel],
+ );
const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) {
if (!persistenceKey || paneFocusSuppressedRef.current) {
return;
@@ -3068,7 +3095,8 @@ function WorkspaceScreenContent({
focusedPaneTabDescriptorMap,
isRouteFocused,
focusedPaneId,
- paneFocusSuspended: Boolean(unfocusedPaneId),
+ // The sheet owns input while it covers the tab.
+ paneFocusSuspended,
onFocusPane: handleFocusPane,
buildMobilePaneContentModel,
});
@@ -3495,28 +3523,42 @@ function WorkspaceScreenContent({
const desktopContent = desktopSplitContent ?? content;
const rendersDesktopSplitContent = !isMobile && desktopSplitContent !== null;
- const workspacePanelContent = (
- tab.tabId)}
- content={isMobile ? content : desktopContent}
- />
+ const workspacePanelTabIds = useMemo(
+ () => [...uiTabs.map((tab) => tab.tabId), ...(sheetTab ? [sheetTab.tabId] : [])],
+ [uiTabs, sheetTab],
);
+ const workspacePanelKey = `${normalizedServerId}:${normalizedWorkspaceId}`;
+ // The sheet is the column's last child, so it covers the compact header as a modal.
const workspaceCenterColumn = isMobile ? (
-
-
- {(topOverlayInset) => (
-
- {workspacePanelContent}
-
- )}
-
-
+
+
+
+ {(topOverlayInset) => (
+
+ {content}
+
+ )}
+
+
+
+
+
+
) : (
{rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()}
@@ -3549,7 +3591,15 @@ function WorkspaceScreenContent({
) : null}
- {workspacePanelContent}
+
+
+ {desktopContent}
+
+
);
diff --git a/packages/app/src/screens/workspace/workspace-sheet-host.tsx b/packages/app/src/screens/workspace/workspace-sheet-host.tsx
new file mode 100644
index 0000000000..d44bdb6cc9
--- /dev/null
+++ b/packages/app/src/screens/workspace/workspace-sheet-host.tsx
@@ -0,0 +1,245 @@
+import BottomSheet, {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+} from "@gorhom/bottom-sheet";
+import { useIsFocused } from "@react-navigation/native";
+import { type ComponentType, useCallback, useEffect, useMemo, useRef } from "react";
+import { BackHandler, View } from "react-native";
+import { useTranslation } from "react-i18next";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { StyleSheet, withUnistyles } from "react-native-unistyles";
+import { SheetHeaderView } from "@/components/adaptive-modal-sheet";
+import { Button } from "@/components/ui/button";
+import { isNative } from "@/constants/platform";
+import type { PanelIconProps } from "@/panels/panel-registry";
+import { getPanelRegistration } from "@/panels/panel-registry";
+import { ensurePanelsRegistered } from "@/panels/register-panels";
+import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
+import {
+ WorkspacePaneContent,
+ type WorkspacePaneContentModel,
+} from "@/screens/workspace/workspace-pane-content";
+import { useWorkspaceLayoutStore, type WorkspaceSheet } from "@/stores/workspace-layout-store";
+
+const SNAP_POINTS = ["92%"];
+
+const ThemedBottomSheet = withUnistyles(BottomSheet, (theme) => ({
+ backgroundStyle: {
+ backgroundColor: theme.colors.surface0,
+ borderTopLeftRadius: theme.borderRadius["2xl"],
+ borderTopRightRadius: theme.borderRadius["2xl"],
+ },
+ handleIndicatorStyle: {
+ backgroundColor: theme.colors.palette.zinc[600],
+ },
+}));
+
+function PanelIcon({
+ icon: Icon,
+ size,
+ color,
+}: { icon: ComponentType } & PanelIconProps) {
+ return ;
+}
+
+const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({
+ color: theme.colors.foregroundMuted,
+}));
+
+function renderBackdrop(props: BottomSheetBackdropProps) {
+ return (
+
+ );
+}
+
+export interface WorkspaceSheetHostProps {
+ workspaceKey: string | null;
+ serverId: string;
+ workspaceId: string;
+ isWorkspaceFocused: boolean;
+ buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
+ /** The workspace's ordinary tab close, including its unsaved-changes guard. */
+ onCloseTab: (tabId: string) => Promise;
+}
+
+interface WorkspaceSheetPresentationProps extends Omit<
+ WorkspaceSheetHostProps,
+ "workspaceKey" | "onCloseTab"
+> {
+ sheet: WorkspaceSheet;
+ onClose: () => Promise;
+ onOpenAsTab: () => void;
+}
+
+/**
+ * Hosts the workspace's sheet tab over the compact tab strip. It renders in the workspace tree,
+ * not through a portal, so the panel keeps every context an ordinary pane has.
+ */
+export function WorkspaceSheetHost({ workspaceKey, onCloseTab, ...rest }: WorkspaceSheetHostProps) {
+ const sheet = useWorkspaceLayoutStore((state) =>
+ workspaceKey ? (state.sheetByWorkspace[workspaceKey] ?? null) : null,
+ );
+ const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab);
+ const sheetTabId = sheet?.tab.tabId ?? null;
+ const handleClose = useCallback(async () => {
+ if (sheetTabId) await onCloseTab(sheetTabId);
+ }, [onCloseTab, sheetTabId]);
+ const handleOpenAsTab = useCallback(() => {
+ if (workspaceKey) openSheetAsTab(workspaceKey);
+ }, [openSheetAsTab, workspaceKey]);
+ if (!sheet) return null;
+ return (
+
+ );
+}
+
+function WorkspaceSheetPresentation({
+ sheet,
+ serverId,
+ workspaceId,
+ isWorkspaceFocused,
+ buildContent,
+ onClose,
+ onOpenAsTab,
+}: WorkspaceSheetPresentationProps) {
+ const sheetRef = useRef(null);
+ const isScreenFocused = useIsFocused();
+ const ownsBackPress = isNative && isWorkspaceFocused && isScreenFocused;
+ const handleChange = useCallback(
+ (index: number) => {
+ if (index !== -1) return;
+ // A declined close (unsaved changes) leaves the tab in the sheet, so bring it back up.
+ void onClose().then(() => sheetRef.current?.snapToIndex(0));
+ },
+ [onClose],
+ );
+ useEffect(() => {
+ // Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns back.
+ if (!ownsBackPress) return;
+ const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
+ void onClose();
+ return true;
+ });
+ return () => subscription.remove();
+ }, [onClose, ownsBackPress]);
+
+ return (
+
+
+
+
+
+ );
+}
+
+function WorkspaceSheetBody({
+ sheet,
+ serverId,
+ workspaceId,
+ isWorkspaceFocused,
+ buildContent,
+ onClose,
+ onOpenAsTab,
+}: WorkspaceSheetPresentationProps) {
+ const { t } = useTranslation();
+ const { tab } = sheet;
+ ensurePanelsRegistered();
+ const registration = getPanelRegistration(tab.target.kind);
+ if (!registration) {
+ throw new Error(`No panel registration for kind: ${tab.target.kind}`);
+ }
+ // The body is keyed by tab id and kind, so the registration and its descriptor hook stay fixed.
+ const descriptor = registration.useDescriptor(tab.target as never, {
+ serverId,
+ workspaceId,
+ tabId: tab.tabId,
+ });
+ const tabDescriptor = useMemo(
+ () => ({
+ key: tab.tabId,
+ tabId: tab.tabId,
+ kind: tab.target.kind,
+ target: tab.target,
+ state: tab.state,
+ }),
+ [tab],
+ );
+ const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]);
+ const handleClose = useCallback(() => {
+ void onClose();
+ }, [onClose]);
+ // The sheet reaches the bottom of the screen, so it owns the system navigation inset.
+ const { bottom: bottomInset } = useSafeAreaInsets();
+ const contentStyle = useMemo(
+ () => [styles.content, { paddingBottom: bottomInset }],
+ [bottomInset],
+ );
+ const header = useMemo(
+ () => ({
+ title: descriptor.label,
+ leading: ,
+ actions: sheet.policy.openAsTab ? (
+
+ ) : undefined,
+ }),
+ [descriptor.icon, descriptor.label, onOpenAsTab, sheet.policy.openAsTab, t],
+ );
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ host: {
+ ...StyleSheet.absoluteFillObject,
+ },
+ body: {
+ flex: 1,
+ minHeight: 0,
+ },
+ content: {
+ flex: 1,
+ minHeight: 0,
+ },
+});
diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts
index d1a4768cb5..8c061d18b3 100644
--- a/packages/app/src/stores/workspace-layout-store.test.ts
+++ b/packages/app/src/stores/workspace-layout-store.test.ts
@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@react-native-async-storage/async-storage", () => {
const storage = new Map();
@@ -16,6 +16,9 @@ vi.mock("@react-native-async-storage/async-storage", () => {
});
import AsyncStorage from "@react-native-async-storage/async-storage";
+import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
+import { useSessionStore, type Agent } from "@/stores/session-store";
+import { setPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
import { createViewedTimelineSync } from "@/timeline/viewed-timeline-sync";
import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model";
import { defaultChangesState, type ChangesState } from "@/panels/changes/state";
@@ -24,6 +27,7 @@ import {
canDismissPaneInLayout,
collectAllPanes,
collectAllTabs,
+ selectPaneFocusSuspended,
createWorkspaceLayoutStore,
observeOpenWorkspaceAgentIds,
createDefaultLayout,
@@ -4438,3 +4442,580 @@ it("persists the once-only PR add after closing, and clears it when purging the
expect(restored.getState().pullRequestTabAutoOpenedByWorkspace[workspaceKey]).toBeUndefined();
expect(restored.getState().autoOpenPullRequestTab(workspaceKey, placement)).toBe("pull_request");
});
+
+describe("compact workspace sheet", () => {
+ const workspaceKey = "server-1:sheet-ws";
+ const fileTarget = { kind: "file" as const, path: "src/index.ts" };
+
+ function createCompactStore() {
+ const store = createWorkspaceLayoutStore(workspaceLayoutIds);
+ store.setState({ layoutByWorkspace: {}, sheetByWorkspace: {} });
+ store.getState().setLayoutMode("compact");
+ store.getState().openTab({
+ workspaceKey,
+ target: { kind: "terminal", terminalId: "term-1" },
+ intent: "reveal",
+ });
+ return store;
+ }
+
+ function tabKinds(store: ReturnType): string[] {
+ const layout = store.getState().layoutByWorkspace[workspaceKey];
+ const tabsById = new Map(collectAllTabs(layout.root).map((tab) => [tab.tabId, tab]));
+ return (findPaneById(layout.root, "main")?.tabIds ?? []).map(
+ (tabId) => tabsById.get(tabId)?.target.kind ?? "missing",
+ );
+ }
+
+ function seedAgents(agents: Array>): void {
+ const timestamp = new Date("2026-04-21T10:00:00.000Z");
+ useSessionStore.getState().initializeSession("server-1", null as unknown as DaemonClient);
+ useSessionStore.getState().setAgents(
+ "server-1",
+ new Map(
+ agents.map((agent) => [
+ agent.id,
+ {
+ serverId: "server-1",
+ provider: "codex",
+ status: "idle",
+ turn: { phase: "idle", cancellationRequestId: null },
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ lastUserMessageAt: null,
+ lastActivityAt: timestamp,
+ capabilities: {
+ supportsStreaming: true,
+ supportsSessionPersistence: true,
+ supportsDynamicModes: true,
+ supportsMcpServers: true,
+ supportsReasoningStream: true,
+ supportsToolInvocations: true,
+ },
+ currentModeId: null,
+ availableModes: [],
+ pendingPermissions: [],
+ persistence: null,
+ runtimeInfo: undefined,
+ lastUsage: undefined,
+ lastError: null,
+ title: agent.id,
+ cwd: "/repo",
+ model: null,
+ features: undefined,
+ thinkingOptionId: undefined,
+ requiresAttention: false,
+ attentionReason: null,
+ attentionTimestamp: null,
+ archivedAt: null,
+ labels: {},
+ projectPlacement: null,
+ ...agent,
+ } satisfies Agent,
+ ]),
+ ),
+ );
+ }
+
+ it("reveals a file in the sheet and leaves the tab strip unchanged", () => {
+ const store = createCompactStore();
+ const before = tabKinds(store);
+
+ const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
+
+ expect(store.getState().sheetByWorkspace[workspaceKey]?.tab).toEqual({
+ tabId,
+ target: fileTarget,
+ createdAt: expect.any(Number),
+ });
+ expect(tabKinds(store)).toEqual(before);
+ });
+
+ it("focuses a target that is already open as a tab instead of using the sheet", () => {
+ const store = createCompactStore();
+ store.getState().setLayoutMode("split-panes");
+ const fileTabId = store
+ .getState()
+ .openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
+ store.getState().openTab({
+ workspaceKey,
+ target: { kind: "terminal", terminalId: "term-1" },
+ intent: "reveal",
+ });
+ store.getState().setLayoutMode("compact");
+
+ const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
+
+ expect(tabId).toBe(fileTabId);
+ expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
+ const layout = store.getState().layoutByWorkspace[workspaceKey];
+ expect(findPaneContainingTab(layout.root, fileTabId ?? "")?.focusedTabId).toBe(fileTabId);
+ });
+
+ it("replaces the sheet content with the next sheet target", () => {
+ const store = createCompactStore();
+ store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
+
+ const nextTarget = { kind: "file" as const, path: "src/other.ts" };
+ store.getState().openTab({ workspaceKey, target: nextTarget, intent: "reveal" });
+
+ expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual(nextTarget);
+ expect(tabKinds(store)).toEqual(["terminal"]);
+ });
+
+ it("closes the sheet when a tab target is revealed, and keeps it for background opens", () => {
+ const store = createCompactStore();
+ store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
+
+ store.getState().openTab({
+ workspaceKey,
+ target: { kind: "file", path: "src/background.ts" },
+ intent: "background",
+ });
+ expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual(fileTarget);
+ expect(tabKinds(store)).toEqual(["terminal", "file"]);
+
+ store.getState().openTab({
+ workspaceKey,
+ target: { kind: "terminal", terminalId: "term-2" },
+ intent: "reveal",
+ });
+ expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
+ });
+
+ it("moves the sheet tab into the focused pane with the same tab id and state", () => {
+ const store = createCompactStore();
+ const sheetTabId = store
+ .getState()
+ .openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
+ if (!sheetTabId) throw new Error("Expected a sheet tab");
+ store.getState().setTabState(workspaceKey, sheetTabId, { scrollTop: 120 });
+
+ const tabId = store.getState().openSheetAsTab(workspaceKey);
+
+ expect(tabId).toBe(sheetTabId);
+ expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
+ const layout = store.getState().layoutByWorkspace[workspaceKey];
+ const pane = findPaneContainingTab(layout.root, sheetTabId);
+ expect(pane?.id).toBe(layout.focusedPaneId);
+ expect(pane?.focusedTabId).toBe(sheetTabId);
+ expect(collectAllTabs(layout.root).find((tab) => tab.tabId === sheetTabId)?.state).toEqual({
+ scrollTop: 120,
+ });
+ });
+
+ it("closes the sheet through the ordinary close and ignores focus requests for it", () => {
+ const store = createCompactStore();
+ const sheetTabId = store
+ .getState()
+ .openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
+ if (!sheetTabId) throw new Error("Expected a sheet tab");
+ const layoutBefore = store.getState().layoutByWorkspace[workspaceKey];
+
+ store.getState().focusTab(workspaceKey, sheetTabId);
+ expect(store.getState().layoutByWorkspace[workspaceKey]).toBe(layoutBefore);
+
+ store.getState().closeTab(workspaceKey, sheetTabId);
+ expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
+ expect(tabKinds(store)).toEqual(["terminal"]);
+ });
+
+ it("suspends pane focus while the sheet covers the panes", () => {
+ const store = createCompactStore();
+ expect(selectPaneFocusSuspended(store.getState(), workspaceKey)).toBe(false);
+
+ const sheetTabId = store
+ .getState()
+ .openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
+ expect(selectPaneFocusSuspended(store.getState(), workspaceKey)).toBe(true);
+
+ store.getState().closeTab(workspaceKey, sheetTabId ?? "");
+ expect(selectPaneFocusSuspended(store.getState(), workspaceKey)).toBe(false);
+ });
+
+ it("never uses the sheet on wide layouts", () => {
+ const store = createCompactStore();
+ store.getState().setLayoutMode("split-panes");
+
+ store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
+
+ expect(store.getState().sheetByWorkspace).toEqual({});
+ expect(tabKinds(store)).toEqual(["terminal", "file"]);
+ });
+
+ it("keeps the sheet content as a tab when the layout widens", () => {
+ const store = createCompactStore();
+ const sheetTabId = store
+ .getState()
+ .openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
+
+ store.getState().setLayoutMode("split-panes");
+
+ expect(store.getState().sheetByWorkspace).toEqual({});
+ const layout = store.getState().layoutByWorkspace[workspaceKey];
+ expect(findPaneContainingTab(layout.root, sheetTabId ?? "")?.focusedTabId).toBe(sheetTabId);
+ });
+
+ it("opens subagents and provider subagents in the sheet but keeps root agents as tabs", () => {
+ seedAgents([
+ { id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
+ { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
+ { id: "other-workspace-child", parentAgentId: "root", workspaceId: "other-ws" },
+ ]);
+ const store = createCompactStore();
+
+ store.getState().openTab({
+ workspaceKey,
+ target: { kind: "agent", agentId: "child" },
+ intent: "reveal",
+ });
+ expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({
+ tab: { target: { kind: "agent", agentId: "child" } },
+ policy: { openAsTab: true },
+ });
+
+ store.getState().openTab({
+ workspaceKey,
+ target: { kind: "provider_subagent", parentAgentId: "root", subagentId: "task-1" },
+ intent: "reveal",
+ });
+ expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({
+ tab: { target: { kind: "provider_subagent", subagentId: "task-1" } },
+ policy: { openAsTab: false },
+ });
+
+ for (const agentId of ["root", "other-workspace-child"]) {
+ store
+ .getState()
+ .openTab({ workspaceKey, target: { kind: "agent", agentId }, intent: "reveal" });
+ }
+ expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
+ expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]);
+ });
+
+ it("gives a sheet target opened from inside the sheet the sheet's own parent", () => {
+ seedAgents([
+ { id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
+ { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
+ ]);
+ const store = createCompactStore();
+ const rootTabId = store
+ .getState()
+ .openTab({ workspaceKey, target: { kind: "agent", agentId: "root" }, intent: "reveal" });
+ const childSheetTabId = store.getState().openTab({
+ workspaceKey,
+ target: { kind: "agent", agentId: "child" },
+ intent: "reveal",
+ parentTabId: rootTabId ?? undefined,
+ });
+
+ const fileSheetTabId = store.getState().openTab({
+ workspaceKey,
+ target: fileTarget,
+ intent: "reveal",
+ parentTabId: childSheetTabId ?? undefined,
+ });
+ expect(store.getState().sheetByWorkspace[workspaceKey]?.parentTabId).toBe(rootTabId);
+
+ store.getState().openSheetAsTab(workspaceKey);
+ const layout = store.getState().layoutByWorkspace[workspaceKey];
+ expect(layout.parentTabIdByTabId?.[fileSheetTabId ?? ""]).toBe(rootTabId);
+ });
+
+ it("closes the sheet and reveals the tab when a tab target is opened from inside the sheet", () => {
+ seedAgents([
+ { id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
+ { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
+ { id: "second-root", parentAgentId: null, workspaceId: "sheet-ws" },
+ ]);
+ const store = createCompactStore();
+ const rootTabId = store
+ .getState()
+ .openTab({ workspaceKey, target: { kind: "agent", agentId: "root" }, intent: "reveal" });
+ const openChildSheet = () =>
+ store.getState().openTab({
+ workspaceKey,
+ target: { kind: "agent", agentId: "child" },
+ intent: "reveal",
+ parentTabId: rootTabId ?? undefined,
+ }) ?? undefined;
+
+ const terminalTabId = store.getState().openTab({
+ workspaceKey,
+ target: { kind: "terminal", terminalId: "term-1" },
+ intent: "reveal",
+ parentTabId: openChildSheet(),
+ });
+ expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
+ let layout = store.getState().layoutByWorkspace[workspaceKey];
+ expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(terminalTabId);
+
+ const secondRootTabId = store.getState().openTab({
+ workspaceKey,
+ target: { kind: "agent", agentId: "second-root" },
+ intent: "reveal",
+ parentTabId: openChildSheet(),
+ });
+ expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
+ layout = store.getState().layoutByWorkspace[workspaceKey];
+ expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(secondRootTabId);
+ expect(layout.parentTabIdByTabId?.[secondRootTabId ?? ""]).toBe(rootTabId);
+ expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]);
+ });
+
+ describe("unsaved sheet work", () => {
+ const sheetPanel = (tabId: string) => ({
+ serverId: "server-1",
+ workspaceId: "sheet-ws",
+ tabId,
+ });
+ let modifiedTabId: string | null = null;
+
+ function openModifiedFileSheet(store: ReturnType): string {
+ const tabId = store
+ .getState()
+ .openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
+ if (!tabId) throw new Error("Expected a sheet tab");
+ store.getState().setTabState(workspaceKey, tabId, { scrollTop: 40 });
+ setPanelInstanceAttributes(sheetPanel(tabId), { modified: true });
+ modifiedTabId = tabId;
+ return tabId;
+ }
+
+ function stripTab(store: ReturnType, tabId: string) {
+ const layout = store.getState().layoutByWorkspace[workspaceKey];
+ return collectAllTabs(layout.root).find((tab) => tab.tabId === tabId) ?? null;
+ }
+
+ function focusedTabId(store: ReturnType): string | null {
+ const layout = store.getState().layoutByWorkspace[workspaceKey];
+ return findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null;
+ }
+
+ afterEach(() => {
+ if (modifiedTabId) setPanelInstanceAttributes(sheetPanel(modifiedTabId), { modified: false });
+ modifiedTabId = null;
+ });
+
+ function sheetTabId(store: ReturnType): string | null {
+ return store.getState().sheetByWorkspace[workspaceKey]?.tab.tabId ?? null;
+ }
+
+ it("opens the next sheet target as a tab under a modified sheet", () => {
+ const store = createCompactStore();
+ const modifiedSheetTabId = openModifiedFileSheet(store);
+
+ const nextTarget = { kind: "file" as const, path: "src/other.ts" };
+ const nextTabId = store
+ .getState()
+ .openTab({ workspaceKey, target: nextTarget, intent: "reveal" });
+
+ expect(sheetTabId(store)).toBe(modifiedSheetTabId);
+ expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.state).toEqual({ scrollTop: 40 });
+ expect(stripTab(store, nextTabId ?? "")).toMatchObject({ target: nextTarget });
+ expect(focusedTabId(store)).toBe(nextTabId);
+ });
+
+ it("opens a tab target under a modified sheet without closing it", () => {
+ const store = createCompactStore();
+ const modifiedSheetTabId = openModifiedFileSheet(store);
+
+ const terminalTabId = store.getState().openTab({
+ workspaceKey,
+ target: { kind: "terminal", terminalId: "term-2" },
+ intent: "reveal",
+ });
+
+ expect(sheetTabId(store)).toBe(modifiedSheetTabId);
+ expect(focusedTabId(store)).toBe(terminalTabId);
+ });
+
+ it("keeps a modified sheet open when another tab is chosen", () => {
+ const store = createCompactStore();
+ const secondTabId = store.getState().openTab({
+ workspaceKey,
+ target: { kind: "terminal", terminalId: "term-2" },
+ intent: "reveal",
+ });
+ const firstTabId = store.getState().getWorkspaceTabs(workspaceKey)[0]?.tabId ?? "";
+ const modifiedSheetTabId = openModifiedFileSheet(store);
+
+ store.getState().focusTab(workspaceKey, firstTabId);
+ expect(sheetTabId(store)).toBe(modifiedSheetTabId);
+ expect(focusedTabId(store)).toBe(firstTabId);
+
+ store.getState().selectTabInPane(workspaceKey, "main", secondTabId ?? "");
+ expect(sheetTabId(store)).toBe(modifiedSheetTabId);
+ expect(focusedTabId(store)).toBe(secondTabId);
+
+ store.getState().closeTab(workspaceKey, modifiedSheetTabId);
+ expect(sheetTabId(store)).toBeNull();
+ expect(tabKinds(store)).toEqual(["terminal", "terminal"]);
+ });
+
+ it("replaces an unmodified sheet with the next sheet target", () => {
+ const store = createCompactStore();
+ store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
+
+ store.getState().openTab({
+ workspaceKey,
+ target: { kind: "file", path: "src/other.ts" },
+ intent: "reveal",
+ });
+
+ expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual({
+ kind: "file",
+ path: "src/other.ts",
+ });
+ expect(tabKinds(store)).toEqual(["terminal"]);
+ });
+
+ it("closes an unmodified sheet when another tab is chosen", () => {
+ const store = createCompactStore();
+ const secondTabId = store.getState().openTab({
+ workspaceKey,
+ target: { kind: "terminal", terminalId: "term-2" },
+ intent: "reveal",
+ });
+ const firstTabId = store.getState().getWorkspaceTabs(workspaceKey)[0]?.tabId ?? "";
+
+ store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
+ store.getState().focusTab(workspaceKey, firstTabId);
+ expect(sheetTabId(store)).toBeNull();
+ expect(focusedTabId(store)).toBe(firstTabId);
+
+ store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
+ store.getState().selectTabInPane(workspaceKey, "main", secondTabId ?? "");
+ expect(sheetTabId(store)).toBeNull();
+ expect(focusedTabId(store)).toBe(secondTabId);
+ expect(tabKinds(store)).toEqual(["terminal", "terminal"]);
+ });
+ });
+
+ it("closes the sheet when its agent leaves the workspace", () => {
+ seedAgents([
+ { id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
+ { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
+ ]);
+ const store = createCompactStore();
+ store.getState().openTab({
+ workspaceKey,
+ target: { kind: "agent", agentId: "child" },
+ intent: "reveal",
+ });
+ const snapshot = {
+ terminalsHydrated: false,
+ activeAgentIds: ["root"],
+ autoOpenAgentIds: [],
+ knownTerminalIds: ["term-1"],
+ standaloneTerminalIds: ["term-1"],
+ hasActivePendingTerminalCreate: false,
+ };
+
+ store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: false });
+ expect(store.getState().sheetByWorkspace[workspaceKey]).toBeDefined();
+
+ store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: true });
+ expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
+ });
+
+ it("closes the sheet when its agent stops being a subagent, leaving one tab for it", () => {
+ seedAgents([
+ { id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
+ { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
+ ]);
+ const store = createCompactStore();
+ const sheetTabId = store.getState().openTab({
+ workspaceKey,
+ target: { kind: "agent", agentId: "child" },
+ intent: "reveal",
+ });
+ const snapshot = {
+ agentsHydrated: true,
+ terminalsHydrated: false,
+ activeAgentIds: ["root", "child"],
+ autoOpenAgentIds: ["child"],
+ knownTerminalIds: ["term-1"],
+ standaloneTerminalIds: ["term-1"],
+ hasActivePendingTerminalCreate: false,
+ };
+
+ store.getState().reconcileTabs(workspaceKey, { ...snapshot, autoOpenAgentIds: [] });
+ expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.tabId).toBe(sheetTabId);
+
+ seedAgents([
+ { id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
+ { id: "child", parentAgentId: null, workspaceId: "sheet-ws" },
+ ]);
+ store.getState().reconcileTabs(workspaceKey, snapshot);
+
+ expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
+ const layout = store.getState().layoutByWorkspace[workspaceKey];
+ expect(
+ collectAllTabs(layout.root).filter(
+ (tab) => tab.target.kind === "agent" && tab.target.agentId === "child",
+ ),
+ ).toHaveLength(1);
+ });
+
+ it("re-resolves the sheet policy when the sheet tab gets another target", () => {
+ seedAgents([
+ { id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
+ { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
+ ]);
+ const store = createCompactStore();
+ const sheetTabId = store.getState().openTab({
+ workspaceKey,
+ target: { kind: "provider_subagent", parentAgentId: "root", subagentId: "task-1" },
+ intent: "reveal",
+ });
+
+ const replacedTabId = store
+ .getState()
+ .replaceTab(workspaceKey, sheetTabId ?? "", { kind: "agent", agentId: "child" });
+
+ expect(replacedTabId).toBe(sheetTabId);
+ expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({
+ tab: { tabId: sheetTabId, target: { kind: "agent", agentId: "child" } },
+ policy: { openAsTab: true },
+ });
+ });
+
+ it("moves the sheet tab into the tab strip when it gets a target the sheet does not host", () => {
+ const store = createCompactStore();
+ const sheetTabId = store
+ .getState()
+ .openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
+
+ const replacedTabId = store.getState().replaceTab(workspaceKey, sheetTabId ?? "", {
+ kind: "terminal",
+ terminalId: "term-2",
+ });
+
+ expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
+ expect(replacedTabId).toBe(sheetTabId);
+ const layout = store.getState().layoutByWorkspace[workspaceKey];
+ expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(sheetTabId);
+ expect(tabKinds(store)).toEqual(["terminal", "terminal"]);
+ });
+
+ it("keeps a chat shown in the sheet open for timeline sync until the sheet closes", () => {
+ const store = createCompactStore();
+ seedAgents([
+ { id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
+ { id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
+ ]);
+ const received: string[][] = [];
+ const stop = observeOpenWorkspaceAgentIds("server-1", (ids) => received.push(ids), store);
+
+ store.getState().openTab({
+ workspaceKey,
+ target: { kind: "agent", agentId: "child" },
+ intent: "reveal",
+ });
+ store.getState().closeSheet(workspaceKey);
+ stop();
+
+ expect(received).toEqual([[], ["child"], []]);
+ });
+});
diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts
index 5de128b631..226265860a 100644
--- a/packages/app/src/stores/workspace-layout-store.ts
+++ b/packages/app/src/stores/workspace-layout-store.ts
@@ -59,7 +59,12 @@ import {
type WorkspaceTabSnapshot,
type WorkspaceLayout,
} from "@/stores/workspace-layout-actions";
-import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity";
+import {
+ buildDeterministicWorkspaceTabId,
+ normalizeWorkspaceTabTarget,
+} from "@/workspace-tabs/identity";
+import { isSheetTabModified, resolveCompactSheetPolicy } from "@/workspace-tabs/compact-sheet";
+import { panelResourceKey, type CompactSheetPolicy } from "@/panels/panel-manifest";
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
import { panelTargetSupportsHostForWorkspaceKey } from "@/plugins/workspace-panels/locations";
@@ -105,7 +110,24 @@ export interface OpenWorkspaceTabInput {
state?: JsonValue;
}
+/**
+ * How the app shell lays out a workspace: compact (one tab at a time, plus the sheet), a single
+ * wide pane (touch tablets, which cannot render splits), or split panes.
+ */
+export type WorkspaceLayoutMode = "compact" | "single-pane" | "split-panes";
+
+/** A tab hosted by the compact workspace sheet instead of a pane. */
+export interface WorkspaceSheet {
+ tab: WorkspaceTab;
+ policy: CompactSheetPolicy;
+ parentTabId: string | null;
+}
+
interface WorkspaceLayoutStore {
+ /** Published once by the app shell; callers never pass a form factor to open a tab. */
+ layoutMode: WorkspaceLayoutMode;
+ /** At most one sheet tab per workspace. Not persisted: a reload returns to the tab strip. */
+ sheetByWorkspace: Record;
layoutByWorkspace: Record;
splitSizesByWorkspace: Record>;
explorerSidebarWidthByWorkspace: Record;
@@ -116,7 +138,19 @@ interface WorkspaceLayoutStore {
sidePaneIdByWorkspace: Record;
/** Workspaces where PR detection already added its tab once; a closed tab never returns. */
pullRequestTabAutoOpenedByWorkspace: Record;
+ /**
+ * The one entry point for showing a target. On compact layouts a target whose panel declares
+ * `compactSheet` opens in the workspace sheet; the returned tab id is the sheet tab's id.
+ */
openTab: (input: OpenWorkspaceTabInput) => string | null;
+ /** Whether `openTab` with this input would show the target in the sheet. */
+ opensInSheet: (
+ input: Pick,
+ ) => boolean;
+ setLayoutMode: (mode: WorkspaceLayoutMode) => void;
+ closeSheet: (workspaceKey: string) => void;
+ /** Moves the sheet's tab, keeping its tab id, into the focused pane. */
+ openSheetAsTab: (workspaceKey: string) => string | null;
/** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */
autoOpenPullRequestTab: (
workspaceKey: string,
@@ -472,6 +506,17 @@ export function selectExplorerSidebarPaneId(
return resolveExplorerSidebarPaneId(layout, state.explorerSidebarPaneIdByWorkspace[workspaceKey]);
}
+/** A workspace's panes give up keyboard focus while focus is parked or the sheet covers them. */
+export function selectPaneFocusSuspended(
+ state: Pick,
+ workspaceKey: string,
+): boolean {
+ return (
+ Boolean(state.focusRestorationByWorkspace[workspaceKey]?.restorePaneId) ||
+ workspaceKey in state.sheetByWorkspace
+ );
+}
+
/** Whether the Explorer sidebar pane is currently on screen. */
export function selectIsExplorerSidebarVisible(
state: ExplorerSidebarState,
@@ -637,6 +682,189 @@ function attachParentTab(input: {
});
}
+function withoutSheet(
+ state: Pick,
+ workspaceKey: string,
+): Pick {
+ if (!(workspaceKey in state.sheetByWorkspace)) {
+ return { sheetByWorkspace: state.sheetByWorkspace };
+ }
+ const { [workspaceKey]: _removed, ...sheetByWorkspace } = state.sheetByWorkspace;
+ return { sheetByWorkspace };
+}
+
+function isTargetOpenAsTab(layout: WorkspaceLayout | undefined, target: WorkspaceTabTarget) {
+ if (!layout) return false;
+ const resourceKey = panelResourceKey(target);
+ return collectAllTabs(layout.root).some((tab) => panelResourceKey(tab.target) === resourceKey);
+}
+
+function resolveSheetPolicy(
+ state: Pick,
+ workspaceKey: string,
+ target: WorkspaceTabTarget,
+ intent: WorkspaceTabOpenIntent,
+): CompactSheetPolicy | null {
+ if (state.layoutMode !== "compact" || intent === "background") return null;
+ if (intent === "reveal" && isTargetOpenAsTab(state.layoutByWorkspace[workspaceKey], target)) {
+ return null;
+ }
+ return resolveCompactSheetPolicy(workspaceKey, target);
+}
+
+/**
+ * The parent a newly opened tab links to. A target opened from inside the sheet names the sheet's
+ * tab, which the open replaces or closes, so it inherits the sheet's own parent instead.
+ */
+function resolveOpenParentTabId(
+ state: Pick,
+ workspaceKey: string,
+ parentTabId: string | undefined,
+): string | null {
+ const sheet = state.sheetByWorkspace[workspaceKey];
+ if (sheet && parentTabId === sheet.tab.tabId) return sheet.parentTabId;
+ return parentTabId ?? null;
+}
+
+/** A reveal of the resource the sheet already shows keeps the sheet's tab. */
+function sheetKeepsTabFor(
+ sheet: WorkspaceSheet | undefined,
+ target: WorkspaceTabTarget,
+ intent: WorkspaceTabOpenIntent,
+): sheet is WorkspaceSheet {
+ return (
+ intent === "reveal" &&
+ sheet !== undefined &&
+ panelResourceKey(sheet.tab.target) === panelResourceKey(target)
+ );
+}
+
+function placeTargetInSheet(input: {
+ /** The sheet whose tab the target keeps, if any. */
+ kept: WorkspaceSheet | undefined;
+ target: WorkspaceTabTarget;
+ intent: WorkspaceTabOpenIntent;
+ policy: CompactSheetPolicy;
+ parentTabId: string | null;
+ state: JsonValue | undefined;
+}): WorkspaceSheet {
+ if (input.kept) {
+ return { ...input.kept, tab: { ...input.kept.tab, target: input.target } };
+ }
+ return {
+ tab: {
+ tabId:
+ input.intent === "reveal"
+ ? buildDeterministicWorkspaceTabId(input.target)
+ : createWorkspaceTabInstanceId(),
+ target: input.target,
+ createdAt: Date.now(),
+ ...(input.state !== undefined ? { state: input.state } : {}),
+ },
+ policy: input.policy,
+ parentTabId: input.parentTabId,
+ };
+}
+
+/** Moves a workspace's sheet tab into its focused pane, keeping its tab id and state. */
+function promoteSheetToTab(
+ state: WorkspaceLayoutStore,
+ workspaceKey: string,
+): { tabId: string; patch: Partial } | null {
+ const sheet = state.sheetByWorkspace[workspaceKey];
+ if (!sheet) return null;
+ const { tab } = sheet;
+ const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT);
+ const input = {
+ ...placement,
+ target: tab.target,
+ now: tab.createdAt,
+ createTabId: () => tab.tabId,
+ state: tab.state,
+ };
+ const result = isTargetOpenAsTab(placement.layout, tab.target)
+ ? revealTargetInLayout(input)
+ : createTabInLayout(input);
+ if (!result) return null;
+ const nextLayout = attachParentTab({
+ layout: keepWorkspaceFocusOutOfExplorerSidebar(
+ result.layout,
+ placement.explorerSidebarPaneId,
+ placement.layout.focusedPaneId,
+ ),
+ childTabId: result.tabId,
+ parentTabId: sheet.parentTabId,
+ });
+ return {
+ tabId: result.tabId,
+ patch: {
+ ...withoutSheet(state, workspaceKey),
+ ...withoutFocusRestoration(state, workspaceKey),
+ hiddenAgentIdsByWorkspace:
+ tab.target.kind === "agent"
+ ? removeAgentIdFromWorkspaceSet(
+ state.hiddenAgentIdsByWorkspace,
+ workspaceKey,
+ tab.target.agentId,
+ )
+ : state.hiddenAgentIdsByWorkspace,
+ layoutByWorkspace: { ...state.layoutByWorkspace, [workspaceKey]: nextLayout },
+ },
+ };
+}
+
+/**
+ * A sheet holding unsaved work is never implicitly displaced: moving its tab would remount the
+ * panel and drop the work. Only the explicit, guarded close removes it.
+ */
+function sheetHoldsUnsavedWork(
+ state: Pick,
+ workspaceKey: string,
+): boolean {
+ const sheet = state.sheetByWorkspace[workspaceKey];
+ return sheet !== undefined && isSheetTabModified(workspaceKey, sheet.tab.tabId);
+}
+
+/**
+ * Where an open lands in the sheet, or null when it opens as a tab. A reveal of the resource the
+ * sheet shows keeps its tab; any other target never displaces unsaved work and opens as a tab.
+ */
+function resolveSheetPlacement(
+ state: WorkspaceLayoutStore,
+ workspaceKey: string,
+ target: WorkspaceTabTarget,
+ intent: WorkspaceTabOpenIntent,
+): { policy: CompactSheetPolicy; kept: WorkspaceSheet | undefined } | null {
+ const policy = resolveSheetPolicy(state, workspaceKey, target, intent);
+ if (!policy) return null;
+ const sheet = state.sheetByWorkspace[workspaceKey];
+ if (sheetKeepsTabFor(sheet, target, intent)) return { policy, kept: sheet };
+ return sheetHoldsUnsavedWork(state, workspaceKey) ? null : { policy, kept: undefined };
+}
+
+/** Closes the sheet because navigation moved on underneath it, unless it holds unsaved work. */
+function dismissSheet(
+ state: WorkspaceLayoutStore,
+ workspaceKey: string,
+): Partial {
+ return sheetHoldsUnsavedWork(state, workspaceKey) ? {} : withoutSheet(state, workspaceKey);
+}
+
+/**
+ * A sheet agent closes once the workspace no longer has it (archived or gone), like its tab would,
+ * and once it stops being a subagent: as a root agent, reconcile gives it its own tab, and the sheet
+ * would hold a second tab with the same id.
+ */
+function sheetAgentNoLongerFits(
+ workspaceKey: string,
+ sheet: WorkspaceSheet | undefined,
+ snapshot: WorkspaceTabSnapshot,
+): boolean {
+ if (sheet?.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false;
+ if (!new Set(snapshot.activeAgentIds).has(sheet.tab.target.agentId)) return true;
+ return resolveCompactSheetPolicy(workspaceKey, sheet.tab.target) === null;
+}
+
/**
* Splits an Explorer sidebar out of the pane the user is in. Only reached by layouts saved
* before the Explorer became part of the default tree; new ones are born with it.
@@ -676,6 +904,8 @@ export function createWorkspaceLayoutStore(
}
return tabId;
},
+ layoutMode: "split-panes",
+ sheetByWorkspace: {},
layoutByWorkspace: {},
splitSizesByWorkspace: {},
explorerSidebarWidthByWorkspace: {},
@@ -691,6 +921,31 @@ export function createWorkspaceLayoutStore(
if (!normalizedWorkspaceKey || !normalizedTarget) {
return null;
}
+ const parentTabId = resolveOpenParentTabId(
+ get(),
+ normalizedWorkspaceKey,
+ input.parentTabId,
+ );
+ const sheetPlacement = resolveSheetPlacement(
+ get(),
+ normalizedWorkspaceKey,
+ normalizedTarget,
+ input.intent,
+ );
+ if (sheetPlacement) {
+ const sheet = placeTargetInSheet({
+ kept: sheetPlacement.kept,
+ target: normalizedTarget,
+ intent: input.intent,
+ policy: sheetPlacement.policy,
+ parentTabId,
+ state: input.state,
+ });
+ set((state) => ({
+ sheetByWorkspace: { ...state.sheetByWorkspace, [normalizedWorkspaceKey]: sheet },
+ }));
+ return sheet.tab.tabId;
+ }
const placement = getOpenTabPlacement(
get(),
normalizedWorkspaceKey,
@@ -734,6 +989,8 @@ export function createWorkspaceLayoutStore(
const shouldPinAgent = input.pin === true && normalizedTarget.kind === "agent";
set((state) => ({
...withoutFocusRestoration(state, normalizedWorkspaceKey),
+ // A tab brought to the front dismisses the sheet over it.
+ ...(input.intent === "background" ? {} : dismissSheet(state, normalizedWorkspaceKey)),
hiddenAgentIdsByWorkspace:
normalizedTarget.kind !== "agent"
? state.hiddenAgentIdsByWorkspace
@@ -751,17 +1008,52 @@ export function createWorkspaceLayoutStore(
: state.pinnedAgentIdsByWorkspace,
layoutByWorkspace: {
...state.layoutByWorkspace,
- [normalizedWorkspaceKey]: input.parentTabId
+ [normalizedWorkspaceKey]: parentTabId
? attachParentTab({
layout: nextLayout,
childTabId: result.tabId,
- parentTabId: input.parentTabId,
+ parentTabId,
})
: nextLayout,
},
}));
return result.tabId;
},
+ opensInSheet: (input) => {
+ const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey);
+ const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
+ if (!normalizedWorkspaceKey || !normalizedTarget) return false;
+ return (
+ resolveSheetPlacement(get(), normalizedWorkspaceKey, normalizedTarget, input.intent) !==
+ null
+ );
+ },
+ setLayoutMode: (mode) => {
+ set((state) => {
+ if (state.layoutMode === mode) return state;
+ if (mode === "compact") return { layoutMode: mode };
+ // Widening keeps whatever the user had open in the sheet as an ordinary tab.
+ const next: WorkspaceLayoutStore = { ...state, layoutMode: mode };
+ for (const workspaceKey of Object.keys(state.sheetByWorkspace)) {
+ const promoted = promoteSheetToTab(next, workspaceKey);
+ Object.assign(next, promoted?.patch ?? withoutSheet(next, workspaceKey));
+ }
+ return next;
+ });
+ },
+ closeSheet: (workspaceKey) => {
+ const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
+ if (!normalizedWorkspaceKey) return;
+ set((state) => withoutSheet(state, normalizedWorkspaceKey));
+ },
+ openSheetAsTab: (workspaceKey) => {
+ const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
+ if (!normalizedWorkspaceKey) return null;
+ const promoted = promoteSheetToTab(get(), normalizedWorkspaceKey);
+ if (!promoted) return null;
+ set(promoted.patch);
+ return promoted.tabId;
+ },
showExplorerSidebar: (workspaceKey) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) {
@@ -881,6 +1173,9 @@ export function createWorkspaceLayoutStore(
}
set((state) => {
+ if (state.sheetByWorkspace[normalizedWorkspaceKey]?.tab.tabId === normalizedTabId) {
+ return withoutSheet(state, normalizedWorkspaceKey);
+ }
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
layout,
@@ -988,6 +1283,8 @@ export function createWorkspaceLayoutStore(
return {
...withoutFocusRestoration(state, normalizedWorkspaceKey),
+ // Choosing a tab dismisses the sheet that covers it.
+ ...dismissSheet(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
@@ -1011,6 +1308,8 @@ export function createWorkspaceLayoutStore(
});
return nextLayout
? {
+ // Choosing a tab dismisses the sheet that covers it.
+ ...dismissSheet(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
@@ -1024,6 +1323,31 @@ export function createWorkspaceLayoutStore(
const normalizedTabId = trimNonEmpty(tabId);
const normalizedTarget = normalizeWorkspaceTabTarget(target);
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null;
+ const sheet = get().sheetByWorkspace[normalizedWorkspaceKey];
+ if (sheet?.tab.tabId === normalizedTabId) {
+ const policy = resolveCompactSheetPolicy(normalizedWorkspaceKey, normalizedTarget);
+ const replaced: WorkspaceSheet = {
+ ...sheet,
+ policy: policy ?? sheet.policy,
+ tab: {
+ ...sheet.tab,
+ target: normalizedTarget,
+ ...(tabState !== undefined ? { state: tabState } : {}),
+ },
+ };
+ const next: WorkspaceLayoutStore = {
+ ...get(),
+ sheetByWorkspace: { ...get().sheetByWorkspace, [normalizedWorkspaceKey]: replaced },
+ };
+ if (policy) {
+ set({ sheetByWorkspace: next.sheetByWorkspace });
+ return normalizedTabId;
+ }
+ // A target the sheet does not host becomes the tab it would have opened as.
+ const promoted = promoteSheetToTab(next, normalizedWorkspaceKey);
+ set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey));
+ return promoted?.tabId ?? null;
+ }
const result = replaceTabTargetInLayout({
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
tabId: normalizedTabId,
@@ -1054,6 +1378,19 @@ export function createWorkspaceLayoutStore(
const normalizedTabId = trimNonEmpty(tabId);
if (!normalizedWorkspaceKey || !normalizedTabId) return;
set((state) => {
+ const sheet = state.sheetByWorkspace[normalizedWorkspaceKey];
+ if (sheet?.tab.tabId === normalizedTabId) {
+ const { state: _previous, ...tab } = sheet.tab;
+ return {
+ sheetByWorkspace: {
+ ...state.sheetByWorkspace,
+ [normalizedWorkspaceKey]: {
+ ...sheet,
+ tab: tabState === undefined ? tab : { ...tab, state: tabState },
+ },
+ },
+ };
+ }
const layout = setTabStateInLayout({
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
tabId: normalizedTabId,
@@ -1148,15 +1485,22 @@ export function createWorkspaceLayoutStore(
);
}
}
+ const sheetAgentGone = sheetAgentNoLongerFits(
+ normalizedWorkspaceKey,
+ state.sheetByWorkspace[normalizedWorkspaceKey],
+ snapshot,
+ );
if (
state.layoutByWorkspace[normalizedWorkspaceKey] &&
nextLayout === rawLayout &&
- pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace
+ pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace &&
+ !sheetAgentGone
) {
return state;
}
return {
+ ...(sheetAgentGone ? withoutSheet(state, normalizedWorkspaceKey) : {}),
pinnedAgentIdsByWorkspace,
layoutByWorkspace: {
...state.layoutByWorkspace,
@@ -1661,7 +2005,8 @@ export function createWorkspaceLayoutStore(
normalizedWorkspaceKey in state.focusRestorationByWorkspace ||
normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace ||
normalizedWorkspaceKey in state.sidePaneIdByWorkspace ||
- normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace;
+ normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace ||
+ normalizedWorkspaceKey in state.sheetByWorkspace;
if (!hasAny) {
return state;
}
@@ -1690,6 +2035,7 @@ export function createWorkspaceLayoutStore(
const { [normalizedWorkspaceKey]: _sidePane, ...sidePaneIdByWorkspace } =
state.sidePaneIdByWorkspace;
return {
+ ...withoutSheet(state, normalizedWorkspaceKey),
pullRequestTabAutoOpenedByWorkspace,
layoutByWorkspace,
splitSizesByWorkspace,
@@ -1795,10 +2141,10 @@ export function createWorkspaceLayoutStore(
export const useWorkspaceLayoutStore = createWorkspaceLayoutStore();
/**
- * The agent tabs that exist right now, across every workspace of this host, independently
- * of which workspace views are mounted. Timeline sync uses this to release a chat when its
- * tab closes. It is not a subscription source: this layout is restored from disk at launch,
- * so subscribing to it would resume every agent the user has ever opened.
+ * The agent tabs that exist right now, including sheet tabs, across every workspace of this host,
+ * independently of which workspace views are mounted. Timeline sync uses this to release a chat
+ * when its tab closes. It is not a subscription source: this layout is restored from disk at
+ * launch, so subscribing to it would resume every agent the user has ever opened.
*/
export function observeOpenWorkspaceAgentIds(
serverId: string,
@@ -1806,11 +2152,19 @@ export function observeOpenWorkspaceAgentIds(
store = useWorkspaceLayoutStore,
): () => void {
let previous: string[] | undefined;
- const publish = (state: Pick) => {
+ const publish = (state: Pick) => {
const ids = new Set();
- for (const [workspaceKey, layout] of Object.entries(state.layoutByWorkspace)) {
+ const tabsByWorkspace = [
+ ...Object.entries(state.layoutByWorkspace).map(
+ ([workspaceKey, layout]) => [workspaceKey, collectAllTabs(layout.root)] as const,
+ ),
+ ...Object.entries(state.sheetByWorkspace).map(
+ ([workspaceKey, sheet]) => [workspaceKey, [sheet.tab]] as const,
+ ),
+ ];
+ for (const [workspaceKey, tabs] of tabsByWorkspace) {
if (!workspaceKey.startsWith(`${serverId}:`)) continue;
- for (const tab of collectAllTabs(layout.root)) {
+ for (const tab of tabs) {
if (tab.target.kind === "agent") ids.add(tab.target.agentId);
}
}
@@ -1821,7 +2175,12 @@ export function observeOpenWorkspaceAgentIds(
listener(next);
};
const unsubscribe = store.subscribe((state, before) => {
- if (state.layoutByWorkspace !== before.layoutByWorkspace) publish(state);
+ if (
+ state.layoutByWorkspace !== before.layoutByWorkspace ||
+ state.sheetByWorkspace !== before.sheetByWorkspace
+ ) {
+ publish(state);
+ }
});
publish(store.getState());
return unsubscribe;
diff --git a/packages/app/src/workspace-tabs/compact-sheet.ts b/packages/app/src/workspace-tabs/compact-sheet.ts
new file mode 100644
index 0000000000..947d4904c5
--- /dev/null
+++ b/packages/app/src/workspace-tabs/compact-sheet.ts
@@ -0,0 +1,38 @@
+import { getWorkspacePanelAttributes } from "@/panels/panel-instance-attributes";
+import { panelCompactSheetPolicy, type CompactSheetPolicy } from "@/panels/panel-manifest";
+import { useSessionStore, type Agent } from "@/stores/session-store";
+import { isWorkspaceRootAgent } from "@/subagents/policies";
+import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
+
+function workspaceAgentLookup(workspaceKey: string): (agentId: string) => Agent | null {
+ const sessions = useSessionStore.getState().sessions;
+ const serverId = Object.keys(sessions)
+ .filter((candidate) => workspaceKey.startsWith(`${candidate}:`))
+ .sort((left, right) => right.length - left.length)[0];
+ const session = serverId ? sessions[serverId] : undefined;
+ return (agentId) => session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null;
+}
+
+/**
+ * The single target-aware boundary deciding whether a compact reveal lands in the workspace
+ * sheet. An agent the client has not loaded yet counts as a root agent and stays a tab.
+ */
+export function resolveCompactSheetPolicy(
+ workspaceKey: string,
+ target: WorkspaceTabTarget,
+): CompactSheetPolicy | null {
+ return panelCompactSheetPolicy(target, {
+ isWorkspaceRootAgent: (agentId) => {
+ const getAgent = workspaceAgentLookup(workspaceKey);
+ const agent = getAgent(agentId);
+ if (!agent) return true;
+ const parent = agent.parentAgentId ? (getAgent(agent.parentAgentId) ?? undefined) : undefined;
+ return isWorkspaceRootAgent(agent, parent);
+ },
+ });
+}
+
+/** Whether the sheet's tab holds unsaved work, which must never be discarded with the sheet. */
+export function isSheetTabModified(workspaceKey: string, tabId: string): boolean {
+ return getWorkspacePanelAttributes(workspaceKey, tabId).modified;
+}
diff --git a/packages/app/src/workspace-tabs/layout-mode-publisher.tsx b/packages/app/src/workspace-tabs/layout-mode-publisher.tsx
new file mode 100644
index 0000000000..03df9b695a
--- /dev/null
+++ b/packages/app/src/workspace-tabs/layout-mode-publisher.tsx
@@ -0,0 +1,21 @@
+import { useLayoutEffect } from "react";
+import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
+import { useWorkspaceLayoutStore, type WorkspaceLayoutMode } from "@/stores/workspace-layout-store";
+
+function resolveWorkspaceLayoutMode(isCompact: boolean): WorkspaceLayoutMode {
+ if (isCompact) return "compact";
+ return supportsDesktopPaneSplits() ? "split-panes" : "single-pane";
+}
+
+/**
+ * Publishes the shell's workspace layout to the layout store, which decides where opened targets
+ * land. Render it before the routes: React runs an earlier sibling's layout effects before a later
+ * sibling's subtree, so a route sees the mode even from its first layout effect.
+ */
+export function WorkspaceLayoutModePublisher(): null {
+ const isCompact = useIsCompactFormFactor();
+ useLayoutEffect(() => {
+ useWorkspaceLayoutStore.getState().setLayoutMode(resolveWorkspaceLayoutMode(isCompact));
+ }, [isCompact]);
+ return null;
+}
diff --git a/packages/app/src/workspace-tabs/open-beside.test.ts b/packages/app/src/workspace-tabs/open-beside.test.ts
new file mode 100644
index 0000000000..ac181eeb75
--- /dev/null
+++ b/packages/app/src/workspace-tabs/open-beside.test.ts
@@ -0,0 +1,177 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@react-native-async-storage/async-storage", () => ({
+ default: {
+ getItem: vi.fn(async () => null),
+ setItem: vi.fn(async () => undefined),
+ removeItem: vi.fn(async () => undefined),
+ },
+}));
+
+import { DEFAULT_APP_SETTINGS } from "@/hooks/use-settings";
+import { setPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
+import {
+ collectAllPanes,
+ collectAllTabs,
+ findPaneById,
+ useWorkspaceLayoutStore,
+} from "@/stores/workspace-layout-store";
+import {
+ openPreferredWorkspacePreview,
+ openPreferredWorkspaceTarget,
+ openWorkspaceTargetBeside,
+} from "@/workspace-tabs/open-beside";
+
+const WORKSPACE_KEY = "server-1:workspace-1";
+
+beforeEach(() => {
+ useWorkspaceLayoutStore.setState({
+ layoutMode: "split-panes",
+ sheetByWorkspace: {},
+ layoutByWorkspace: {},
+ explorerSidebarPaneIdByWorkspace: {},
+ sidePaneIdByWorkspace: {},
+ });
+});
+
+describe("openPreferredWorkspacePreview", () => {
+ it("opens a compact file preview in the sheet instead of replacing the active file tab", () => {
+ const store = useWorkspaceLayoutStore.getState();
+ const fileTabId = store.openTab({
+ workspaceKey: WORKSPACE_KEY,
+ target: { kind: "file", path: "src/open.ts" },
+ intent: "reveal",
+ });
+ store.setLayoutMode("compact");
+
+ const tabId = openPreferredWorkspacePreview({
+ workspaceKey: WORKSPACE_KEY,
+ serverId: "server-1",
+ workspaceId: "workspace-1",
+ explorerSidebarPaneId: "explorer",
+ lastMainPaneId: "main",
+ target: { kind: "file", path: "src/preview.ts" },
+ source: "explorerFiles",
+ preferences: DEFAULT_APP_SETTINGS.openInSidePane,
+ });
+
+ const state = useWorkspaceLayoutStore.getState();
+ expect(state.sheetByWorkspace[WORKSPACE_KEY]?.tab).toMatchObject({
+ tabId,
+ target: { kind: "file", path: "src/preview.ts" },
+ });
+ const root = state.layoutByWorkspace[WORKSPACE_KEY].root;
+ expect(findPaneById(root, "main")?.tabIds).toEqual([fileTabId]);
+ expect(collectAllTabs(root).find((tab) => tab.tabId === fileTabId)?.target).toEqual({
+ kind: "file",
+ path: "src/open.ts",
+ });
+ });
+});
+
+describe("previews under a sheet with unsaved work", () => {
+ const sheetTarget = { kind: "file" as const, path: "src/draft.ts" };
+ let sheetTabId: string | null = null;
+
+ afterEach(() => {
+ if (sheetTabId) {
+ setPanelInstanceAttributes(
+ { serverId: "server-1", workspaceId: "workspace-1", tabId: sheetTabId },
+ { modified: false },
+ );
+ }
+ sheetTabId = null;
+ });
+
+ function previewFile(path: string): string | null {
+ return openPreferredWorkspacePreview({
+ workspaceKey: WORKSPACE_KEY,
+ serverId: "server-1",
+ workspaceId: "workspace-1",
+ explorerSidebarPaneId: "explorer",
+ lastMainPaneId: "main",
+ target: { kind: "file", path },
+ source: "explorerFiles",
+ preferences: DEFAULT_APP_SETTINGS.openInSidePane,
+ });
+ }
+
+ it("reuses one preview tab instead of adding a tab per link", () => {
+ const store = useWorkspaceLayoutStore.getState();
+ store.openTab({
+ workspaceKey: WORKSPACE_KEY,
+ target: { kind: "terminal", terminalId: "term-1" },
+ intent: "reveal",
+ });
+ store.setLayoutMode("compact");
+ sheetTabId = store.openTab({
+ workspaceKey: WORKSPACE_KEY,
+ target: sheetTarget,
+ intent: "reveal",
+ });
+ setPanelInstanceAttributes(
+ { serverId: "server-1", workspaceId: "workspace-1", tabId: sheetTabId ?? "" },
+ { modified: true },
+ );
+
+ const firstPreviewTabId = previewFile("src/first.ts");
+ const secondPreviewTabId = previewFile("src/second.ts");
+
+ const state = useWorkspaceLayoutStore.getState();
+ expect(state.sheetByWorkspace[WORKSPACE_KEY]?.tab.target).toEqual(sheetTarget);
+ expect(secondPreviewTabId).toBe(firstPreviewTabId);
+ const root = state.layoutByWorkspace[WORKSPACE_KEY].root;
+ const tabsById = new Map(collectAllTabs(root).map((tab) => [tab.tabId, tab.target]));
+ expect(findPaneById(root, "main")?.tabIds.map((tabId) => tabsById.get(tabId))).toEqual([
+ { kind: "terminal", terminalId: "term-1" },
+ { kind: "file", path: "src/second.ts" },
+ ]);
+ });
+});
+
+describe("opening beside", () => {
+ const sidePreference = { ...DEFAULT_APP_SETTINGS.openInSidePane, subagents: true };
+
+ function openSubagentWithSidePreference(): string | null {
+ useWorkspaceLayoutStore.getState().openTab({
+ workspaceKey: WORKSPACE_KEY,
+ target: { kind: "file", path: "src/open.ts" },
+ intent: "reveal",
+ });
+ return openPreferredWorkspaceTarget({
+ workspaceKey: WORKSPACE_KEY,
+ target: { kind: "file", path: "src/subagent.ts" },
+ source: "subagents",
+ preferences: sidePreference,
+ });
+ }
+
+ function paneIdHolding(tabId: string | null): string | undefined {
+ const root = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY].root;
+ return collectAllPanes(root).find((pane) => pane.tabIds.includes(tabId ?? ""))?.id;
+ }
+
+ it("follows the side preference where the workspace renders split panes", () => {
+ useWorkspaceLayoutStore.getState().setLayoutMode("split-panes");
+
+ const tabId = openSubagentWithSidePreference();
+
+ const sidePaneId = useWorkspaceLayoutStore.getState().sidePaneIdByWorkspace[WORKSPACE_KEY];
+ expect(sidePaneId).toEqual(expect.any(String));
+ expect(paneIdHolding(tabId)).toBe(sidePaneId);
+ });
+
+ it("keeps a single-pane workspace in one pane despite the side preference", () => {
+ useWorkspaceLayoutStore.getState().setLayoutMode("single-pane");
+
+ const preferredTabId = openSubagentWithSidePreference();
+ const besideTabId = openWorkspaceTargetBeside({
+ workspaceKey: WORKSPACE_KEY,
+ target: { kind: "file", path: "src/beside.ts" },
+ });
+
+ expect(useWorkspaceLayoutStore.getState().sidePaneIdByWorkspace[WORKSPACE_KEY]).toBeFalsy();
+ expect(paneIdHolding(preferredTabId)).toBe("main");
+ expect(paneIdHolding(besideTabId)).toBe("main");
+ });
+});
diff --git a/packages/app/src/workspace-tabs/open-beside.ts b/packages/app/src/workspace-tabs/open-beside.ts
index b36d227c3b..0c0c9ebdf5 100644
--- a/packages/app/src/workspace-tabs/open-beside.ts
+++ b/packages/app/src/workspace-tabs/open-beside.ts
@@ -22,13 +22,11 @@ interface OpenWorkspaceTargetInput {
}
export interface OpenPreferredWorkspaceTargetInput extends OpenWorkspaceTargetInput {
- isCompact: boolean;
source: OpenInSidePaneSource;
preferences: OpenInSidePanePreferences;
}
interface OpenWorkspaceTargetAtLocationInput extends OpenWorkspaceTargetInput {
- isCompact: boolean;
location: WorkspaceTargetOpenLocation;
}
@@ -39,6 +37,11 @@ interface OpenPreferredWorkspacePreviewInput extends OpenPreferredWorkspaceTarge
lastMainPaneId: string | null;
}
+/** A side pane exists only where the workspace renders split panes. */
+function canOpenBeside(): boolean {
+ return useWorkspaceLayoutStore.getState().layoutMode === "split-panes";
+}
+
function resolveMainPane(input: {
workspaceKey: string;
explorerSidebarPaneId: string | null;
@@ -86,7 +89,6 @@ export function openPreferredWorkspaceTarget(
input: OpenPreferredWorkspaceTargetInput,
): string | null {
return openWorkspaceTargetAtLocation({
- isCompact: input.isCompact,
workspaceKey: input.workspaceKey,
target: input.target,
location: input.preferences[input.source] ? "side" : "main",
@@ -105,7 +107,7 @@ export function openWorkspaceTargetAtLocation(
layout &&
collectAllTabs(layout.root).some((tab) => workspaceTabTargetsEqual(tab.target, input.target)),
);
- const shouldOpenBeside = !input.isCompact && input.location === "side";
+ const shouldOpenBeside = canOpenBeside() && input.location === "side";
let placement: WorkspaceTabPlacement | undefined;
if (shouldOpenBeside && !targetAlreadyExists) {
const paneId = store.ensureSidePane(input.workspaceKey);
@@ -135,7 +137,13 @@ export function openPreferredWorkspacePreview(
const existing = collectAllTabs(layout.root).some((tab) =>
workspaceTabTargetsEqual(tab.target, input.target),
);
- if (existing) return openPreferredWorkspaceTarget(input);
+ // The sheet is already a single replaceable slot, so it needs no preview tab.
+ if (
+ existing ||
+ store.opensInSheet({ workspaceKey: input.workspaceKey, target: input.target, intent: "reveal" })
+ ) {
+ return openPreferredWorkspaceTarget(input);
+ }
const mainPane = resolveMainPane({
workspaceKey: input.workspaceKey,
@@ -143,7 +151,7 @@ export function openPreferredWorkspacePreview(
lastMainPaneId: input.lastMainPaneId,
});
const destinationPaneId =
- !input.isCompact && input.preferences[input.source]
+ canOpenBeside() && input.preferences[input.source]
? store.ensureSidePane(input.workspaceKey)
: mainPane?.id;
if (!destinationPaneId) return null;
@@ -173,9 +181,15 @@ export function openPreferredWorkspacePreview(
});
}
-/** Explicit Open to Side: creates the side pane and places the target there. */
+/**
+ * Explicit Open to Side: creates the side pane and places the target there. Without split panes
+ * the target opens like any other.
+ */
export function openWorkspaceTargetBeside(input: OpenWorkspaceTargetInput): string | null {
if (!input.workspaceKey) return null;
+ if (!canOpenBeside()) {
+ return openWorkspaceTargetAtLocation({ ...input, location: "main" });
+ }
const store = useWorkspaceLayoutStore.getState();
const paneId = store.ensureSidePane(input.workspaceKey);
return store.openTab({
diff --git a/packages/app/src/workspace-tabs/open-supporting-view.ts b/packages/app/src/workspace-tabs/open-supporting-view.ts
index b91bf732c9..bb567d4bd0 100644
--- a/packages/app/src/workspace-tabs/open-supporting-view.ts
+++ b/packages/app/src/workspace-tabs/open-supporting-view.ts
@@ -46,7 +46,6 @@ export function openWorkspaceChanges(input: OpenWorkspaceChangesInput): string |
return null;
}
return openPreferredWorkspaceTarget({
- isCompact: input.isCompact,
workspaceKey: input.workspaceKey,
target: { kind: "working_diff" },
source: "diffs",
@@ -70,7 +69,6 @@ export function openWorkspacePullRequest(input: OpenWorkspacePullRequestInput):
return null;
}
return openWorkspaceTargetAtLocation({
- isCompact: input.isCompact,
workspaceKey: input.workspaceKey,
target: { kind: "pull_request" },
location: input.destination,