mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Open files and subagents in a closable sheet on phones (#6827)
* feat(app): open files and subagents in a closable sheet on compact On phones, opening a file, a provider subagent, or a Paseo subagent now shows it in a bottom sheet over the current tab instead of adding a tab, so the tab strip stays for terminals and root agents. Paseo subagents get "Open as tab" to move the same tab (same tabId and state) into a full tab with a composer; agents in a sheet hide the composer. The decision lives in the workspace layout store's openTab, driven by a per-kind `compactSheet` rule in the panel manifest, so every open path (chat links, Explorer, subagents track, notifications, plugins) follows it without call-site branching, and a new kind opts in with one manifest entry. The app shell publishes the layout mode (compact / single-pane / split-panes) once, so callers stop passing isCompact for placement. A sheet holding unsaved edits is never implicitly displaced; later opens go to tabs underneath, and closing it goes through the existing discard guard. The editor host is position: sticky because CodeMirror's scrollIntoView otherwise scrolls the bottom sheet's container and drags the sheet to full height. * test(app): fail the compact sheet snap check when the sheet is oversized --------- Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
paseo-bot[bot]
parent
335a11eb82
commit
e135e9c101
@@ -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/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-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/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/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/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 |
|
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ contracts.
|
|||||||
| ---------------- | ---------------------------- | ------------------------------------------ |
|
| ---------------- | ---------------------------- | ------------------------------------------ |
|
||||||
| Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock |
|
| 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 |
|
| 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
|
## 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
|
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
|
||||||
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
|
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
|
## Explorer sidebar
|
||||||
|
|
||||||
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
|
`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
|
`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
|
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
|
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
|
ordinary pane stays when its final tab closes and shows the New launcher. An empty workspace does
|
||||||
|
|||||||
@@ -304,7 +304,9 @@ track phase.
|
|||||||
|
|
||||||
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This
|
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
|
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
|
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,
|
but stores it in the modal queue and later reads `.current`; provider dismissal, replacement,
|
||||||
|
|||||||
@@ -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.
|
- **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**.
|
- **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).
|
- **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".
|
- **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).
|
- **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).
|
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test } from "../support/fixtures";
|
||||||
|
import { expectComposerVisible } from "../support/helpers/composer";
|
||||||
|
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||||
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
import { openSubagentFromCompactTrack, seedSubagent } from "../support/helpers/subagents";
|
||||||
|
import {
|
||||||
|
closeWorkspaceSheet,
|
||||||
|
expectOpenTabCount,
|
||||||
|
openWorkspaceSheetAsTab,
|
||||||
|
expectDeclinedDiscardPrompt,
|
||||||
|
expectWorkspaceSheetAtSnap,
|
||||||
|
replaceWorkspaceSheetFileText,
|
||||||
|
selectTabFromSwitcher,
|
||||||
|
tapWorkspaceSheetBackdrop,
|
||||||
|
workspaceSheet,
|
||||||
|
} from "../support/helpers/workspace-sheet";
|
||||||
|
|
||||||
|
const FILE_LINK = "linked.ts:3";
|
||||||
|
|
||||||
|
function promptMentioning(target: string): string {
|
||||||
|
return [
|
||||||
|
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||||
|
"Return JSON only with fields 'title' and 'branch'.",
|
||||||
|
"",
|
||||||
|
"<user-prompt>",
|
||||||
|
`Open \`${target}\` now`,
|
||||||
|
"</user-prompt>",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("compact opens files and subagents in a sheet, leaving the tabs for agents", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const workspace = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "compact-workspace-sheet-",
|
||||||
|
title: "Sheet parent",
|
||||||
|
initialPrompt: promptMentioning(FILE_LINK),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const gate = await installDaemonWebSocketGate(page);
|
||||||
|
const linkedPath = path.join(workspace.cwd, "linked.ts");
|
||||||
|
await writeFile(linkedPath, "one\ntwo\nthree\n", "utf8");
|
||||||
|
const child = await seedSubagent(
|
||||||
|
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||||
|
{ parentId: workspace.agentId, title: "Sheet child" },
|
||||||
|
);
|
||||||
|
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||||
|
await expectOpenTabCount(page, 1);
|
||||||
|
|
||||||
|
await test.step("a chat file link opens the file in a sheet", async () => {
|
||||||
|
const fileLink = page.getByText(FILE_LINK, { exact: true });
|
||||||
|
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||||
|
await fileLink.click();
|
||||||
|
|
||||||
|
const sheet = workspaceSheet(page, "linked.ts");
|
||||||
|
await expect(sheet).toBeVisible();
|
||||||
|
await expect(sheet.getByText("three")).toBeVisible();
|
||||||
|
await expectWorkspaceSheetAtSnap(page, "linked.ts");
|
||||||
|
await expect(sheet.getByRole("button", { name: "Open as tab" })).toHaveCount(0);
|
||||||
|
await expectOpenTabCount(page, 1);
|
||||||
|
await closeWorkspaceSheet(page, "linked.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("a subagent opens in a sheet without its composer", async () => {
|
||||||
|
await openSubagentFromCompactTrack(page, child.id);
|
||||||
|
|
||||||
|
const sheet = workspaceSheet(page, child.title);
|
||||||
|
await expect(sheet).toBeVisible();
|
||||||
|
await expectWorkspaceSheetAtSnap(page, child.title);
|
||||||
|
await expect(sheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
|
||||||
|
await expectOpenTabCount(page, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("Open as tab promotes the subagent to a full tab", async () => {
|
||||||
|
await openWorkspaceSheetAsTab(page, child.title);
|
||||||
|
await expectOpenTabCount(page, 2);
|
||||||
|
await expectComposerVisible(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("a sheet with unsaved edits closes only through the guarded close", async () => {
|
||||||
|
await selectTabFromSwitcher(page, "Sheet parent");
|
||||||
|
await page.getByText(FILE_LINK, { exact: true }).click();
|
||||||
|
const sheet = workspaceSheet(page, "linked.ts");
|
||||||
|
await expect(sheet).toBeVisible();
|
||||||
|
// Hold the autosave so the edit stays unsaved.
|
||||||
|
gate.holdNextClientRequest("fs.file.write.request");
|
||||||
|
await replaceWorkspaceSheetFileText(page, "linked.ts", "edited in the sheet");
|
||||||
|
await gate.waitForHeldClientRequest();
|
||||||
|
|
||||||
|
await expectDeclinedDiscardPrompt(page, () => tapWorkspaceSheetBackdrop(page));
|
||||||
|
await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet");
|
||||||
|
await expectDeclinedDiscardPrompt(page, () =>
|
||||||
|
sheet.getByRole("button", { name: "Close" }).click(),
|
||||||
|
);
|
||||||
|
await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet");
|
||||||
|
|
||||||
|
gate.releaseHeldClientRequest();
|
||||||
|
await expect.poll(() => readFile(linkedPath, "utf8")).toBe("edited in the sheet");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -49,31 +49,37 @@ export async function seedParentWithSubagent(
|
|||||||
modeId: "load-test",
|
modeId: "load-test",
|
||||||
model: "ten-second-stream",
|
model: "ten-second-stream",
|
||||||
});
|
});
|
||||||
const child = await workspace.client.createAgent({
|
const child = await seedSubagent(workspace, { parentId: parent.id, title: input.childTitle });
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
parent: {
|
parent: {
|
||||||
id: parent.id,
|
id: parent.id,
|
||||||
title: input.parentTitle,
|
title: input.parentTitle,
|
||||||
},
|
},
|
||||||
child: {
|
child,
|
||||||
id: child.id,
|
|
||||||
title: input.childTitle,
|
|
||||||
},
|
|
||||||
workspaceId: workspace.workspaceId,
|
workspaceId: workspace.workspaceId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Seeds an idle Paseo subagent of an existing agent in the same workspace. */
|
||||||
|
export async function seedSubagent(
|
||||||
|
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
|
||||||
|
input: { parentId: string; title: string },
|
||||||
|
): Promise<{ id: string; title: string }> {
|
||||||
|
const child = await workspace.client.createAgent({
|
||||||
|
provider: "mock",
|
||||||
|
cwd: workspace.repoPath,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
title: input.title,
|
||||||
|
modeId: "load-test",
|
||||||
|
model: "ten-second-stream",
|
||||||
|
labels: {
|
||||||
|
[PARENT_AGENT_ID_LABEL]: input.parentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { id: child.id, title: input.title };
|
||||||
|
}
|
||||||
|
|
||||||
export async function seedParentWithCrossWorkspaceSubagent(
|
export async function seedParentWithCrossWorkspaceSubagent(
|
||||||
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId" | "projectId">,
|
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId" | "projectId">,
|
||||||
input: { parentTitle: string; childTitle: string },
|
input: { parentTitle: string; childTitle: string },
|
||||||
@@ -141,6 +147,12 @@ export async function openSubagentsTrack(page: Page): Promise<void> {
|
|||||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** On compact the track opens as a sheet, which closes once a row is chosen. */
|
||||||
|
export async function openSubagentFromCompactTrack(page: Page, childId: string): Promise<void> {
|
||||||
|
await page.getByTestId("subagents-track-header").click();
|
||||||
|
await page.getByTestId(`subagents-track-row-${childId}`).click();
|
||||||
|
}
|
||||||
|
|
||||||
export async function expectSubagentRowVisible(page: Page, childId: string): Promise<void> {
|
export async function expectSubagentRowVisible(page: Page, childId: string): Promise<void> {
|
||||||
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toBeVisible({
|
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { expect, type Locator, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
/** The compact workspace sheet, named after the tab it hosts. */
|
||||||
|
export function workspaceSheet(page: Page, label: string): Locator {
|
||||||
|
return page.getByRole("dialog", { name: label });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sheet height as a fraction of the viewport; mirrors the host's 92% snap point. */
|
||||||
|
const SHEET_SNAP_FRACTION = 0.92;
|
||||||
|
/** The dialog starts below the sheet's drag handle. */
|
||||||
|
const SHEET_HANDLE_MAX_HEIGHT = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sheet rests at its snap point, docked to the bottom edge. A sheet pushed up past it
|
||||||
|
* covers the workspace header; a sheet sunk below it hides content.
|
||||||
|
*/
|
||||||
|
export async function expectWorkspaceSheetAtSnap(page: Page, label: string): Promise<void> {
|
||||||
|
const viewportHeight = page.viewportSize()?.height ?? 0;
|
||||||
|
const snapTop = Math.round(viewportHeight * (1 - SHEET_SNAP_FRACTION));
|
||||||
|
const sheet = workspaceSheet(page, label);
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const box = await sheet.boundingBox();
|
||||||
|
if (!box) return null;
|
||||||
|
const top = Math.round(box.y);
|
||||||
|
return {
|
||||||
|
dockedToBottom: Math.round(box.y + box.height) === viewportHeight,
|
||||||
|
topAtSnap: top >= snapTop && top <= snapTop + SHEET_HANDLE_MAX_HEIGHT,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.toEqual({ dockedToBottom: true, topAtSnap: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectOpenTabCount(page: Page, count: number): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: `Switch tabs (${count} open)` }).filter({ visible: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeWorkspaceSheet(page: Page, label: string): Promise<void> {
|
||||||
|
const sheet = workspaceSheet(page, label);
|
||||||
|
await sheet.getByRole("button", { name: "Close" }).click();
|
||||||
|
await expect(sheet).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openWorkspaceSheetAsTab(page: Page, label: string): Promise<void> {
|
||||||
|
const sheet = workspaceSheet(page, label);
|
||||||
|
await sheet.getByRole("button", { name: "Open as tab" }).click();
|
||||||
|
await expect(sheet).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceWorkspaceSheetFileText(
|
||||||
|
page: Page,
|
||||||
|
label: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const editor = workspaceSheet(page, label)
|
||||||
|
.getByTestId("file-source-editor")
|
||||||
|
.locator(".cm-content");
|
||||||
|
await editor.click();
|
||||||
|
await editor.press("ControlOrMeta+A");
|
||||||
|
await editor.pressSequentially(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectTabFromSwitcher(page: Page, label: string): Promise<void> {
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /Switch tabs/ })
|
||||||
|
.filter({ visible: true })
|
||||||
|
.click();
|
||||||
|
const option = page.getByText(label, { exact: true }).filter({ visible: true }).last();
|
||||||
|
await expect(option).toBeVisible();
|
||||||
|
await option.click();
|
||||||
|
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tapWorkspaceSheetBackdrop(page: Page): Promise<void> {
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Bottom sheet backdrop" })
|
||||||
|
.click({ position: { x: 10, y: 10 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a close that hits the unsaved-changes guard, and declines the discard. */
|
||||||
|
export async function expectDeclinedDiscardPrompt(
|
||||||
|
page: Page,
|
||||||
|
close: () => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
const prompt = page.waitForEvent("dialog").then(async (dialog) => {
|
||||||
|
const message = dialog.message();
|
||||||
|
await dialog.dismiss();
|
||||||
|
return message;
|
||||||
|
});
|
||||||
|
await close();
|
||||||
|
expect(await prompt).toContain("discard the draft");
|
||||||
|
}
|
||||||
@@ -116,6 +116,7 @@ import { usePanelStore } from "@/stores/panel-store";
|
|||||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||||
import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme";
|
import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { WorkspaceLayoutModePublisher } from "@/workspace-tabs/layout-mode-publisher";
|
||||||
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
|
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import {
|
import {
|
||||||
@@ -549,6 +550,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
|||||||
}
|
}
|
||||||
const workspaceChrome = (
|
const workspaceChrome = (
|
||||||
<View style={rowStyle}>
|
<View style={rowStyle}>
|
||||||
|
<WorkspaceLayoutModePublisher />
|
||||||
{!isCompactLayout ? (
|
{!isCompactLayout ? (
|
||||||
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
|
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
|
||||||
{themedSidebarChrome}
|
{themedSidebarChrome}
|
||||||
|
|||||||
@@ -165,4 +165,7 @@ const FRAME_STYLE = {
|
|||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
} as const;
|
} 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;
|
||||||
|
|||||||
@@ -613,6 +613,9 @@ export const ar: TranslationResources = {
|
|||||||
updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.",
|
updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.",
|
||||||
unableToSubscribe: "غير قادر على الاشتراك في المحطة",
|
unableToSubscribe: "غير قادر على الاشتراك في المحطة",
|
||||||
},
|
},
|
||||||
|
sheet: {
|
||||||
|
openAsTab: "فتح كعلامة تبويب",
|
||||||
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
loading: "تحميل...",
|
loading: "تحميل...",
|
||||||
modified: "تغييرات غير محفوظة",
|
modified: "تغييرات غير محفوظة",
|
||||||
|
|||||||
@@ -610,6 +610,9 @@ export const en = {
|
|||||||
updateHost: "Update the host to use the native terminal renderer.",
|
updateHost: "Update the host to use the native terminal renderer.",
|
||||||
unableToSubscribe: "Unable to subscribe to terminal",
|
unableToSubscribe: "Unable to subscribe to terminal",
|
||||||
},
|
},
|
||||||
|
sheet: {
|
||||||
|
openAsTab: "Open as tab",
|
||||||
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
loading: "Loading...",
|
loading: "Loading...",
|
||||||
modified: "Unsaved changes",
|
modified: "Unsaved changes",
|
||||||
|
|||||||
@@ -619,6 +619,9 @@ export const es: TranslationResources = {
|
|||||||
updateHost: "Actualiza el host para usar el renderizador de terminal nativo.",
|
updateHost: "Actualiza el host para usar el renderizador de terminal nativo.",
|
||||||
unableToSubscribe: "No se puede suscribir al terminal",
|
unableToSubscribe: "No se puede suscribir al terminal",
|
||||||
},
|
},
|
||||||
|
sheet: {
|
||||||
|
openAsTab: "Abrir como pestaña",
|
||||||
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
loading: "Cargando...",
|
loading: "Cargando...",
|
||||||
modified: "Cambios sin guardar",
|
modified: "Cambios sin guardar",
|
||||||
|
|||||||
@@ -618,6 +618,9 @@ export const fr: TranslationResources = {
|
|||||||
updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.",
|
updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.",
|
||||||
unableToSubscribe: "Impossible de s’abonner au terminal",
|
unableToSubscribe: "Impossible de s’abonner au terminal",
|
||||||
},
|
},
|
||||||
|
sheet: {
|
||||||
|
openAsTab: "Ouvrir dans un onglet",
|
||||||
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
loading: "Chargement…",
|
loading: "Chargement…",
|
||||||
modified: "Modifications non enregistrées",
|
modified: "Modifications non enregistrées",
|
||||||
|
|||||||
@@ -619,6 +619,9 @@ export const ja: TranslationResources = {
|
|||||||
updateHost: "ネイティブターミナルを使用するにはホストを更新してください。",
|
updateHost: "ネイティブターミナルを使用するにはホストを更新してください。",
|
||||||
unableToSubscribe: "ターミナルに接続できません",
|
unableToSubscribe: "ターミナルに接続できません",
|
||||||
},
|
},
|
||||||
|
sheet: {
|
||||||
|
openAsTab: "タブで開く",
|
||||||
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
loading: "読み込み中...",
|
loading: "読み込み中...",
|
||||||
modified: "未保存の変更",
|
modified: "未保存の変更",
|
||||||
|
|||||||
@@ -614,6 +614,9 @@ export const ko: TranslationResources = {
|
|||||||
updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.",
|
updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.",
|
||||||
unableToSubscribe: "터미널을 구독할 수 없습니다",
|
unableToSubscribe: "터미널을 구독할 수 없습니다",
|
||||||
},
|
},
|
||||||
|
sheet: {
|
||||||
|
openAsTab: "탭으로 열기",
|
||||||
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
loading: "불러오는 중...",
|
loading: "불러오는 중...",
|
||||||
modified: "저장되지 않은 변경사항",
|
modified: "저장되지 않은 변경사항",
|
||||||
|
|||||||
@@ -618,6 +618,9 @@ export const ptBR: TranslationResources = {
|
|||||||
updateHost: "Atualize o host para usar o renderizador de terminal nativo.",
|
updateHost: "Atualize o host para usar o renderizador de terminal nativo.",
|
||||||
unableToSubscribe: "Não foi possível assinar o terminal",
|
unableToSubscribe: "Não foi possível assinar o terminal",
|
||||||
},
|
},
|
||||||
|
sheet: {
|
||||||
|
openAsTab: "Abrir como aba",
|
||||||
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
loading: "Carregando...",
|
loading: "Carregando...",
|
||||||
modified: "Alterações não salvas",
|
modified: "Alterações não salvas",
|
||||||
|
|||||||
@@ -620,6 +620,9 @@ export const ru: TranslationResources = {
|
|||||||
updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.",
|
updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.",
|
||||||
unableToSubscribe: "Не удалось подключиться к терминалу",
|
unableToSubscribe: "Не удалось подключиться к терминалу",
|
||||||
},
|
},
|
||||||
|
sheet: {
|
||||||
|
openAsTab: "Открыть во вкладке",
|
||||||
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
loading: "Загрузка...",
|
loading: "Загрузка...",
|
||||||
modified: "Несохранённые изменения",
|
modified: "Несохранённые изменения",
|
||||||
|
|||||||
@@ -613,6 +613,9 @@ export const zhCN: TranslationResources = {
|
|||||||
updateHost: "请更新主机以使用原生终端渲染器。",
|
updateHost: "请更新主机以使用原生终端渲染器。",
|
||||||
unableToSubscribe: "无法订阅 Terminal",
|
unableToSubscribe: "无法订阅 Terminal",
|
||||||
},
|
},
|
||||||
|
sheet: {
|
||||||
|
openAsTab: "作为标签页打开",
|
||||||
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
loading: "正在加载...",
|
loading: "正在加载...",
|
||||||
modified: "未保存的更改",
|
modified: "未保存的更改",
|
||||||
|
|||||||
@@ -1153,6 +1153,8 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
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 subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
|
||||||
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
|
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
|
||||||
state.sessions[serverId]?.agentTasks.get(agentId),
|
state.sessions[serverId]?.agentTasks.get(agentId),
|
||||||
@@ -1216,7 +1218,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
composerState,
|
composerState,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const composerSection = (
|
const composerSection = showsComposer ? (
|
||||||
<RenderProfile id={`AgentComposerSection:${agentId}`}>
|
<RenderProfile id={`AgentComposerSection:${agentId}`}>
|
||||||
<AgentComposerSection
|
<AgentComposerSection
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
@@ -1233,7 +1235,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
onMessageSent={handleMessageSent}
|
onMessageSent={handleMessageSent}
|
||||||
/>
|
/>
|
||||||
</RenderProfile>
|
</RenderProfile>
|
||||||
);
|
) : null;
|
||||||
const streamContent = (
|
const streamContent = (
|
||||||
<View style={animatedStaticStyles.content}>
|
<View style={animatedStaticStyles.content}>
|
||||||
<RenderProfile id={`AgentStreamSection:${agentId}`}>
|
<RenderProfile id={`AgentStreamSection:${agentId}`}>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill";
|
|||||||
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
|
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
|
||||||
import { AgentTaskList } from "@/composer/task-list";
|
import { AgentTaskList } from "@/composer/task-list";
|
||||||
import { ComposerTrackBar } from "@/composer/tracks";
|
import { ComposerTrackBar } from "@/composer/tracks";
|
||||||
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { usePaneContext } from "@/panels/pane-context";
|
import { usePaneContext } from "@/panels/pane-context";
|
||||||
import { useSettings } from "@/hooks/use-settings";
|
import { useSettings } from "@/hooks/use-settings";
|
||||||
import { PluginComposerPills } from "@/plugins";
|
import { PluginComposerPills } from "@/plugins";
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import { SubagentsTrack } from "@/subagents/track";
|
import { SubagentsTrack } from "@/subagents/track";
|
||||||
import type { TodoEntry } from "@/types/stream";
|
import type { TodoEntry } from "@/types/stream";
|
||||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
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 { openPreferredWorkspaceTarget } from "@/workspace-tabs/open-beside";
|
||||||
import { openComposerChanges } from "@/workspace-tabs/open-supporting-view";
|
import { openComposerChanges } from "@/workspace-tabs/open-supporting-view";
|
||||||
|
|
||||||
@@ -49,10 +49,9 @@ export const AgentTracks = memo(function AgentTracks({
|
|||||||
onArchiveFinished: () => void;
|
onArchiveFinished: () => void;
|
||||||
hasPluginComposerPills: boolean;
|
hasPluginComposerPills: boolean;
|
||||||
}): ReactElement | null {
|
}): ReactElement | null {
|
||||||
const { tabId, openTab } = usePaneContext();
|
const { tabId } = usePaneContext();
|
||||||
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
|
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const canSplit = supportsDesktopPaneSplits() && !isCompact;
|
|
||||||
const openInSidePane = useSettings((settings) => settings.openInSidePane);
|
const openInSidePane = useSettings((settings) => settings.openInSidePane);
|
||||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||||
const canDetachSubagents = useSessionStore(
|
const canDetachSubagents = useSessionStore(
|
||||||
@@ -60,45 +59,36 @@ export const AgentTracks = memo(function AgentTracks({
|
|||||||
);
|
);
|
||||||
const archiveSubagent = useArchiveSubagent({ serverId });
|
const archiveSubagent = useArchiveSubagent({ serverId });
|
||||||
const detachSubagent = useDetachSubagent({ 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(
|
const handleOpenSubagent = useCallback(
|
||||||
(subagentId: string) => {
|
(subagentId: string) => {
|
||||||
const session = useSessionStore.getState().sessions[serverId];
|
const session = useSessionStore.getState().sessions[serverId];
|
||||||
const agent = session?.agents.get(subagentId) ?? session?.agentDetails.get(subagentId);
|
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) {
|
if (agent?.workspaceId && agent.workspaceId !== workspaceId) {
|
||||||
navigateToAgent({ serverId, agentId: subagentId });
|
navigateToAgent({ serverId, agentId: subagentId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (canSplit && workspaceKey) {
|
openSubagentTarget({ kind: "agent", agentId: subagentId });
|
||||||
openPreferredWorkspaceTarget({
|
|
||||||
isCompact,
|
|
||||||
workspaceKey,
|
|
||||||
target: { kind: "agent", agentId: subagentId },
|
|
||||||
source: "subagents",
|
|
||||||
preferences: openInSidePane,
|
|
||||||
parentTabId: tabId,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigateToAgent({ serverId, agentId: subagentId });
|
|
||||||
},
|
},
|
||||||
[canSplit, isCompact, openInSidePane, serverId, tabId, workspaceId, workspaceKey],
|
[openSubagentTarget, serverId, workspaceId],
|
||||||
);
|
);
|
||||||
const handleOpenProviderSubagent = useCallback(
|
const handleOpenProviderSubagent = useCallback(
|
||||||
(parentAgentId: string, subagentId: string) => {
|
(parentAgentId: string, subagentId: string) => {
|
||||||
if (canSplit && workspaceKey) {
|
openSubagentTarget({ kind: "provider_subagent", parentAgentId, subagentId });
|
||||||
openPreferredWorkspaceTarget({
|
|
||||||
isCompact,
|
|
||||||
workspaceKey,
|
|
||||||
target: { kind: "provider_subagent", parentAgentId, subagentId },
|
|
||||||
source: "subagents",
|
|
||||||
preferences: openInSidePane,
|
|
||||||
parentTabId: tabId,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
|
|
||||||
},
|
},
|
||||||
[canSplit, isCompact, openInSidePane, openTab, tabId, workspaceKey],
|
[openSubagentTarget],
|
||||||
);
|
);
|
||||||
const handleOpenChanges = useCallback(() => {
|
const handleOpenChanges = useCallback(() => {
|
||||||
if (!workspaceKey) {
|
if (!workspaceKey) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||||
import { usePaneContext } from "@/panels/pane-context";
|
import { usePaneContext } from "@/panels/pane-context";
|
||||||
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
|
|
||||||
export interface PanelInstanceIdentity {
|
export interface PanelInstanceIdentity {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -18,8 +19,20 @@ const listenersByPanel = new Map<string, Set<() => void>>();
|
|||||||
const allListeners = new Set<() => void>();
|
const allListeners = new Set<() => void>();
|
||||||
let attributesRevision = 0;
|
let attributesRevision = 0;
|
||||||
|
|
||||||
|
function buildWorkspacePanelKey(workspaceKey: string, tabId: string): string {
|
||||||
|
return `${workspaceKey}:${tabId}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildPanelInstanceKey(identity: PanelInstanceIdentity): string {
|
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(
|
export function getPanelInstanceAttributes(
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ describe("panel manifest", () => {
|
|||||||
expect(panelSupportsHost("setup", "explorer")).toBe(false);
|
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", () => {
|
it("lets navigation tabs hide their close button without losing their close action", () => {
|
||||||
expect(getPanelManifest("files").showCloseButton).toBe(false);
|
expect(getPanelManifest("files").showCloseButton).toBe(false);
|
||||||
expect(getPanelManifest("changes_tree").showCloseButton).toBe(false);
|
expect(getPanelManifest("changes_tree").showCloseButton).toBe(false);
|
||||||
|
|||||||
@@ -1,12 +1,38 @@
|
|||||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
|
|
||||||
export type PaneHost = "main" | "explorer";
|
/** Where a tab renders: a split pane, the Explorer dock, or the compact workspace sheet. */
|
||||||
|
export type PaneHost = "main" | "explorer" | "sheet";
|
||||||
|
|
||||||
|
/** How a target behaves while it is shown in the compact workspace sheet. */
|
||||||
|
export interface CompactSheetPolicy {
|
||||||
|
/** The sheet offers Open as tab, which moves the same tab into the focused pane. */
|
||||||
|
openAsTab: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Facts about a target that live outside the layout, resolved lazily by the workspace. */
|
||||||
|
export interface PanelTargetFacts {
|
||||||
|
isWorkspaceRootAgent(agentId: string): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompactSheetRule<K extends WorkspaceTabTarget["kind"]> =
|
||||||
|
| CompactSheetPolicy
|
||||||
|
| null
|
||||||
|
| ((
|
||||||
|
target: Extract<WorkspaceTabTarget, { kind: K }>,
|
||||||
|
facts: PanelTargetFacts,
|
||||||
|
) => CompactSheetPolicy | null);
|
||||||
|
|
||||||
export interface PanelManifest<K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"]> {
|
export interface PanelManifest<K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"]> {
|
||||||
kind: K;
|
kind: K;
|
||||||
supportedHosts: readonly PaneHost[];
|
/** The sheet is never listed: a kind renders there exactly when it has a `compactSheet` rule. */
|
||||||
|
supportedHosts: readonly Exclude<PaneHost, "sheet">[];
|
||||||
showCloseButton: boolean;
|
showCloseButton: boolean;
|
||||||
singleton: boolean;
|
singleton: boolean;
|
||||||
|
/**
|
||||||
|
* On compact layouts, revealing this target opens it in the workspace sheet over the current
|
||||||
|
* tab instead of adding a tab. `null` keeps it in the tab strip.
|
||||||
|
*/
|
||||||
|
compactSheet: CompactSheetRule<K>;
|
||||||
resourceKey(target: Extract<WorkspaceTabTarget, { kind: K }>): string;
|
resourceKey(target: Extract<WorkspaceTabTarget, { kind: K }>): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +46,7 @@ const manifests = {
|
|||||||
supportedHosts: ["main", "explorer"],
|
supportedHosts: ["main", "explorer"],
|
||||||
showCloseButton: true,
|
showCloseButton: true,
|
||||||
singleton: false,
|
singleton: false,
|
||||||
|
compactSheet: null,
|
||||||
resourceKey: () => "new_tab",
|
resourceKey: () => "new_tab",
|
||||||
},
|
},
|
||||||
draft: {
|
draft: {
|
||||||
@@ -27,6 +54,7 @@ const manifests = {
|
|||||||
supportedHosts: ["main", "explorer"],
|
supportedHosts: ["main", "explorer"],
|
||||||
showCloseButton: true,
|
showCloseButton: true,
|
||||||
singleton: false,
|
singleton: false,
|
||||||
|
compactSheet: null,
|
||||||
resourceKey: (target) => target.draftId,
|
resourceKey: (target) => target.draftId,
|
||||||
},
|
},
|
||||||
agent: {
|
agent: {
|
||||||
@@ -34,6 +62,8 @@ const manifests = {
|
|||||||
supportedHosts: ["main", "explorer"],
|
supportedHosts: ["main", "explorer"],
|
||||||
showCloseButton: true,
|
showCloseButton: true,
|
||||||
singleton: false,
|
singleton: false,
|
||||||
|
compactSheet: (target, facts) =>
|
||||||
|
facts.isWorkspaceRootAgent(target.agentId) ? null : { openAsTab: true },
|
||||||
resourceKey: (target) => target.agentId,
|
resourceKey: (target) => target.agentId,
|
||||||
},
|
},
|
||||||
provider_subagent: {
|
provider_subagent: {
|
||||||
@@ -41,6 +71,7 @@ const manifests = {
|
|||||||
supportedHosts: ["main", "explorer"],
|
supportedHosts: ["main", "explorer"],
|
||||||
showCloseButton: true,
|
showCloseButton: true,
|
||||||
singleton: false,
|
singleton: false,
|
||||||
|
compactSheet: { openAsTab: false },
|
||||||
resourceKey: (target) => `${target.parentAgentId}:${target.subagentId}`,
|
resourceKey: (target) => `${target.parentAgentId}:${target.subagentId}`,
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
@@ -48,6 +79,7 @@ const manifests = {
|
|||||||
supportedHosts: ["main", "explorer"],
|
supportedHosts: ["main", "explorer"],
|
||||||
showCloseButton: true,
|
showCloseButton: true,
|
||||||
singleton: false,
|
singleton: false,
|
||||||
|
compactSheet: null,
|
||||||
resourceKey: (target) => target.terminalId,
|
resourceKey: (target) => target.terminalId,
|
||||||
},
|
},
|
||||||
browser: {
|
browser: {
|
||||||
@@ -55,6 +87,7 @@ const manifests = {
|
|||||||
supportedHosts: ["main"],
|
supportedHosts: ["main"],
|
||||||
showCloseButton: true,
|
showCloseButton: true,
|
||||||
singleton: false,
|
singleton: false,
|
||||||
|
compactSheet: null,
|
||||||
resourceKey: (target) => target.browserId,
|
resourceKey: (target) => target.browserId,
|
||||||
},
|
},
|
||||||
changes_tree: {
|
changes_tree: {
|
||||||
@@ -62,6 +95,7 @@ const manifests = {
|
|||||||
supportedHosts: ["explorer"],
|
supportedHosts: ["explorer"],
|
||||||
showCloseButton: false,
|
showCloseButton: false,
|
||||||
singleton: true,
|
singleton: true,
|
||||||
|
compactSheet: null,
|
||||||
resourceKey: () => "changes_tree",
|
resourceKey: () => "changes_tree",
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
@@ -69,6 +103,7 @@ const manifests = {
|
|||||||
supportedHosts: ["explorer"],
|
supportedHosts: ["explorer"],
|
||||||
showCloseButton: false,
|
showCloseButton: false,
|
||||||
singleton: true,
|
singleton: true,
|
||||||
|
compactSheet: null,
|
||||||
resourceKey: () => "files",
|
resourceKey: () => "files",
|
||||||
},
|
},
|
||||||
pull_request: {
|
pull_request: {
|
||||||
@@ -76,6 +111,7 @@ const manifests = {
|
|||||||
supportedHosts: ["main", "explorer"],
|
supportedHosts: ["main", "explorer"],
|
||||||
showCloseButton: true,
|
showCloseButton: true,
|
||||||
singleton: false,
|
singleton: false,
|
||||||
|
compactSheet: null,
|
||||||
resourceKey: () => "pull_request",
|
resourceKey: () => "pull_request",
|
||||||
},
|
},
|
||||||
file: {
|
file: {
|
||||||
@@ -83,6 +119,7 @@ const manifests = {
|
|||||||
supportedHosts: ["main", "explorer"],
|
supportedHosts: ["main", "explorer"],
|
||||||
showCloseButton: true,
|
showCloseButton: true,
|
||||||
singleton: false,
|
singleton: false,
|
||||||
|
compactSheet: { openAsTab: false },
|
||||||
resourceKey: (target) => target.path,
|
resourceKey: (target) => target.path,
|
||||||
},
|
},
|
||||||
working_diff: {
|
working_diff: {
|
||||||
@@ -90,6 +127,7 @@ const manifests = {
|
|||||||
supportedHosts: ["main", "explorer"],
|
supportedHosts: ["main", "explorer"],
|
||||||
showCloseButton: true,
|
showCloseButton: true,
|
||||||
singleton: false,
|
singleton: false,
|
||||||
|
compactSheet: null,
|
||||||
resourceKey: () => "working_diff",
|
resourceKey: () => "working_diff",
|
||||||
},
|
},
|
||||||
plugin: {
|
plugin: {
|
||||||
@@ -98,6 +136,7 @@ const manifests = {
|
|||||||
supportedHosts: ["main", "explorer"],
|
supportedHosts: ["main", "explorer"],
|
||||||
showCloseButton: true,
|
showCloseButton: true,
|
||||||
singleton: false,
|
singleton: false,
|
||||||
|
compactSheet: null,
|
||||||
resourceKey: (target) =>
|
resourceKey: (target) =>
|
||||||
target.context === "agent"
|
target.context === "agent"
|
||||||
? `${target.pluginId}:${target.panelId}:agent:${target.agentId}`
|
? `${target.pluginId}:${target.panelId}:agent:${target.agentId}`
|
||||||
@@ -108,6 +147,7 @@ const manifests = {
|
|||||||
supportedHosts: ["main"],
|
supportedHosts: ["main"],
|
||||||
showCloseButton: true,
|
showCloseButton: true,
|
||||||
singleton: false,
|
singleton: false,
|
||||||
|
compactSheet: null,
|
||||||
resourceKey: (target) => target.workspaceId,
|
resourceKey: (target) => target.workspaceId,
|
||||||
},
|
},
|
||||||
commit_diff: {
|
commit_diff: {
|
||||||
@@ -115,6 +155,7 @@ const manifests = {
|
|||||||
supportedHosts: ["main", "explorer"],
|
supportedHosts: ["main", "explorer"],
|
||||||
showCloseButton: true,
|
showCloseButton: true,
|
||||||
singleton: false,
|
singleton: false,
|
||||||
|
compactSheet: null,
|
||||||
resourceKey: (target) => target.sha,
|
resourceKey: (target) => target.sha,
|
||||||
},
|
},
|
||||||
} satisfies PanelManifestByKind;
|
} satisfies PanelManifestByKind;
|
||||||
@@ -124,7 +165,17 @@ export function getPanelManifest<K extends WorkspaceTabTarget["kind"]>(kind: K):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function panelSupportsHost(kind: WorkspaceTabTarget["kind"], host: PaneHost): boolean {
|
export function panelSupportsHost(kind: WorkspaceTabTarget["kind"], host: PaneHost): boolean {
|
||||||
return getPanelManifest(kind).supportedHosts.includes(host);
|
const manifest = getPanelManifest(kind);
|
||||||
|
if (host === "sheet") return manifest.compactSheet !== null;
|
||||||
|
return manifest.supportedHosts.includes(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function panelCompactSheetPolicy(
|
||||||
|
target: WorkspaceTabTarget,
|
||||||
|
facts: PanelTargetFacts,
|
||||||
|
): CompactSheetPolicy | null {
|
||||||
|
const rule = getPanelManifest(target.kind).compactSheet as CompactSheetRule<typeof target.kind>;
|
||||||
|
return typeof rule === "function" ? rule(target as never, facts) : rule;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function panelCanLaunchInPane(
|
export function panelCanLaunchInPane(
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function panelTargetSupportsHost(
|
|||||||
target: WorkspaceTabTarget,
|
target: WorkspaceTabTarget,
|
||||||
host: PaneHost,
|
host: PaneHost,
|
||||||
): boolean {
|
): 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);
|
const panel = findPluginPanel(serverId, target);
|
||||||
// Preserve a persisted unavailable panel where the user left it until its plugin loads again.
|
// Preserve a persisted unavailable panel where the user left it until its plugin loads again.
|
||||||
if (!panel) return true;
|
if (!panel) return true;
|
||||||
|
|||||||
@@ -110,3 +110,25 @@ test("pane retargeting replaces the viewed agent and duplicate panes collapse to
|
|||||||
}),
|
}),
|
||||||
}).toEqual({ duplicate: ["agent-a"], retargeted: ["agent-a", "agent-b"] });
|
}).toEqual({ duplicate: ["agent-a"], retargeted: ["agent-a", "agent-b"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the agent shown in the workspace sheet is viewed alongside the pane under it", () => {
|
||||||
|
const layout: WorkspaceLayout = {
|
||||||
|
focusedPaneId: "main",
|
||||||
|
root: { kind: "pane", pane: { id: "main", tabIds: ["a"], focusedTabId: "a" } },
|
||||||
|
};
|
||||||
|
const tabs: WorkspaceTab[] = [
|
||||||
|
{ tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||||
|
];
|
||||||
|
const sheetTab: WorkspaceTab = {
|
||||||
|
tabId: "sheet",
|
||||||
|
target: { kind: "agent", agentId: "agent-child" },
|
||||||
|
createdAt: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: true, focusedPaneOnly: true }),
|
||||||
|
).toEqual(["agent-a", "agent-child"]);
|
||||||
|
expect(
|
||||||
|
selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: false, focusedPaneOnly: true }),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { deriveWorkspacePaneState } from "./workspace-pane-state";
|
|||||||
export function selectVisibleAgentIds(input: {
|
export function selectVisibleAgentIds(input: {
|
||||||
layout: WorkspaceLayout | null;
|
layout: WorkspaceLayout | null;
|
||||||
tabs: WorkspaceTab[];
|
tabs: WorkspaceTab[];
|
||||||
|
/** The workspace sheet's tab, shown over the panes. */
|
||||||
|
sheetTab?: WorkspaceTab | null;
|
||||||
routeFocused: boolean;
|
routeFocused: boolean;
|
||||||
focusedPaneOnly: boolean;
|
focusedPaneOnly: boolean;
|
||||||
}): string[] {
|
}): string[] {
|
||||||
@@ -16,20 +18,21 @@ export function selectVisibleAgentIds(input: {
|
|||||||
? collectAllPanes(input.layout.root).filter((pane) => pane.id === input.layout?.focusedPaneId)
|
? collectAllPanes(input.layout.root).filter((pane) => pane.id === input.layout?.focusedPaneId)
|
||||||
: collectAllPanes(input.layout.root);
|
: 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 [
|
return [
|
||||||
...new Set(
|
...new Set(targets.flatMap((target) => (target.kind === "agent" ? [target.agentId] : []))),
|
||||||
panes.flatMap((pane) => {
|
|
||||||
const target = deriveWorkspacePaneState({ pane, tabs: input.tabs }).activeTab?.descriptor
|
|
||||||
.target;
|
|
||||||
return target?.kind === "agent" ? [target.agentId] : [];
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
].sort();
|
].sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useVisibleAgentIds(input: {
|
export function useVisibleAgentIds(input: {
|
||||||
layout: WorkspaceLayout | null;
|
layout: WorkspaceLayout | null;
|
||||||
tabs: WorkspaceTab[];
|
tabs: WorkspaceTab[];
|
||||||
|
sheetTab: WorkspaceTab | null;
|
||||||
routeFocused: boolean;
|
routeFocused: boolean;
|
||||||
focusedPaneOnly: boolean;
|
focusedPaneOnly: boolean;
|
||||||
}): string[] {
|
}): string[] {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import {
|
|||||||
getFocusedBrowserId,
|
getFocusedBrowserId,
|
||||||
FOCUSED_PANE_PLACEMENT,
|
FOCUSED_PANE_PLACEMENT,
|
||||||
selectExplorerSidebarPaneId,
|
selectExplorerSidebarPaneId,
|
||||||
|
selectPaneFocusSuspended,
|
||||||
type WorkspaceLayout,
|
type WorkspaceLayout,
|
||||||
type WorkspaceTabPlacement,
|
type WorkspaceTabPlacement,
|
||||||
useWorkspaceLayoutStore,
|
useWorkspaceLayoutStore,
|
||||||
@@ -160,6 +161,8 @@ import {
|
|||||||
type WorkspacePaneContentModel,
|
type WorkspacePaneContentModel,
|
||||||
} from "@/screens/workspace/workspace-pane-content";
|
} from "@/screens/workspace/workspace-pane-content";
|
||||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
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 { WorkspaceFocusProvider } from "@/workspace/focus";
|
||||||
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
|
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
|
||||||
import type { NewTabSelection } from "@/workspace-tabs/new-tab";
|
import type { NewTabSelection } from "@/workspace-tabs/new-tab";
|
||||||
@@ -751,22 +754,20 @@ function WorkspaceContentProviders({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkspacePanelContent({
|
function WorkspacePanelProviders({
|
||||||
launcher,
|
launcher,
|
||||||
content,
|
children,
|
||||||
tabIds,
|
tabIds,
|
||||||
workspaceKey,
|
workspaceKey,
|
||||||
}: {
|
}: {
|
||||||
workspaceKey: string;
|
workspaceKey: string;
|
||||||
launcher: NewTabLauncher;
|
launcher: NewTabLauncher;
|
||||||
content: ReactNode;
|
children: ReactNode;
|
||||||
tabIds: readonly string[];
|
tabIds: readonly string[];
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<WorkspaceComposerEditingSessionsProvider workspaceKey={workspaceKey} tabIds={tabIds}>
|
<WorkspaceComposerEditingSessionsProvider workspaceKey={workspaceKey} tabIds={tabIds}>
|
||||||
<NewTabLauncherProvider value={launcher}>
|
<NewTabLauncherProvider value={launcher}>{children}</NewTabLauncherProvider>
|
||||||
<View style={styles.content}>{content}</View>
|
|
||||||
</NewTabLauncherProvider>
|
|
||||||
</WorkspaceComposerEditingSessionsProvider>
|
</WorkspaceComposerEditingSessionsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1267,6 +1268,9 @@ function WorkspaceScreenContent({
|
|||||||
const explorerSidebarPaneId = useWorkspaceLayoutStore((state) =>
|
const explorerSidebarPaneId = useWorkspaceLayoutStore((state) =>
|
||||||
persistenceKey ? selectExplorerSidebarPaneId(state, persistenceKey) : null,
|
persistenceKey ? selectExplorerSidebarPaneId(state, persistenceKey) : null,
|
||||||
);
|
);
|
||||||
|
const paneFocusSuspended = useWorkspaceLayoutStore((state) =>
|
||||||
|
persistenceKey ? selectPaneFocusSuspended(state, persistenceKey) : false,
|
||||||
|
);
|
||||||
const lastMainPaneRef = useLastMainPane({
|
const lastMainPaneRef = useLastMainPane({
|
||||||
workspaceKey: persistenceKey,
|
workspaceKey: persistenceKey,
|
||||||
layout: workspaceLayout,
|
layout: workspaceLayout,
|
||||||
@@ -1305,6 +1309,9 @@ function WorkspaceScreenContent({
|
|||||||
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||||
const selectWorkspaceTabInPane = useWorkspaceLayoutStore((state) => state.selectTabInPane);
|
const selectWorkspaceTabInPane = useWorkspaceLayoutStore((state) => state.selectTabInPane);
|
||||||
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
||||||
|
const sheetTab = useWorkspaceLayoutStore((state) =>
|
||||||
|
persistenceKey ? (state.sheetByWorkspace[persistenceKey]?.tab ?? null) : null,
|
||||||
|
);
|
||||||
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
||||||
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
||||||
const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState);
|
const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState);
|
||||||
@@ -1377,6 +1384,7 @@ function WorkspaceScreenContent({
|
|||||||
const visibleAgentIds = useVisibleAgentIds({
|
const visibleAgentIds = useVisibleAgentIds({
|
||||||
layout: workspaceLayout,
|
layout: workspaceLayout,
|
||||||
tabs: uiTabs,
|
tabs: uiTabs,
|
||||||
|
sheetTab,
|
||||||
routeFocused: isRouteFocused,
|
routeFocused: isRouteFocused,
|
||||||
focusedPaneOnly: syncFocusedPaneOnly,
|
focusedPaneOnly: syncFocusedPaneOnly,
|
||||||
});
|
});
|
||||||
@@ -1678,7 +1686,6 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabId = openPreferredWorkspaceTarget({
|
const tabId = openPreferredWorkspaceTarget({
|
||||||
isCompact: isMobile,
|
|
||||||
workspaceKey: persistenceKey,
|
workspaceKey: persistenceKey,
|
||||||
target: createWorkspaceFileTabTarget(location),
|
target: createWorkspaceFileTabTarget(location),
|
||||||
source: "chatFiles",
|
source: "chatFiles",
|
||||||
@@ -2062,6 +2069,12 @@ function WorkspaceScreenContent({
|
|||||||
|
|
||||||
const handleCloseTabById = useCallback(
|
const handleCloseTabById = useCallback(
|
||||||
async (tabId: string) => {
|
async (tabId: string) => {
|
||||||
|
if (persistenceKey && sheetTab?.tabId === tabId) {
|
||||||
|
if (await confirmDiscardModifiedTab(tabId)) {
|
||||||
|
closeWorkspaceTab(persistenceKey, tabId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const tab = allTabDescriptorsById.get(tabId);
|
const tab = allTabDescriptorsById.get(tabId);
|
||||||
if (!tab) {
|
if (!tab) {
|
||||||
return;
|
return;
|
||||||
@@ -2081,10 +2094,13 @@ function WorkspaceScreenContent({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
allTabDescriptorsById,
|
allTabDescriptorsById,
|
||||||
|
closeWorkspaceTab,
|
||||||
confirmDiscardModifiedTab,
|
confirmDiscardModifiedTab,
|
||||||
handleCloseAgentTab,
|
handleCloseAgentTab,
|
||||||
handleClosePassiveTab,
|
handleClosePassiveTab,
|
||||||
handleCloseTerminalTab,
|
handleCloseTerminalTab,
|
||||||
|
persistenceKey,
|
||||||
|
sheetTab,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2918,17 +2934,19 @@ function WorkspaceScreenContent({
|
|||||||
tab: WorkspaceTabDescriptor;
|
tab: WorkspaceTabDescriptor;
|
||||||
paneId?: string | null;
|
paneId?: string | null;
|
||||||
focusPaneBeforeOpen?: boolean;
|
focusPaneBeforeOpen?: boolean;
|
||||||
|
host?: PaneHost;
|
||||||
}) =>
|
}) =>
|
||||||
buildWorkspacePaneContentModel({
|
buildWorkspacePaneContentModel({
|
||||||
tab: input.tab,
|
tab: input.tab,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
normalizedWorkspaceId,
|
normalizedWorkspaceId,
|
||||||
host:
|
host:
|
||||||
canRenderDesktopPaneSplits &&
|
input.host ??
|
||||||
|
(canRenderDesktopPaneSplits &&
|
||||||
input.paneId !== null &&
|
input.paneId !== null &&
|
||||||
input.paneId === explorerSidebarPaneId
|
input.paneId === explorerSidebarPaneId
|
||||||
? "explorer"
|
? "explorer"
|
||||||
: "main",
|
: "main"),
|
||||||
fileNavigationRevision: fileNavigationRevisionByTabId[input.tab.tabId] ?? 0,
|
fileNavigationRevision: fileNavigationRevisionByTabId[input.tab.tabId] ?? 0,
|
||||||
onOpenTab: (target) => {
|
onOpenTab: (target) => {
|
||||||
if (!persistenceKey) {
|
if (!persistenceKey) {
|
||||||
@@ -2947,7 +2965,6 @@ function WorkspaceScreenContent({
|
|||||||
onOpenPreferredTarget: (target, source) => {
|
onOpenPreferredTarget: (target, source) => {
|
||||||
if (!persistenceKey) return;
|
if (!persistenceKey) return;
|
||||||
const tabId = openPreferredWorkspacePreview({
|
const tabId = openPreferredWorkspacePreview({
|
||||||
isCompact: isMobile,
|
|
||||||
workspaceKey: persistenceKey,
|
workspaceKey: persistenceKey,
|
||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
workspaceId: normalizedWorkspaceId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
@@ -3009,7 +3026,6 @@ function WorkspaceScreenContent({
|
|||||||
canRenderDesktopPaneSplits,
|
canRenderDesktopPaneSplits,
|
||||||
openImportSheet,
|
openImportSheet,
|
||||||
openInSidePane,
|
openInSidePane,
|
||||||
isMobile,
|
|
||||||
requestFileNavigation,
|
requestFileNavigation,
|
||||||
revealWorkspaceChildTab,
|
revealWorkspaceChildTab,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
@@ -3053,6 +3069,17 @@ function WorkspaceScreenContent({
|
|||||||
},
|
},
|
||||||
[buildPaneContentModel],
|
[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) {
|
const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) {
|
||||||
if (!persistenceKey || paneFocusSuppressedRef.current) {
|
if (!persistenceKey || paneFocusSuppressedRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -3068,7 +3095,8 @@ function WorkspaceScreenContent({
|
|||||||
focusedPaneTabDescriptorMap,
|
focusedPaneTabDescriptorMap,
|
||||||
isRouteFocused,
|
isRouteFocused,
|
||||||
focusedPaneId,
|
focusedPaneId,
|
||||||
paneFocusSuspended: Boolean(unfocusedPaneId),
|
// The sheet owns input while it covers the tab.
|
||||||
|
paneFocusSuspended,
|
||||||
onFocusPane: handleFocusPane,
|
onFocusPane: handleFocusPane,
|
||||||
buildMobilePaneContentModel,
|
buildMobilePaneContentModel,
|
||||||
});
|
});
|
||||||
@@ -3495,28 +3523,42 @@ function WorkspaceScreenContent({
|
|||||||
const desktopContent = desktopSplitContent ?? content;
|
const desktopContent = desktopSplitContent ?? content;
|
||||||
const rendersDesktopSplitContent = !isMobile && desktopSplitContent !== null;
|
const rendersDesktopSplitContent = !isMobile && desktopSplitContent !== null;
|
||||||
|
|
||||||
const workspacePanelContent = (
|
const workspacePanelTabIds = useMemo(
|
||||||
<WorkspacePanelContent
|
() => [...uiTabs.map((tab) => tab.tabId), ...(sheetTab ? [sheetTab.tabId] : [])],
|
||||||
launcher={newTabLauncher}
|
[uiTabs, sheetTab],
|
||||||
workspaceKey={`${normalizedServerId}:${normalizedWorkspaceId}`}
|
|
||||||
tabIds={uiTabs.map((tab) => tab.tabId)}
|
|
||||||
content={isMobile ? content : desktopContent}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
const workspacePanelKey = `${normalizedServerId}:${normalizedWorkspaceId}`;
|
||||||
|
|
||||||
|
// The sheet is the column's last child, so it covers the compact header as a modal.
|
||||||
const workspaceCenterColumn = isMobile ? (
|
const workspaceCenterColumn = isMobile ? (
|
||||||
<View style={styles.centerColumn}>
|
<WorkspacePanelProviders
|
||||||
<FloatingHeaderLayout
|
launcher={newTabLauncher}
|
||||||
header={compactWorkspaceHeader}
|
workspaceKey={workspacePanelKey}
|
||||||
floating={floatsHeaderOverContent(activeTabDescriptor)}
|
tabIds={workspacePanelTabIds}
|
||||||
>
|
>
|
||||||
{(topOverlayInset) => (
|
<View style={styles.centerColumn}>
|
||||||
<StreamTopOverlayProvider value={topOverlayInset}>
|
<FloatingHeaderLayout
|
||||||
{workspacePanelContent}
|
header={compactWorkspaceHeader}
|
||||||
</StreamTopOverlayProvider>
|
floating={floatsHeaderOverContent(activeTabDescriptor)}
|
||||||
)}
|
>
|
||||||
</FloatingHeaderLayout>
|
{(topOverlayInset) => (
|
||||||
</View>
|
<StreamTopOverlayProvider value={topOverlayInset}>
|
||||||
|
<View style={styles.content}>{content}</View>
|
||||||
|
</StreamTopOverlayProvider>
|
||||||
|
)}
|
||||||
|
</FloatingHeaderLayout>
|
||||||
|
<StreamTopOverlayProvider value={0}>
|
||||||
|
<WorkspaceSheetHost
|
||||||
|
workspaceKey={persistenceKey}
|
||||||
|
serverId={normalizedServerId}
|
||||||
|
workspaceId={normalizedWorkspaceId}
|
||||||
|
isWorkspaceFocused={isRouteFocused}
|
||||||
|
buildContent={buildSheetPaneContentModel}
|
||||||
|
onCloseTab={handleCloseTabById}
|
||||||
|
/>
|
||||||
|
</StreamTopOverlayProvider>
|
||||||
|
</View>
|
||||||
|
</WorkspacePanelProviders>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.centerColumn}>
|
<View style={styles.centerColumn}>
|
||||||
{rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()}
|
{rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()}
|
||||||
@@ -3549,7 +3591,15 @@ function WorkspaceScreenContent({
|
|||||||
</NewTabLauncherProvider>
|
</NewTabLauncherProvider>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<View style={styles.centerContent}>{workspacePanelContent}</View>
|
<View style={styles.centerContent}>
|
||||||
|
<WorkspacePanelProviders
|
||||||
|
launcher={newTabLauncher}
|
||||||
|
workspaceKey={workspacePanelKey}
|
||||||
|
tabIds={workspacePanelTabIds}
|
||||||
|
>
|
||||||
|
<View style={styles.content}>{desktopContent}</View>
|
||||||
|
</WorkspacePanelProviders>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import BottomSheet, {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
|
import { type ComponentType, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { BackHandler, View } from "react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { SheetHeaderView } from "@/components/adaptive-modal-sheet";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
import type { PanelIconProps } from "@/panels/panel-registry";
|
||||||
|
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||||
|
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||||
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
|
import {
|
||||||
|
WorkspacePaneContent,
|
||||||
|
type WorkspacePaneContentModel,
|
||||||
|
} from "@/screens/workspace/workspace-pane-content";
|
||||||
|
import { useWorkspaceLayoutStore, type WorkspaceSheet } from "@/stores/workspace-layout-store";
|
||||||
|
|
||||||
|
const SNAP_POINTS = ["92%"];
|
||||||
|
|
||||||
|
const ThemedBottomSheet = withUnistyles(BottomSheet, (theme) => ({
|
||||||
|
backgroundStyle: {
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||||
|
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||||
|
},
|
||||||
|
handleIndicatorStyle: {
|
||||||
|
backgroundColor: theme.colors.palette.zinc[600],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function PanelIcon({
|
||||||
|
icon: Icon,
|
||||||
|
size,
|
||||||
|
color,
|
||||||
|
}: { icon: ComponentType<PanelIconProps> } & PanelIconProps) {
|
||||||
|
return <Icon size={size} color={color} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderBackdrop(props: BottomSheetBackdropProps) {
|
||||||
|
return (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
opacity={0.45}
|
||||||
|
pressBehavior="close"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceSheetHostProps {
|
||||||
|
workspaceKey: string | null;
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
isWorkspaceFocused: boolean;
|
||||||
|
buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
|
||||||
|
/** The workspace's ordinary tab close, including its unsaved-changes guard. */
|
||||||
|
onCloseTab: (tabId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceSheetPresentationProps extends Omit<
|
||||||
|
WorkspaceSheetHostProps,
|
||||||
|
"workspaceKey" | "onCloseTab"
|
||||||
|
> {
|
||||||
|
sheet: WorkspaceSheet;
|
||||||
|
onClose: () => Promise<void>;
|
||||||
|
onOpenAsTab: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts the workspace's sheet tab over the compact tab strip. It renders in the workspace tree,
|
||||||
|
* not through a portal, so the panel keeps every context an ordinary pane has.
|
||||||
|
*/
|
||||||
|
export function WorkspaceSheetHost({ workspaceKey, onCloseTab, ...rest }: WorkspaceSheetHostProps) {
|
||||||
|
const sheet = useWorkspaceLayoutStore((state) =>
|
||||||
|
workspaceKey ? (state.sheetByWorkspace[workspaceKey] ?? null) : null,
|
||||||
|
);
|
||||||
|
const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab);
|
||||||
|
const sheetTabId = sheet?.tab.tabId ?? null;
|
||||||
|
const handleClose = useCallback(async () => {
|
||||||
|
if (sheetTabId) await onCloseTab(sheetTabId);
|
||||||
|
}, [onCloseTab, sheetTabId]);
|
||||||
|
const handleOpenAsTab = useCallback(() => {
|
||||||
|
if (workspaceKey) openSheetAsTab(workspaceKey);
|
||||||
|
}, [openSheetAsTab, workspaceKey]);
|
||||||
|
if (!sheet) return null;
|
||||||
|
return (
|
||||||
|
<WorkspaceSheetPresentation
|
||||||
|
{...rest}
|
||||||
|
sheet={sheet}
|
||||||
|
onClose={handleClose}
|
||||||
|
onOpenAsTab={handleOpenAsTab}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceSheetPresentation({
|
||||||
|
sheet,
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
isWorkspaceFocused,
|
||||||
|
buildContent,
|
||||||
|
onClose,
|
||||||
|
onOpenAsTab,
|
||||||
|
}: WorkspaceSheetPresentationProps) {
|
||||||
|
const sheetRef = useRef<BottomSheet>(null);
|
||||||
|
const isScreenFocused = useIsFocused();
|
||||||
|
const ownsBackPress = isNative && isWorkspaceFocused && isScreenFocused;
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index !== -1) return;
|
||||||
|
// A declined close (unsaved changes) leaves the tab in the sheet, so bring it back up.
|
||||||
|
void onClose().then(() => sheetRef.current?.snapToIndex(0));
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
// Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns back.
|
||||||
|
if (!ownsBackPress) return;
|
||||||
|
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||||
|
void onClose();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return () => subscription.remove();
|
||||||
|
}, [onClose, ownsBackPress]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.host} pointerEvents="box-none">
|
||||||
|
<ThemedBottomSheet
|
||||||
|
ref={sheetRef}
|
||||||
|
index={0}
|
||||||
|
snapPoints={SNAP_POINTS}
|
||||||
|
enableDynamicSizing={false}
|
||||||
|
enablePanDownToClose
|
||||||
|
enableContentPanningGesture={false}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
<WorkspaceSheetBody
|
||||||
|
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
|
||||||
|
sheet={sheet}
|
||||||
|
serverId={serverId}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
|
buildContent={buildContent}
|
||||||
|
onClose={onClose}
|
||||||
|
onOpenAsTab={onOpenAsTab}
|
||||||
|
/>
|
||||||
|
</ThemedBottomSheet>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceSheetBody({
|
||||||
|
sheet,
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
isWorkspaceFocused,
|
||||||
|
buildContent,
|
||||||
|
onClose,
|
||||||
|
onOpenAsTab,
|
||||||
|
}: WorkspaceSheetPresentationProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { tab } = sheet;
|
||||||
|
ensurePanelsRegistered();
|
||||||
|
const registration = getPanelRegistration(tab.target.kind);
|
||||||
|
if (!registration) {
|
||||||
|
throw new Error(`No panel registration for kind: ${tab.target.kind}`);
|
||||||
|
}
|
||||||
|
// The body is keyed by tab id and kind, so the registration and its descriptor hook stay fixed.
|
||||||
|
const descriptor = registration.useDescriptor(tab.target as never, {
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
tabId: tab.tabId,
|
||||||
|
});
|
||||||
|
const tabDescriptor = useMemo<WorkspaceTabDescriptor>(
|
||||||
|
() => ({
|
||||||
|
key: tab.tabId,
|
||||||
|
tabId: tab.tabId,
|
||||||
|
kind: tab.target.kind,
|
||||||
|
target: tab.target,
|
||||||
|
state: tab.state,
|
||||||
|
}),
|
||||||
|
[tab],
|
||||||
|
);
|
||||||
|
const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]);
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
void onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
// The sheet reaches the bottom of the screen, so it owns the system navigation inset.
|
||||||
|
const { bottom: bottomInset } = useSafeAreaInsets();
|
||||||
|
const contentStyle = useMemo(
|
||||||
|
() => [styles.content, { paddingBottom: bottomInset }],
|
||||||
|
[bottomInset],
|
||||||
|
);
|
||||||
|
const header = useMemo(
|
||||||
|
() => ({
|
||||||
|
title: descriptor.label,
|
||||||
|
leading: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
|
||||||
|
actions: sheet.policy.openAsTab ? (
|
||||||
|
<Button size="sm" variant="secondary" onPress={onOpenAsTab}>
|
||||||
|
{t("workspace.sheet.openAsTab")}
|
||||||
|
</Button>
|
||||||
|
) : undefined,
|
||||||
|
}),
|
||||||
|
[descriptor.icon, descriptor.label, onOpenAsTab, sheet.policy.openAsTab, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.body} role="dialog" aria-label={descriptor.label}>
|
||||||
|
<SheetHeaderView header={header} onClose={handleClose} />
|
||||||
|
<View style={contentStyle}>
|
||||||
|
<WorkspacePaneContent
|
||||||
|
content={content}
|
||||||
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
|
isPaneFocused={isWorkspaceFocused}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
host: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||||
const storage = new Map<string, string>();
|
const storage = new Map<string, string>();
|
||||||
@@ -16,6 +16,9 @@ vi.mock("@react-native-async-storage/async-storage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import 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 { createViewedTimelineSync } from "@/timeline/viewed-timeline-sync";
|
||||||
import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model";
|
import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model";
|
||||||
import { defaultChangesState, type ChangesState } from "@/panels/changes/state";
|
import { defaultChangesState, type ChangesState } from "@/panels/changes/state";
|
||||||
@@ -24,6 +27,7 @@ import {
|
|||||||
canDismissPaneInLayout,
|
canDismissPaneInLayout,
|
||||||
collectAllPanes,
|
collectAllPanes,
|
||||||
collectAllTabs,
|
collectAllTabs,
|
||||||
|
selectPaneFocusSuspended,
|
||||||
createWorkspaceLayoutStore,
|
createWorkspaceLayoutStore,
|
||||||
observeOpenWorkspaceAgentIds,
|
observeOpenWorkspaceAgentIds,
|
||||||
createDefaultLayout,
|
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().pullRequestTabAutoOpenedByWorkspace[workspaceKey]).toBeUndefined();
|
||||||
expect(restored.getState().autoOpenPullRequestTab(workspaceKey, placement)).toBe("pull_request");
|
expect(restored.getState().autoOpenPullRequestTab(workspaceKey, placement)).toBe("pull_request");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("compact workspace sheet", () => {
|
||||||
|
const workspaceKey = "server-1:sheet-ws";
|
||||||
|
const fileTarget = { kind: "file" as const, path: "src/index.ts" };
|
||||||
|
|
||||||
|
function createCompactStore() {
|
||||||
|
const store = createWorkspaceLayoutStore(workspaceLayoutIds);
|
||||||
|
store.setState({ layoutByWorkspace: {}, sheetByWorkspace: {} });
|
||||||
|
store.getState().setLayoutMode("compact");
|
||||||
|
store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "terminal", terminalId: "term-1" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabKinds(store: ReturnType<typeof createCompactStore>): string[] {
|
||||||
|
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
const tabsById = new Map(collectAllTabs(layout.root).map((tab) => [tab.tabId, tab]));
|
||||||
|
return (findPaneById(layout.root, "main")?.tabIds ?? []).map(
|
||||||
|
(tabId) => tabsById.get(tabId)?.target.kind ?? "missing",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedAgents(agents: Array<Pick<Agent, "id" | "parentAgentId" | "workspaceId">>): void {
|
||||||
|
const timestamp = new Date("2026-04-21T10:00:00.000Z");
|
||||||
|
useSessionStore.getState().initializeSession("server-1", null as unknown as DaemonClient);
|
||||||
|
useSessionStore.getState().setAgents(
|
||||||
|
"server-1",
|
||||||
|
new Map(
|
||||||
|
agents.map((agent) => [
|
||||||
|
agent.id,
|
||||||
|
{
|
||||||
|
serverId: "server-1",
|
||||||
|
provider: "codex",
|
||||||
|
status: "idle",
|
||||||
|
turn: { phase: "idle", cancellationRequestId: null },
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
lastActivityAt: timestamp,
|
||||||
|
capabilities: {
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsSessionPersistence: true,
|
||||||
|
supportsDynamicModes: true,
|
||||||
|
supportsMcpServers: true,
|
||||||
|
supportsReasoningStream: true,
|
||||||
|
supportsToolInvocations: true,
|
||||||
|
},
|
||||||
|
currentModeId: null,
|
||||||
|
availableModes: [],
|
||||||
|
pendingPermissions: [],
|
||||||
|
persistence: null,
|
||||||
|
runtimeInfo: undefined,
|
||||||
|
lastUsage: undefined,
|
||||||
|
lastError: null,
|
||||||
|
title: agent.id,
|
||||||
|
cwd: "/repo",
|
||||||
|
model: null,
|
||||||
|
features: undefined,
|
||||||
|
thinkingOptionId: undefined,
|
||||||
|
requiresAttention: false,
|
||||||
|
attentionReason: null,
|
||||||
|
attentionTimestamp: null,
|
||||||
|
archivedAt: null,
|
||||||
|
labels: {},
|
||||||
|
projectPlacement: null,
|
||||||
|
...agent,
|
||||||
|
} satisfies Agent,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reveals a file in the sheet and leaves the tab strip unchanged", () => {
|
||||||
|
const store = createCompactStore();
|
||||||
|
const before = tabKinds(store);
|
||||||
|
|
||||||
|
const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||||
|
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab).toEqual({
|
||||||
|
tabId,
|
||||||
|
target: fileTarget,
|
||||||
|
createdAt: expect.any(Number),
|
||||||
|
});
|
||||||
|
expect(tabKinds(store)).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses a target that is already open as a tab instead of using the sheet", () => {
|
||||||
|
const store = createCompactStore();
|
||||||
|
store.getState().setLayoutMode("split-panes");
|
||||||
|
const fileTabId = store
|
||||||
|
.getState()
|
||||||
|
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||||
|
store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "terminal", terminalId: "term-1" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
store.getState().setLayoutMode("compact");
|
||||||
|
|
||||||
|
const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||||
|
|
||||||
|
expect(tabId).toBe(fileTabId);
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||||
|
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
expect(findPaneContainingTab(layout.root, fileTabId ?? "")?.focusedTabId).toBe(fileTabId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the sheet content with the next sheet target", () => {
|
||||||
|
const store = createCompactStore();
|
||||||
|
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||||
|
|
||||||
|
const nextTarget = { kind: "file" as const, path: "src/other.ts" };
|
||||||
|
store.getState().openTab({ workspaceKey, target: nextTarget, intent: "reveal" });
|
||||||
|
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual(nextTarget);
|
||||||
|
expect(tabKinds(store)).toEqual(["terminal"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the sheet when a tab target is revealed, and keeps it for background opens", () => {
|
||||||
|
const store = createCompactStore();
|
||||||
|
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||||
|
|
||||||
|
store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "file", path: "src/background.ts" },
|
||||||
|
intent: "background",
|
||||||
|
});
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual(fileTarget);
|
||||||
|
expect(tabKinds(store)).toEqual(["terminal", "file"]);
|
||||||
|
|
||||||
|
store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "terminal", terminalId: "term-2" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the sheet tab into the focused pane with the same tab id and state", () => {
|
||||||
|
const store = createCompactStore();
|
||||||
|
const sheetTabId = store
|
||||||
|
.getState()
|
||||||
|
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||||
|
if (!sheetTabId) throw new Error("Expected a sheet tab");
|
||||||
|
store.getState().setTabState(workspaceKey, sheetTabId, { scrollTop: 120 });
|
||||||
|
|
||||||
|
const tabId = store.getState().openSheetAsTab(workspaceKey);
|
||||||
|
|
||||||
|
expect(tabId).toBe(sheetTabId);
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||||
|
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
const pane = findPaneContainingTab(layout.root, sheetTabId);
|
||||||
|
expect(pane?.id).toBe(layout.focusedPaneId);
|
||||||
|
expect(pane?.focusedTabId).toBe(sheetTabId);
|
||||||
|
expect(collectAllTabs(layout.root).find((tab) => tab.tabId === sheetTabId)?.state).toEqual({
|
||||||
|
scrollTop: 120,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the sheet through the ordinary close and ignores focus requests for it", () => {
|
||||||
|
const store = createCompactStore();
|
||||||
|
const sheetTabId = store
|
||||||
|
.getState()
|
||||||
|
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||||
|
if (!sheetTabId) throw new Error("Expected a sheet tab");
|
||||||
|
const layoutBefore = store.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
|
||||||
|
store.getState().focusTab(workspaceKey, sheetTabId);
|
||||||
|
expect(store.getState().layoutByWorkspace[workspaceKey]).toBe(layoutBefore);
|
||||||
|
|
||||||
|
store.getState().closeTab(workspaceKey, sheetTabId);
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||||
|
expect(tabKinds(store)).toEqual(["terminal"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suspends pane focus while the sheet covers the panes", () => {
|
||||||
|
const store = createCompactStore();
|
||||||
|
expect(selectPaneFocusSuspended(store.getState(), workspaceKey)).toBe(false);
|
||||||
|
|
||||||
|
const sheetTabId = store
|
||||||
|
.getState()
|
||||||
|
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||||
|
expect(selectPaneFocusSuspended(store.getState(), workspaceKey)).toBe(true);
|
||||||
|
|
||||||
|
store.getState().closeTab(workspaceKey, sheetTabId ?? "");
|
||||||
|
expect(selectPaneFocusSuspended(store.getState(), workspaceKey)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never uses the sheet on wide layouts", () => {
|
||||||
|
const store = createCompactStore();
|
||||||
|
store.getState().setLayoutMode("split-panes");
|
||||||
|
|
||||||
|
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||||
|
|
||||||
|
expect(store.getState().sheetByWorkspace).toEqual({});
|
||||||
|
expect(tabKinds(store)).toEqual(["terminal", "file"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the sheet content as a tab when the layout widens", () => {
|
||||||
|
const store = createCompactStore();
|
||||||
|
const sheetTabId = store
|
||||||
|
.getState()
|
||||||
|
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||||
|
|
||||||
|
store.getState().setLayoutMode("split-panes");
|
||||||
|
|
||||||
|
expect(store.getState().sheetByWorkspace).toEqual({});
|
||||||
|
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
expect(findPaneContainingTab(layout.root, sheetTabId ?? "")?.focusedTabId).toBe(sheetTabId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens subagents and provider subagents in the sheet but keeps root agents as tabs", () => {
|
||||||
|
seedAgents([
|
||||||
|
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||||
|
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||||
|
{ id: "other-workspace-child", parentAgentId: "root", workspaceId: "other-ws" },
|
||||||
|
]);
|
||||||
|
const store = createCompactStore();
|
||||||
|
|
||||||
|
store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "agent", agentId: "child" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({
|
||||||
|
tab: { target: { kind: "agent", agentId: "child" } },
|
||||||
|
policy: { openAsTab: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "provider_subagent", parentAgentId: "root", subagentId: "task-1" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({
|
||||||
|
tab: { target: { kind: "provider_subagent", subagentId: "task-1" } },
|
||||||
|
policy: { openAsTab: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const agentId of ["root", "other-workspace-child"]) {
|
||||||
|
store
|
||||||
|
.getState()
|
||||||
|
.openTab({ workspaceKey, target: { kind: "agent", agentId }, intent: "reveal" });
|
||||||
|
}
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||||
|
expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives a sheet target opened from inside the sheet the sheet's own parent", () => {
|
||||||
|
seedAgents([
|
||||||
|
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||||
|
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||||
|
]);
|
||||||
|
const store = createCompactStore();
|
||||||
|
const rootTabId = store
|
||||||
|
.getState()
|
||||||
|
.openTab({ workspaceKey, target: { kind: "agent", agentId: "root" }, intent: "reveal" });
|
||||||
|
const childSheetTabId = store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "agent", agentId: "child" },
|
||||||
|
intent: "reveal",
|
||||||
|
parentTabId: rootTabId ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileSheetTabId = store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: fileTarget,
|
||||||
|
intent: "reveal",
|
||||||
|
parentTabId: childSheetTabId ?? undefined,
|
||||||
|
});
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]?.parentTabId).toBe(rootTabId);
|
||||||
|
|
||||||
|
store.getState().openSheetAsTab(workspaceKey);
|
||||||
|
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
expect(layout.parentTabIdByTabId?.[fileSheetTabId ?? ""]).toBe(rootTabId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the sheet and reveals the tab when a tab target is opened from inside the sheet", () => {
|
||||||
|
seedAgents([
|
||||||
|
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||||
|
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||||
|
{ id: "second-root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||||
|
]);
|
||||||
|
const store = createCompactStore();
|
||||||
|
const rootTabId = store
|
||||||
|
.getState()
|
||||||
|
.openTab({ workspaceKey, target: { kind: "agent", agentId: "root" }, intent: "reveal" });
|
||||||
|
const openChildSheet = () =>
|
||||||
|
store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "agent", agentId: "child" },
|
||||||
|
intent: "reveal",
|
||||||
|
parentTabId: rootTabId ?? undefined,
|
||||||
|
}) ?? undefined;
|
||||||
|
|
||||||
|
const terminalTabId = store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "terminal", terminalId: "term-1" },
|
||||||
|
intent: "reveal",
|
||||||
|
parentTabId: openChildSheet(),
|
||||||
|
});
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||||
|
let layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(terminalTabId);
|
||||||
|
|
||||||
|
const secondRootTabId = store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "agent", agentId: "second-root" },
|
||||||
|
intent: "reveal",
|
||||||
|
parentTabId: openChildSheet(),
|
||||||
|
});
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||||
|
layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(secondRootTabId);
|
||||||
|
expect(layout.parentTabIdByTabId?.[secondRootTabId ?? ""]).toBe(rootTabId);
|
||||||
|
expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unsaved sheet work", () => {
|
||||||
|
const sheetPanel = (tabId: string) => ({
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "sheet-ws",
|
||||||
|
tabId,
|
||||||
|
});
|
||||||
|
let modifiedTabId: string | null = null;
|
||||||
|
|
||||||
|
function openModifiedFileSheet(store: ReturnType<typeof createCompactStore>): string {
|
||||||
|
const tabId = store
|
||||||
|
.getState()
|
||||||
|
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||||
|
if (!tabId) throw new Error("Expected a sheet tab");
|
||||||
|
store.getState().setTabState(workspaceKey, tabId, { scrollTop: 40 });
|
||||||
|
setPanelInstanceAttributes(sheetPanel(tabId), { modified: true });
|
||||||
|
modifiedTabId = tabId;
|
||||||
|
return tabId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTab(store: ReturnType<typeof createCompactStore>, tabId: string) {
|
||||||
|
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
return collectAllTabs(layout.root).find((tab) => tab.tabId === tabId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusedTabId(store: ReturnType<typeof createCompactStore>): string | null {
|
||||||
|
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
return findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (modifiedTabId) setPanelInstanceAttributes(sheetPanel(modifiedTabId), { modified: false });
|
||||||
|
modifiedTabId = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function sheetTabId(store: ReturnType<typeof createCompactStore>): string | null {
|
||||||
|
return store.getState().sheetByWorkspace[workspaceKey]?.tab.tabId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("opens the next sheet target as a tab under a modified sheet", () => {
|
||||||
|
const store = createCompactStore();
|
||||||
|
const modifiedSheetTabId = openModifiedFileSheet(store);
|
||||||
|
|
||||||
|
const nextTarget = { kind: "file" as const, path: "src/other.ts" };
|
||||||
|
const nextTabId = store
|
||||||
|
.getState()
|
||||||
|
.openTab({ workspaceKey, target: nextTarget, intent: "reveal" });
|
||||||
|
|
||||||
|
expect(sheetTabId(store)).toBe(modifiedSheetTabId);
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.state).toEqual({ scrollTop: 40 });
|
||||||
|
expect(stripTab(store, nextTabId ?? "")).toMatchObject({ target: nextTarget });
|
||||||
|
expect(focusedTabId(store)).toBe(nextTabId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a tab target under a modified sheet without closing it", () => {
|
||||||
|
const store = createCompactStore();
|
||||||
|
const modifiedSheetTabId = openModifiedFileSheet(store);
|
||||||
|
|
||||||
|
const terminalTabId = store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "terminal", terminalId: "term-2" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sheetTabId(store)).toBe(modifiedSheetTabId);
|
||||||
|
expect(focusedTabId(store)).toBe(terminalTabId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a modified sheet open when another tab is chosen", () => {
|
||||||
|
const store = createCompactStore();
|
||||||
|
const secondTabId = store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "terminal", terminalId: "term-2" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
const firstTabId = store.getState().getWorkspaceTabs(workspaceKey)[0]?.tabId ?? "";
|
||||||
|
const modifiedSheetTabId = openModifiedFileSheet(store);
|
||||||
|
|
||||||
|
store.getState().focusTab(workspaceKey, firstTabId);
|
||||||
|
expect(sheetTabId(store)).toBe(modifiedSheetTabId);
|
||||||
|
expect(focusedTabId(store)).toBe(firstTabId);
|
||||||
|
|
||||||
|
store.getState().selectTabInPane(workspaceKey, "main", secondTabId ?? "");
|
||||||
|
expect(sheetTabId(store)).toBe(modifiedSheetTabId);
|
||||||
|
expect(focusedTabId(store)).toBe(secondTabId);
|
||||||
|
|
||||||
|
store.getState().closeTab(workspaceKey, modifiedSheetTabId);
|
||||||
|
expect(sheetTabId(store)).toBeNull();
|
||||||
|
expect(tabKinds(store)).toEqual(["terminal", "terminal"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces an unmodified sheet with the next sheet target", () => {
|
||||||
|
const store = createCompactStore();
|
||||||
|
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||||
|
|
||||||
|
store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "file", path: "src/other.ts" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual({
|
||||||
|
kind: "file",
|
||||||
|
path: "src/other.ts",
|
||||||
|
});
|
||||||
|
expect(tabKinds(store)).toEqual(["terminal"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes an unmodified sheet when another tab is chosen", () => {
|
||||||
|
const store = createCompactStore();
|
||||||
|
const secondTabId = store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "terminal", terminalId: "term-2" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
const firstTabId = store.getState().getWorkspaceTabs(workspaceKey)[0]?.tabId ?? "";
|
||||||
|
|
||||||
|
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||||
|
store.getState().focusTab(workspaceKey, firstTabId);
|
||||||
|
expect(sheetTabId(store)).toBeNull();
|
||||||
|
expect(focusedTabId(store)).toBe(firstTabId);
|
||||||
|
|
||||||
|
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||||
|
store.getState().selectTabInPane(workspaceKey, "main", secondTabId ?? "");
|
||||||
|
expect(sheetTabId(store)).toBeNull();
|
||||||
|
expect(focusedTabId(store)).toBe(secondTabId);
|
||||||
|
expect(tabKinds(store)).toEqual(["terminal", "terminal"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the sheet when its agent leaves the workspace", () => {
|
||||||
|
seedAgents([
|
||||||
|
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||||
|
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||||
|
]);
|
||||||
|
const store = createCompactStore();
|
||||||
|
store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "agent", agentId: "child" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
const snapshot = {
|
||||||
|
terminalsHydrated: false,
|
||||||
|
activeAgentIds: ["root"],
|
||||||
|
autoOpenAgentIds: [],
|
||||||
|
knownTerminalIds: ["term-1"],
|
||||||
|
standaloneTerminalIds: ["term-1"],
|
||||||
|
hasActivePendingTerminalCreate: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: false });
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeDefined();
|
||||||
|
|
||||||
|
store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: true });
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the sheet when its agent stops being a subagent, leaving one tab for it", () => {
|
||||||
|
seedAgents([
|
||||||
|
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||||
|
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||||
|
]);
|
||||||
|
const store = createCompactStore();
|
||||||
|
const sheetTabId = store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "agent", agentId: "child" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
const snapshot = {
|
||||||
|
agentsHydrated: true,
|
||||||
|
terminalsHydrated: false,
|
||||||
|
activeAgentIds: ["root", "child"],
|
||||||
|
autoOpenAgentIds: ["child"],
|
||||||
|
knownTerminalIds: ["term-1"],
|
||||||
|
standaloneTerminalIds: ["term-1"],
|
||||||
|
hasActivePendingTerminalCreate: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
store.getState().reconcileTabs(workspaceKey, { ...snapshot, autoOpenAgentIds: [] });
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.tabId).toBe(sheetTabId);
|
||||||
|
|
||||||
|
seedAgents([
|
||||||
|
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||||
|
{ id: "child", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||||
|
]);
|
||||||
|
store.getState().reconcileTabs(workspaceKey, snapshot);
|
||||||
|
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||||
|
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
expect(
|
||||||
|
collectAllTabs(layout.root).filter(
|
||||||
|
(tab) => tab.target.kind === "agent" && tab.target.agentId === "child",
|
||||||
|
),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-resolves the sheet policy when the sheet tab gets another target", () => {
|
||||||
|
seedAgents([
|
||||||
|
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||||
|
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||||
|
]);
|
||||||
|
const store = createCompactStore();
|
||||||
|
const sheetTabId = store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "provider_subagent", parentAgentId: "root", subagentId: "task-1" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
|
||||||
|
const replacedTabId = store
|
||||||
|
.getState()
|
||||||
|
.replaceTab(workspaceKey, sheetTabId ?? "", { kind: "agent", agentId: "child" });
|
||||||
|
|
||||||
|
expect(replacedTabId).toBe(sheetTabId);
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({
|
||||||
|
tab: { tabId: sheetTabId, target: { kind: "agent", agentId: "child" } },
|
||||||
|
policy: { openAsTab: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the sheet tab into the tab strip when it gets a target the sheet does not host", () => {
|
||||||
|
const store = createCompactStore();
|
||||||
|
const sheetTabId = store
|
||||||
|
.getState()
|
||||||
|
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||||
|
|
||||||
|
const replacedTabId = store.getState().replaceTab(workspaceKey, sheetTabId ?? "", {
|
||||||
|
kind: "terminal",
|
||||||
|
terminalId: "term-2",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||||
|
expect(replacedTabId).toBe(sheetTabId);
|
||||||
|
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||||
|
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(sheetTabId);
|
||||||
|
expect(tabKinds(store)).toEqual(["terminal", "terminal"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a chat shown in the sheet open for timeline sync until the sheet closes", () => {
|
||||||
|
const store = createCompactStore();
|
||||||
|
seedAgents([
|
||||||
|
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||||
|
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||||
|
]);
|
||||||
|
const received: string[][] = [];
|
||||||
|
const stop = observeOpenWorkspaceAgentIds("server-1", (ids) => received.push(ids), store);
|
||||||
|
|
||||||
|
store.getState().openTab({
|
||||||
|
workspaceKey,
|
||||||
|
target: { kind: "agent", agentId: "child" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
store.getState().closeSheet(workspaceKey);
|
||||||
|
stop();
|
||||||
|
|
||||||
|
expect(received).toEqual([[], ["child"], []]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -59,7 +59,12 @@ import {
|
|||||||
type WorkspaceTabSnapshot,
|
type WorkspaceTabSnapshot,
|
||||||
type WorkspaceLayout,
|
type WorkspaceLayout,
|
||||||
} from "@/stores/workspace-layout-actions";
|
} 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 { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
||||||
import { panelTargetSupportsHostForWorkspaceKey } from "@/plugins/workspace-panels/locations";
|
import { panelTargetSupportsHostForWorkspaceKey } from "@/plugins/workspace-panels/locations";
|
||||||
|
|
||||||
@@ -105,7 +110,24 @@ export interface OpenWorkspaceTabInput {
|
|||||||
state?: JsonValue;
|
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 {
|
interface WorkspaceLayoutStore {
|
||||||
|
/** Published once by the app shell; callers never pass a form factor to open a tab. */
|
||||||
|
layoutMode: WorkspaceLayoutMode;
|
||||||
|
/** At most one sheet tab per workspace. Not persisted: a reload returns to the tab strip. */
|
||||||
|
sheetByWorkspace: Record<string, WorkspaceSheet>;
|
||||||
layoutByWorkspace: Record<string, WorkspaceLayout>;
|
layoutByWorkspace: Record<string, WorkspaceLayout>;
|
||||||
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
||||||
explorerSidebarWidthByWorkspace: Record<string, number>;
|
explorerSidebarWidthByWorkspace: Record<string, number>;
|
||||||
@@ -116,7 +138,19 @@ interface WorkspaceLayoutStore {
|
|||||||
sidePaneIdByWorkspace: Record<string, string | null>;
|
sidePaneIdByWorkspace: Record<string, string | null>;
|
||||||
/** Workspaces where PR detection already added its tab once; a closed tab never returns. */
|
/** Workspaces where PR detection already added its tab once; a closed tab never returns. */
|
||||||
pullRequestTabAutoOpenedByWorkspace: Record<string, true>;
|
pullRequestTabAutoOpenedByWorkspace: Record<string, true>;
|
||||||
|
/**
|
||||||
|
* The one entry point for showing a target. On compact layouts a target whose panel declares
|
||||||
|
* `compactSheet` opens in the workspace sheet; the returned tab id is the sheet tab's id.
|
||||||
|
*/
|
||||||
openTab: (input: OpenWorkspaceTabInput) => string | null;
|
openTab: (input: OpenWorkspaceTabInput) => string | null;
|
||||||
|
/** Whether `openTab` with this input would show the target in the sheet. */
|
||||||
|
opensInSheet: (
|
||||||
|
input: Pick<OpenWorkspaceTabInput, "workspaceKey" | "target" | "intent">,
|
||||||
|
) => boolean;
|
||||||
|
setLayoutMode: (mode: WorkspaceLayoutMode) => void;
|
||||||
|
closeSheet: (workspaceKey: string) => void;
|
||||||
|
/** Moves the sheet's tab, keeping its tab id, into the focused pane. */
|
||||||
|
openSheetAsTab: (workspaceKey: string) => string | null;
|
||||||
/** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */
|
/** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */
|
||||||
autoOpenPullRequestTab: (
|
autoOpenPullRequestTab: (
|
||||||
workspaceKey: string,
|
workspaceKey: string,
|
||||||
@@ -472,6 +506,17 @@ export function selectExplorerSidebarPaneId(
|
|||||||
return resolveExplorerSidebarPaneId(layout, state.explorerSidebarPaneIdByWorkspace[workspaceKey]);
|
return resolveExplorerSidebarPaneId(layout, state.explorerSidebarPaneIdByWorkspace[workspaceKey]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A workspace's panes give up keyboard focus while focus is parked or the sheet covers them. */
|
||||||
|
export function selectPaneFocusSuspended(
|
||||||
|
state: Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace" | "sheetByWorkspace">,
|
||||||
|
workspaceKey: string,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
Boolean(state.focusRestorationByWorkspace[workspaceKey]?.restorePaneId) ||
|
||||||
|
workspaceKey in state.sheetByWorkspace
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether the Explorer sidebar pane is currently on screen. */
|
/** Whether the Explorer sidebar pane is currently on screen. */
|
||||||
export function selectIsExplorerSidebarVisible(
|
export function selectIsExplorerSidebarVisible(
|
||||||
state: ExplorerSidebarState,
|
state: ExplorerSidebarState,
|
||||||
@@ -637,6 +682,189 @@ function attachParentTab(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withoutSheet(
|
||||||
|
state: Pick<WorkspaceLayoutStore, "sheetByWorkspace">,
|
||||||
|
workspaceKey: string,
|
||||||
|
): Pick<WorkspaceLayoutStore, "sheetByWorkspace"> {
|
||||||
|
if (!(workspaceKey in state.sheetByWorkspace)) {
|
||||||
|
return { sheetByWorkspace: state.sheetByWorkspace };
|
||||||
|
}
|
||||||
|
const { [workspaceKey]: _removed, ...sheetByWorkspace } = state.sheetByWorkspace;
|
||||||
|
return { sheetByWorkspace };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTargetOpenAsTab(layout: WorkspaceLayout | undefined, target: WorkspaceTabTarget) {
|
||||||
|
if (!layout) return false;
|
||||||
|
const resourceKey = panelResourceKey(target);
|
||||||
|
return collectAllTabs(layout.root).some((tab) => panelResourceKey(tab.target) === resourceKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSheetPolicy(
|
||||||
|
state: Pick<WorkspaceLayoutStore, "layoutMode" | "layoutByWorkspace">,
|
||||||
|
workspaceKey: string,
|
||||||
|
target: WorkspaceTabTarget,
|
||||||
|
intent: WorkspaceTabOpenIntent,
|
||||||
|
): CompactSheetPolicy | null {
|
||||||
|
if (state.layoutMode !== "compact" || intent === "background") return null;
|
||||||
|
if (intent === "reveal" && isTargetOpenAsTab(state.layoutByWorkspace[workspaceKey], target)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolveCompactSheetPolicy(workspaceKey, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parent a newly opened tab links to. A target opened from inside the sheet names the sheet's
|
||||||
|
* tab, which the open replaces or closes, so it inherits the sheet's own parent instead.
|
||||||
|
*/
|
||||||
|
function resolveOpenParentTabId(
|
||||||
|
state: Pick<WorkspaceLayoutStore, "sheetByWorkspace">,
|
||||||
|
workspaceKey: string,
|
||||||
|
parentTabId: string | undefined,
|
||||||
|
): string | null {
|
||||||
|
const sheet = state.sheetByWorkspace[workspaceKey];
|
||||||
|
if (sheet && parentTabId === sheet.tab.tabId) return sheet.parentTabId;
|
||||||
|
return parentTabId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A reveal of the resource the sheet already shows keeps the sheet's tab. */
|
||||||
|
function sheetKeepsTabFor(
|
||||||
|
sheet: WorkspaceSheet | undefined,
|
||||||
|
target: WorkspaceTabTarget,
|
||||||
|
intent: WorkspaceTabOpenIntent,
|
||||||
|
): sheet is WorkspaceSheet {
|
||||||
|
return (
|
||||||
|
intent === "reveal" &&
|
||||||
|
sheet !== undefined &&
|
||||||
|
panelResourceKey(sheet.tab.target) === panelResourceKey(target)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeTargetInSheet(input: {
|
||||||
|
/** The sheet whose tab the target keeps, if any. */
|
||||||
|
kept: WorkspaceSheet | undefined;
|
||||||
|
target: WorkspaceTabTarget;
|
||||||
|
intent: WorkspaceTabOpenIntent;
|
||||||
|
policy: CompactSheetPolicy;
|
||||||
|
parentTabId: string | null;
|
||||||
|
state: JsonValue | undefined;
|
||||||
|
}): WorkspaceSheet {
|
||||||
|
if (input.kept) {
|
||||||
|
return { ...input.kept, tab: { ...input.kept.tab, target: input.target } };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tab: {
|
||||||
|
tabId:
|
||||||
|
input.intent === "reveal"
|
||||||
|
? buildDeterministicWorkspaceTabId(input.target)
|
||||||
|
: createWorkspaceTabInstanceId(),
|
||||||
|
target: input.target,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
...(input.state !== undefined ? { state: input.state } : {}),
|
||||||
|
},
|
||||||
|
policy: input.policy,
|
||||||
|
parentTabId: input.parentTabId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves a workspace's sheet tab into its focused pane, keeping its tab id and state. */
|
||||||
|
function promoteSheetToTab(
|
||||||
|
state: WorkspaceLayoutStore,
|
||||||
|
workspaceKey: string,
|
||||||
|
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
|
||||||
|
const sheet = state.sheetByWorkspace[workspaceKey];
|
||||||
|
if (!sheet) return null;
|
||||||
|
const { tab } = sheet;
|
||||||
|
const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT);
|
||||||
|
const input = {
|
||||||
|
...placement,
|
||||||
|
target: tab.target,
|
||||||
|
now: tab.createdAt,
|
||||||
|
createTabId: () => tab.tabId,
|
||||||
|
state: tab.state,
|
||||||
|
};
|
||||||
|
const result = isTargetOpenAsTab(placement.layout, tab.target)
|
||||||
|
? revealTargetInLayout(input)
|
||||||
|
: createTabInLayout(input);
|
||||||
|
if (!result) return null;
|
||||||
|
const nextLayout = attachParentTab({
|
||||||
|
layout: keepWorkspaceFocusOutOfExplorerSidebar(
|
||||||
|
result.layout,
|
||||||
|
placement.explorerSidebarPaneId,
|
||||||
|
placement.layout.focusedPaneId,
|
||||||
|
),
|
||||||
|
childTabId: result.tabId,
|
||||||
|
parentTabId: sheet.parentTabId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
tabId: result.tabId,
|
||||||
|
patch: {
|
||||||
|
...withoutSheet(state, workspaceKey),
|
||||||
|
...withoutFocusRestoration(state, workspaceKey),
|
||||||
|
hiddenAgentIdsByWorkspace:
|
||||||
|
tab.target.kind === "agent"
|
||||||
|
? removeAgentIdFromWorkspaceSet(
|
||||||
|
state.hiddenAgentIdsByWorkspace,
|
||||||
|
workspaceKey,
|
||||||
|
tab.target.agentId,
|
||||||
|
)
|
||||||
|
: state.hiddenAgentIdsByWorkspace,
|
||||||
|
layoutByWorkspace: { ...state.layoutByWorkspace, [workspaceKey]: nextLayout },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sheet holding unsaved work is never implicitly displaced: moving its tab would remount the
|
||||||
|
* panel and drop the work. Only the explicit, guarded close removes it.
|
||||||
|
*/
|
||||||
|
function sheetHoldsUnsavedWork(
|
||||||
|
state: Pick<WorkspaceLayoutStore, "sheetByWorkspace">,
|
||||||
|
workspaceKey: string,
|
||||||
|
): boolean {
|
||||||
|
const sheet = state.sheetByWorkspace[workspaceKey];
|
||||||
|
return sheet !== undefined && isSheetTabModified(workspaceKey, sheet.tab.tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where an open lands in the sheet, or null when it opens as a tab. A reveal of the resource the
|
||||||
|
* sheet shows keeps its tab; any other target never displaces unsaved work and opens as a tab.
|
||||||
|
*/
|
||||||
|
function resolveSheetPlacement(
|
||||||
|
state: WorkspaceLayoutStore,
|
||||||
|
workspaceKey: string,
|
||||||
|
target: WorkspaceTabTarget,
|
||||||
|
intent: WorkspaceTabOpenIntent,
|
||||||
|
): { policy: CompactSheetPolicy; kept: WorkspaceSheet | undefined } | null {
|
||||||
|
const policy = resolveSheetPolicy(state, workspaceKey, target, intent);
|
||||||
|
if (!policy) return null;
|
||||||
|
const sheet = state.sheetByWorkspace[workspaceKey];
|
||||||
|
if (sheetKeepsTabFor(sheet, target, intent)) return { policy, kept: sheet };
|
||||||
|
return sheetHoldsUnsavedWork(state, workspaceKey) ? null : { policy, kept: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the sheet because navigation moved on underneath it, unless it holds unsaved work. */
|
||||||
|
function dismissSheet(
|
||||||
|
state: WorkspaceLayoutStore,
|
||||||
|
workspaceKey: string,
|
||||||
|
): Partial<WorkspaceLayoutStore> {
|
||||||
|
return sheetHoldsUnsavedWork(state, workspaceKey) ? {} : withoutSheet(state, workspaceKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sheet agent closes once the workspace no longer has it (archived or gone), like its tab would,
|
||||||
|
* and once it stops being a subagent: as a root agent, reconcile gives it its own tab, and the sheet
|
||||||
|
* would hold a second tab with the same id.
|
||||||
|
*/
|
||||||
|
function sheetAgentNoLongerFits(
|
||||||
|
workspaceKey: string,
|
||||||
|
sheet: WorkspaceSheet | undefined,
|
||||||
|
snapshot: WorkspaceTabSnapshot,
|
||||||
|
): boolean {
|
||||||
|
if (sheet?.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false;
|
||||||
|
if (!new Set(snapshot.activeAgentIds).has(sheet.tab.target.agentId)) return true;
|
||||||
|
return resolveCompactSheetPolicy(workspaceKey, sheet.tab.target) === null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Splits an Explorer sidebar out of the pane the user is in. Only reached by layouts saved
|
* 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.
|
* before the Explorer became part of the default tree; new ones are born with it.
|
||||||
@@ -676,6 +904,8 @@ export function createWorkspaceLayoutStore(
|
|||||||
}
|
}
|
||||||
return tabId;
|
return tabId;
|
||||||
},
|
},
|
||||||
|
layoutMode: "split-panes",
|
||||||
|
sheetByWorkspace: {},
|
||||||
layoutByWorkspace: {},
|
layoutByWorkspace: {},
|
||||||
splitSizesByWorkspace: {},
|
splitSizesByWorkspace: {},
|
||||||
explorerSidebarWidthByWorkspace: {},
|
explorerSidebarWidthByWorkspace: {},
|
||||||
@@ -691,6 +921,31 @@ export function createWorkspaceLayoutStore(
|
|||||||
if (!normalizedWorkspaceKey || !normalizedTarget) {
|
if (!normalizedWorkspaceKey || !normalizedTarget) {
|
||||||
return null;
|
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(
|
const placement = getOpenTabPlacement(
|
||||||
get(),
|
get(),
|
||||||
normalizedWorkspaceKey,
|
normalizedWorkspaceKey,
|
||||||
@@ -734,6 +989,8 @@ export function createWorkspaceLayoutStore(
|
|||||||
const shouldPinAgent = input.pin === true && normalizedTarget.kind === "agent";
|
const shouldPinAgent = input.pin === true && normalizedTarget.kind === "agent";
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||||
|
// A tab brought to the front dismisses the sheet over it.
|
||||||
|
...(input.intent === "background" ? {} : dismissSheet(state, normalizedWorkspaceKey)),
|
||||||
hiddenAgentIdsByWorkspace:
|
hiddenAgentIdsByWorkspace:
|
||||||
normalizedTarget.kind !== "agent"
|
normalizedTarget.kind !== "agent"
|
||||||
? state.hiddenAgentIdsByWorkspace
|
? state.hiddenAgentIdsByWorkspace
|
||||||
@@ -751,17 +1008,52 @@ export function createWorkspaceLayoutStore(
|
|||||||
: state.pinnedAgentIdsByWorkspace,
|
: state.pinnedAgentIdsByWorkspace,
|
||||||
layoutByWorkspace: {
|
layoutByWorkspace: {
|
||||||
...state.layoutByWorkspace,
|
...state.layoutByWorkspace,
|
||||||
[normalizedWorkspaceKey]: input.parentTabId
|
[normalizedWorkspaceKey]: parentTabId
|
||||||
? attachParentTab({
|
? attachParentTab({
|
||||||
layout: nextLayout,
|
layout: nextLayout,
|
||||||
childTabId: result.tabId,
|
childTabId: result.tabId,
|
||||||
parentTabId: input.parentTabId,
|
parentTabId,
|
||||||
})
|
})
|
||||||
: nextLayout,
|
: nextLayout,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return result.tabId;
|
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) => {
|
showExplorerSidebar: (workspaceKey) => {
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||||
if (!normalizedWorkspaceKey) {
|
if (!normalizedWorkspaceKey) {
|
||||||
@@ -881,6 +1173,9 @@ export function createWorkspaceLayoutStore(
|
|||||||
}
|
}
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
if (state.sheetByWorkspace[normalizedWorkspaceKey]?.tab.tabId === normalizedTabId) {
|
||||||
|
return withoutSheet(state, normalizedWorkspaceKey);
|
||||||
|
}
|
||||||
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
||||||
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
||||||
layout,
|
layout,
|
||||||
@@ -988,6 +1283,8 @@ export function createWorkspaceLayoutStore(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||||
|
// Choosing a tab dismisses the sheet that covers it.
|
||||||
|
...dismissSheet(state, normalizedWorkspaceKey),
|
||||||
layoutByWorkspace: {
|
layoutByWorkspace: {
|
||||||
...state.layoutByWorkspace,
|
...state.layoutByWorkspace,
|
||||||
[normalizedWorkspaceKey]: nextLayout,
|
[normalizedWorkspaceKey]: nextLayout,
|
||||||
@@ -1011,6 +1308,8 @@ export function createWorkspaceLayoutStore(
|
|||||||
});
|
});
|
||||||
return nextLayout
|
return nextLayout
|
||||||
? {
|
? {
|
||||||
|
// Choosing a tab dismisses the sheet that covers it.
|
||||||
|
...dismissSheet(state, normalizedWorkspaceKey),
|
||||||
layoutByWorkspace: {
|
layoutByWorkspace: {
|
||||||
...state.layoutByWorkspace,
|
...state.layoutByWorkspace,
|
||||||
[normalizedWorkspaceKey]: nextLayout,
|
[normalizedWorkspaceKey]: nextLayout,
|
||||||
@@ -1024,6 +1323,31 @@ export function createWorkspaceLayoutStore(
|
|||||||
const normalizedTabId = trimNonEmpty(tabId);
|
const normalizedTabId = trimNonEmpty(tabId);
|
||||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||||
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null;
|
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({
|
const result = replaceTabTargetInLayout({
|
||||||
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
||||||
tabId: normalizedTabId,
|
tabId: normalizedTabId,
|
||||||
@@ -1054,6 +1378,19 @@ export function createWorkspaceLayoutStore(
|
|||||||
const normalizedTabId = trimNonEmpty(tabId);
|
const normalizedTabId = trimNonEmpty(tabId);
|
||||||
if (!normalizedWorkspaceKey || !normalizedTabId) return;
|
if (!normalizedWorkspaceKey || !normalizedTabId) return;
|
||||||
set((state) => {
|
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({
|
const layout = setTabStateInLayout({
|
||||||
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
||||||
tabId: normalizedTabId,
|
tabId: normalizedTabId,
|
||||||
@@ -1148,15 +1485,22 @@ export function createWorkspaceLayoutStore(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const sheetAgentGone = sheetAgentNoLongerFits(
|
||||||
|
normalizedWorkspaceKey,
|
||||||
|
state.sheetByWorkspace[normalizedWorkspaceKey],
|
||||||
|
snapshot,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
state.layoutByWorkspace[normalizedWorkspaceKey] &&
|
state.layoutByWorkspace[normalizedWorkspaceKey] &&
|
||||||
nextLayout === rawLayout &&
|
nextLayout === rawLayout &&
|
||||||
pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace
|
pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace &&
|
||||||
|
!sheetAgentGone
|
||||||
) {
|
) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...(sheetAgentGone ? withoutSheet(state, normalizedWorkspaceKey) : {}),
|
||||||
pinnedAgentIdsByWorkspace,
|
pinnedAgentIdsByWorkspace,
|
||||||
layoutByWorkspace: {
|
layoutByWorkspace: {
|
||||||
...state.layoutByWorkspace,
|
...state.layoutByWorkspace,
|
||||||
@@ -1661,7 +2005,8 @@ export function createWorkspaceLayoutStore(
|
|||||||
normalizedWorkspaceKey in state.focusRestorationByWorkspace ||
|
normalizedWorkspaceKey in state.focusRestorationByWorkspace ||
|
||||||
normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace ||
|
normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace ||
|
||||||
normalizedWorkspaceKey in state.sidePaneIdByWorkspace ||
|
normalizedWorkspaceKey in state.sidePaneIdByWorkspace ||
|
||||||
normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace;
|
normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace ||
|
||||||
|
normalizedWorkspaceKey in state.sheetByWorkspace;
|
||||||
if (!hasAny) {
|
if (!hasAny) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -1690,6 +2035,7 @@ export function createWorkspaceLayoutStore(
|
|||||||
const { [normalizedWorkspaceKey]: _sidePane, ...sidePaneIdByWorkspace } =
|
const { [normalizedWorkspaceKey]: _sidePane, ...sidePaneIdByWorkspace } =
|
||||||
state.sidePaneIdByWorkspace;
|
state.sidePaneIdByWorkspace;
|
||||||
return {
|
return {
|
||||||
|
...withoutSheet(state, normalizedWorkspaceKey),
|
||||||
pullRequestTabAutoOpenedByWorkspace,
|
pullRequestTabAutoOpenedByWorkspace,
|
||||||
layoutByWorkspace,
|
layoutByWorkspace,
|
||||||
splitSizesByWorkspace,
|
splitSizesByWorkspace,
|
||||||
@@ -1795,10 +2141,10 @@ export function createWorkspaceLayoutStore(
|
|||||||
export const useWorkspaceLayoutStore = createWorkspaceLayoutStore();
|
export const useWorkspaceLayoutStore = createWorkspaceLayoutStore();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The agent tabs that exist right now, across every workspace of this host, independently
|
* The agent tabs that exist right now, including sheet tabs, across every workspace of this host,
|
||||||
* of which workspace views are mounted. Timeline sync uses this to release a chat when its
|
* independently of which workspace views are mounted. Timeline sync uses this to release a chat
|
||||||
* tab closes. It is not a subscription source: this layout is restored from disk at launch,
|
* when its tab closes. It is not a subscription source: this layout is restored from disk at
|
||||||
* so subscribing to it would resume every agent the user has ever opened.
|
* launch, so subscribing to it would resume every agent the user has ever opened.
|
||||||
*/
|
*/
|
||||||
export function observeOpenWorkspaceAgentIds(
|
export function observeOpenWorkspaceAgentIds(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
@@ -1806,11 +2152,19 @@ export function observeOpenWorkspaceAgentIds(
|
|||||||
store = useWorkspaceLayoutStore,
|
store = useWorkspaceLayoutStore,
|
||||||
): () => void {
|
): () => void {
|
||||||
let previous: string[] | undefined;
|
let previous: string[] | undefined;
|
||||||
const publish = (state: Pick<WorkspaceLayoutStore, "layoutByWorkspace">) => {
|
const publish = (state: Pick<WorkspaceLayoutStore, "layoutByWorkspace" | "sheetByWorkspace">) => {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const [workspaceKey, layout] of Object.entries(state.layoutByWorkspace)) {
|
const tabsByWorkspace = [
|
||||||
|
...Object.entries(state.layoutByWorkspace).map(
|
||||||
|
([workspaceKey, layout]) => [workspaceKey, collectAllTabs(layout.root)] as const,
|
||||||
|
),
|
||||||
|
...Object.entries(state.sheetByWorkspace).map(
|
||||||
|
([workspaceKey, sheet]) => [workspaceKey, [sheet.tab]] as const,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (const [workspaceKey, tabs] of tabsByWorkspace) {
|
||||||
if (!workspaceKey.startsWith(`${serverId}:`)) continue;
|
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);
|
if (tab.target.kind === "agent") ids.add(tab.target.agentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1821,7 +2175,12 @@ export function observeOpenWorkspaceAgentIds(
|
|||||||
listener(next);
|
listener(next);
|
||||||
};
|
};
|
||||||
const unsubscribe = store.subscribe((state, before) => {
|
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());
|
publish(store.getState());
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { getWorkspacePanelAttributes } from "@/panels/panel-instance-attributes";
|
||||||
|
import { panelCompactSheetPolicy, type CompactSheetPolicy } from "@/panels/panel-manifest";
|
||||||
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
|
import { isWorkspaceRootAgent } from "@/subagents/policies";
|
||||||
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
|
|
||||||
|
function workspaceAgentLookup(workspaceKey: string): (agentId: string) => Agent | null {
|
||||||
|
const sessions = useSessionStore.getState().sessions;
|
||||||
|
const serverId = Object.keys(sessions)
|
||||||
|
.filter((candidate) => workspaceKey.startsWith(`${candidate}:`))
|
||||||
|
.sort((left, right) => right.length - left.length)[0];
|
||||||
|
const session = serverId ? sessions[serverId] : undefined;
|
||||||
|
return (agentId) => session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single target-aware boundary deciding whether a compact reveal lands in the workspace
|
||||||
|
* sheet. An agent the client has not loaded yet counts as a root agent and stays a tab.
|
||||||
|
*/
|
||||||
|
export function resolveCompactSheetPolicy(
|
||||||
|
workspaceKey: string,
|
||||||
|
target: WorkspaceTabTarget,
|
||||||
|
): CompactSheetPolicy | null {
|
||||||
|
return panelCompactSheetPolicy(target, {
|
||||||
|
isWorkspaceRootAgent: (agentId) => {
|
||||||
|
const getAgent = workspaceAgentLookup(workspaceKey);
|
||||||
|
const agent = getAgent(agentId);
|
||||||
|
if (!agent) return true;
|
||||||
|
const parent = agent.parentAgentId ? (getAgent(agent.parentAgentId) ?? undefined) : undefined;
|
||||||
|
return isWorkspaceRootAgent(agent, parent);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the sheet's tab holds unsaved work, which must never be discarded with the sheet. */
|
||||||
|
export function isSheetTabModified(workspaceKey: string, tabId: string): boolean {
|
||||||
|
return getWorkspacePanelAttributes(workspaceKey, tabId).modified;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useLayoutEffect } from "react";
|
||||||
|
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import { useWorkspaceLayoutStore, type WorkspaceLayoutMode } from "@/stores/workspace-layout-store";
|
||||||
|
|
||||||
|
function resolveWorkspaceLayoutMode(isCompact: boolean): WorkspaceLayoutMode {
|
||||||
|
if (isCompact) return "compact";
|
||||||
|
return supportsDesktopPaneSplits() ? "split-panes" : "single-pane";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes the shell's workspace layout to the layout store, which decides where opened targets
|
||||||
|
* land. Render it before the routes: React runs an earlier sibling's layout effects before a later
|
||||||
|
* sibling's subtree, so a route sees the mode even from its first layout effect.
|
||||||
|
*/
|
||||||
|
export function WorkspaceLayoutModePublisher(): null {
|
||||||
|
const isCompact = useIsCompactFormFactor();
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
useWorkspaceLayoutStore.getState().setLayoutMode(resolveWorkspaceLayoutMode(isCompact));
|
||||||
|
}, [isCompact]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||||
|
default: {
|
||||||
|
getItem: vi.fn(async () => null),
|
||||||
|
setItem: vi.fn(async () => undefined),
|
||||||
|
removeItem: vi.fn(async () => undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { DEFAULT_APP_SETTINGS } from "@/hooks/use-settings";
|
||||||
|
import { setPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||||
|
import {
|
||||||
|
collectAllPanes,
|
||||||
|
collectAllTabs,
|
||||||
|
findPaneById,
|
||||||
|
useWorkspaceLayoutStore,
|
||||||
|
} from "@/stores/workspace-layout-store";
|
||||||
|
import {
|
||||||
|
openPreferredWorkspacePreview,
|
||||||
|
openPreferredWorkspaceTarget,
|
||||||
|
openWorkspaceTargetBeside,
|
||||||
|
} from "@/workspace-tabs/open-beside";
|
||||||
|
|
||||||
|
const WORKSPACE_KEY = "server-1:workspace-1";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useWorkspaceLayoutStore.setState({
|
||||||
|
layoutMode: "split-panes",
|
||||||
|
sheetByWorkspace: {},
|
||||||
|
layoutByWorkspace: {},
|
||||||
|
explorerSidebarPaneIdByWorkspace: {},
|
||||||
|
sidePaneIdByWorkspace: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openPreferredWorkspacePreview", () => {
|
||||||
|
it("opens a compact file preview in the sheet instead of replacing the active file tab", () => {
|
||||||
|
const store = useWorkspaceLayoutStore.getState();
|
||||||
|
const fileTabId = store.openTab({
|
||||||
|
workspaceKey: WORKSPACE_KEY,
|
||||||
|
target: { kind: "file", path: "src/open.ts" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
store.setLayoutMode("compact");
|
||||||
|
|
||||||
|
const tabId = openPreferredWorkspacePreview({
|
||||||
|
workspaceKey: WORKSPACE_KEY,
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
explorerSidebarPaneId: "explorer",
|
||||||
|
lastMainPaneId: "main",
|
||||||
|
target: { kind: "file", path: "src/preview.ts" },
|
||||||
|
source: "explorerFiles",
|
||||||
|
preferences: DEFAULT_APP_SETTINGS.openInSidePane,
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useWorkspaceLayoutStore.getState();
|
||||||
|
expect(state.sheetByWorkspace[WORKSPACE_KEY]?.tab).toMatchObject({
|
||||||
|
tabId,
|
||||||
|
target: { kind: "file", path: "src/preview.ts" },
|
||||||
|
});
|
||||||
|
const root = state.layoutByWorkspace[WORKSPACE_KEY].root;
|
||||||
|
expect(findPaneById(root, "main")?.tabIds).toEqual([fileTabId]);
|
||||||
|
expect(collectAllTabs(root).find((tab) => tab.tabId === fileTabId)?.target).toEqual({
|
||||||
|
kind: "file",
|
||||||
|
path: "src/open.ts",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("previews under a sheet with unsaved work", () => {
|
||||||
|
const sheetTarget = { kind: "file" as const, path: "src/draft.ts" };
|
||||||
|
let sheetTabId: string | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (sheetTabId) {
|
||||||
|
setPanelInstanceAttributes(
|
||||||
|
{ serverId: "server-1", workspaceId: "workspace-1", tabId: sheetTabId },
|
||||||
|
{ modified: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
sheetTabId = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function previewFile(path: string): string | null {
|
||||||
|
return openPreferredWorkspacePreview({
|
||||||
|
workspaceKey: WORKSPACE_KEY,
|
||||||
|
serverId: "server-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
explorerSidebarPaneId: "explorer",
|
||||||
|
lastMainPaneId: "main",
|
||||||
|
target: { kind: "file", path },
|
||||||
|
source: "explorerFiles",
|
||||||
|
preferences: DEFAULT_APP_SETTINGS.openInSidePane,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reuses one preview tab instead of adding a tab per link", () => {
|
||||||
|
const store = useWorkspaceLayoutStore.getState();
|
||||||
|
store.openTab({
|
||||||
|
workspaceKey: WORKSPACE_KEY,
|
||||||
|
target: { kind: "terminal", terminalId: "term-1" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
store.setLayoutMode("compact");
|
||||||
|
sheetTabId = store.openTab({
|
||||||
|
workspaceKey: WORKSPACE_KEY,
|
||||||
|
target: sheetTarget,
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
setPanelInstanceAttributes(
|
||||||
|
{ serverId: "server-1", workspaceId: "workspace-1", tabId: sheetTabId ?? "" },
|
||||||
|
{ modified: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstPreviewTabId = previewFile("src/first.ts");
|
||||||
|
const secondPreviewTabId = previewFile("src/second.ts");
|
||||||
|
|
||||||
|
const state = useWorkspaceLayoutStore.getState();
|
||||||
|
expect(state.sheetByWorkspace[WORKSPACE_KEY]?.tab.target).toEqual(sheetTarget);
|
||||||
|
expect(secondPreviewTabId).toBe(firstPreviewTabId);
|
||||||
|
const root = state.layoutByWorkspace[WORKSPACE_KEY].root;
|
||||||
|
const tabsById = new Map(collectAllTabs(root).map((tab) => [tab.tabId, tab.target]));
|
||||||
|
expect(findPaneById(root, "main")?.tabIds.map((tabId) => tabsById.get(tabId))).toEqual([
|
||||||
|
{ kind: "terminal", terminalId: "term-1" },
|
||||||
|
{ kind: "file", path: "src/second.ts" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("opening beside", () => {
|
||||||
|
const sidePreference = { ...DEFAULT_APP_SETTINGS.openInSidePane, subagents: true };
|
||||||
|
|
||||||
|
function openSubagentWithSidePreference(): string | null {
|
||||||
|
useWorkspaceLayoutStore.getState().openTab({
|
||||||
|
workspaceKey: WORKSPACE_KEY,
|
||||||
|
target: { kind: "file", path: "src/open.ts" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
return openPreferredWorkspaceTarget({
|
||||||
|
workspaceKey: WORKSPACE_KEY,
|
||||||
|
target: { kind: "file", path: "src/subagent.ts" },
|
||||||
|
source: "subagents",
|
||||||
|
preferences: sidePreference,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function paneIdHolding(tabId: string | null): string | undefined {
|
||||||
|
const root = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY].root;
|
||||||
|
return collectAllPanes(root).find((pane) => pane.tabIds.includes(tabId ?? ""))?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("follows the side preference where the workspace renders split panes", () => {
|
||||||
|
useWorkspaceLayoutStore.getState().setLayoutMode("split-panes");
|
||||||
|
|
||||||
|
const tabId = openSubagentWithSidePreference();
|
||||||
|
|
||||||
|
const sidePaneId = useWorkspaceLayoutStore.getState().sidePaneIdByWorkspace[WORKSPACE_KEY];
|
||||||
|
expect(sidePaneId).toEqual(expect.any(String));
|
||||||
|
expect(paneIdHolding(tabId)).toBe(sidePaneId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a single-pane workspace in one pane despite the side preference", () => {
|
||||||
|
useWorkspaceLayoutStore.getState().setLayoutMode("single-pane");
|
||||||
|
|
||||||
|
const preferredTabId = openSubagentWithSidePreference();
|
||||||
|
const besideTabId = openWorkspaceTargetBeside({
|
||||||
|
workspaceKey: WORKSPACE_KEY,
|
||||||
|
target: { kind: "file", path: "src/beside.ts" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useWorkspaceLayoutStore.getState().sidePaneIdByWorkspace[WORKSPACE_KEY]).toBeFalsy();
|
||||||
|
expect(paneIdHolding(preferredTabId)).toBe("main");
|
||||||
|
expect(paneIdHolding(besideTabId)).toBe("main");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,13 +22,11 @@ interface OpenWorkspaceTargetInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenPreferredWorkspaceTargetInput extends OpenWorkspaceTargetInput {
|
export interface OpenPreferredWorkspaceTargetInput extends OpenWorkspaceTargetInput {
|
||||||
isCompact: boolean;
|
|
||||||
source: OpenInSidePaneSource;
|
source: OpenInSidePaneSource;
|
||||||
preferences: OpenInSidePanePreferences;
|
preferences: OpenInSidePanePreferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OpenWorkspaceTargetAtLocationInput extends OpenWorkspaceTargetInput {
|
interface OpenWorkspaceTargetAtLocationInput extends OpenWorkspaceTargetInput {
|
||||||
isCompact: boolean;
|
|
||||||
location: WorkspaceTargetOpenLocation;
|
location: WorkspaceTargetOpenLocation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +37,11 @@ interface OpenPreferredWorkspacePreviewInput extends OpenPreferredWorkspaceTarge
|
|||||||
lastMainPaneId: string | null;
|
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: {
|
function resolveMainPane(input: {
|
||||||
workspaceKey: string;
|
workspaceKey: string;
|
||||||
explorerSidebarPaneId: string | null;
|
explorerSidebarPaneId: string | null;
|
||||||
@@ -86,7 +89,6 @@ export function openPreferredWorkspaceTarget(
|
|||||||
input: OpenPreferredWorkspaceTargetInput,
|
input: OpenPreferredWorkspaceTargetInput,
|
||||||
): string | null {
|
): string | null {
|
||||||
return openWorkspaceTargetAtLocation({
|
return openWorkspaceTargetAtLocation({
|
||||||
isCompact: input.isCompact,
|
|
||||||
workspaceKey: input.workspaceKey,
|
workspaceKey: input.workspaceKey,
|
||||||
target: input.target,
|
target: input.target,
|
||||||
location: input.preferences[input.source] ? "side" : "main",
|
location: input.preferences[input.source] ? "side" : "main",
|
||||||
@@ -105,7 +107,7 @@ export function openWorkspaceTargetAtLocation(
|
|||||||
layout &&
|
layout &&
|
||||||
collectAllTabs(layout.root).some((tab) => workspaceTabTargetsEqual(tab.target, input.target)),
|
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;
|
let placement: WorkspaceTabPlacement | undefined;
|
||||||
if (shouldOpenBeside && !targetAlreadyExists) {
|
if (shouldOpenBeside && !targetAlreadyExists) {
|
||||||
const paneId = store.ensureSidePane(input.workspaceKey);
|
const paneId = store.ensureSidePane(input.workspaceKey);
|
||||||
@@ -135,7 +137,13 @@ export function openPreferredWorkspacePreview(
|
|||||||
const existing = collectAllTabs(layout.root).some((tab) =>
|
const existing = collectAllTabs(layout.root).some((tab) =>
|
||||||
workspaceTabTargetsEqual(tab.target, input.target),
|
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({
|
const mainPane = resolveMainPane({
|
||||||
workspaceKey: input.workspaceKey,
|
workspaceKey: input.workspaceKey,
|
||||||
@@ -143,7 +151,7 @@ export function openPreferredWorkspacePreview(
|
|||||||
lastMainPaneId: input.lastMainPaneId,
|
lastMainPaneId: input.lastMainPaneId,
|
||||||
});
|
});
|
||||||
const destinationPaneId =
|
const destinationPaneId =
|
||||||
!input.isCompact && input.preferences[input.source]
|
canOpenBeside() && input.preferences[input.source]
|
||||||
? store.ensureSidePane(input.workspaceKey)
|
? store.ensureSidePane(input.workspaceKey)
|
||||||
: mainPane?.id;
|
: mainPane?.id;
|
||||||
if (!destinationPaneId) return null;
|
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 {
|
export function openWorkspaceTargetBeside(input: OpenWorkspaceTargetInput): string | null {
|
||||||
if (!input.workspaceKey) return null;
|
if (!input.workspaceKey) return null;
|
||||||
|
if (!canOpenBeside()) {
|
||||||
|
return openWorkspaceTargetAtLocation({ ...input, location: "main" });
|
||||||
|
}
|
||||||
const store = useWorkspaceLayoutStore.getState();
|
const store = useWorkspaceLayoutStore.getState();
|
||||||
const paneId = store.ensureSidePane(input.workspaceKey);
|
const paneId = store.ensureSidePane(input.workspaceKey);
|
||||||
return store.openTab({
|
return store.openTab({
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export function openWorkspaceChanges(input: OpenWorkspaceChangesInput): string |
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return openPreferredWorkspaceTarget({
|
return openPreferredWorkspaceTarget({
|
||||||
isCompact: input.isCompact,
|
|
||||||
workspaceKey: input.workspaceKey,
|
workspaceKey: input.workspaceKey,
|
||||||
target: { kind: "working_diff" },
|
target: { kind: "working_diff" },
|
||||||
source: "diffs",
|
source: "diffs",
|
||||||
@@ -70,7 +69,6 @@ export function openWorkspacePullRequest(input: OpenWorkspacePullRequestInput):
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return openWorkspaceTargetAtLocation({
|
return openWorkspaceTargetAtLocation({
|
||||||
isCompact: input.isCompact,
|
|
||||||
workspaceKey: input.workspaceKey,
|
workspaceKey: input.workspaceKey,
|
||||||
target: { kind: "pull_request" },
|
target: { kind: "pull_request" },
|
||||||
location: input.destination,
|
location: input.destination,
|
||||||
|
|||||||
Reference in New Issue
Block a user