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