Open files and subagents in a closable sheet on phones (#6827)

* feat(app): open files and subagents in a closable sheet on compact

On phones, opening a file, a provider subagent, or a Paseo subagent now shows it
in a bottom sheet over the current tab instead of adding a tab, so the tab strip
stays for terminals and root agents. Paseo subagents get "Open as tab" to move
the same tab (same tabId and state) into a full tab with a composer; agents in a
sheet hide the composer.

The decision lives in the workspace layout store's openTab, driven by a
per-kind `compactSheet` rule in the panel manifest, so every open path (chat
links, Explorer, subagents track, notifications, plugins) follows it without
call-site branching, and a new kind opts in with one manifest entry. The app
shell publishes the layout mode (compact / single-pane / split-panes) once, so
callers stop passing isCompact for placement.

A sheet holding unsaved edits is never implicitly displaced; later opens go to
tabs underneath, and closing it goes through the existing discard guard. The
editor host is position: sticky because CodeMirror's scrollIntoView otherwise
scrolls the bottom sheet's container and drags the sheet to full height.

* test(app): fail the compact sheet snap check when the sheet is oversized

---------

Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
Mohamed Boudra
2026-10-14 00:20:10 +07:00
committed by GitHub
co-authored by paseo-bot[bot]
parent 335a11eb82
commit e135e9c101
36 changed files with 1959 additions and 119 deletions
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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.
+22 -1
View File
@@ -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
+3 -1
View File
@@ -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,
+1
View File
@@ -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).
@@ -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'.",
"",
"<user-prompt>",
`Open \`${target}\` now`,
"</user-prompt>",
].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();
}
});
+27 -15
View File
@@ -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<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
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<SeededWorkspace, "client" | "repoPath" | "workspaceId" | "projectId">,
input: { parentTitle: string; childTitle: string },
@@ -141,6 +147,12 @@ export async function openSubagentsTrack(page: Page): Promise<void> {
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<void> {
await page.getByTestId("subagents-track-header").click();
await page.getByTestId(`subagents-track-row-${childId}`).click();
}
export async function expectSubagentRowVisible(page: Page, childId: string): Promise<void> {
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toBeVisible({
timeout: 30_000,
@@ -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<void> {
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<void> {
await expect(
page.getByRole("button", { name: `Switch tabs (${count} open)` }).filter({ visible: true }),
).toBeVisible();
}
export async function closeWorkspaceSheet(page: Page, label: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void>,
): Promise<void> {
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");
}
+2
View File
@@ -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 = (
<View style={rowStyle}>
<WorkspaceLayoutModePublisher />
{!isCompactLayout ? (
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
{themedSidebarChrome}
@@ -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;
+3
View File
@@ -613,6 +613,9 @@ export const ar: TranslationResources = {
updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.",
unableToSubscribe: "غير قادر على الاشتراك في المحطة",
},
sheet: {
openAsTab: "فتح كعلامة تبويب",
},
tabs: {
loading: "تحميل...",
modified: "تغييرات غير محفوظة",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -619,6 +619,9 @@ export const ja: TranslationResources = {
updateHost: "ネイティブターミナルを使用するにはホストを更新してください。",
unableToSubscribe: "ターミナルに接続できません",
},
sheet: {
openAsTab: "タブで開く",
},
tabs: {
loading: "読み込み中...",
modified: "未保存の変更",
+3
View File
@@ -614,6 +614,9 @@ export const ko: TranslationResources = {
updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.",
unableToSubscribe: "터미널을 구독할 수 없습니다",
},
sheet: {
openAsTab: "탭으로 열기",
},
tabs: {
loading: "불러오는 중...",
modified: "저장되지 않은 변경사항",
+3
View File
@@ -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",
+3
View File
@@ -620,6 +620,9 @@ export const ru: TranslationResources = {
updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.",
unableToSubscribe: "Не удалось подключиться к терминалу",
},
sheet: {
openAsTab: "Открыть во вкладке",
},
tabs: {
loading: "Загрузка...",
modified: "Несохранённые изменения",
+3
View File
@@ -613,6 +613,9 @@ export const zhCN: TranslationResources = {
updateHost: "请更新主机以使用原生终端渲染器。",
unableToSubscribe: "无法订阅 Terminal",
},
sheet: {
openAsTab: "作为标签页打开",
},
tabs: {
loading: "正在加载...",
modified: "未保存的更改",
+4 -2
View File
@@ -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 ? (
<RenderProfile id={`AgentComposerSection:${agentId}`}>
<AgentComposerSection
agentId={agentId}
@@ -1233,7 +1235,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
onMessageSent={handleMessageSent}
/>
</RenderProfile>
);
) : null;
const streamContent = (
<View style={animatedStaticStyles.content}>
<RenderProfile id={`AgentStreamSection:${agentId}`}>
+20 -30
View File
@@ -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) {
@@ -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<string, Set<() => 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(
@@ -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);
+54 -3
View File
@@ -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<K extends WorkspaceTabTarget["kind"]> =
| CompactSheetPolicy
| null
| ((
target: Extract<WorkspaceTabTarget, { kind: K }>,
facts: PanelTargetFacts,
) => CompactSheetPolicy | null);
export interface PanelManifest<K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"]> {
kind: K;
supportedHosts: readonly PaneHost[];
/** The sheet is never listed: a kind renders there exactly when it has a `compactSheet` rule. */
supportedHosts: readonly Exclude<PaneHost, "sheet">[];
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<K>;
resourceKey(target: Extract<WorkspaceTabTarget, { kind: K }>): 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<K extends WorkspaceTabTarget["kind"]>(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<typeof target.kind>;
return typeof rule === "function" ? rule(target as never, facts) : rule;
}
export function panelCanLaunchInPane(
@@ -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;
@@ -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([]);
});
@@ -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);
return [
...new Set(
panes.flatMap((pane) => {
const targets = panes.flatMap((pane) => {
const target = deriveWorkspacePaneState({ pane, tabs: input.tabs }).activeTab?.descriptor
.target;
return target?.kind === "agent" ? [target.agentId] : [];
}),
),
return target ? [target] : [];
});
if (input.sheetTab) targets.push(input.sheetTab.target);
return [
...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[] {
@@ -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 (
<WorkspaceComposerEditingSessionsProvider workspaceKey={workspaceKey} tabIds={tabIds}>
<NewTabLauncherProvider value={launcher}>
<View style={styles.content}>{content}</View>
</NewTabLauncherProvider>
<NewTabLauncherProvider value={launcher}>{children}</NewTabLauncherProvider>
</WorkspaceComposerEditingSessionsProvider>
);
}
@@ -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,16 +3523,19 @@ function WorkspaceScreenContent({
const desktopContent = desktopSplitContent ?? content;
const rendersDesktopSplitContent = !isMobile && desktopSplitContent !== null;
const workspacePanelContent = (
<WorkspacePanelContent
launcher={newTabLauncher}
workspaceKey={`${normalizedServerId}:${normalizedWorkspaceId}`}
tabIds={uiTabs.map((tab) => 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 ? (
<WorkspacePanelProviders
launcher={newTabLauncher}
workspaceKey={workspacePanelKey}
tabIds={workspacePanelTabIds}
>
<View style={styles.centerColumn}>
<FloatingHeaderLayout
header={compactWorkspaceHeader}
@@ -3512,11 +3543,22 @@ function WorkspaceScreenContent({
>
{(topOverlayInset) => (
<StreamTopOverlayProvider value={topOverlayInset}>
{workspacePanelContent}
<View style={styles.content}>{content}</View>
</StreamTopOverlayProvider>
)}
</FloatingHeaderLayout>
<StreamTopOverlayProvider value={0}>
<WorkspaceSheetHost
workspaceKey={persistenceKey}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isRouteFocused}
buildContent={buildSheetPaneContentModel}
onCloseTab={handleCloseTabById}
/>
</StreamTopOverlayProvider>
</View>
</WorkspacePanelProviders>
) : (
<View style={styles.centerColumn}>
{rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()}
@@ -3549,7 +3591,15 @@ function WorkspaceScreenContent({
</NewTabLauncherProvider>
) : null}
<View style={styles.centerContent}>{workspacePanelContent}</View>
<View style={styles.centerContent}>
<WorkspacePanelProviders
launcher={newTabLauncher}
workspaceKey={workspacePanelKey}
tabIds={workspacePanelTabIds}
>
<View style={styles.content}>{desktopContent}</View>
</WorkspacePanelProviders>
</View>
</View>
);
@@ -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> } & PanelIconProps) {
return <Icon size={size} color={color} />;
}
const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({
color: theme.colors.foregroundMuted,
}));
function renderBackdrop(props: BottomSheetBackdropProps) {
return (
<BottomSheetBackdrop
{...props}
appearsOnIndex={0}
disappearsOnIndex={-1}
opacity={0.45}
pressBehavior="close"
/>
);
}
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<void>;
}
interface WorkspaceSheetPresentationProps extends Omit<
WorkspaceSheetHostProps,
"workspaceKey" | "onCloseTab"
> {
sheet: WorkspaceSheet;
onClose: () => Promise<void>;
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 (
<WorkspaceSheetPresentation
{...rest}
sheet={sheet}
onClose={handleClose}
onOpenAsTab={handleOpenAsTab}
/>
);
}
function WorkspaceSheetPresentation({
sheet,
serverId,
workspaceId,
isWorkspaceFocused,
buildContent,
onClose,
onOpenAsTab,
}: WorkspaceSheetPresentationProps) {
const sheetRef = useRef<BottomSheet>(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 (
<View style={styles.host} pointerEvents="box-none">
<ThemedBottomSheet
ref={sheetRef}
index={0}
snapPoints={SNAP_POINTS}
enableDynamicSizing={false}
enablePanDownToClose
enableContentPanningGesture={false}
backdropComponent={renderBackdrop}
onChange={handleChange}
>
<WorkspaceSheetBody
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
sheet={sheet}
serverId={serverId}
workspaceId={workspaceId}
isWorkspaceFocused={isWorkspaceFocused}
buildContent={buildContent}
onClose={onClose}
onOpenAsTab={onOpenAsTab}
/>
</ThemedBottomSheet>
</View>
);
}
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<WorkspaceTabDescriptor>(
() => ({
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: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
actions: sheet.policy.openAsTab ? (
<Button size="sm" variant="secondary" onPress={onOpenAsTab}>
{t("workspace.sheet.openAsTab")}
</Button>
) : undefined,
}),
[descriptor.icon, descriptor.label, onOpenAsTab, sheet.policy.openAsTab, t],
);
return (
<View style={styles.body} role="dialog" aria-label={descriptor.label}>
<SheetHeaderView header={header} onClose={handleClose} />
<View style={contentStyle}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isWorkspaceFocused}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
host: {
...StyleSheet.absoluteFillObject,
},
body: {
flex: 1,
minHeight: 0,
},
content: {
flex: 1,
minHeight: 0,
},
});
@@ -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<string, string>();
@@ -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<typeof createCompactStore>): 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<Pick<Agent, "id" | "parentAgentId" | "workspaceId">>): 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<typeof createCompactStore>): 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<typeof createCompactStore>, tabId: string) {
const layout = store.getState().layoutByWorkspace[workspaceKey];
return collectAllTabs(layout.root).find((tab) => tab.tabId === tabId) ?? null;
}
function focusedTabId(store: ReturnType<typeof createCompactStore>): 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<typeof createCompactStore>): 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"], []]);
});
});
+372 -13
View File
@@ -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<string, WorkspaceSheet>;
layoutByWorkspace: Record<string, WorkspaceLayout>;
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
explorerSidebarWidthByWorkspace: Record<string, number>;
@@ -116,7 +138,19 @@ interface WorkspaceLayoutStore {
sidePaneIdByWorkspace: Record<string, string | null>;
/** Workspaces where PR detection already added its tab once; a closed tab never returns. */
pullRequestTabAutoOpenedByWorkspace: Record<string, true>;
/**
* 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<OpenWorkspaceTabInput, "workspaceKey" | "target" | "intent">,
) => 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<WorkspaceLayoutStore, "focusRestorationByWorkspace" | "sheetByWorkspace">,
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<WorkspaceLayoutStore, "sheetByWorkspace">,
workspaceKey: string,
): Pick<WorkspaceLayoutStore, "sheetByWorkspace"> {
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<WorkspaceLayoutStore, "layoutMode" | "layoutByWorkspace">,
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<WorkspaceLayoutStore, "sheetByWorkspace">,
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<WorkspaceLayoutStore> } | 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<WorkspaceLayoutStore, "sheetByWorkspace">,
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<WorkspaceLayoutStore> {
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<WorkspaceLayoutStore, "layoutByWorkspace">) => {
const publish = (state: Pick<WorkspaceLayoutStore, "layoutByWorkspace" | "sheetByWorkspace">) => {
const ids = new Set<string>();
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;
@@ -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;
}
@@ -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;
}
@@ -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");
});
});
+21 -7
View File
@@ -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({
@@ -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,