mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
744878bf62 | ||
|
|
e1ce819d11 | ||
|
|
6d7aaf5461 | ||
|
|
9e596e3377 | ||
|
|
e5efa7324d | ||
|
|
303ee418cc | ||
|
|
b282bbb944 | ||
|
|
3e977b5e7b |
@@ -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, side-pane, and compact workspace sheet host contracts, lifecycle, placement, routing preferences |
|
||||
| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and 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 |
|
||||
|
||||
@@ -151,12 +151,11 @@ lifecycle queue. Resume chooses its history or interactive purpose from the dura
|
||||
entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed
|
||||
close retains the runtime for cleanup and blocks replacement through that close operation.
|
||||
|
||||
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. The retained
|
||||
history session contains only the read results and must release its provider process once replay
|
||||
finishes. Codex reads the persisted thread and child histories on a temporary app-server and never
|
||||
loads, resumes, or unarchives a native thread, including legacy records whose native archive failed.
|
||||
ACP providers spawn the CLI, replay the session through `session/load`, and terminate the process
|
||||
before returning. Interactive resume remains responsible for repairing a provider session archived
|
||||
Authoritative timeline catch-up can use a runtime-only `history` resume purpose. For Codex, that
|
||||
purpose initializes a temporary app-server, reads the persisted thread and child histories, and
|
||||
releases the process before returning. It never loads, resumes, or unarchives a native thread,
|
||||
including legacy records whose native archive failed. The retained history session contains only
|
||||
the read results. Interactive resume remains responsible for repairing a provider session archived
|
||||
outside Paseo while its Paseo agent is active.
|
||||
|
||||
Provider session connection owns every process it spawns until the session is registered with
|
||||
@@ -212,7 +211,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. 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.
|
||||
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.
|
||||
|
||||
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,7 +7,6 @@ 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
|
||||
|
||||
@@ -23,24 +22,6 @@ 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.
|
||||
@@ -81,9 +62,7 @@ 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. 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.
|
||||
right split around the workspace root; later side opens reuse it.
|
||||
|
||||
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,9 +304,7 @@ 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. The one exception is the
|
||||
[workspace sheet](explorer-sidebar.md#workspace-sheet), an in-tree Gorhom `BottomSheet` that hosts a
|
||||
whole workspace panel.
|
||||
sibling dialog over an existing iOS presenter.
|
||||
|
||||
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,7 +21,6 @@ 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).
|
||||
|
||||
@@ -223,7 +223,6 @@ test.describe("Add Project command-center flow", () => {
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAddProjectPage(page, "directory-search");
|
||||
await expect(addProjectFlowInput(page)).toBeFocused();
|
||||
await page.keyboard.type(projectPickerFixture.fuzzyQuery);
|
||||
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName, {
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -303,10 +303,12 @@ async function retryRestoredSubmission(page: Page, prompt: string): Promise<void
|
||||
}
|
||||
|
||||
async function expectRejectedPromptBeforeError(page: Page, prompt: string, errorMessage: string) {
|
||||
const messages = page.getByTestId("user-message").or(page.getByTestId("assistant-message"));
|
||||
const messages = page
|
||||
.getByTestId("user-message")
|
||||
.or(page.getByTestId("timeline-notification").filter({ hasText: errorMessage }));
|
||||
await expect(messages).toHaveCount(2);
|
||||
await expect(messages.nth(0)).toContainText(prompt);
|
||||
await expect(messages.nth(1)).toContainText(`[System Error] ${errorMessage}`);
|
||||
await expect(messages.nth(1)).toHaveText(errorMessage);
|
||||
}
|
||||
|
||||
async function configureSteerInSettings(page: Page): Promise<void> {
|
||||
|
||||
@@ -51,7 +51,7 @@ test.describe("Assistant fork menu", () => {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await submitMessage(page, "Emit a synthetic turn failure.");
|
||||
await expect(page.getByText("[System Error] Requested mock provider failure")).toBeVisible({
|
||||
await expect(page.getByText("Requested mock provider failure")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -116,7 +116,6 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
);
|
||||
expect(workspaces).toHaveLength(1);
|
||||
gate.release();
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
|
||||
const first = (await client.fetchAgents()).entries[0]!.agent;
|
||||
await client.waitForFinish(first.id, 20_000);
|
||||
@@ -129,7 +128,6 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
|
||||
await fillComposerDraft(page, secondPrompt);
|
||||
await pressSubmitBeforeTheNextRender(page, "Send message");
|
||||
await gate.waitForCreated();
|
||||
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
|
||||
const second = (await client.fetchAgents()).entries.find(
|
||||
({ agent }) => agent.id !== first.id,
|
||||
@@ -208,40 +206,21 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
|
||||
async function holdLegacyAgentCreation(page: Page, port: number) {
|
||||
const readers = await loadSessionMessageReaders();
|
||||
const arrived = Promise.withResolvers<void>();
|
||||
let created = Promise.withResolvers<void>();
|
||||
let createRequestId: string | undefined;
|
||||
let release = () => {};
|
||||
let holding = true;
|
||||
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((frame) => {
|
||||
const message = readers.client(frame);
|
||||
if (message?.type === "create_agent_request") createRequestId = message.requestId;
|
||||
if (holding && message?.type === "create_agent_request") {
|
||||
if (holding && readers.client(frame)?.type === "create_agent_request") {
|
||||
holding = false;
|
||||
release = () => server.send(frame);
|
||||
arrived.resolve();
|
||||
} else server.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => {
|
||||
const message = readers.server(frame);
|
||||
if (
|
||||
message?.type === "status" &&
|
||||
message.payload.status === "agent_created" &&
|
||||
message.payload.requestId === createRequestId
|
||||
) {
|
||||
created.resolve();
|
||||
}
|
||||
browser.send(frame);
|
||||
});
|
||||
server.onMessage((frame) => browser.send(frame));
|
||||
});
|
||||
return {
|
||||
waitForRequest: () => arrived.promise,
|
||||
async waitForCreated() {
|
||||
// Agent-list membership includes initialization, before the initial prompt starts.
|
||||
await created.promise;
|
||||
created = Promise.withResolvers<void>();
|
||||
},
|
||||
release() {
|
||||
const send = release;
|
||||
release = () => {};
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
addCustomModel,
|
||||
expectCustomModelRetained,
|
||||
openCustomModels,
|
||||
removeCustomModel,
|
||||
} from "../support/helpers/custom-models";
|
||||
|
||||
const longModelId = "custom-model-with-a-long-name-".repeat(6);
|
||||
const shortModelId = "custom-short";
|
||||
|
||||
for (const width of [1200, 390]) {
|
||||
test(`removes a long custom model at ${width}px without removing another model`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1200, height: 1000 });
|
||||
await openCustomModels(page);
|
||||
await addCustomModel(page, shortModelId);
|
||||
await addCustomModel(page, longModelId);
|
||||
|
||||
await test.step("remove the long model at the target width", async () => {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
await removeCustomModel(page, longModelId);
|
||||
await expectCustomModelRetained(page, shortModelId);
|
||||
});
|
||||
|
||||
await removeCustomModel(page, shortModelId);
|
||||
});
|
||||
}
|
||||
@@ -2,10 +2,10 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
let workspace: SeededWorkspace;
|
||||
@@ -444,25 +444,3 @@ test("hides unsupported file operations and revert actions", async ({ page }) =>
|
||||
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
|
||||
await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("downloads a file over direct TCP with its original filename and bytes", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const fileName = "rapport données.bin";
|
||||
const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]);
|
||||
await writeFile(path.join(workspace.workspaceDirectory, fileName), source);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const downloaded = await downloadFileFromExplorer(page, fileName);
|
||||
const url = new URL(downloaded.url());
|
||||
expect(url.protocol).toBe("http:");
|
||||
expect(url.port).toBe(getE2EDaemonPort());
|
||||
expect(url.pathname).toBe("/api/files/download");
|
||||
expect(url.searchParams.get("token")).toBeTruthy();
|
||||
expect(downloaded.suggestedFilename()).toBe(fileName);
|
||||
const savedFile = testInfo.outputPath(fileName);
|
||||
await downloaded.saveAs(savedFile);
|
||||
expect(await downloaded.failure()).toBeNull();
|
||||
expect(await readFile(savedFile)).toEqual(source);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import {
|
||||
expectPairingOffer,
|
||||
expectCompletePairingQr,
|
||||
expectRelayConsent,
|
||||
openPairDeviceModal,
|
||||
preparePairingHost,
|
||||
@@ -20,20 +18,3 @@ test("opens relay consent in browser web", async ({ page }) => {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the pairing QR code inside its compact tile", async ({ page }, testInfo) => {
|
||||
const daemon = await startIsolatedHostDaemon("pair-device-browser-compact", {
|
||||
mutableRelay: { enabled: true },
|
||||
});
|
||||
try {
|
||||
await preparePairingHost(page, daemon);
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
await openPairDeviceModal(page);
|
||||
await expectPairingOffer(page);
|
||||
|
||||
await expectCompletePairingQr(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("pairing-compact.png") });
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
expectQuestionDismissEnabled,
|
||||
expectQuestionHidden,
|
||||
expectQuestionNavigationEnabled,
|
||||
expectQuestionOptionPreview,
|
||||
expectQuestionOptionSelected,
|
||||
expectQuestionPrimaryActionDisabled,
|
||||
expectQuestionPrimaryActionEnabled,
|
||||
@@ -62,10 +61,6 @@ test.describe("Question prompt pagination", () => {
|
||||
await expectQuestionOptionSelected(page, "App");
|
||||
|
||||
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
|
||||
await expectQuestionOptionPreview(page, {
|
||||
option: "Behind feature flag",
|
||||
preview: 'flags.enabled("new-surface")',
|
||||
});
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 3,
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
switchWorkspaceViaSidebar,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
|
||||
async function readVisibleWorkspaceOrder(page: Page): Promise<string[]> {
|
||||
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
|
||||
await expect(rows).toHaveCount(12);
|
||||
return rows.evaluateAll(
|
||||
(elements, prefix) =>
|
||||
elements.map((element) => element.getAttribute("data-testid")!.slice(prefix.length)),
|
||||
`sidebar-workspace-row-${getServerId()}:`,
|
||||
);
|
||||
}
|
||||
|
||||
async function navigateWorkspace(
|
||||
page: Page,
|
||||
direction: "next" | "previous",
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await page.keyboard.press(direction === "next" ? "Alt+]" : "Alt+[");
|
||||
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId });
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}$`));
|
||||
}
|
||||
|
||||
test("previous and next traverse every visible workspace while digit shortcuts stop at nine", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const project = await seedWorkspace({
|
||||
repoPrefix: "relative-navigation-",
|
||||
title: "Workspace 01",
|
||||
});
|
||||
try {
|
||||
for (let index = 2; index <= 12; index += 1) {
|
||||
const created = await project.client.createWorkspace({
|
||||
source: { kind: "directory", path: project.repoPath },
|
||||
title: `Workspace ${String(index).padStart(2, "0")}`,
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
}
|
||||
await page.setViewportSize({ width: 1440, height: 1100 });
|
||||
await gotoAppShell(page);
|
||||
const order = await readVisibleWorkspaceOrder(page);
|
||||
|
||||
await test.step("next crosses the ninth workspace and previous returns to it", async () => {
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[8] });
|
||||
await navigateWorkspace(page, "next", order[9]);
|
||||
await navigateWorkspace(page, "previous", order[8]);
|
||||
});
|
||||
|
||||
await test.step("wrapping uses the true first and last visible workspace", async () => {
|
||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
|
||||
await navigateWorkspace(page, "previous", order[11]);
|
||||
await navigateWorkspace(page, "next", order[0]);
|
||||
});
|
||||
|
||||
await test.step("the ninth digit shortcut still selects the ninth workspace", async () => {
|
||||
await page.keyboard.press("Alt+9");
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: order[8],
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
# Native question option preview
|
||||
|
||||
`native-question-option-preview.android.ad` checks that every line of a long option preview is reachable on Android. Before replay, open a mock agent prompted with `emit synthetic questions` so its question card shows the first question, on a phone-sized emulator (validated on a 1080×1920 API 35 AVD). The flow opens the `rollout` question, drags slowly up from inside the "Behind feature flag" preview, and asserts the preview's last line is visible.
|
||||
|
||||
```sh
|
||||
agent-device replay packages/app/e2e/mobile/agent-device/native-question-option-preview.android.ad --platform android --serial emulator-5554
|
||||
```
|
||||
@@ -1,10 +0,0 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "label=\"rollout\"" 20000
|
||||
press "label=\"rollout\""
|
||||
wait "id=\"question-form-option-preview\"" 10000
|
||||
gesture pan 570 1120 0 -440 600
|
||||
is visible "text=\"// LAST PREVIEW LINE\""
|
||||
close
|
||||
@@ -1,43 +0,0 @@
|
||||
// Start on Settings > Host > Pair device with relay enabled in the Android app.
|
||||
// node packages/app/e2e/mobile/pair-device/android.mjs <agent-device-session>
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const [, , session] = process.argv;
|
||||
assert(session, "Pass the agent-device session for the connected Android app");
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
ad(
|
||||
"press",
|
||||
'label="Pair a device, Scan a QR code or copy a link to connect your phone to this host"',
|
||||
"--settle",
|
||||
);
|
||||
ad("wait", "text", "Pairing QR code", "10000");
|
||||
const { data } = JSON.parse(ad("snapshot", "-i", "--json"));
|
||||
const sheet = data.nodes.find((node) => node.label === "Bottom Sheet");
|
||||
const qr = data.nodes.find((node) => node.label === "Pairing QR code");
|
||||
assert(sheet?.rect, "Pairing sheet must be visible");
|
||||
assert(qr?.rect, "Pairing QR code must be visible");
|
||||
assert(qr.rect.x > sheet.rect.x, "QR must have an unclipped left margin inside the sheet");
|
||||
assert(
|
||||
qr.rect.x + qr.rect.width < sheet.rect.x + sheet.rect.width,
|
||||
"QR must have an unclipped right margin inside the sheet",
|
||||
);
|
||||
assert(Math.abs(qr.rect.width - qr.rect.height) <= 1, "Complete QR must remain square");
|
||||
assert(qr.rect.y >= sheet.rect.y, "QR top must be visible inside the sheet");
|
||||
assert(
|
||||
qr.rect.y + qr.rect.height <= sheet.rect.y + sheet.rect.height,
|
||||
"QR bottom must be visible inside the sheet",
|
||||
);
|
||||
assert(
|
||||
data.nodes.some((node) => node.label === "Copy" && node.enabled),
|
||||
"Copy must remain available",
|
||||
);
|
||||
console.log("PASS: complete square QR fits inside the pairing sheet", qr.rect);
|
||||
ad("press", 'label="Close"', "--settle");
|
||||
@@ -186,8 +186,9 @@ export async function createCreationScenario(page: Page) {
|
||||
await requests.settled();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("assistant-message")
|
||||
.filter({ hasText: "[System Error] Requested mock prompt rejection", visible: true }),
|
||||
.getByTestId("timeline-notification")
|
||||
.filter({ hasText: "Requested mock prompt rejection" })
|
||||
.filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
@@ -203,11 +204,15 @@ export async function createCreationScenario(page: Page) {
|
||||
async expectPromptBeforeError(prompt: string) {
|
||||
const messages = page
|
||||
.getByTestId("user-message")
|
||||
.or(page.getByTestId("assistant-message"))
|
||||
.or(
|
||||
page
|
||||
.getByTestId("timeline-notification")
|
||||
.filter({ hasText: "Requested mock prompt rejection" }),
|
||||
)
|
||||
.filter({ visible: true });
|
||||
await expect(messages).toHaveCount(2);
|
||||
await expect(messages.nth(0)).toContainText(prompt);
|
||||
await expect(messages.nth(1)).toContainText("[System Error]");
|
||||
await expect(messages.nth(1)).toHaveText("Requested mock prompt rejection");
|
||||
await expect(messages.nth(1)).toBeInViewport();
|
||||
},
|
||||
async reloadAgent() {
|
||||
@@ -217,10 +222,7 @@ export async function createCreationScenario(page: Page) {
|
||||
},
|
||||
async expectAssistantReply() {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("assistant-message")
|
||||
.filter({ visible: true, hasNotText: "[System Error]" })
|
||||
.first(),
|
||||
page.getByTestId("assistant-message").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
},
|
||||
async expectStartupFailure() {
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { openSettingsHostSection } from "./settings";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export async function openCustomModels(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await page.getByRole("button", { name: "Claude provider details", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function addCustomModel(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add model", exact: true }).click();
|
||||
await page.getByPlaceholder("e.g. openai/gpt-5").fill(id);
|
||||
await page
|
||||
.getByTestId("add-custom-model-sheet")
|
||||
.getByRole("button", { name: "Add", exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
|
||||
}
|
||||
|
||||
export async function removeCustomModel(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
|
||||
await expect(remove).toBeInViewport({ ratio: 1 });
|
||||
await remove.click();
|
||||
await expect(remove).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectCustomModelRetained(page: Page, id: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search models" }).fill(id);
|
||||
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Download, type Page } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { openFilesPanel } from "./workspace-tabs";
|
||||
|
||||
function fileExplorerTree(page: Page) {
|
||||
@@ -39,10 +39,3 @@ export async function expectFileTabOpen(page: Page, filePath: string): Promise<v
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadFileFromExplorer(page: Page, fileName: string): Promise<Download> {
|
||||
await fileExplorerEntry(page, fileName).click({ button: "right" });
|
||||
const downloaded = page.waitForEvent("download");
|
||||
await page.getByRole("menuitem", { name: "Download", exact: true }).click();
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
@@ -220,24 +220,3 @@ export function expectDaemonPidUnchanged(
|
||||
expect(before).toBeDefined();
|
||||
expect(after).toBe(before);
|
||||
}
|
||||
|
||||
export async function expectCompletePairingQr(page: Page): Promise<void> {
|
||||
const qr = page.getByRole("img", { name: "Pairing QR code" });
|
||||
await qr.scrollIntoViewIfNeeded();
|
||||
// IntersectionObserver rounds fractional sheet/scroll coordinates.
|
||||
await expect(qr).toBeInViewport({ ratio: 0.999 });
|
||||
const bounds = await qr.evaluate((element) => {
|
||||
const tile = element.parentElement;
|
||||
if (!tile) throw new Error("Pairing QR tile is missing");
|
||||
const qrRect = element.getBoundingClientRect();
|
||||
const tileRect = tile.getBoundingClientRect();
|
||||
return {
|
||||
qr: { x: qrRect.x, y: qrRect.y, right: qrRect.right, bottom: qrRect.bottom },
|
||||
tile: { x: tileRect.x, y: tileRect.y, right: tileRect.right, bottom: tileRect.bottom },
|
||||
};
|
||||
});
|
||||
expect(bounds.qr.x).toBeGreaterThanOrEqual(bounds.tile.x);
|
||||
expect(bounds.qr.y).toBeGreaterThanOrEqual(bounds.tile.y);
|
||||
expect(bounds.qr.right).toBeLessThanOrEqual(bounds.tile.right);
|
||||
expect(bounds.qr.bottom).toBeLessThanOrEqual(bounds.tile.bottom);
|
||||
}
|
||||
|
||||
@@ -43,15 +43,6 @@ export async function expectQuestionOptionSelected(page: Page, option: string):
|
||||
await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true");
|
||||
}
|
||||
|
||||
export async function expectQuestionOptionPreview(
|
||||
page: Page,
|
||||
input: { option: string; preview: string },
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
questionOption(page, input.option).getByTestId("question-form-option-preview"),
|
||||
).toContainText(input.preview);
|
||||
}
|
||||
|
||||
export async function openQuestion(
|
||||
page: Page,
|
||||
input: { index: number; total: number },
|
||||
|
||||
@@ -49,37 +49,31 @@ export async function seedParentWithSubagent(
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const child = await seedSubagent(workspace, { parentId: parent.id, title: input.childTitle });
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
parent: {
|
||||
id: parent.id,
|
||||
title: input.parentTitle,
|
||||
},
|
||||
child,
|
||||
child: {
|
||||
id: child.id,
|
||||
title: input.childTitle,
|
||||
},
|
||||
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 },
|
||||
@@ -147,12 +141,6 @@ 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,
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -29,15 +29,4 @@ describe("formatThinkingOptionLabel", () => {
|
||||
expect(formatThinkingOptionLabel({ id: "think-hard", label: "think-hard" })).toBe("Think hard");
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh" })).toBe("Extra high");
|
||||
});
|
||||
|
||||
it("preserves provider-supplied xhigh labels with extra detail", () => {
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh (56 | 8m)" })).toBe(
|
||||
"X high (56 | 8m)",
|
||||
);
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh (45 | 6m)" })).toBe(
|
||||
"X high (45 | 6m)",
|
||||
);
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh" })).toBe("Extra high");
|
||||
expect(formatThinkingOptionLabel({ id: "xhigh", label: "Extra high" })).toBe("Extra high");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,9 +34,10 @@ export function formatAgentModeLabel(mode: ControlLabelInput): string {
|
||||
|
||||
export function formatThinkingOptionLabel(option: ControlLabelInput): string {
|
||||
const rawLabel = (option.label ?? option.id).trim();
|
||||
const compactId = option.id.replace(/[\s_-]+/g, "").toLowerCase();
|
||||
const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase();
|
||||
|
||||
if (compactLabel === "xhigh" || compactLabel === "extrahigh") {
|
||||
if (compactId === "xhigh" || compactLabel === "xhigh") {
|
||||
return i18n.t("agentControls.thinking.extraHigh");
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean })
|
||||
isTurnActive: input.isTurnActive,
|
||||
history: strategy.orderTail([userMessage("user"), assistant]),
|
||||
liveHead: [],
|
||||
timingByAssistantId: timingFor(assistant.id),
|
||||
timingByResponseItemId: timingFor(assistant.id),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -126,7 +126,7 @@ describe("turn footer spacing", () => {
|
||||
isTurnActive,
|
||||
history: strategy.orderTail([userMessage("user"), first]),
|
||||
liveHead: strategy.orderHead([last]),
|
||||
timingByAssistantId: timingFor(last.id),
|
||||
timingByResponseItemId: timingFor(last.id),
|
||||
});
|
||||
const firstLayout = layout.history.find((item) => item.item.id === first.id);
|
||||
const lastLayout = layout.liveHead.find((item) => item.item.id === last.id);
|
||||
|
||||
@@ -105,7 +105,7 @@ function layoutFor(input: {
|
||||
strategy,
|
||||
streamHead: input.head ?? [],
|
||||
}),
|
||||
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
|
||||
timingByResponseItemId: timingFor(...(input.timingIds ?? [])),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,6 +149,35 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
|
||||
}
|
||||
|
||||
describe("layoutStream", () => {
|
||||
it.each(["web", "android"] as const)(
|
||||
"hosts failed response footers on the error row on %s",
|
||||
(platform) => {
|
||||
const error: StreamItem = {
|
||||
kind: "notification",
|
||||
sourceType: "error",
|
||||
level: "error",
|
||||
id: "error-1",
|
||||
message: "Provider failed",
|
||||
timestamp: timestamp(2),
|
||||
timelineCursor: { epoch: "timeline-1", seq: 42 },
|
||||
};
|
||||
const user = userMessage("user-1", 1);
|
||||
const latest = layoutFor({ platform, tail: [user, error] });
|
||||
expect(latest.auxiliaryTurnFooter?.itemId).toBe(error.id);
|
||||
const completed = layoutFor({
|
||||
platform,
|
||||
tail: [user, error, userMessage("user-2", 3)],
|
||||
isTurnActive: true,
|
||||
});
|
||||
expect(findLayoutItem(completed, error.id).completedFooter?.itemId).toBe(error.id);
|
||||
const afterReply = layoutFor({
|
||||
platform,
|
||||
tail: [user, assistantMessage("assistant-1", 2), error],
|
||||
});
|
||||
expect(afterReply.auxiliaryTurnFooter?.itemId).toBe(error.id);
|
||||
},
|
||||
);
|
||||
|
||||
it("places one response footer after an adjacent tagged tool-only turn", () => {
|
||||
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
|
||||
const nextTool = toolCall("next-tool", 2, "turn-2");
|
||||
@@ -172,7 +201,7 @@ describe("layoutStream", () => {
|
||||
isTurnActive: true,
|
||||
history,
|
||||
liveHead,
|
||||
timingByAssistantId: timingFor(priorAssistant.id),
|
||||
timingByResponseItemId: timingFor(priorAssistant.id),
|
||||
});
|
||||
|
||||
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
|
||||
@@ -181,7 +210,7 @@ describe("layoutStream", () => {
|
||||
isTurnActive: true,
|
||||
history,
|
||||
liveHead,
|
||||
timingByAssistantId: timingFor(priorAssistant.id),
|
||||
timingByResponseItemId: timingFor(priorAssistant.id),
|
||||
});
|
||||
|
||||
expect(footerOwners(first)).toEqual([]);
|
||||
|
||||
@@ -39,13 +39,13 @@ export interface StreamLayoutInput {
|
||||
isTurnActive: boolean;
|
||||
history: StreamItem[];
|
||||
liveHead: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
timingByResponseItemId: Map<string, TurnTiming>;
|
||||
}
|
||||
|
||||
interface LayoutSegmentInput {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
timingByResponseItemId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
hasAuxiliaryFooter: boolean;
|
||||
frameOrder: StreamFrameChildOrder;
|
||||
@@ -57,8 +57,8 @@ interface LayoutSegmentInput {
|
||||
phase: "streaming" | "complete";
|
||||
}
|
||||
|
||||
interface AssistantFooterSource {
|
||||
item: Extract<StreamItem, { kind: "assistant_message" }>;
|
||||
interface ResponseFooterSource {
|
||||
item: Extract<StreamItem, { kind: "assistant_message" | "notification" }>;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
}
|
||||
@@ -67,23 +67,23 @@ function createTurnFooterHost(input: {
|
||||
item: StreamItem;
|
||||
items: StreamItem[];
|
||||
index: number;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
timingByResponseItemId: Map<string, TurnTiming>;
|
||||
}): TurnFooterHost {
|
||||
return {
|
||||
itemId: input.item.id,
|
||||
items: input.items,
|
||||
timing: input.timingByAssistantId.get(input.item.id),
|
||||
timing: input.timingByResponseItemId.get(input.item.id),
|
||||
startIndex: input.index,
|
||||
};
|
||||
}
|
||||
|
||||
function findLatestAssistantInResponse(input: {
|
||||
function findLatestFooterSourceInResponse(input: {
|
||||
strategy: StreamStrategy;
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
boundaryAboveItems?: StreamItem[] | null;
|
||||
boundaryAboveIndex?: number | null;
|
||||
}): AssistantFooterSource | null {
|
||||
}): ResponseFooterSource | null {
|
||||
let items = input.items;
|
||||
let index = input.startIndex;
|
||||
let canCrossBoundary = true;
|
||||
@@ -99,7 +99,10 @@ function findLatestAssistantInResponse(input: {
|
||||
if (!item || (laterItem && !continuesResponse(item, laterItem))) {
|
||||
return null;
|
||||
}
|
||||
if (item.kind === "assistant_message") {
|
||||
if (
|
||||
item.kind === "assistant_message" ||
|
||||
(item.kind === "notification" && item.sourceType === "error")
|
||||
) {
|
||||
return { item, items, index };
|
||||
}
|
||||
laterItem = item;
|
||||
@@ -131,20 +134,20 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
|
||||
return null;
|
||||
}
|
||||
|
||||
const assistant = findLatestAssistantInResponse({
|
||||
const source = findLatestFooterSourceInResponse({
|
||||
strategy: input.strategy,
|
||||
items: footerItems,
|
||||
startIndex: latestIndex,
|
||||
});
|
||||
if (!assistant) {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createTurnFooterHost({
|
||||
item: assistant.item,
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
item: source.item,
|
||||
items: source.items,
|
||||
index: source.index,
|
||||
timingByResponseItemId: input.timingByResponseItemId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,7 +157,7 @@ function resolveCompletedFooter(input: {
|
||||
index: number;
|
||||
item: StreamItem;
|
||||
belowItem: StreamItem | null;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
timingByResponseItemId: Map<string, TurnTiming>;
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
boundaryAboveItems: StreamItem[] | null;
|
||||
boundaryAboveIndex: number | null;
|
||||
@@ -163,21 +166,21 @@ function resolveCompletedFooter(input: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assistant = findLatestAssistantInResponse({
|
||||
const source = findLatestFooterSourceInResponse({
|
||||
strategy: input.strategy,
|
||||
items: input.items,
|
||||
startIndex: input.index,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
});
|
||||
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
|
||||
if (!source || input.auxiliaryTurnFooter?.itemId === source.item.id) {
|
||||
return null;
|
||||
}
|
||||
return createTurnFooterHost({
|
||||
item: assistant.item,
|
||||
items: assistant.items,
|
||||
index: assistant.index,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
item: source.item,
|
||||
items: source.items,
|
||||
index: source.index,
|
||||
timingByResponseItemId: input.timingByResponseItemId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -306,7 +309,7 @@ function layoutSegmentItem(
|
||||
index,
|
||||
item,
|
||||
belowItem,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
timingByResponseItemId: input.timingByResponseItemId,
|
||||
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
|
||||
boundaryAboveItems: input.boundaryAboveItems,
|
||||
boundaryAboveIndex: input.boundaryAboveIndex,
|
||||
@@ -377,7 +380,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
history = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
items: input.history,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
timingByResponseItemId: input.timingByResponseItemId,
|
||||
auxiliaryTurnFooter,
|
||||
hasAuxiliaryFooter,
|
||||
frameOrder,
|
||||
@@ -397,7 +400,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
const liveHead = layoutSegment({
|
||||
strategy: input.strategy,
|
||||
items: input.liveHead,
|
||||
timingByAssistantId: input.timingByAssistantId,
|
||||
timingByResponseItemId: input.timingByResponseItemId,
|
||||
auxiliaryTurnFooter,
|
||||
hasAuxiliaryFooter,
|
||||
frameOrder,
|
||||
|
||||
@@ -69,8 +69,8 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
historyStart: 2,
|
||||
});
|
||||
|
||||
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
|
||||
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
|
||||
expect(model.turnTiming.byResponseItemId.has("hidden-a")).toBe(false);
|
||||
expect(model.turnTiming.byResponseItemId.get("visible-a")).toEqual({
|
||||
completedAt: tail[3]?.timestamp,
|
||||
durationMs: 1000,
|
||||
});
|
||||
@@ -184,7 +184,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
});
|
||||
|
||||
expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp);
|
||||
expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false);
|
||||
expect(model.turnTiming.byResponseItemId.has("live-a")).toBe(false);
|
||||
});
|
||||
|
||||
it("maps completed turn timing to assistant ids across committed history and live head", () => {
|
||||
@@ -201,7 +201,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
});
|
||||
|
||||
expect(model.turnTiming.runningStartedAt).toBe(null);
|
||||
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
|
||||
expect(model.turnTiming.byResponseItemId.get("live-a")).toEqual({
|
||||
completedAt: head[0]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
@@ -220,7 +220,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
});
|
||||
|
||||
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
|
||||
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
|
||||
expect(model.turnTiming.byResponseItemId.get("a1")).toEqual({
|
||||
completedAt: tail[1]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
@@ -238,6 +238,6 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(model.turnTiming.byAssistantId.size).toBe(0);
|
||||
expect(model.turnTiming.byResponseItemId.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,12 @@ function assistantMessage(
|
||||
describe("resolveAssistantTurnForkBoundary", () => {
|
||||
it("forks a failed assistant turn from its Paseo timeline cursor without a provider message id", () => {
|
||||
const failedTurn = {
|
||||
...assistantMessage("assistant-error", 2),
|
||||
kind: "notification" as const,
|
||||
sourceType: "error" as const,
|
||||
level: "error" as const,
|
||||
message: "Provider failed",
|
||||
id: "error-1",
|
||||
timestamp: timestamp(2),
|
||||
timelineCursor: { epoch: "timeline-1", seq: 42 },
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@ export function resolveAssistantTurnForkBoundary(input: {
|
||||
supportsTimelineCursor: boolean;
|
||||
}): AssistantTurnForkBoundary | undefined {
|
||||
const item = input.items[input.startIndex];
|
||||
if (item?.kind === "notification" && item.sourceType === "error") {
|
||||
return input.supportsTimelineCursor && item.timelineCursor
|
||||
? { boundaryCursor: item.timelineCursor }
|
||||
: undefined;
|
||||
}
|
||||
if (item?.kind !== "assistant_message") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ function layoutFor(items: StreamItem[], isTurnActive: boolean) {
|
||||
isTurnActive,
|
||||
history: model.segments.historyMounted,
|
||||
liveHead: model.segments.liveHead,
|
||||
timingByAssistantId: model.turnTiming.byAssistantId,
|
||||
timingByResponseItemId: model.turnTiming.byResponseItemId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -123,7 +123,7 @@ describe("canonical turn membership", () => {
|
||||
const completedItems = [...build(turnId), assistant("done", 9, turnId)];
|
||||
const completed = layoutFor(completedItems, false);
|
||||
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
|
||||
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
|
||||
expect(completed.model.turnTiming.byResponseItemId.get("done")).toMatchObject({
|
||||
completedAt: at(9),
|
||||
durationMs: 8000,
|
||||
});
|
||||
|
||||
@@ -593,12 +593,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
isTurnActive,
|
||||
history: baseRenderModel.history,
|
||||
liveHead: baseRenderModel.segments.liveHead,
|
||||
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
|
||||
timingByResponseItemId: baseRenderModel.turnTiming.byResponseItemId,
|
||||
}),
|
||||
[
|
||||
baseRenderModel.history,
|
||||
baseRenderModel.segments.liveHead,
|
||||
baseRenderModel.turnTiming.byAssistantId,
|
||||
baseRenderModel.turnTiming.byResponseItemId,
|
||||
isTurnActive,
|
||||
streamRenderStrategy,
|
||||
],
|
||||
|
||||
@@ -116,7 +116,6 @@ 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 {
|
||||
@@ -550,7 +549,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
}
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
<WorkspaceLayoutModePublisher />
|
||||
{!isCompactLayout ? (
|
||||
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
|
||||
{themedSidebarChrome}
|
||||
|
||||
@@ -2161,7 +2161,7 @@ export const Notification = memo(function Notification({
|
||||
[resolvedDisableOuterSpacing, config.bg],
|
||||
);
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={containerStyle} testID="timeline-notification">
|
||||
<View style={notificationStylesheet.content}>
|
||||
<View style={notificationStylesheet.row}>
|
||||
<View style={notificationStylesheet.iconContainer}>
|
||||
|
||||
@@ -95,11 +95,11 @@ function CustomModelRow({
|
||||
|
||||
return (
|
||||
<View style={sheetStyles.modelRow}>
|
||||
<Text style={[sheetStyles.modelTitle, sheetStyles.customModelText]} numberOfLines={1}>
|
||||
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={[sheetStyles.monoHint, sheetStyles.customModelText]}
|
||||
style={sheetStyles.monoHint}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
@@ -762,7 +762,6 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
iconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
flexShrink: 0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -808,10 +807,6 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
flexShrink: 0,
|
||||
},
|
||||
customModelText: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
modelRowFiller: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@@ -147,29 +147,4 @@ describe("question form card core", () => {
|
||||
Response: "custom",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps option previews so options can be compared", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Which layout?",
|
||||
header: "Layout",
|
||||
options: [
|
||||
{
|
||||
label: "Tabs",
|
||||
description: "One tab per section",
|
||||
preview: "<Tabs>\n <Tab />\n</Tabs>",
|
||||
},
|
||||
{ label: "Single page" },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(questions?.[0]?.options).toEqual([
|
||||
{ label: "Tabs", description: "One tab per section", preview: "<Tabs>\n <Tab />\n</Tabs>" },
|
||||
{ label: "Single page", description: undefined, preview: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export interface QuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export interface QuestionFormQuestion {
|
||||
@@ -47,7 +46,6 @@ export function parseQuestionFormQuestions(input: unknown): QuestionFormQuestion
|
||||
options.push({
|
||||
label: o.label,
|
||||
description: typeof o.description === "string" ? o.description : undefined,
|
||||
preview: readOptionalString(o, "preview"),
|
||||
});
|
||||
}
|
||||
questions.push({
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
|
||||
import { ScrollableCodeSurface } from "@/components/ui/scrollable-code-surface";
|
||||
import {
|
||||
areQuestionsAnswered,
|
||||
buildQuestionFormAnswers,
|
||||
@@ -30,9 +29,6 @@ interface QuestionFormCardProps {
|
||||
}
|
||||
|
||||
const IS_WEB = isWeb;
|
||||
// Native previews are uncapped: inside the option's Pressable, Android never hands a
|
||||
// drag to the preview's nested scroll, so lines past a cap would be unreachable.
|
||||
const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined;
|
||||
|
||||
function getQuestionInputPlaceholder({
|
||||
question,
|
||||
@@ -137,20 +133,6 @@ function QuestionOptionRow({
|
||||
{option.description ? (
|
||||
<Text style={optionDescriptionStyle}>{option.description}</Text>
|
||||
) : null}
|
||||
{option.preview ? (
|
||||
// Shown for every option at once so they can be compared before picking.
|
||||
// Long lines wrap, as in Claude Code, since most previews are prose.
|
||||
<ScrollableCodeSurface
|
||||
tone="surface0"
|
||||
maxHeight={OPTION_PREVIEW_MAX_HEIGHT}
|
||||
horizontal={false}
|
||||
selectable={false}
|
||||
textStyle={styles.optionPreviewText}
|
||||
testID="question-form-option-preview"
|
||||
>
|
||||
{option.preview}
|
||||
</ScrollableCodeSurface>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -722,10 +704,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 20,
|
||||
},
|
||||
optionPreviewText: {
|
||||
lineHeight: 20,
|
||||
...(isWeb ? { whiteSpace: "pre-wrap", overflowWrap: "break-word" } : null),
|
||||
},
|
||||
selectionControl: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
|
||||
@@ -125,7 +125,8 @@ function buildCreateAgentOptions({
|
||||
provider,
|
||||
}: {
|
||||
composerState: {
|
||||
effectiveModeId: string;
|
||||
modeOptions: { id: string }[];
|
||||
selectedMode: string;
|
||||
effectiveModelId: string | null;
|
||||
effectiveThinkingOptionId: string | null;
|
||||
};
|
||||
@@ -136,11 +137,20 @@ function buildCreateAgentOptions({
|
||||
workspaceId: string;
|
||||
provider: CreateAgentRequestOptions["provider"];
|
||||
}): CreateAgentRequestOptions {
|
||||
// Reconcile the selected mode against the discovered modes. The mode picker
|
||||
// shows modeOptions[0] when the stored mode isn't in the list (e.g. a stale
|
||||
// globally-remembered mode this workspace's provider config no longer
|
||||
// defines), so the submitted mode must match that display rather than send a
|
||||
// stale mode the provider would reject.
|
||||
const modeOptionIds = composerState.modeOptions.map((mode) => mode.id);
|
||||
const reconciledMode = modeOptionIds.includes(composerState.selectedMode)
|
||||
? composerState.selectedMode
|
||||
: (modeOptionIds[0] ?? "");
|
||||
return {
|
||||
provider,
|
||||
cwd: workspaceDirectory,
|
||||
workspaceId,
|
||||
...(composerState.effectiveModeId ? { modeId: composerState.effectiveModeId } : {}),
|
||||
...(reconciledMode !== "" ? { modeId: reconciledMode } : {}),
|
||||
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
|
||||
...(composerState.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from "@/composer/draft/input-draft-core";
|
||||
import {
|
||||
buildDraftCommandTarget,
|
||||
resolveEffectiveComposerModeId,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
type ProviderSelectionState,
|
||||
@@ -47,7 +46,6 @@ interface UseAgentInputDraftInput {
|
||||
|
||||
type DraftComposerState = UseAgentFormStateResult & {
|
||||
workingDir: string;
|
||||
effectiveModeId: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
@@ -258,11 +256,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
],
|
||||
);
|
||||
|
||||
const effectiveModeId = useMemo(
|
||||
() => resolveEffectiveComposerModeId(providerSelection, formState.isModelLoading),
|
||||
[formState.isModelLoading, providerSelection],
|
||||
);
|
||||
|
||||
const effectiveModelId = useMemo(
|
||||
() => resolveEffectiveComposerModelId(providerSelection),
|
||||
[providerSelection],
|
||||
@@ -322,7 +315,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
return {
|
||||
...formState,
|
||||
workingDir,
|
||||
effectiveModeId,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
featureValues: draftFeatureValues,
|
||||
@@ -337,7 +329,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
}, [
|
||||
commandDraft,
|
||||
composerOptions,
|
||||
effectiveModeId,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
draftFeatures,
|
||||
@@ -364,7 +355,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
|
||||
export const __private__ = {
|
||||
resolveDraftKey,
|
||||
resolveEffectiveComposerModeId,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
buildDraftCommandTarget,
|
||||
|
||||
@@ -86,30 +86,46 @@ function resolveAutoSubmitConfig(
|
||||
};
|
||||
}
|
||||
|
||||
// Reconcile the form's selected mode against the currently discovered modes.
|
||||
// The mode picker displays modeOptions[0] when the stored mode isn't in the
|
||||
// list (e.g. a globally-remembered "plan" that this workspace's OpenCode config
|
||||
// no longer defines), so the submitted mode must match that display — otherwise
|
||||
// we'd send a stale mode the provider rejects while the UI showed a valid one.
|
||||
function reconcileSelectedMode(modeOptionIds: readonly string[], selectedMode: string): string {
|
||||
if (modeOptionIds.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return modeOptionIds.includes(selectedMode) ? selectedMode : (modeOptionIds[0] ?? "");
|
||||
}
|
||||
|
||||
function resolveDraftModeIdOverride(input: {
|
||||
autoSubmitConfig: AutoSubmitConfig | null;
|
||||
effectiveModeId: string;
|
||||
modeOptionIds: readonly string[];
|
||||
selectedMode: string;
|
||||
}): { modeId: string } | Record<string, never> {
|
||||
const { autoSubmitConfig, effectiveModeId } = input;
|
||||
const { autoSubmitConfig, modeOptionIds, selectedMode } = input;
|
||||
if (autoSubmitConfig?.modeId) {
|
||||
return { modeId: autoSubmitConfig.modeId };
|
||||
}
|
||||
if (effectiveModeId !== "") {
|
||||
return { modeId: effectiveModeId };
|
||||
const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode);
|
||||
if (reconciled !== "") {
|
||||
return { modeId: reconciled };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function resolveDraftModeId(input: {
|
||||
autoSubmitConfig: AutoSubmitConfig | null;
|
||||
effectiveModeId: string;
|
||||
modeOptionIds: readonly string[];
|
||||
selectedMode: string;
|
||||
}): string | null {
|
||||
const { autoSubmitConfig, effectiveModeId } = input;
|
||||
const { autoSubmitConfig, modeOptionIds, selectedMode } = input;
|
||||
if (autoSubmitConfig?.modeId !== undefined) {
|
||||
return autoSubmitConfig.modeId;
|
||||
}
|
||||
if (effectiveModeId !== "") {
|
||||
return effectiveModeId;
|
||||
const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode);
|
||||
if (reconciled !== "") {
|
||||
return reconciled;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -127,7 +143,8 @@ async function submitDraftCreateRequest(input: {
|
||||
autoSubmitConfig: AutoSubmitConfig | null;
|
||||
composerState: {
|
||||
selectedProvider: string | null;
|
||||
effectiveModeId: string;
|
||||
selectedMode: string;
|
||||
modeOptions: readonly { id: string }[];
|
||||
effectiveModelId: string | null;
|
||||
effectiveThinkingOptionId: string | null;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
@@ -160,7 +177,8 @@ async function submitDraftCreateRequest(input: {
|
||||
}
|
||||
const modeIdOverride = resolveDraftModeIdOverride({
|
||||
autoSubmitConfig,
|
||||
effectiveModeId: composerState.effectiveModeId,
|
||||
modeOptionIds: composerState.modeOptions.map((mode) => mode.id),
|
||||
selectedMode: composerState.selectedMode,
|
||||
});
|
||||
const config = buildWorkspaceDraftAgentConfig({
|
||||
provider,
|
||||
@@ -201,7 +219,8 @@ function buildDraftAgentSnapshot(input: {
|
||||
composerState: {
|
||||
effectiveModelId: string | null;
|
||||
effectiveThinkingOptionId: string | null;
|
||||
effectiveModeId: string;
|
||||
modeOptions: readonly { id: string }[];
|
||||
selectedMode: string;
|
||||
selectedProvider: string | null;
|
||||
agentControls: { features?: Agent["features"] };
|
||||
};
|
||||
@@ -215,7 +234,8 @@ function buildDraftAgentSnapshot(input: {
|
||||
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || null);
|
||||
const modeId = resolveDraftModeId({
|
||||
autoSubmitConfig,
|
||||
effectiveModeId: composerState.effectiveModeId,
|
||||
modeOptionIds: composerState.modeOptions.map((mode) => mode.id),
|
||||
selectedMode: composerState.selectedMode,
|
||||
});
|
||||
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
|
||||
if (!provider) {
|
||||
|
||||
@@ -80,6 +80,7 @@ export function PairDeviceSection({ serverId, onClose }: PairDeviceSectionProps)
|
||||
type: "svg",
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
width: 480,
|
||||
}),
|
||||
enabled: Boolean(pairingQuery.data?.url),
|
||||
dataShape: "value",
|
||||
@@ -274,8 +275,7 @@ function PairingQr({ svg, isError }: { svg: string | null; isError: boolean }) {
|
||||
return (
|
||||
<SvgXml
|
||||
xml={svg}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={styles.qrImage}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={t("pairing.device.qrAccessibility")}
|
||||
/>
|
||||
@@ -365,6 +365,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.palette.white,
|
||||
},
|
||||
qrImage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
linkRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -165,7 +165,4 @@ const FRAME_STYLE = {
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
} 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;
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useDownloadStore } from "@/stores/download-store";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
|
||||
@@ -12,9 +12,8 @@ interface UseFileDownloadParams {
|
||||
/**
|
||||
* Returns a stable callback that downloads a single workspace file by its
|
||||
* workspace-relative path. Shared by the file explorer tree and the git diff
|
||||
* pane so both surfaces download through the same download-store pipeline instead
|
||||
* of duplicating the plumbing: the HTTP token download over a direct TCP connection,
|
||||
* the session socket over anything else.
|
||||
* pane so both surfaces download through the same host token + download-store
|
||||
* pipeline instead of duplicating the plumbing.
|
||||
*/
|
||||
export function useFileDownload({
|
||||
serverId,
|
||||
@@ -31,7 +30,7 @@ export function useFileDownload({
|
||||
() => workspaceId?.trim() || normalizedWorkspaceRoot,
|
||||
[normalizedWorkspaceRoot, workspaceId],
|
||||
);
|
||||
const { requestFileDownloadToken, requestFileBytes } = useFileExplorerActions({
|
||||
const { requestFileDownloadToken } = useFileExplorerActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
@@ -49,18 +48,9 @@ export function useFileDownload({
|
||||
fileName,
|
||||
path,
|
||||
daemonProfile,
|
||||
activeConnectionId: getHostRuntimeStore().getSnapshot(serverId)?.activeConnectionId ?? null,
|
||||
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
|
||||
readFile: requestFileBytes,
|
||||
});
|
||||
},
|
||||
[
|
||||
daemonProfile,
|
||||
requestFileBytes,
|
||||
requestFileDownloadToken,
|
||||
serverId,
|
||||
startDownload,
|
||||
workspaceScopeId,
|
||||
],
|
||||
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -253,21 +253,6 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
[client, normalizedWorkspaceRoot, t],
|
||||
);
|
||||
|
||||
const requestFileBytes = useCallback(
|
||||
async (path: string, maxBytes: number) => {
|
||||
if (!normalizedWorkspaceRoot) {
|
||||
throw new Error(t("workspace.fileExplorer.states.unavailable"));
|
||||
}
|
||||
if (!client) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
// No request timeout: a large file over a slow relay legitimately takes longer than the
|
||||
// 60 s default, and a dead connection still rejects on disconnect.
|
||||
return client.readFile(normalizedWorkspaceRoot, path, undefined, maxBytes, 0);
|
||||
},
|
||||
[client, normalizedWorkspaceRoot, t],
|
||||
);
|
||||
|
||||
const createEntry = useCallback(
|
||||
async (input: { parentPath: string; name: string; kind: "file" | "directory" }) => {
|
||||
if (!client || !normalizedWorkspaceRoot) {
|
||||
@@ -357,7 +342,6 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
requestFileDownloadToken,
|
||||
requestFileBytes,
|
||||
createEntry,
|
||||
renameEntry,
|
||||
duplicateEntry,
|
||||
|
||||
@@ -613,9 +613,6 @@ export const ar: TranslationResources = {
|
||||
updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.",
|
||||
unableToSubscribe: "غير قادر على الاشتراك في المحطة",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "فتح كعلامة تبويب",
|
||||
},
|
||||
tabs: {
|
||||
loading: "تحميل...",
|
||||
modified: "تغييرات غير محفوظة",
|
||||
|
||||
@@ -610,9 +610,6 @@ 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,9 +619,6 @@ 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,9 +618,6 @@ 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,9 +619,6 @@ export const ja: TranslationResources = {
|
||||
updateHost: "ネイティブターミナルを使用するにはホストを更新してください。",
|
||||
unableToSubscribe: "ターミナルに接続できません",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "タブで開く",
|
||||
},
|
||||
tabs: {
|
||||
loading: "読み込み中...",
|
||||
modified: "未保存の変更",
|
||||
|
||||
@@ -614,9 +614,6 @@ export const ko: TranslationResources = {
|
||||
updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.",
|
||||
unableToSubscribe: "터미널을 구독할 수 없습니다",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "탭으로 열기",
|
||||
},
|
||||
tabs: {
|
||||
loading: "불러오는 중...",
|
||||
modified: "저장되지 않은 변경사항",
|
||||
|
||||
@@ -618,9 +618,6 @@ 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,9 +620,6 @@ export const ru: TranslationResources = {
|
||||
updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.",
|
||||
unableToSubscribe: "Не удалось подключиться к терминалу",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "Открыть во вкладке",
|
||||
},
|
||||
tabs: {
|
||||
loading: "Загрузка...",
|
||||
modified: "Несохранённые изменения",
|
||||
|
||||
@@ -613,9 +613,6 @@ export const zhCN: TranslationResources = {
|
||||
updateHost: "请更新主机以使用原生终端渲染器。",
|
||||
unableToSubscribe: "无法订阅 Terminal",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "作为标签页打开",
|
||||
},
|
||||
tabs: {
|
||||
loading: "正在加载...",
|
||||
modified: "未保存的更改",
|
||||
|
||||
@@ -1027,71 +1027,6 @@ describe("unassigned shortcuts", () => {
|
||||
});
|
||||
|
||||
describe("matching", () => {
|
||||
it("dispatches a captured Russian letter shortcut on the same key", () => {
|
||||
const event = keyboardInput({ key: "и", code: "KeyB", ctrlKey: true });
|
||||
const combo = keyboardEventToComboString(event as KeyboardEvent);
|
||||
expect(combo).toBe("Ctrl+B");
|
||||
const bindings = buildEffectiveBindings({
|
||||
"sidebar-toggle-left-ctrl-period-non-mac": combo!,
|
||||
});
|
||||
|
||||
expect(resolveShortcut({ event, context: desktopNonMac, bindings }).match?.action).toBe(
|
||||
"sidebar.toggle.left",
|
||||
);
|
||||
});
|
||||
|
||||
it("dispatches a recorded dotted-uppercase letter with Shift held", () => {
|
||||
const event = keyboardInput({ key: "İ", code: "KeyS", ctrlKey: true, shiftKey: true });
|
||||
const combo = keyboardEventToComboString(event as KeyboardEvent);
|
||||
expect(combo).toBe("Ctrl+Shift+S");
|
||||
const bindings = buildEffectiveBindings({
|
||||
"sidebar-toggle-left-ctrl-period-non-mac": combo!,
|
||||
});
|
||||
expect(resolveShortcut({ event, context: desktopNonMac, bindings }).match?.action).toBe(
|
||||
"sidebar.toggle.left",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["и", "И", "β", "ش", "ב", "é"])(
|
||||
"matches default and recorded letter bindings for unsupported letter %s",
|
||||
(key) => {
|
||||
const event = keyboardInput({ key, code: "KeyB", ctrlKey: true });
|
||||
expectShortcutResolution({
|
||||
event,
|
||||
context: desktopNonMac,
|
||||
action: "sidebar.toggle.left",
|
||||
});
|
||||
const combo = keyboardEventToComboString(event as KeyboardEvent);
|
||||
expect(combo).toBe("Ctrl+B");
|
||||
expect(
|
||||
resolveShortcut({
|
||||
event,
|
||||
context: desktopNonMac,
|
||||
bindings: buildEffectiveBindings({
|
||||
"sidebar-toggle-left-ctrl-period-non-mac": combo!,
|
||||
}),
|
||||
}).match?.action,
|
||||
).toBe("sidebar.toggle.left");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["v", "†", "Dead", "Unidentified", ""])(
|
||||
"does not match an unrelated character or named key %s by physical letter",
|
||||
(key) => {
|
||||
expectNoShortcutResolution({
|
||||
event: { key, code: "KeyB", ctrlKey: true },
|
||||
context: desktopNonMac,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not match non-letter bindings by physical key for unsupported letters", () => {
|
||||
expectNoShortcutResolution({
|
||||
event: { key: "ю", code: "Period", ctrlKey: true },
|
||||
context: desktopNonMac,
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches a captured Dvorak binding on the same logical key", () => {
|
||||
const event = keyboardInput({ key: "b", code: "KeyN", metaKey: true });
|
||||
const combo = keyboardEventToComboString(event as KeyboardEvent);
|
||||
|
||||
@@ -1285,12 +1285,6 @@ function matchesKeyOrCode(combo: KeyCombo, event: KeyboardShortcutInput): boolea
|
||||
if (combo.shift === true && combo.shiftedKey !== undefined && eventKey === combo.shiftedKey) {
|
||||
return true;
|
||||
}
|
||||
// Recording an unsupported letter (for example Cyrillic и on KeyB)
|
||||
// stores its physical A-Z key. Dispatch must accept that same key without
|
||||
// giving Latin layouts such as Dvorak a second physical-key binding.
|
||||
if (/^[a-z]$/.test(combo.key) && /^\p{Letter}$/u.test(event.key) && !/^[a-z]$/.test(eventKey)) {
|
||||
return event.code === combo.code;
|
||||
}
|
||||
// macOS rewrites event.key when Option is held (Option+T -> "†",
|
||||
// Option+[ -> "“"), so Alt-bound letter / bracket bindings can only
|
||||
// match by event.code. Stay key-first for non-Alt bindings so Dvorak
|
||||
|
||||
@@ -1153,8 +1153,6 @@ 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),
|
||||
@@ -1218,7 +1216,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
composerState,
|
||||
],
|
||||
);
|
||||
const composerSection = showsComposer ? (
|
||||
const composerSection = (
|
||||
<RenderProfile id={`AgentComposerSection:${agentId}`}>
|
||||
<AgentComposerSection
|
||||
agentId={agentId}
|
||||
@@ -1235,7 +1233,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 { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { supportsDesktopPaneSplits, 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, type WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { openPreferredWorkspaceTarget } from "@/workspace-tabs/open-beside";
|
||||
import { openComposerChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
|
||||
@@ -49,9 +49,10 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
onArchiveFinished: () => void;
|
||||
hasPluginComposerPills: boolean;
|
||||
}): ReactElement | null {
|
||||
const { tabId } = usePaneContext();
|
||||
const { tabId, openTab } = 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(
|
||||
@@ -59,36 +60,45 @@ 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;
|
||||
}
|
||||
openSubagentTarget({ kind: "agent", agentId: subagentId });
|
||||
if (canSplit && workspaceKey) {
|
||||
openPreferredWorkspaceTarget({
|
||||
isCompact,
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: subagentId },
|
||||
source: "subagents",
|
||||
preferences: openInSidePane,
|
||||
parentTabId: tabId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigateToAgent({ serverId, agentId: subagentId });
|
||||
},
|
||||
[openSubagentTarget, serverId, workspaceId],
|
||||
[canSplit, isCompact, openInSidePane, serverId, tabId, workspaceId, workspaceKey],
|
||||
);
|
||||
const handleOpenProviderSubagent = useCallback(
|
||||
(parentAgentId: string, subagentId: string) => {
|
||||
openSubagentTarget({ kind: "provider_subagent", parentAgentId, subagentId });
|
||||
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],
|
||||
[canSplit, isCompact, openInSidePane, openTab, tabId, workspaceKey],
|
||||
);
|
||||
const handleOpenChanges = useCallback(() => {
|
||||
if (!workspaceKey) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
|
||||
export interface PanelInstanceIdentity {
|
||||
serverId: string;
|
||||
@@ -19,20 +18,8 @@ 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 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;
|
||||
return `${identity.serverId}:${identity.workspaceId}:${identity.tabId}`;
|
||||
}
|
||||
|
||||
export function getPanelInstanceAttributes(
|
||||
|
||||
@@ -18,14 +18,6 @@ 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,38 +1,12 @@
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
/** 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 type PaneHost = "main" | "explorer";
|
||||
|
||||
export interface PanelManifest<K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"]> {
|
||||
kind: K;
|
||||
/** The sheet is never listed: a kind renders there exactly when it has a `compactSheet` rule. */
|
||||
supportedHosts: readonly Exclude<PaneHost, "sheet">[];
|
||||
supportedHosts: readonly PaneHost[];
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -46,7 +20,6 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: () => "new_tab",
|
||||
},
|
||||
draft: {
|
||||
@@ -54,7 +27,6 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: (target) => target.draftId,
|
||||
},
|
||||
agent: {
|
||||
@@ -62,8 +34,6 @@ 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: {
|
||||
@@ -71,7 +41,6 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: { openAsTab: false },
|
||||
resourceKey: (target) => `${target.parentAgentId}:${target.subagentId}`,
|
||||
},
|
||||
terminal: {
|
||||
@@ -79,7 +48,6 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: (target) => target.terminalId,
|
||||
},
|
||||
browser: {
|
||||
@@ -87,7 +55,6 @@ const manifests = {
|
||||
supportedHosts: ["main"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: (target) => target.browserId,
|
||||
},
|
||||
changes_tree: {
|
||||
@@ -95,7 +62,6 @@ const manifests = {
|
||||
supportedHosts: ["explorer"],
|
||||
showCloseButton: false,
|
||||
singleton: true,
|
||||
compactSheet: null,
|
||||
resourceKey: () => "changes_tree",
|
||||
},
|
||||
files: {
|
||||
@@ -103,7 +69,6 @@ const manifests = {
|
||||
supportedHosts: ["explorer"],
|
||||
showCloseButton: false,
|
||||
singleton: true,
|
||||
compactSheet: null,
|
||||
resourceKey: () => "files",
|
||||
},
|
||||
pull_request: {
|
||||
@@ -111,7 +76,6 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: () => "pull_request",
|
||||
},
|
||||
file: {
|
||||
@@ -119,7 +83,6 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: { openAsTab: false },
|
||||
resourceKey: (target) => target.path,
|
||||
},
|
||||
working_diff: {
|
||||
@@ -127,7 +90,6 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: () => "working_diff",
|
||||
},
|
||||
plugin: {
|
||||
@@ -136,7 +98,6 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: (target) =>
|
||||
target.context === "agent"
|
||||
? `${target.pluginId}:${target.panelId}:agent:${target.agentId}`
|
||||
@@ -147,7 +108,6 @@ const manifests = {
|
||||
supportedHosts: ["main"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: (target) => target.workspaceId,
|
||||
},
|
||||
commit_diff: {
|
||||
@@ -155,7 +115,6 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: (target) => target.sha,
|
||||
},
|
||||
} satisfies PanelManifestByKind;
|
||||
@@ -165,17 +124,7 @@ export function getPanelManifest<K extends WorkspaceTabTarget["kind"]>(kind: K):
|
||||
}
|
||||
|
||||
export function panelSupportsHost(kind: WorkspaceTabTarget["kind"], host: PaneHost): boolean {
|
||||
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;
|
||||
return getPanelManifest(kind).supportedHosts.includes(host);
|
||||
}
|
||||
|
||||
export function panelCanLaunchInPane(
|
||||
|
||||
@@ -50,7 +50,7 @@ export function panelTargetSupportsHost(
|
||||
target: WorkspaceTabTarget,
|
||||
host: PaneHost,
|
||||
): boolean {
|
||||
if (target.kind !== "plugin" || host === "sheet") return panelSupportsHost(target.kind, host);
|
||||
if (target.kind !== "plugin") 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;
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
buildSelectedTriggerLabel,
|
||||
filterAndRankModelRows,
|
||||
matchesModelSearch,
|
||||
resolveEffectiveComposerModeId,
|
||||
resolveSelectedModelLabel,
|
||||
resolveSubmissionReadiness,
|
||||
} from "./provider-selection";
|
||||
@@ -442,56 +441,3 @@ function getAllModelLabels(providers: ReturnType<typeof buildSelectableProviderS
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
describe("resolveEffectiveComposerModeId", () => {
|
||||
const selection = {
|
||||
provider: "pi",
|
||||
modelId: "",
|
||||
thinkingOptionId: "",
|
||||
availableModels: [],
|
||||
};
|
||||
|
||||
it("drops a remembered mode when the provider offers no modes", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerModeId({ ...selection, modeId: "build", modeOptions: [] }, false),
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("submits the mode the picker shows when the remembered mode is not offered", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerModeId(
|
||||
{
|
||||
...selection,
|
||||
modeId: "plan",
|
||||
modeOptions: [
|
||||
{ id: "build", label: "Build" },
|
||||
{ id: "ask", label: "Ask" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
),
|
||||
).toBe("build");
|
||||
});
|
||||
|
||||
it("keeps a selected mode the provider offers", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerModeId(
|
||||
{
|
||||
...selection,
|
||||
modeId: "ask",
|
||||
modeOptions: [
|
||||
{ id: "build", label: "Build" },
|
||||
{ id: "ask", label: "Ask" },
|
||||
],
|
||||
},
|
||||
false,
|
||||
),
|
||||
).toBe("ask");
|
||||
});
|
||||
|
||||
it("keeps the selected mode while the provider's modes are still loading", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerModeId({ ...selection, modeId: "build", modeOptions: [] }, true),
|
||||
).toBe("build");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,23 +272,6 @@ export function resolveEffectiveComposerModelId(selection: ProviderSelectionStat
|
||||
);
|
||||
}
|
||||
|
||||
// The mode picker shows the first offered mode when the selected one isn't
|
||||
// offered (e.g. a remembered mode the provider no longer advertises), so the
|
||||
// submitted mode must match that display. A provider with no modes gets none.
|
||||
// While the provider is still loading its modes aren't known yet, so the
|
||||
// selected mode is kept.
|
||||
export function resolveEffectiveComposerModeId(
|
||||
selection: ProviderSelectionState,
|
||||
isModelLoading: boolean,
|
||||
): string {
|
||||
const selectedModeId = selection.modeId.trim();
|
||||
if (isModelLoading) {
|
||||
return selectedModeId;
|
||||
}
|
||||
const modeIds = selection.modeOptions.map((mode) => mode.id);
|
||||
return modeIds.includes(selectedModeId) ? selectedModeId : (modeIds[0] ?? "");
|
||||
}
|
||||
|
||||
export function resolveEffectiveComposerThinkingOptionId(
|
||||
selection: ProviderSelectionState,
|
||||
effectiveModelId: string,
|
||||
|
||||
@@ -891,7 +891,7 @@ function buildWorkspaceDraftSetupFromComposer(input: {
|
||||
return {
|
||||
provider: input.provider,
|
||||
cwd: input.cwd,
|
||||
modeId: input.composerState.effectiveModeId || null,
|
||||
modeId: input.composerState.selectedMode || null,
|
||||
model: input.composerState.effectiveModelId || null,
|
||||
thinkingOptionId: input.composerState.effectiveThinkingOptionId || null,
|
||||
featureValues: input.composerState.featureValues ?? {},
|
||||
@@ -971,7 +971,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
|
||||
config: {
|
||||
provider,
|
||||
cwd,
|
||||
modeId: composerState.effectiveModeId || undefined,
|
||||
modeId: composerState.selectedMode || undefined,
|
||||
model: composerState.effectiveModelId || undefined,
|
||||
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
|
||||
featureValues: composerState.featureValues,
|
||||
@@ -1094,7 +1094,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
|
||||
return {
|
||||
cwd: workspaceDirectory,
|
||||
provider,
|
||||
modeId: composerState.effectiveModeId || null,
|
||||
modeId: composerState.selectedMode || null,
|
||||
model: composerState.effectiveModelId || null,
|
||||
thinkingOptionId: composerState.effectiveThinkingOptionId || null,
|
||||
featureValues: composerState.featureValues,
|
||||
|
||||
@@ -110,25 +110,3 @@ 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,8 +6,6 @@ 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[] {
|
||||
@@ -18,21 +16,20 @@ 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(targets.flatMap((target) => (target.kind === "agent" ? [target.agentId] : []))),
|
||||
...new Set(
|
||||
panes.flatMap((pane) => {
|
||||
const target = deriveWorkspacePaneState({ pane, tabs: input.tabs }).activeTab?.descriptor
|
||||
.target;
|
||||
return target?.kind === "agent" ? [target.agentId] : [];
|
||||
}),
|
||||
),
|
||||
].sort();
|
||||
}
|
||||
|
||||
export function useVisibleAgentIds(input: {
|
||||
layout: WorkspaceLayout | null;
|
||||
tabs: WorkspaceTab[];
|
||||
sheetTab: WorkspaceTab | null;
|
||||
routeFocused: boolean;
|
||||
focusedPaneOnly: boolean;
|
||||
}): string[] {
|
||||
|
||||
@@ -73,7 +73,6 @@ import {
|
||||
getFocusedBrowserId,
|
||||
FOCUSED_PANE_PLACEMENT,
|
||||
selectExplorerSidebarPaneId,
|
||||
selectPaneFocusSuspended,
|
||||
type WorkspaceLayout,
|
||||
type WorkspaceTabPlacement,
|
||||
useWorkspaceLayoutStore,
|
||||
@@ -161,8 +160,6 @@ 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";
|
||||
@@ -754,20 +751,22 @@ function WorkspaceContentProviders({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspacePanelProviders({
|
||||
function WorkspacePanelContent({
|
||||
launcher,
|
||||
children,
|
||||
content,
|
||||
tabIds,
|
||||
workspaceKey,
|
||||
}: {
|
||||
workspaceKey: string;
|
||||
launcher: NewTabLauncher;
|
||||
children: ReactNode;
|
||||
content: ReactNode;
|
||||
tabIds: readonly string[];
|
||||
}) {
|
||||
return (
|
||||
<WorkspaceComposerEditingSessionsProvider workspaceKey={workspaceKey} tabIds={tabIds}>
|
||||
<NewTabLauncherProvider value={launcher}>{children}</NewTabLauncherProvider>
|
||||
<NewTabLauncherProvider value={launcher}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
</NewTabLauncherProvider>
|
||||
</WorkspaceComposerEditingSessionsProvider>
|
||||
);
|
||||
}
|
||||
@@ -1268,9 +1267,6 @@ 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,
|
||||
@@ -1309,9 +1305,6 @@ 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);
|
||||
@@ -1384,7 +1377,6 @@ function WorkspaceScreenContent({
|
||||
const visibleAgentIds = useVisibleAgentIds({
|
||||
layout: workspaceLayout,
|
||||
tabs: uiTabs,
|
||||
sheetTab,
|
||||
routeFocused: isRouteFocused,
|
||||
focusedPaneOnly: syncFocusedPaneOnly,
|
||||
});
|
||||
@@ -1686,6 +1678,7 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
|
||||
const tabId = openPreferredWorkspaceTarget({
|
||||
isCompact: isMobile,
|
||||
workspaceKey: persistenceKey,
|
||||
target: createWorkspaceFileTabTarget(location),
|
||||
source: "chatFiles",
|
||||
@@ -2069,12 +2062,6 @@ 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;
|
||||
@@ -2094,13 +2081,10 @@ function WorkspaceScreenContent({
|
||||
},
|
||||
[
|
||||
allTabDescriptorsById,
|
||||
closeWorkspaceTab,
|
||||
confirmDiscardModifiedTab,
|
||||
handleCloseAgentTab,
|
||||
handleClosePassiveTab,
|
||||
handleCloseTerminalTab,
|
||||
persistenceKey,
|
||||
sheetTab,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2934,19 +2918,17 @@ function WorkspaceScreenContent({
|
||||
tab: WorkspaceTabDescriptor;
|
||||
paneId?: string | null;
|
||||
focusPaneBeforeOpen?: boolean;
|
||||
host?: PaneHost;
|
||||
}) =>
|
||||
buildWorkspacePaneContentModel({
|
||||
tab: input.tab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
host:
|
||||
input.host ??
|
||||
(canRenderDesktopPaneSplits &&
|
||||
canRenderDesktopPaneSplits &&
|
||||
input.paneId !== null &&
|
||||
input.paneId === explorerSidebarPaneId
|
||||
? "explorer"
|
||||
: "main"),
|
||||
: "main",
|
||||
fileNavigationRevision: fileNavigationRevisionByTabId[input.tab.tabId] ?? 0,
|
||||
onOpenTab: (target) => {
|
||||
if (!persistenceKey) {
|
||||
@@ -2965,6 +2947,7 @@ function WorkspaceScreenContent({
|
||||
onOpenPreferredTarget: (target, source) => {
|
||||
if (!persistenceKey) return;
|
||||
const tabId = openPreferredWorkspacePreview({
|
||||
isCompact: isMobile,
|
||||
workspaceKey: persistenceKey,
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
@@ -3026,6 +3009,7 @@ function WorkspaceScreenContent({
|
||||
canRenderDesktopPaneSplits,
|
||||
openImportSheet,
|
||||
openInSidePane,
|
||||
isMobile,
|
||||
requestFileNavigation,
|
||||
revealWorkspaceChildTab,
|
||||
persistenceKey,
|
||||
@@ -3069,17 +3053,6 @@ 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;
|
||||
@@ -3095,8 +3068,7 @@ function WorkspaceScreenContent({
|
||||
focusedPaneTabDescriptorMap,
|
||||
isRouteFocused,
|
||||
focusedPaneId,
|
||||
// The sheet owns input while it covers the tab.
|
||||
paneFocusSuspended,
|
||||
paneFocusSuspended: Boolean(unfocusedPaneId),
|
||||
onFocusPane: handleFocusPane,
|
||||
buildMobilePaneContentModel,
|
||||
});
|
||||
@@ -3523,42 +3495,28 @@ function WorkspaceScreenContent({
|
||||
const desktopContent = desktopSplitContent ?? content;
|
||||
const rendersDesktopSplitContent = !isMobile && desktopSplitContent !== null;
|
||||
|
||||
const workspacePanelTabIds = useMemo(
|
||||
() => [...uiTabs.map((tab) => tab.tabId), ...(sheetTab ? [sheetTab.tabId] : [])],
|
||||
[uiTabs, sheetTab],
|
||||
);
|
||||
const workspacePanelKey = `${normalizedServerId}:${normalizedWorkspaceId}`;
|
||||
|
||||
// The sheet is the column's last child, so it covers the compact header as a modal.
|
||||
const workspaceCenterColumn = isMobile ? (
|
||||
<WorkspacePanelProviders
|
||||
const workspacePanelContent = (
|
||||
<WorkspacePanelContent
|
||||
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>
|
||||
workspaceKey={`${normalizedServerId}:${normalizedWorkspaceId}`}
|
||||
tabIds={uiTabs.map((tab) => tab.tabId)}
|
||||
content={isMobile ? content : desktopContent}
|
||||
/>
|
||||
);
|
||||
|
||||
const workspaceCenterColumn = isMobile ? (
|
||||
<View style={styles.centerColumn}>
|
||||
<FloatingHeaderLayout
|
||||
header={compactWorkspaceHeader}
|
||||
floating={floatsHeaderOverContent(activeTabDescriptor)}
|
||||
>
|
||||
{(topOverlayInset) => (
|
||||
<StreamTopOverlayProvider value={topOverlayInset}>
|
||||
{workspacePanelContent}
|
||||
</StreamTopOverlayProvider>
|
||||
)}
|
||||
</FloatingHeaderLayout>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.centerColumn}>
|
||||
{rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()}
|
||||
@@ -3591,15 +3549,7 @@ function WorkspaceScreenContent({
|
||||
</NewTabLauncherProvider>
|
||||
) : null}
|
||||
|
||||
<View style={styles.centerContent}>
|
||||
<WorkspacePanelProviders
|
||||
launcher={newTabLauncher}
|
||||
workspaceKey={workspacePanelKey}
|
||||
tabIds={workspacePanelTabIds}
|
||||
>
|
||||
<View style={styles.content}>{desktopContent}</View>
|
||||
</WorkspacePanelProviders>
|
||||
</View>
|
||||
<View style={styles.centerContent}>{workspacePanelContent}</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
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,265 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import {
|
||||
MAX_SESSION_DOWNLOAD_BYTES,
|
||||
useDownloadStore,
|
||||
type Download,
|
||||
type DownloadedBytes,
|
||||
} from "@/stores/download-store";
|
||||
import type {
|
||||
DirectTcpHostConnection,
|
||||
HostProfile,
|
||||
RelayHostConnection,
|
||||
} from "@/types/host-connection";
|
||||
|
||||
const FILE_BYTES = new TextEncoder().encode("relay download payload");
|
||||
const FILE_PATH = "reports/données été.bin";
|
||||
const FILE_NAME = "données été.bin";
|
||||
const MIME_TYPE = "application/octet-stream";
|
||||
|
||||
const relayConnection: RelayHostConnection = {
|
||||
id: "relay:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
daemonPublicKeyB64: "cHVibGljLWtleQ==",
|
||||
};
|
||||
|
||||
// The #3753 shape: the relay hostname stored as a directTcp entry that nothing can reach.
|
||||
const staleTcpConnection: DirectTcpHostConnection = {
|
||||
id: "direct:relay.paseo.sh:443",
|
||||
type: "directTcp",
|
||||
endpoint: "relay.paseo.sh:443",
|
||||
};
|
||||
|
||||
const localTcpConnection: DirectTcpHostConnection = {
|
||||
id: "direct:localhost:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "localhost:6767",
|
||||
};
|
||||
|
||||
function profileWith(connections: HostProfile["connections"]): HostProfile {
|
||||
return {
|
||||
serverId: "srv_remote",
|
||||
label: "Remote",
|
||||
appearance: defaultHostAppearance(),
|
||||
lifecycle: {},
|
||||
connections,
|
||||
preferredConnectionId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function createTokenRequest(size: number | null = FILE_BYTES.byteLength) {
|
||||
const requested: string[] = [];
|
||||
return {
|
||||
requested,
|
||||
request: async (path: string) => {
|
||||
requested.push(path);
|
||||
return { token: "tok", fileName: FILE_NAME, mimeType: MIME_TYPE, size, error: null };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFileReader(read: () => Promise<{ bytes: Uint8Array; size: number }>) {
|
||||
const requested: string[] = [];
|
||||
return {
|
||||
requested,
|
||||
read: async (path: string) => {
|
||||
requested.push(path);
|
||||
return read();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFileSaver() {
|
||||
const savedFiles: DownloadedBytes[] = [];
|
||||
return {
|
||||
savedFiles,
|
||||
save: async (file: DownloadedBytes) => {
|
||||
savedFiles.push(file);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface StartInput {
|
||||
profile: HostProfile;
|
||||
activeConnectionId: string | null;
|
||||
token?: ReturnType<typeof createTokenRequest>;
|
||||
reader?: ReturnType<typeof createFileReader>;
|
||||
saver?: ReturnType<typeof createFileSaver>;
|
||||
}
|
||||
|
||||
async function startDownload(input: StartInput): Promise<Download | undefined> {
|
||||
const token = input.token ?? createTokenRequest();
|
||||
const reader =
|
||||
input.reader ??
|
||||
createFileReader(async () => ({ bytes: FILE_BYTES, size: FILE_BYTES.byteLength }));
|
||||
const saver = input.saver ?? createFileSaver();
|
||||
await useDownloadStore.getState().startDownload({
|
||||
serverId: "srv_remote",
|
||||
scopeId: "workspace-1",
|
||||
fileName: FILE_NAME,
|
||||
path: FILE_PATH,
|
||||
daemonProfile: input.profile,
|
||||
activeConnectionId: input.activeConnectionId,
|
||||
requestFileDownloadToken: token.request,
|
||||
readFile: reader.read,
|
||||
saveFile: saver.save,
|
||||
});
|
||||
const { activeDownloadId, downloads } = useDownloadStore.getState();
|
||||
return activeDownloadId ? downloads.get(activeDownloadId) : undefined;
|
||||
}
|
||||
|
||||
describe("download store transport selection", () => {
|
||||
beforeEach(() => {
|
||||
useDownloadStore.setState({ downloads: new Map(), activeDownloadId: null });
|
||||
});
|
||||
|
||||
it("downloads over the session when the active connection is the relay", async () => {
|
||||
const token = createTokenRequest();
|
||||
const reader = createFileReader(async () => ({
|
||||
bytes: FILE_BYTES,
|
||||
size: FILE_BYTES.byteLength,
|
||||
}));
|
||||
const saver = createFileSaver();
|
||||
|
||||
const download = await startDownload({
|
||||
profile: profileWith([relayConnection]),
|
||||
activeConnectionId: relayConnection.id,
|
||||
token,
|
||||
reader,
|
||||
saver,
|
||||
});
|
||||
|
||||
expect(download).toMatchObject({ fileName: FILE_NAME, status: "complete" });
|
||||
expect(token.requested).toEqual([FILE_PATH]);
|
||||
expect(reader.requested).toEqual([FILE_PATH]);
|
||||
expect(saver.savedFiles).toEqual([
|
||||
{ bytes: FILE_BYTES, fileName: FILE_NAME, mimeType: MIME_TYPE },
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds the session read even when the token reports a smaller file", async () => {
|
||||
const readRequests: Array<{ path: string; maxBytes: number }> = [];
|
||||
const saver = createFileSaver();
|
||||
await useDownloadStore.getState().startDownload({
|
||||
serverId: "srv_remote",
|
||||
scopeId: "workspace-1",
|
||||
fileName: FILE_NAME,
|
||||
path: FILE_PATH,
|
||||
daemonProfile: profileWith([relayConnection]),
|
||||
activeConnectionId: relayConnection.id,
|
||||
requestFileDownloadToken: createTokenRequest(1).request,
|
||||
readFile: async (path: string, maxBytes: number) => {
|
||||
readRequests.push({ path, maxBytes });
|
||||
return { bytes: FILE_BYTES, size: FILE_BYTES.byteLength };
|
||||
},
|
||||
saveFile: saver.save,
|
||||
});
|
||||
expect(readRequests).toEqual([{ path: FILE_PATH, maxBytes: MAX_SESSION_DOWNLOAD_BYTES }]);
|
||||
expect(saver.savedFiles).toEqual([
|
||||
{ bytes: FILE_BYTES, fileName: FILE_NAME, mimeType: MIME_TYPE },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores a directTcp entry that is not the active connection", async () => {
|
||||
const reader = createFileReader(async () => ({
|
||||
bytes: FILE_BYTES,
|
||||
size: FILE_BYTES.byteLength,
|
||||
}));
|
||||
const saver = createFileSaver();
|
||||
|
||||
const download = await startDownload({
|
||||
profile: profileWith([staleTcpConnection, relayConnection]),
|
||||
activeConnectionId: relayConnection.id,
|
||||
reader,
|
||||
saver,
|
||||
});
|
||||
|
||||
expect(download).toMatchObject({ status: "complete" });
|
||||
expect(reader.requested).toEqual([FILE_PATH]);
|
||||
expect(saver.savedFiles).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the HTTP token download when the active connection is directTcp", async () => {
|
||||
const reader = createFileReader(async () => ({
|
||||
bytes: FILE_BYTES,
|
||||
size: FILE_BYTES.byteLength,
|
||||
}));
|
||||
const saver = createFileSaver();
|
||||
|
||||
const download = await startDownload({
|
||||
profile: profileWith([localTcpConnection, relayConnection]),
|
||||
activeConnectionId: localTcpConnection.id,
|
||||
reader,
|
||||
saver,
|
||||
});
|
||||
|
||||
expect(download).toMatchObject({ status: "complete" });
|
||||
expect(reader.requested).toEqual([]);
|
||||
expect(saver.savedFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails the download and saves nothing when the session read fails", async () => {
|
||||
const reader = createFileReader(async () => {
|
||||
throw new Error("Connection lost");
|
||||
});
|
||||
const saver = createFileSaver();
|
||||
|
||||
const download = await startDownload({
|
||||
profile: profileWith([relayConnection]),
|
||||
activeConnectionId: relayConnection.id,
|
||||
reader,
|
||||
saver,
|
||||
});
|
||||
|
||||
expect(download).toMatchObject({ status: "error", message: "Connection lost" });
|
||||
expect(saver.savedFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a file over the session size limit before reading it", async () => {
|
||||
const token = createTokenRequest(MAX_SESSION_DOWNLOAD_BYTES + 1);
|
||||
const reader = createFileReader(async () => ({
|
||||
bytes: FILE_BYTES,
|
||||
size: FILE_BYTES.byteLength,
|
||||
}));
|
||||
const saver = createFileSaver();
|
||||
|
||||
const download = await startDownload({
|
||||
profile: profileWith([relayConnection]),
|
||||
activeConnectionId: relayConnection.id,
|
||||
token,
|
||||
reader,
|
||||
saver,
|
||||
});
|
||||
|
||||
expect(download).toMatchObject({
|
||||
status: "error",
|
||||
message: i18n.t("composer.errors.fileTooLarge", { fileName: FILE_NAME, size: "64 MB" }),
|
||||
});
|
||||
expect(download?.message).toContain("64 MB");
|
||||
expect(reader.requested).toEqual([]);
|
||||
expect(saver.savedFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails instead of saving an empty file when the daemon returns no content", async () => {
|
||||
const reader = createFileReader(async () => ({ bytes: new Uint8Array(), size: 6 }));
|
||||
const saver = createFileSaver();
|
||||
|
||||
const download = await startDownload({
|
||||
profile: profileWith([relayConnection]),
|
||||
activeConnectionId: relayConnection.id,
|
||||
reader,
|
||||
saver,
|
||||
});
|
||||
|
||||
expect(download).toMatchObject({
|
||||
status: "error",
|
||||
message: "File transfer incomplete: expected 6 bytes, received 0.",
|
||||
});
|
||||
expect(saver.savedFiles).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -16,16 +16,6 @@ interface DownloadProgress {
|
||||
eta: number;
|
||||
}
|
||||
|
||||
// The session path holds the file in memory two to three times over (client chunks, the
|
||||
// concatenated result, then the Blob or native write). Lift once the client streams chunks to disk.
|
||||
export const MAX_SESSION_DOWNLOAD_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
export interface DownloadedBytes {
|
||||
bytes: Uint8Array;
|
||||
fileName: string;
|
||||
mimeType: string | null;
|
||||
}
|
||||
|
||||
export interface Download {
|
||||
id: string;
|
||||
serverId: string;
|
||||
@@ -47,18 +37,12 @@ interface DownloadState {
|
||||
fileName: string;
|
||||
path: string;
|
||||
daemonProfile: HostProfile | undefined;
|
||||
/** Connection the host runtime is using right now; `null` while disconnected. */
|
||||
activeConnectionId: string | null;
|
||||
requestFileDownloadToken: (path: string) => Promise<{
|
||||
token: string | null;
|
||||
fileName: string | null;
|
||||
mimeType: string | null;
|
||||
size: number | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
/** Reads the whole file over the session socket; used when no direct HTTP endpoint is active. */
|
||||
readFile: (path: string, maxBytes: number) => Promise<{ bytes: Uint8Array; size: number }>;
|
||||
saveFile?: typeof saveDownloadedBytes;
|
||||
}) => Promise<void>;
|
||||
|
||||
updateProgress: (id: string, progress: DownloadProgress) => void;
|
||||
@@ -82,10 +66,7 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
|
||||
fileName,
|
||||
path,
|
||||
daemonProfile,
|
||||
activeConnectionId,
|
||||
requestFileDownloadToken,
|
||||
readFile,
|
||||
saveFile = saveDownloadedBytes,
|
||||
}) => {
|
||||
const id = generateDownloadId();
|
||||
const download: Download = {
|
||||
@@ -108,42 +89,12 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
|
||||
throw new Error(tokenResponse.error ?? i18n.t("downloads.requestTokenFailed"));
|
||||
}
|
||||
|
||||
const resolvedFileName = tokenResponse.fileName ?? fileName;
|
||||
const downloadTarget = resolveDaemonDownloadTarget(daemonProfile, activeConnectionId);
|
||||
if (!downloadTarget) {
|
||||
// Relay, SSH, socket and pipe hosts have no HTTP endpoint: read the file over the
|
||||
// session socket that is already connected (#543).
|
||||
if (
|
||||
typeof tokenResponse.size === "number" &&
|
||||
tokenResponse.size > MAX_SESSION_DOWNLOAD_BYTES
|
||||
) {
|
||||
throw new Error(
|
||||
i18n.t("composer.errors.fileTooLarge", { fileName: resolvedFileName, size: "64 MB" }),
|
||||
);
|
||||
}
|
||||
const { bytes, size } = await readFile(path, MAX_SESSION_DOWNLOAD_BYTES);
|
||||
// A daemon without a binary channel answers `encoding: "none"` for a binary file: no
|
||||
// content, real size. Never save that as a complete download.
|
||||
if (bytes.byteLength !== size) {
|
||||
throw new Error(
|
||||
`File transfer incomplete: expected ${size} bytes, received ${bytes.byteLength}.`,
|
||||
);
|
||||
}
|
||||
const savedUri = await saveFile({
|
||||
bytes,
|
||||
fileName: resolvedFileName,
|
||||
mimeType: tokenResponse.mimeType,
|
||||
});
|
||||
get().completeDownload(id);
|
||||
if (savedUri !== null) {
|
||||
await shareSavedFile(savedUri, tokenResponse.mimeType, resolvedFileName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const downloadTarget = resolveDaemonDownloadTarget(daemonProfile);
|
||||
if (!downloadTarget.baseUrl) {
|
||||
throw new Error(i18n.t("downloads.hostUnavailable"));
|
||||
}
|
||||
|
||||
const resolvedFileName = tokenResponse.fileName ?? fileName;
|
||||
const downloadUrl = buildDownloadUrl(
|
||||
downloadTarget.baseUrl,
|
||||
tokenResponse.token,
|
||||
@@ -195,7 +146,14 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
|
||||
|
||||
get().completeDownload(id);
|
||||
|
||||
await shareSavedFile(result.uri, tokenResponse.mimeType, resolvedFileName);
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(result.uri, {
|
||||
mimeType: tokenResponse.mimeType ?? undefined,
|
||||
dialogTitle: resolvedFileName
|
||||
? i18n.t("downloads.shareFileNamed", { fileName: resolvedFileName })
|
||||
: i18n.t("downloads.shareFile"),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : i18n.t("downloads.failed");
|
||||
if (isWeb) {
|
||||
@@ -286,18 +244,10 @@ interface DownloadTarget {
|
||||
authCredentials: { username: string; password: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTTP download route is only reachable over a direct TCP connection. Returns `null` when
|
||||
* the connection in use is anything else (relay, SSH, socket, pipe, or none yet), so the caller
|
||||
* reads the file over the session socket instead.
|
||||
*/
|
||||
function resolveDaemonDownloadTarget(
|
||||
daemon: HostProfile | undefined,
|
||||
activeConnectionId: string | null,
|
||||
): DownloadTarget | null {
|
||||
const connection = daemon?.connections.find((conn) => conn.id === activeConnectionId);
|
||||
if (connection?.type !== "directTcp") {
|
||||
return null;
|
||||
function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget {
|
||||
const connection = daemon?.connections.find((conn) => conn.type === "directTcp") ?? null;
|
||||
if (!connection) {
|
||||
return { baseUrl: null, authHeader: null, authCredentials: null };
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
@@ -349,47 +299,6 @@ function buildDownloadUrl(
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves bytes read over the session. Web and Electron hand them to the browser's download
|
||||
* manager and return `null`; native writes the file and returns its URI for the share sheet.
|
||||
*/
|
||||
async function saveDownloadedBytes({
|
||||
bytes,
|
||||
fileName,
|
||||
mimeType,
|
||||
}: DownloadedBytes): Promise<string | null> {
|
||||
if (isWeb) {
|
||||
const buffer = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(buffer).set(bytes);
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([buffer], { type: mimeType ?? "application/octet-stream" }),
|
||||
);
|
||||
triggerBrowserDownload(url, fileName);
|
||||
// The browser may still be reading the blob while its save prompt is open.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
return null;
|
||||
}
|
||||
// Resolve and write in one tick so two downloads of the same name cannot pick the same path.
|
||||
const targetFile = resolveDownloadTargetFile(fileName);
|
||||
targetFile.write(bytes);
|
||||
return targetFile.uri;
|
||||
}
|
||||
|
||||
async function shareSavedFile(
|
||||
uri: string,
|
||||
mimeType: string | null,
|
||||
fileName: string,
|
||||
): Promise<void> {
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(uri, {
|
||||
mimeType: mimeType ?? undefined,
|
||||
dialogTitle: fileName
|
||||
? i18n.t("downloads.shareFileNamed", { fileName })
|
||||
: i18n.t("downloads.shareFile"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function triggerBrowserDownload(url: string, fileName: string) {
|
||||
if (typeof document === "undefined") {
|
||||
if (typeof window !== "undefined") {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
@@ -16,9 +16,6 @@ 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";
|
||||
@@ -27,7 +24,6 @@ import {
|
||||
canDismissPaneInLayout,
|
||||
collectAllPanes,
|
||||
collectAllTabs,
|
||||
selectPaneFocusSuspended,
|
||||
createWorkspaceLayoutStore,
|
||||
observeOpenWorkspaceAgentIds,
|
||||
createDefaultLayout,
|
||||
@@ -4442,580 +4438,3 @@ 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,12 +59,7 @@ import {
|
||||
type WorkspaceTabSnapshot,
|
||||
type WorkspaceLayout,
|
||||
} from "@/stores/workspace-layout-actions";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
normalizeWorkspaceTabTarget,
|
||||
} from "@/workspace-tabs/identity";
|
||||
import { isSheetTabModified, resolveCompactSheetPolicy } from "@/workspace-tabs/compact-sheet";
|
||||
import { panelResourceKey, type CompactSheetPolicy } from "@/panels/panel-manifest";
|
||||
import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity";
|
||||
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
||||
import { panelTargetSupportsHostForWorkspaceKey } from "@/plugins/workspace-panels/locations";
|
||||
|
||||
@@ -110,24 +105,7 @@ 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>;
|
||||
@@ -138,19 +116,7 @@ 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,
|
||||
@@ -506,17 +472,6 @@ 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,
|
||||
@@ -682,189 +637,6 @@ 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.
|
||||
@@ -904,8 +676,6 @@ export function createWorkspaceLayoutStore(
|
||||
}
|
||||
return tabId;
|
||||
},
|
||||
layoutMode: "split-panes",
|
||||
sheetByWorkspace: {},
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
explorerSidebarWidthByWorkspace: {},
|
||||
@@ -921,31 +691,6 @@ 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,
|
||||
@@ -989,8 +734,6 @@ 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
|
||||
@@ -1008,52 +751,17 @@ export function createWorkspaceLayoutStore(
|
||||
: state.pinnedAgentIdsByWorkspace,
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: parentTabId
|
||||
[normalizedWorkspaceKey]: input.parentTabId
|
||||
? attachParentTab({
|
||||
layout: nextLayout,
|
||||
childTabId: result.tabId,
|
||||
parentTabId,
|
||||
parentTabId: input.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) {
|
||||
@@ -1173,9 +881,6 @@ 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,
|
||||
@@ -1283,8 +988,6 @@ export function createWorkspaceLayoutStore(
|
||||
|
||||
return {
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
// Choosing a tab dismisses the sheet that covers it.
|
||||
...dismissSheet(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
@@ -1308,8 +1011,6 @@ export function createWorkspaceLayoutStore(
|
||||
});
|
||||
return nextLayout
|
||||
? {
|
||||
// Choosing a tab dismisses the sheet that covers it.
|
||||
...dismissSheet(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
@@ -1323,31 +1024,6 @@ 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,
|
||||
@@ -1378,19 +1054,6 @@ 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,
|
||||
@@ -1485,22 +1148,15 @@ export function createWorkspaceLayoutStore(
|
||||
);
|
||||
}
|
||||
}
|
||||
const sheetAgentGone = sheetAgentNoLongerFits(
|
||||
normalizedWorkspaceKey,
|
||||
state.sheetByWorkspace[normalizedWorkspaceKey],
|
||||
snapshot,
|
||||
);
|
||||
if (
|
||||
state.layoutByWorkspace[normalizedWorkspaceKey] &&
|
||||
nextLayout === rawLayout &&
|
||||
pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace &&
|
||||
!sheetAgentGone
|
||||
pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...(sheetAgentGone ? withoutSheet(state, normalizedWorkspaceKey) : {}),
|
||||
pinnedAgentIdsByWorkspace,
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
@@ -2005,8 +1661,7 @@ export function createWorkspaceLayoutStore(
|
||||
normalizedWorkspaceKey in state.focusRestorationByWorkspace ||
|
||||
normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace ||
|
||||
normalizedWorkspaceKey in state.sidePaneIdByWorkspace ||
|
||||
normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace ||
|
||||
normalizedWorkspaceKey in state.sheetByWorkspace;
|
||||
normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace;
|
||||
if (!hasAny) {
|
||||
return state;
|
||||
}
|
||||
@@ -2035,7 +1690,6 @@ export function createWorkspaceLayoutStore(
|
||||
const { [normalizedWorkspaceKey]: _sidePane, ...sidePaneIdByWorkspace } =
|
||||
state.sidePaneIdByWorkspace;
|
||||
return {
|
||||
...withoutSheet(state, normalizedWorkspaceKey),
|
||||
pullRequestTabAutoOpenedByWorkspace,
|
||||
layoutByWorkspace,
|
||||
splitSizesByWorkspace,
|
||||
@@ -2141,10 +1795,10 @@ export function createWorkspaceLayoutStore(
|
||||
export const useWorkspaceLayoutStore = createWorkspaceLayoutStore();
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function observeOpenWorkspaceAgentIds(
|
||||
serverId: string,
|
||||
@@ -2152,19 +1806,11 @@ export function observeOpenWorkspaceAgentIds(
|
||||
store = useWorkspaceLayoutStore,
|
||||
): () => void {
|
||||
let previous: string[] | undefined;
|
||||
const publish = (state: Pick<WorkspaceLayoutStore, "layoutByWorkspace" | "sheetByWorkspace">) => {
|
||||
const publish = (state: Pick<WorkspaceLayoutStore, "layoutByWorkspace">) => {
|
||||
const ids = new Set<string>();
|
||||
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) {
|
||||
for (const [workspaceKey, layout] of Object.entries(state.layoutByWorkspace)) {
|
||||
if (!workspaceKey.startsWith(`${serverId}:`)) continue;
|
||||
for (const tab of tabs) {
|
||||
for (const tab of collectAllTabs(layout.root)) {
|
||||
if (tab.target.kind === "agent") ids.add(tab.target.agentId);
|
||||
}
|
||||
}
|
||||
@@ -2175,12 +1821,7 @@ export function observeOpenWorkspaceAgentIds(
|
||||
listener(next);
|
||||
};
|
||||
const unsubscribe = store.subscribe((state, before) => {
|
||||
if (
|
||||
state.layoutByWorkspace !== before.layoutByWorkspace ||
|
||||
state.sheetByWorkspace !== before.sheetByWorkspace
|
||||
) {
|
||||
publish(state);
|
||||
}
|
||||
if (state.layoutByWorkspace !== before.layoutByWorkspace) publish(state);
|
||||
});
|
||||
publish(store.getState());
|
||||
return unsubscribe;
|
||||
|
||||
@@ -22,6 +22,31 @@ function assistant(id: string, timestamp: Date): StreamItem {
|
||||
}
|
||||
|
||||
describe("deriveStreamTurnTiming", () => {
|
||||
it("retains completed timing for a failed response with or without assistant text", () => {
|
||||
const userAt = new Date("2026-05-15T00:00:00.000Z");
|
||||
const failedAt = new Date("2026-05-15T00:00:07.000Z");
|
||||
const error: StreamItem = {
|
||||
kind: "notification",
|
||||
sourceType: "error",
|
||||
level: "error",
|
||||
id: "error-1",
|
||||
message: "Provider failed",
|
||||
timestamp: failedAt,
|
||||
};
|
||||
for (const replies of [[], [assistant("assistant-1", new Date("2026-05-15T00:00:03.000Z"))]]) {
|
||||
const timing = deriveStreamTurnTiming({
|
||||
isTurnActive: false,
|
||||
activeTurnStartedAt: null,
|
||||
tail: [user("user-1", userAt), ...replies, error],
|
||||
head: [],
|
||||
});
|
||||
assert.deepEqual(timing.byResponseItemId.get(error.id), {
|
||||
completedAt: failedAt,
|
||||
durationMs: 7000,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("starts elapsed time from the submitted prompt", () => {
|
||||
const submittedAt = new Date("2026-05-15T00:00:00.000Z");
|
||||
|
||||
@@ -51,7 +76,7 @@ describe("deriveStreamTurnTiming", () => {
|
||||
});
|
||||
|
||||
assert.equal(timing.runningStartedAt, secondUserAt);
|
||||
assert.equal(timing.byAssistantId.has("a2"), false);
|
||||
assert.equal(timing.byResponseItemId.has("a2"), false);
|
||||
});
|
||||
|
||||
it("derives completed turn timing from user and assistant item timestamps", () => {
|
||||
@@ -69,7 +94,7 @@ describe("deriveStreamTurnTiming", () => {
|
||||
head: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(timing.byAssistantId.get("a1"), {
|
||||
assert.deepEqual(timing.byResponseItemId.get("a1"), {
|
||||
completedAt: assistantAt,
|
||||
durationMs: 7000,
|
||||
});
|
||||
@@ -95,8 +120,8 @@ describe("deriveStreamTurnTiming", () => {
|
||||
completedAt: lastAssistantAt,
|
||||
durationMs: 7000,
|
||||
};
|
||||
assert.deepEqual(timing.byAssistantId.get("a1"), expected);
|
||||
assert.deepEqual(timing.byAssistantId.get("a2"), expected);
|
||||
assert.deepEqual(timing.byResponseItemId.get("a1"), expected);
|
||||
assert.deepEqual(timing.byResponseItemId.get("a2"), expected);
|
||||
});
|
||||
|
||||
it("preserves the completion timestamp when a canonical turn has no visible prompt", () => {
|
||||
@@ -120,7 +145,7 @@ describe("deriveStreamTurnTiming", () => {
|
||||
head: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(timing.byAssistantId.get("hidden-prompt-a2"), {
|
||||
assert.deepEqual(timing.byResponseItemId.get("hidden-prompt-a2"), {
|
||||
completedAt: hiddenPromptTurnAt,
|
||||
durationMs: null,
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface TurnTiming {
|
||||
}
|
||||
|
||||
export interface StreamTurnTiming {
|
||||
byAssistantId: Map<string, TurnTiming>;
|
||||
byResponseItemId: Map<string, TurnTiming>;
|
||||
runningStartedAt: Date | null;
|
||||
}
|
||||
|
||||
@@ -17,14 +17,14 @@ export function deriveStreamTurnTiming(params: {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
}): StreamTurnTiming {
|
||||
const byAssistantId = new Map<string, TurnTiming>();
|
||||
const byResponseItemId = new Map<string, TurnTiming>();
|
||||
let currentUserAt: Date | null = null;
|
||||
let currentLastItemAt: Date | null = null;
|
||||
let currentAssistantIds: string[] = [];
|
||||
let currentResponseItemIds: string[] = [];
|
||||
let previousItem: StreamItem | null = null;
|
||||
|
||||
const flushCompletedTurn = () => {
|
||||
if (!currentLastItemAt || currentAssistantIds.length === 0) {
|
||||
if (!currentLastItemAt || currentResponseItemIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const timing: TurnTiming = {
|
||||
@@ -33,8 +33,8 @@ export function deriveStreamTurnTiming(params: {
|
||||
? Math.max(0, currentLastItemAt.getTime() - currentUserAt.getTime())
|
||||
: null,
|
||||
};
|
||||
for (const id of currentAssistantIds) {
|
||||
byAssistantId.set(id, timing);
|
||||
for (const id of currentResponseItemIds) {
|
||||
byResponseItemId.set(id, timing);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -43,11 +43,14 @@ export function deriveStreamTurnTiming(params: {
|
||||
flushCompletedTurn();
|
||||
currentUserAt = item.kind === "user_message" ? item.timestamp : null;
|
||||
currentLastItemAt = null;
|
||||
currentAssistantIds = [];
|
||||
currentResponseItemIds = [];
|
||||
}
|
||||
currentLastItemAt = item.timestamp;
|
||||
if (item.kind === "assistant_message") {
|
||||
currentAssistantIds.push(item.id);
|
||||
if (
|
||||
item.kind === "assistant_message" ||
|
||||
(item.kind === "notification" && item.sourceType === "error")
|
||||
) {
|
||||
currentResponseItemIds.push(item.id);
|
||||
}
|
||||
previousItem = item;
|
||||
};
|
||||
@@ -65,7 +68,7 @@ export function deriveStreamTurnTiming(params: {
|
||||
}
|
||||
|
||||
return {
|
||||
byAssistantId,
|
||||
byResponseItemId,
|
||||
runningStartedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
|
||||
import { routeKeyboardShortcut } from "@/keyboard/route-shortcut";
|
||||
|
||||
import {
|
||||
buildSidebarShortcutModel,
|
||||
@@ -66,46 +65,6 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
|
||||
}
|
||||
|
||||
describe("buildSidebarShortcutModel", () => {
|
||||
it.each([
|
||||
[9, 1, 10],
|
||||
[10, -1, 9],
|
||||
[12, 1, 1],
|
||||
[1, -1, 12],
|
||||
] as const)(
|
||||
"navigates from workspace %i by %i to workspace %i across the full visible sidebar",
|
||||
(current, delta, next) => {
|
||||
const workspaces = Array.from({ length: 12 }, (_, index) =>
|
||||
workspace({
|
||||
serverId: "s",
|
||||
workspaceId: `ws-${index + 1}`,
|
||||
workspaceDirectory: `/repo/w${index + 1}`,
|
||||
name: `w${index + 1}`,
|
||||
}),
|
||||
);
|
||||
const model = buildSidebarShortcutModel({
|
||||
projects: [project("p", workspaces)],
|
||||
collapsedProjectKeys: new Set<string>(),
|
||||
});
|
||||
|
||||
expect(
|
||||
routeKeyboardShortcut(
|
||||
{ action: "workspace.navigate.relative", payload: { delta } },
|
||||
{
|
||||
pathname: `/h/s/workspace/ws-${current}`,
|
||||
isMobile: false,
|
||||
sidebarShortcutTargets: model.shortcutTargets,
|
||||
navigationActiveWorkspace: null,
|
||||
commandCenterOpen: false,
|
||||
shortcutsDialogOpen: false,
|
||||
},
|
||||
),
|
||||
).toEqual({ kind: "navigate-workspace", serverId: "s", workspaceId: `ws-${next}` });
|
||||
expect(model.shortcutIndexByWorkspaceKey.size).toBe(9);
|
||||
expect(model.shortcutIndexByWorkspaceKey.get("s:ws-9")).toBe(9);
|
||||
expect(model.shortcutIndexByWorkspaceKey.has("s:ws-10")).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("builds shortcut targets in visual order and excludes collapsed projects", () => {
|
||||
const projects = [
|
||||
project("p1", [
|
||||
@@ -152,7 +111,7 @@ describe("buildSidebarShortcutModel", () => {
|
||||
expect(model.shortcutIndexByWorkspaceKey.get("s1:ws-repo2-main")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps all traversal targets while numbering only the first 9", () => {
|
||||
it("limits shortcuts to 9", () => {
|
||||
const workspaces = Array.from({ length: 20 }, (_, index) =>
|
||||
workspace({
|
||||
serverId: "s",
|
||||
@@ -168,11 +127,9 @@ describe("buildSidebarShortcutModel", () => {
|
||||
collapsedProjectKeys: new Set<string>(),
|
||||
});
|
||||
|
||||
expect(model.shortcutTargets).toHaveLength(20);
|
||||
expect(model.shortcutTargets).toHaveLength(9);
|
||||
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "ws-1" });
|
||||
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "ws-9" });
|
||||
expect(model.shortcutTargets[9]).toEqual({ serverId: "s", workspaceId: "ws-10" });
|
||||
expect(model.shortcutIndexByWorkspaceKey.get("s:ws-9")).toBe(9);
|
||||
expect(model.shortcutIndexByWorkspaceKey.get("s:ws-10")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("excludes a collapsed project's workspaces regardless of project kind", () => {
|
||||
|
||||
@@ -70,10 +70,13 @@ export function buildSidebarShortcutSections(input: {
|
||||
}
|
||||
|
||||
for (const workspace of section.workspaces) {
|
||||
shortcutTargets.push(createShortcutTarget(workspace));
|
||||
if (shortcutTargets.length <= maxShortcuts) {
|
||||
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutTargets.length);
|
||||
if (shortcutTargets.length >= maxShortcuts) {
|
||||
break;
|
||||
}
|
||||
|
||||
const shortcutNumber = shortcutTargets.length + 1;
|
||||
shortcutTargets.push(createShortcutTarget(workspace));
|
||||
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
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,11 +22,13 @@ interface OpenWorkspaceTargetInput {
|
||||
}
|
||||
|
||||
export interface OpenPreferredWorkspaceTargetInput extends OpenWorkspaceTargetInput {
|
||||
isCompact: boolean;
|
||||
source: OpenInSidePaneSource;
|
||||
preferences: OpenInSidePanePreferences;
|
||||
}
|
||||
|
||||
interface OpenWorkspaceTargetAtLocationInput extends OpenWorkspaceTargetInput {
|
||||
isCompact: boolean;
|
||||
location: WorkspaceTargetOpenLocation;
|
||||
}
|
||||
|
||||
@@ -37,11 +39,6 @@ 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;
|
||||
@@ -89,6 +86,7 @@ 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",
|
||||
@@ -107,7 +105,7 @@ export function openWorkspaceTargetAtLocation(
|
||||
layout &&
|
||||
collectAllTabs(layout.root).some((tab) => workspaceTabTargetsEqual(tab.target, input.target)),
|
||||
);
|
||||
const shouldOpenBeside = canOpenBeside() && input.location === "side";
|
||||
const shouldOpenBeside = !input.isCompact && input.location === "side";
|
||||
let placement: WorkspaceTabPlacement | undefined;
|
||||
if (shouldOpenBeside && !targetAlreadyExists) {
|
||||
const paneId = store.ensureSidePane(input.workspaceKey);
|
||||
@@ -137,13 +135,7 @@ export function openPreferredWorkspacePreview(
|
||||
const existing = collectAllTabs(layout.root).some((tab) =>
|
||||
workspaceTabTargetsEqual(tab.target, input.target),
|
||||
);
|
||||
// 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);
|
||||
}
|
||||
if (existing) return openPreferredWorkspaceTarget(input);
|
||||
|
||||
const mainPane = resolveMainPane({
|
||||
workspaceKey: input.workspaceKey,
|
||||
@@ -151,7 +143,7 @@ export function openPreferredWorkspacePreview(
|
||||
lastMainPaneId: input.lastMainPaneId,
|
||||
});
|
||||
const destinationPaneId =
|
||||
canOpenBeside() && input.preferences[input.source]
|
||||
!input.isCompact && input.preferences[input.source]
|
||||
? store.ensureSidePane(input.workspaceKey)
|
||||
: mainPane?.id;
|
||||
if (!destinationPaneId) return null;
|
||||
@@ -181,15 +173,9 @@ export function openPreferredWorkspacePreview(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit Open to Side: creates the side pane and places the target there. Without split panes
|
||||
* the target opens like any other.
|
||||
*/
|
||||
/** Explicit Open to Side: creates the side pane and places the target there. */
|
||||
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,6 +46,7 @@ export function openWorkspaceChanges(input: OpenWorkspaceChangesInput): string |
|
||||
return null;
|
||||
}
|
||||
return openPreferredWorkspaceTarget({
|
||||
isCompact: input.isCompact,
|
||||
workspaceKey: input.workspaceKey,
|
||||
target: { kind: "working_diff" },
|
||||
source: "diffs",
|
||||
@@ -69,6 +70,7 @@ export function openWorkspacePullRequest(input: OpenWorkspacePullRequestInput):
|
||||
return null;
|
||||
}
|
||||
return openWorkspaceTargetAtLocation({
|
||||
isCompact: input.isCompact,
|
||||
workspaceKey: input.workspaceKey,
|
||||
target: { kind: "pull_request" },
|
||||
location: input.destination,
|
||||
|
||||
@@ -1,60 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { buildAgentLsFetchOptions, listAgents } from "./ls.js";
|
||||
|
||||
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
|
||||
|
||||
function createPagingClient() {
|
||||
const daemonAgents: AgentSnapshotPayload[] = [3, 2, 1].map((n) => ({
|
||||
id: `${n}${n}${n}${n}${n}${n}${n}${n}-0000-4000-8000-000000000000`,
|
||||
provider: "codex",
|
||||
title: `agent ${n}`,
|
||||
status: "closed",
|
||||
archivedAt: null,
|
||||
cwd: "/tmp/project",
|
||||
createdAt: `2026-10-0${n}T00:00:00.000Z`,
|
||||
updatedAt: `2026-10-0${n}T00:00:00.000Z`,
|
||||
labels: {},
|
||||
model: null,
|
||||
lastUserMessageAt: null,
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
}));
|
||||
|
||||
// The fake daemon pages two agents at a time, newest first by the requested key
|
||||
// (updated_at by default), and agent 1 is updated right after the first page is served.
|
||||
const fetchAgents = async (options?: {
|
||||
sort?: { key: string }[];
|
||||
page?: { cursor?: string };
|
||||
}) => {
|
||||
const key: "createdAt" | "updatedAt" =
|
||||
options?.sort?.[0]?.key === "created_at" ? "createdAt" : "updatedAt";
|
||||
const cursor = options?.page?.cursor;
|
||||
const ordered = daemonAgents
|
||||
.filter((agent) => cursor === undefined || agent[key] < cursor)
|
||||
.sort((left, right) => right[key].localeCompare(left[key]));
|
||||
const page = ordered.slice(0, 2);
|
||||
const hasMore = ordered.length > 2;
|
||||
if (cursor === undefined) {
|
||||
daemonAgents[2]!.updatedAt = "2026-10-09T00:00:00.000Z";
|
||||
}
|
||||
return {
|
||||
entries: page.map((agent) => ({ agent })),
|
||||
pageInfo: { nextCursor: hasMore ? page[page.length - 1]![key] : null },
|
||||
};
|
||||
};
|
||||
return { fetchAgents, close: async () => {} };
|
||||
}
|
||||
import { buildAgentLsFetchOptions } from "./ls.js";
|
||||
|
||||
describe("buildAgentLsFetchOptions", () => {
|
||||
it("fetches active agents by default", () => {
|
||||
@@ -121,13 +66,3 @@ describe("buildAgentLsFetchOptions", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runLsCommand", () => {
|
||||
it("lists agents on every page, including one updated during the listing", async () => {
|
||||
const result = await listAgents({ daemonTarget, all: true, global: true }, async () =>
|
||||
createPagingClient(),
|
||||
);
|
||||
|
||||
expect(result.data.map((item) => item.name)).toEqual(["agent 3", "agent 2", "agent 1"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,14 +165,6 @@ export function buildAgentLsFetchOptions(
|
||||
return fetchOptions;
|
||||
}
|
||||
|
||||
interface AgentLsClient {
|
||||
fetchAgents(options?: FetchAgentsOptions): Promise<{
|
||||
entries: { agent: AgentSnapshotPayload }[];
|
||||
pageInfo: { nextCursor: string | null };
|
||||
}>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent ls command semantics:
|
||||
* - `paseo agent ls` → active non-archived agents
|
||||
@@ -184,16 +176,7 @@ export async function runLsCommand(
|
||||
options: AgentLsOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentLsResult> {
|
||||
return listAgents(options);
|
||||
}
|
||||
|
||||
export async function listAgents(
|
||||
options: AgentLsOptions,
|
||||
connect: (
|
||||
options: Parameters<typeof connectToDaemon>[0],
|
||||
) => Promise<AgentLsClient> = connectToDaemon,
|
||||
): Promise<AgentLsResult> {
|
||||
const client = await connect({ target: options.daemonTarget });
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const normalizedThinkingOptionId = options.thinking?.trim();
|
||||
@@ -206,19 +189,8 @@ export async function listAgents(
|
||||
}
|
||||
|
||||
const labelFilters = parseLabelFilters(options.label);
|
||||
const fetchOptions = buildAgentLsFetchOptions(options);
|
||||
let agents: AgentSnapshotPayload[] = [];
|
||||
let cursor: string | undefined;
|
||||
// createdAt never changes, so agents cannot move past the cursor while the pages are read.
|
||||
do {
|
||||
const payload = await client.fetchAgents({
|
||||
...fetchOptions,
|
||||
sort: [{ key: "created_at", direction: "desc" }],
|
||||
page: { limit: 200, ...(cursor ? { cursor } : {}) },
|
||||
});
|
||||
agents.push(...payload.entries.map((entry) => entry.agent));
|
||||
cursor = payload.pageInfo.nextCursor ?? undefined;
|
||||
} while (cursor);
|
||||
const fetchPayload = await client.fetchAgents(buildAgentLsFetchOptions(options));
|
||||
let agents = fetchPayload.entries.map((entry) => entry.agent);
|
||||
|
||||
// By default, exclude archived agents. `-a` includes them.
|
||||
if (!options.all) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import {
|
||||
createClientChannel,
|
||||
type EncryptedChannel,
|
||||
@@ -9,11 +8,7 @@ import type {
|
||||
DaemonTransportFactory,
|
||||
TransportLogger,
|
||||
} from "./daemon-client-transport-types.js";
|
||||
import {
|
||||
extractRelayMessage,
|
||||
normalizeTransportPayload,
|
||||
messageTooLargeError,
|
||||
} from "./daemon-client-transport-utils.js";
|
||||
import { extractRelayMessage, normalizeTransportPayload } from "./daemon-client-transport-utils.js";
|
||||
|
||||
type OpenHandler = () => void;
|
||||
type CloseHandler = (event?: unknown) => void;
|
||||
@@ -142,13 +137,7 @@ export function createEncryptedTransport(
|
||||
if (!channel) {
|
||||
throw new Error("Encrypted channel not ready");
|
||||
}
|
||||
const payload = normalizeTransportPayload(data);
|
||||
// Reject synchronously so the requesting operation fails without closing the transport.
|
||||
const wireBytes = channel.outboundWireByteLength(payload);
|
||||
if (wireBytes > MAX_RELAY_WEBSOCKET_MESSAGE_BYTES) {
|
||||
throw messageTooLargeError(wireBytes, MAX_RELAY_WEBSOCKET_MESSAGE_BYTES);
|
||||
}
|
||||
void channel.send(payload).catch((error) => {
|
||||
void channel.send(normalizeTransportPayload(data)).catch((error) => {
|
||||
emitError(error);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -133,10 +133,3 @@ export function encodeUtf8String(value: string): Uint8Array {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function messageTooLargeError(size: number, limit: number): Error {
|
||||
const toMiB = (bytes: number) => (bytes / (1024 * 1024)).toFixed(1);
|
||||
return new Error(
|
||||
`Message is too large to send (${toMiB(size)} MiB). The limit is ${toMiB(limit)} MiB, so send fewer or smaller attachments.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2801,102 +2801,6 @@ test("readFile drops an old daemon's over-budget binary chunks and reports the r
|
||||
await expect(responsePromise).rejects.toThrow("File is too large to display");
|
||||
});
|
||||
|
||||
async function connectFileClient(clientId: string) {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId,
|
||||
logger: createMockLogger(),
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
return { mock, client };
|
||||
}
|
||||
|
||||
function sendBinaryFile(
|
||||
mock: ReturnType<typeof createMockTransport>,
|
||||
requestId: string,
|
||||
announcedSize: number,
|
||||
chunks: string[],
|
||||
) {
|
||||
mock.triggerMessage(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileBegin,
|
||||
requestId,
|
||||
metadata: {
|
||||
mime: "application/octet-stream",
|
||||
size: announcedSize,
|
||||
encoding: "binary",
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
for (const chunk of chunks) {
|
||||
mock.triggerMessage(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
requestId,
|
||||
payload: new TextEncoder().encode(chunk),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test("readFile with timeout 0 outlives the default request timeout", async () => {
|
||||
useHeartbeatClock();
|
||||
const { mock, client } = await connectFileClient("clsk_file_no_timeout");
|
||||
|
||||
const responsePromise = client.readFile("/tmp/project", "slow.bin", "req-slow", undefined, 0);
|
||||
await vi.advanceTimersByTimeAsync(60_001);
|
||||
|
||||
sendBinaryFile(mock, "req-slow", 5, ["hello"]);
|
||||
mock.triggerMessage(
|
||||
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-slow" }),
|
||||
);
|
||||
|
||||
const result = await responsePromise;
|
||||
expect(result.size).toBe(5);
|
||||
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
|
||||
});
|
||||
|
||||
test("readFile rejects a binary transfer that ends before the announced size", async () => {
|
||||
const { mock, client } = await connectFileClient("clsk_file_short");
|
||||
|
||||
const responsePromise = client.readFile("/tmp/project", "short.bin", "req-short");
|
||||
sendBinaryFile(mock, "req-short", 10, ["hello"]);
|
||||
mock.triggerMessage(
|
||||
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-short" }),
|
||||
);
|
||||
|
||||
await expect(responsePromise).rejects.toThrow(
|
||||
"File transfer incomplete: expected 10 bytes, received 5.",
|
||||
);
|
||||
});
|
||||
|
||||
test("readFile rejects a binary transfer that exceeds the announced size", async () => {
|
||||
const { mock, client } = await connectFileClient("clsk_file_long");
|
||||
|
||||
const responsePromise = client.readFile("/tmp/project", "long.bin", "req-long");
|
||||
const settled = responsePromise.then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
sendBinaryFile(mock, "req-long", 4, ["hello!"]);
|
||||
|
||||
expect(() =>
|
||||
mock.triggerMessage(
|
||||
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-long" }),
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(await settled).toMatchObject({
|
||||
message: "File transfer incomplete: expected 4 bytes, received 6.",
|
||||
});
|
||||
});
|
||||
|
||||
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
@@ -7623,29 +7527,3 @@ test.each([false, true])(
|
||||
await send;
|
||||
},
|
||||
);
|
||||
|
||||
test("measures an outbound message in UTF-8 bytes against the daemon's message limit", async () => {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "message_limit_unit_test",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
const mib = 1024 * 1024;
|
||||
|
||||
// "é" is one UTF-16 unit but two UTF-8 bytes, so this text is ~98 MiB on the wire.
|
||||
void client.sendMessage("agent-1", "é".repeat(49 * mib)).catch(() => undefined);
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
|
||||
// ~60M characters is under the 100 MiB limit; ~120 MiB of UTF-8 is over it.
|
||||
await expect(client.sendMessage("agent-1", "é".repeat(60 * mib))).rejects.toThrow(
|
||||
"Message is too large to send (120.0 MiB). The limit is 100.0 MiB",
|
||||
);
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
expect(client.getConnectionState().status).toBe("connected");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { messageTooLargeError } from "./daemon-client-transport-utils.js";
|
||||
import { legacyUsageIcon } from "./legacy-usage-icons.js";
|
||||
import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js";
|
||||
import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js";
|
||||
@@ -14,7 +13,6 @@ import type { z } from "zod";
|
||||
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
|
||||
import type { ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||
import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification";
|
||||
import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import {
|
||||
AgentCreateFailedStatusPayloadSchema,
|
||||
AgentCreatedStatusPayloadSchema,
|
||||
@@ -1134,19 +1132,6 @@ function getTransportFrameSize(frame: string | Uint8Array | ArrayBuffer): number
|
||||
return frame.byteLength;
|
||||
}
|
||||
|
||||
function assertTransportFrameFits(frame: string | Uint8Array | ArrayBuffer): void {
|
||||
if (typeof frame !== "string") {
|
||||
if (frame.byteLength > MAX_WEBSOCKET_MESSAGE_BYTES)
|
||||
throw messageTooLargeError(frame.byteLength, MAX_WEBSOCKET_MESSAGE_BYTES);
|
||||
return;
|
||||
}
|
||||
// UTF-8 needs at most three bytes per UTF-16 unit, so only a string near the limit is measured.
|
||||
if (frame.length * 3 <= MAX_WEBSOCKET_MESSAGE_BYTES) return;
|
||||
const size = new TextEncoder().encode(frame).byteLength;
|
||||
if (size > MAX_WEBSOCKET_MESSAGE_BYTES)
|
||||
throw messageTooLargeError(size, MAX_WEBSOCKET_MESSAGE_BYTES);
|
||||
}
|
||||
|
||||
function describeInboundTransportFrame(
|
||||
frame: unknown,
|
||||
rawBytes: Uint8Array | null,
|
||||
@@ -1782,8 +1767,6 @@ export class DaemonClient {
|
||||
if (!this.transport) {
|
||||
throw new DaemonConnectionError("Transport not connected");
|
||||
}
|
||||
// The daemon closes the connection on an oversized message, and creations resubmit on reconnect.
|
||||
assertTransportFrameFits(frame);
|
||||
const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", {
|
||||
kind: typeof frame === "string" ? "text" : "binary",
|
||||
size: String(getTransportFrameSize(frame)),
|
||||
@@ -4758,7 +4741,6 @@ export class DaemonClient {
|
||||
requestId?: string,
|
||||
acceptBinary = false,
|
||||
maxBytes?: number,
|
||||
timeout?: number,
|
||||
): Promise<FileExplorerPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
@@ -4771,7 +4753,6 @@ export class DaemonClient {
|
||||
...(maxBytes ? { maxBytes } : {}),
|
||||
},
|
||||
responseType: "file_explorer_response",
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4790,17 +4771,11 @@ export class DaemonClient {
|
||||
return payload.directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* `timeout` bounds the whole transfer, not each frame. `0` disables the timer (as
|
||||
* `workspace.create` does) for files that legitimately take longer than the default over a
|
||||
* slow link; a dead connection still rejects on disconnect.
|
||||
*/
|
||||
async readFile(
|
||||
cwd: string,
|
||||
path: string,
|
||||
requestId?: string,
|
||||
maxBytes?: number,
|
||||
timeout?: number,
|
||||
): Promise<FileReadResult> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path, maxBytes });
|
||||
@@ -4812,7 +4787,6 @@ export class DaemonClient {
|
||||
resolvedRequestId,
|
||||
true,
|
||||
maxBytes,
|
||||
timeout,
|
||||
);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
@@ -6549,18 +6523,6 @@ export class DaemonClient {
|
||||
return;
|
||||
}
|
||||
|
||||
const received = transfer.chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
|
||||
if (received !== transfer.size) {
|
||||
this.activeBinaryFileTransfers.delete(frame.requestId);
|
||||
this.rejectWaitersForRequestId(
|
||||
frame.requestId,
|
||||
new Error(
|
||||
`File transfer incomplete: expected ${transfer.size} bytes, received ${received}.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = concatByteChunks(transfer.chunks, transfer.size);
|
||||
this.activeBinaryFileTransfers.delete(frame.requestId);
|
||||
this.completedBinaryFileReads.set(frame.requestId, {
|
||||
|
||||
@@ -1051,7 +1051,7 @@ async function waitForProvidersReady(
|
||||
|
||||
try {
|
||||
await observation.ready;
|
||||
if (signal.aborted) throw new Error("Paseo API is disposed");
|
||||
signal.throwIfAborted();
|
||||
return await new Promise<PaseoProviderSnapshotResult>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let requestId: string | null = null;
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const modulePath = fileURLToPath(new URL("./console-streams.ts", import.meta.url));
|
||||
|
||||
type Output = "stdout" | "stderr";
|
||||
|
||||
async function writeToConsole(input: { closed?: Output; invalidStdout?: boolean } = {}) {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"-e",
|
||||
`
|
||||
const { protectConsoleStreams } = require(${JSON.stringify(modulePath)});
|
||||
protectConsoleStreams();
|
||||
process.send("ready");
|
||||
process.once("message", () => {
|
||||
if (${JSON.stringify(input.invalidStdout)}) require("node:fs").closeSync(1);
|
||||
console.info("desktop info");
|
||||
console.warn("desktop warning");
|
||||
setImmediate(() => { process.send("alive"); process.disconnect(); });
|
||||
});
|
||||
`,
|
||||
],
|
||||
{
|
||||
cwd: fileURLToPath(new URL(".", import.meta.url)),
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
env: Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== "FORCE_COLOR")),
|
||||
},
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let alive = false;
|
||||
child.stdout.setEncoding("utf8").on("data", (text: string) => {
|
||||
stdout += text;
|
||||
});
|
||||
child.stderr.setEncoding("utf8").on("data", (text: string) => {
|
||||
stderr += text;
|
||||
});
|
||||
child.on("message", (message) => {
|
||||
if (message === "ready") {
|
||||
if (input.closed) child[input.closed].destroy();
|
||||
child.send("write");
|
||||
}
|
||||
if (message === "alive") alive = true;
|
||||
});
|
||||
const code = await new Promise<number | null>((resolve, reject) => {
|
||||
child.once("close", resolve);
|
||||
child.once("error", reject);
|
||||
});
|
||||
return { code, stdout, stderr, alive };
|
||||
}
|
||||
|
||||
describe("desktop console streams", () => {
|
||||
for (const stream of ["stdout", "stderr"] as const) {
|
||||
it(`keeps running when the launcher closes ${stream}`, async () => {
|
||||
const result = await writeToConsole({ closed: stream });
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(result.alive).toBe(true);
|
||||
expect(result[stream === "stdout" ? "stderr" : "stdout"]).toBe(
|
||||
stream === "stdout" ? "desktop warning\n" : "desktop info\n",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Windows stdout uses a native handle rather than the POSIX fd closed here.
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"does not suppress a console failure other than a closed pipe",
|
||||
async () => {
|
||||
const result = await writeToConsole({ invalidStdout: true });
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.alive).toBe(false);
|
||||
expect(result.stderr).toContain("EBADF");
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves logging while the console has readers", async () => {
|
||||
expect(await writeToConsole()).toEqual({
|
||||
code: 0,
|
||||
stdout: "desktop info\n",
|
||||
stderr: "desktop warning\n",
|
||||
alive: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
function ignoreClosedConsole(error: NodeJS.ErrnoException): void {
|
||||
if (error.code !== "EPIPE") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep desktop logging independent of the program that launched its console. */
|
||||
export function protectConsoleStreams(): void {
|
||||
process.stdout.on("error", ignoreClosedConsole);
|
||||
process.stderr.on("error", ignoreClosedConsole);
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
import { protectConsoleStreams } from "./diagnostics/console-streams.js";
|
||||
protectConsoleStreams();
|
||||
|
||||
process.emitWarning = (() => {}) as typeof process.emitWarning;
|
||||
|
||||
import log from "electron-log/main";
|
||||
|
||||
@@ -144,34 +144,6 @@ const title = "Hello";
|
||||
);
|
||||
});
|
||||
|
||||
it("highlights Astro markup containing only an empty expression", () => {
|
||||
const code = "<p>{}</p>";
|
||||
|
||||
const tokens = highlightCode(code, "Empty.astro").flat();
|
||||
|
||||
expect(tokens.map((token) => token.text).join("")).toBe(code);
|
||||
expect(tokens).toContainEqual({ text: "p", style: "tag" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
"<p>{title} {} {title}</p>",
|
||||
'<p class="{} {title}">{title}</p>',
|
||||
"<p class={} title={title}>{title}</p>",
|
||||
])("highlights Astro containing an empty expression: %s", (markup) => {
|
||||
const code = `---\nconst title = "Hello";\n---\n${markup}\n<style>.page { color: red; }</style>`;
|
||||
|
||||
const lines = highlightCode(code, "Empty.astro");
|
||||
|
||||
expect(lines).toHaveLength(code.split("\n").length);
|
||||
for (const [index, tokens] of lines.entries()) {
|
||||
expect(tokens.map((token) => token.text).join("")).toBe(code.split("\n")[index]);
|
||||
}
|
||||
expect(lines.flat()).toContainEqual({ text: "const", style: "keyword" });
|
||||
expect(lines.flat()).toContainEqual({ text: "p", style: "tag" });
|
||||
expect(lines.flat()).toContainEqual({ text: "color", style: "property" });
|
||||
expect(lines.flat()).toContainEqual({ text: "title", style: "variable" });
|
||||
});
|
||||
|
||||
it("keeps an Astro expression open through nested template strings", () => {
|
||||
const code = "<p>{`a ${`}`} b`}</p>";
|
||||
|
||||
|
||||
@@ -45,12 +45,10 @@ function maskExpressions(text: string): string {
|
||||
}
|
||||
|
||||
function expressionOverlays(node: SyntaxNodeRef, input: Input): Range[] | null {
|
||||
const overlays = findExpressions(input.read(node.from, node.to))
|
||||
.filter(({ from, to }) => to > from + 1)
|
||||
.map(({ from, to }) => ({
|
||||
from: node.from + from + 1,
|
||||
to: node.from + to,
|
||||
}));
|
||||
const overlays = findExpressions(input.read(node.from, node.to)).map(({ from, to }) => ({
|
||||
from: node.from + from + 1,
|
||||
to: node.from + to,
|
||||
}));
|
||||
return overlays.length > 0 ? overlays : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
/** Largest WebSocket message the daemon accepts. A bigger one closes the connection with 1009. */
|
||||
export const MAX_WEBSOCKET_MESSAGE_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
/** Largest encrypted frame the relay accepts. */
|
||||
export const MAX_RELAY_WEBSOCKET_MESSAGE_BYTES = 32 * 1024 * 1024;
|
||||
@@ -11,10 +11,9 @@ OPENAI_TTS_BASE_URL=
|
||||
|
||||
# TTS Configuration (optional - defaults shown)
|
||||
TTS_VOICE=alloy
|
||||
# Any voice the server knows: OpenAI ships alloy, echo, fable, onyx, nova, shimmer
|
||||
# Available voices: alloy, echo, fable, onyx, nova, shimmer
|
||||
TTS_MODEL=tts-1
|
||||
# OpenAI ships tts-1 (faster) and tts-1-hd (higher quality); self-hosted
|
||||
# servers accept whatever model id they serve. Case sensitive.
|
||||
# Available models: tts-1 (faster), tts-1-hd (higher quality)
|
||||
|
||||
# Server Configuration
|
||||
# Location for runtime state (agents.json, etc.). Defaults to ~/.paseo
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user