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