mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare 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).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -241,32 +241,6 @@ test.describe("Plugin sidebar items", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("a plugin screen toggles the sidebar on a wide layout", async ({ page }) => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await row.click();
|
||||
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
|
||||
await visibleTestId(page, "menu-button").click();
|
||||
await expect(row).not.toBeVisible();
|
||||
await visibleTestId(page, "menu-button").click();
|
||||
await expect(row).toBeVisible();
|
||||
await closeScreen(page);
|
||||
|
||||
// With the sidebar closed elsewhere, the screen's own toggle is the way back to it.
|
||||
await visibleTestId(page, "menu-button").click();
|
||||
await expect(row).not.toBeVisible();
|
||||
await page.keyboard.press("Control+k");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await panel.getByTestId("command-center-input").fill("Open deploys");
|
||||
await panel.getByRole("button", { name: "Open deploys", exact: true }).click();
|
||||
await expect(page.getByText("Deploys screen body", { exact: true })).toBeVisible();
|
||||
await visibleTestId(page, "menu-button").click();
|
||||
await expect(row).toBeVisible();
|
||||
});
|
||||
|
||||
test("a header row's shortcut hint presses the row", async ({ page }) => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -8,7 +8,6 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import { CompactHeader } from "@/components/headers/compact-header";
|
||||
import { SidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle";
|
||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||
import { ScreenTitle } from "@/components/headers/screen-title";
|
||||
import { HostFilter } from "@/components/hosts/host-filter";
|
||||
@@ -173,7 +172,6 @@ export function PluginSurfaceScreen() {
|
||||
const headerLeft = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<SidebarMenuToggle />
|
||||
{Icon ? (
|
||||
<HeaderIconBadge>
|
||||
<ThemedPluginHeaderIcon Icon={Icon} uniProps={mutedColorMapping} />
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7527,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)),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/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",
|
||||
|
||||
@@ -3966,11 +3966,7 @@ test("a prompt after provider replacement reopens the stale session", async () =
|
||||
expect(timeline.filter((item) => item.type === "user_message")).toMatchObject([
|
||||
{ text: "continue after reload", clientMessageId: "stale-session-retry" },
|
||||
]);
|
||||
expect(
|
||||
timeline.some(
|
||||
(item) => item.type === "assistant_message" && item.text.includes("[System Error]"),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(timeline.some((item) => item.type === "error")).toBe(false);
|
||||
} finally {
|
||||
await manager.closeAgent(created.id).catch(() => undefined);
|
||||
await manager.flush().catch(() => undefined);
|
||||
@@ -7032,47 +7028,78 @@ test("ignores stale autonomous terminals without lowering the active turn lifecy
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves terminal fallback when no active turn identity was observed", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-untracked-terminal-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
let capturedSession: TestAgentSession | null = null;
|
||||
test.each(["Task completed.", "[System Error] untracked failure"])(
|
||||
"preserves terminal fallback and separates untracked failure activity after %s",
|
||||
async (assistantText) => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-untracked-terminal-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
let capturedSession: TestAgentSession | null = null;
|
||||
|
||||
class LiveEventClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
capturedSession = new TestAgentSession(config);
|
||||
return capturedSession;
|
||||
class LiveEventClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
capturedSession = new TestAgentSession(config);
|
||||
return capturedSession;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new LiveEventClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000136",
|
||||
});
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
capturedSession!.pushEvent({
|
||||
type: "turn_failed",
|
||||
provider: "codex",
|
||||
turnId: "untracked-turn",
|
||||
error: "untracked failure",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const failed = manager.getAgent(snapshot.id);
|
||||
expect(failed?.lifecycle).toBe("error");
|
||||
expect(failed?.lastError).toBe("untracked failure");
|
||||
});
|
||||
expect(manager.getTimeline(snapshot.id)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new LiveEventClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000136",
|
||||
});
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const events: AgentManagerEvent[] = [];
|
||||
manager.subscribe((event) => events.push(event), { agentId: snapshot.id, replayState: false });
|
||||
await manager.appendTimelineItem(snapshot.id, {
|
||||
type: "assistant_message",
|
||||
text: expect.stringContaining("untracked failure"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
text: assistantText,
|
||||
messageId: "assistant-1",
|
||||
});
|
||||
|
||||
capturedSession!.pushEvent({
|
||||
type: "turn_failed",
|
||||
provider: "codex",
|
||||
turnId: "untracked-turn",
|
||||
error: "untracked failure",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const failed = manager.getAgent(snapshot.id);
|
||||
expect(failed?.lifecycle).toBe("error");
|
||||
expect(failed?.lastError).toBe("untracked failure");
|
||||
});
|
||||
expect(manager.getTimeline(snapshot.id)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "error",
|
||||
message: expect.stringContaining("untracked failure"),
|
||||
}),
|
||||
);
|
||||
const projected = projectTimelineRows({
|
||||
rows: await manager.getTimelineRows(snapshot.id),
|
||||
mode: "projected",
|
||||
});
|
||||
expect(projected.map((entry) => entry.item)).toEqual([
|
||||
{
|
||||
type: "assistant_message",
|
||||
text: assistantText,
|
||||
messageId: "assistant-1",
|
||||
},
|
||||
{ type: "error", message: "untracked failure" },
|
||||
]);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "agent_stream",
|
||||
event: expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: { type: "error", message: "untracked failure" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("cancelAgentRun waits for an acknowledged autonomous interrupt to settle", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-cancel-"));
|
||||
@@ -8357,7 +8384,7 @@ test("streamAgent clears pending run when startTurn fails before a turn id exist
|
||||
clientMessageId: "rejected-prompt",
|
||||
messageId: "rejected-prompt",
|
||||
},
|
||||
{ type: "assistant_message", text: "[System Error] Invalid request: missing field `text`" },
|
||||
{ type: "error", message: "Invalid request: missing field `text`" },
|
||||
]);
|
||||
|
||||
await expect(manager.runAgent(agent.id, "second turn")).resolves.toEqual(
|
||||
@@ -9184,7 +9211,7 @@ test("archiveAgent cascade surfaces partial child archive failures", async () =>
|
||||
);
|
||||
});
|
||||
|
||||
test("turn_failed emits a system error assistant timeline message and keeps error lifecycle", async () => {
|
||||
test("turn_failed emits error activity and keeps error lifecycle", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-failed-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
@@ -9252,12 +9279,9 @@ test("turn_failed emits a system error assistant timeline message and keeps erro
|
||||
|
||||
const systemErrors = manager
|
||||
.getTimeline(agent.id)
|
||||
.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
|
||||
item.type === "assistant_message" && item.text.includes("[System Error]"),
|
||||
);
|
||||
.filter((item): item is Extract<AgentTimelineItem, { type: "error" }> => item.type === "error");
|
||||
expect(systemErrors).toHaveLength(1);
|
||||
expect(systemErrors[0]?.text).toContain("invalid model id");
|
||||
expect(systemErrors[0]?.message).toContain("invalid model id");
|
||||
});
|
||||
|
||||
test("turn_failed surfaces provider code and diagnostic in system error message", async () => {
|
||||
@@ -9324,17 +9348,16 @@ test("turn_failed surfaces provider code and diagnostic in system error message"
|
||||
|
||||
await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow("Provider execution failed");
|
||||
|
||||
expect(manager.getAgent(agent.id)?.lastError).toBe("Provider execution failed");
|
||||
expect(manager.getAgent(agent.id)?.lastError).toBe(
|
||||
"Provider execution failed\n\ncode: 126\n\nNo preset version installed for command claude",
|
||||
);
|
||||
|
||||
const systemError = manager
|
||||
.getTimeline(agent.id)
|
||||
.find(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
|
||||
item.type === "assistant_message" && item.text.includes("[System Error]"),
|
||||
);
|
||||
expect(systemError?.text).toContain("Provider execution failed");
|
||||
expect(systemError?.text).toContain("code: 126");
|
||||
expect(systemError?.text).toContain("No preset version installed for command claude");
|
||||
.find((item): item is Extract<AgentTimelineItem, { type: "error" }> => item.type === "error");
|
||||
expect(systemError?.message).toContain("Provider execution failed");
|
||||
expect(systemError?.message).toContain("code: 126");
|
||||
expect(systemError?.message).toContain("No preset version installed for command claude");
|
||||
});
|
||||
|
||||
test("permission request notifies once without forcing unread attention state", async () => {
|
||||
|
||||
@@ -551,8 +551,6 @@ interface AgentMetadataPatch {
|
||||
labels?: AgentLabelPatch;
|
||||
}
|
||||
|
||||
const SYSTEM_ERROR_PREFIX = "[System Error]";
|
||||
|
||||
function attachPersistenceCwd(
|
||||
handle: AgentPersistenceHandle | null,
|
||||
cwd: string,
|
||||
@@ -4633,13 +4631,8 @@ export class AgentManager {
|
||||
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
|
||||
agent.lifecycle = "error";
|
||||
}
|
||||
agent.lastError = event.error;
|
||||
await this.appendSystemErrorTimelineMessage(
|
||||
agent,
|
||||
event.provider,
|
||||
this.formatTurnFailedMessage(event),
|
||||
options,
|
||||
);
|
||||
agent.lastError = this.formatTurnFailedMessage(event);
|
||||
await this.appendSystemErrorTimelineMessage(agent, event.provider, agent.lastError, options);
|
||||
this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Turn failed");
|
||||
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
|
||||
this.emitState(agent);
|
||||
@@ -4883,13 +4876,12 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = `${SYSTEM_ERROR_PREFIX} ${normalized}`;
|
||||
const lastItem = await this.getLastItemFromStores(agent.id);
|
||||
if (lastItem?.type === "assistant_message" && lastItem.text === text) {
|
||||
if (lastItem?.type === "error" && lastItem.message === normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item: AgentTimelineItem = { type: "assistant_message", text };
|
||||
const item: AgentTimelineItem = { type: "error", message: normalized };
|
||||
const row = this.recordTimeline(agent.id, item);
|
||||
this.dispatchStream(
|
||||
agent.id,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { join } from "node:path";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { setupFinishNotification, waitForAgentRunStartWithTimeout } from "./agent-prompt.js";
|
||||
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
|
||||
@@ -861,3 +862,101 @@ test("a human pasting an agent envelope stays a human message through provider r
|
||||
clientMessageId: "human-submission",
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
error: "Requested fake provider failure",
|
||||
code: "126",
|
||||
diagnostic: "Provider executable failed",
|
||||
closeChild: true,
|
||||
},
|
||||
{ error: "long failure " + "x".repeat(5000), closeChild: false },
|
||||
])(
|
||||
"errored finish notifications preserve a bounded failure while delivery waits ($closeChild)",
|
||||
async ({ closeChild, ...failure }) => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-finish-error-"));
|
||||
const logger = createTestLogger();
|
||||
let releaseRead!: () => void;
|
||||
let signalRead!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
releaseRead = resolve;
|
||||
});
|
||||
const readStarted = new Promise<void>((resolve) => {
|
||||
signalRead = resolve;
|
||||
});
|
||||
class HoldingStorage extends AgentStorage {
|
||||
blockedAgentId: string | null = null;
|
||||
override async get(id: string) {
|
||||
if (id === this.blockedAgentId) {
|
||||
signalRead();
|
||||
await blocked;
|
||||
}
|
||||
return super.get(id);
|
||||
}
|
||||
}
|
||||
const storage = new HoldingStorage(join(workdir, "agents"), logger);
|
||||
await storage.initialize();
|
||||
const prompts: string[] = [];
|
||||
const manager = new AgentManager({
|
||||
logger,
|
||||
registry: storage,
|
||||
clients: createTestAgentClients({
|
||||
turnFailure: failure,
|
||||
onStartTurn(prompt) {
|
||||
prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt));
|
||||
},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "claude", cwd: workdir, modeId: "bypassPermissions" },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "claude", cwd: workdir, modeId: "bypassPermissions" },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
await manager.runAgent(child.id, "Earlier successful request");
|
||||
const earlierReply = await manager.getLastAssistantMessage(child.id);
|
||||
expect(earlierReply).toBeTruthy();
|
||||
storage.blockedAgentId = parent.id;
|
||||
setupFinishNotification({
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
childAgentId: child.id,
|
||||
callerAgentId: parent.id,
|
||||
logger,
|
||||
});
|
||||
await expect(manager.runAgent(child.id, "Emit a turn failure")).rejects.toThrow(
|
||||
failure.error,
|
||||
);
|
||||
await readStarted;
|
||||
if (closeChild) await manager.closeAgent(child.id);
|
||||
releaseRead();
|
||||
const fullReason = [
|
||||
failure.error,
|
||||
...(failure.code ? [`code: ${failure.code}`] : []),
|
||||
...(failure.diagnostic ? [failure.diagnostic] : []),
|
||||
].join("\n\n");
|
||||
const expectedReason =
|
||||
fullReason.length > 4000
|
||||
? `${fullReason.slice(0, 4000)}\n[truncated ${fullReason.length - 4000} chars; use get_agent_activity for the full response]`
|
||||
: fullReason;
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
parseAgentMessage(prompts.find((prompt) => prompt.includes("errored.")) ?? "")?.text,
|
||||
).toContain(`<agent-error>\n${expectedReason}\n</agent-error>`),
|
||||
);
|
||||
expect(
|
||||
parseAgentMessage(prompts.find((prompt) => prompt.includes("errored.")) ?? "")?.text,
|
||||
).not.toContain("<agent-response>");
|
||||
if (!closeChild) await manager.closeAgent(child.id);
|
||||
await manager.closeAgent(parent.id);
|
||||
} finally {
|
||||
releaseRead();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -430,9 +430,17 @@ interface FinishNotificationBodyInput {
|
||||
title: string;
|
||||
reason: FinishNotificationReason;
|
||||
lastAssistantMessage: string | null;
|
||||
lastError?: string;
|
||||
permissionRequest?: AgentPermissionRequest;
|
||||
}
|
||||
|
||||
function limitFinishNotificationMessage(message: string): string {
|
||||
const text = message.trim();
|
||||
if (text.length <= FINISH_NOTIFICATION_MESSAGE_LIMIT) return text;
|
||||
const omitted = text.length - FINISH_NOTIFICATION_MESSAGE_LIMIT;
|
||||
return `${text.slice(0, FINISH_NOTIFICATION_MESSAGE_LIMIT)}\n[truncated ${omitted} chars; use get_agent_activity for the full response]`;
|
||||
}
|
||||
|
||||
function formatFinishNotificationBody(params: FinishNotificationBodyInput): string {
|
||||
const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`;
|
||||
const sections = [statusLine];
|
||||
@@ -450,18 +458,21 @@ function formatFinishNotificationBody(params: FinishNotificationBodyInput): stri
|
||||
)}\n</permission-request>`,
|
||||
);
|
||||
}
|
||||
let lastAssistantMessage = params.lastAssistantMessage?.trim();
|
||||
if (lastAssistantMessage) {
|
||||
if (lastAssistantMessage.length > FINISH_NOTIFICATION_MESSAGE_LIMIT) {
|
||||
const omitted = lastAssistantMessage.length - FINISH_NOTIFICATION_MESSAGE_LIMIT;
|
||||
lastAssistantMessage = `${lastAssistantMessage.slice(0, FINISH_NOTIFICATION_MESSAGE_LIMIT)}\n[truncated ${omitted} chars; use get_agent_activity for the full response]`;
|
||||
}
|
||||
sections.push(`<agent-response>\n${lastAssistantMessage}\n</agent-response>`);
|
||||
if (params.reason === "errored" && params.lastError) {
|
||||
sections.push(
|
||||
`<agent-error>\n${limitFinishNotificationMessage(params.lastError)}\n</agent-error>`,
|
||||
);
|
||||
}
|
||||
if (params.lastAssistantMessage?.trim()) {
|
||||
sections.push(
|
||||
`<agent-response>\n${limitFinishNotificationMessage(params.lastAssistantMessage)}\n</agent-response>`,
|
||||
);
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
interface NotifySafelyOptions {
|
||||
lastError?: string;
|
||||
terminal?: boolean;
|
||||
permissionRequest?: AgentPermissionRequest;
|
||||
}
|
||||
@@ -505,7 +516,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
|
||||
async function notify(
|
||||
reason: FinishNotificationReason,
|
||||
permissionRequest?: AgentPermissionRequest,
|
||||
{ permissionRequest, lastError }: Pick<NotifySafelyOptions, "permissionRequest" | "lastError">,
|
||||
): Promise<void> {
|
||||
const callerRecord = await agentStorage.get(callerAgentId);
|
||||
if (callerRecord?.archivedAt) {
|
||||
@@ -517,12 +528,14 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
return;
|
||||
}
|
||||
const title = record?.title ?? childAgentId;
|
||||
const lastAssistantMessage = await agentManager.getLastAssistantMessage(childAgentId);
|
||||
const lastAssistantMessage =
|
||||
reason === "errored" ? null : await agentManager.getLastAssistantMessage(childAgentId);
|
||||
const body = formatFinishNotificationBody({
|
||||
childAgentId,
|
||||
title,
|
||||
reason,
|
||||
lastAssistantMessage,
|
||||
lastError,
|
||||
permissionRequest,
|
||||
});
|
||||
|
||||
@@ -546,7 +559,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
if (stopped) return;
|
||||
if (options.terminal ?? true) stop();
|
||||
notificationQueue = notificationQueue
|
||||
.then(() => notify(reason, options.permissionRequest))
|
||||
.then(() => notify(reason, options))
|
||||
.catch((error) => {
|
||||
logger.error(
|
||||
{ err: error, childAgentId, callerAgentId, reason },
|
||||
@@ -572,7 +585,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
// Prior terminal state is not this task's outcome. A failure before turn start
|
||||
// is reported by the dispatch acknowledgement instead of a finish callback.
|
||||
if (agent.lifecycle === "error" && hasSeenTurn) {
|
||||
notifySafely("errored");
|
||||
notifySafely("errored", { lastError: agent.lastError });
|
||||
return;
|
||||
}
|
||||
// Idle is published before the terminal stream event for both completion
|
||||
|
||||
@@ -3296,50 +3296,6 @@ describe("create_agent MCP tool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("notifies the caller when a created child finishes its initial turn before the turn start returns", async () => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-create-fast-child-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const parentClient = new HeldTurnAgentClient("claude", false);
|
||||
const childClient = new HeldTurnAgentClient("codex", false, { finishTurnsDuringStart: true });
|
||||
const agentManager = new AgentManager({
|
||||
clients: { claude: parentClient, codex: childClient },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
const parent = await agentManager.createAgent(
|
||||
{ provider: "claude", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage: storage,
|
||||
callerAgentId: parent.id,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
|
||||
const response = await invokeToolWithParsedInput(registeredTool(server, "create_agent"), {
|
||||
...subagentCurrentWorkspace(),
|
||||
title: "Fast Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Finish immediately",
|
||||
});
|
||||
const childId = z.object({ agentId: z.string() }).parse(response.structuredContent).agentId;
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const parentPrompts = parentClient.sessions[0]!.prompts;
|
||||
expect(parentPrompts).toHaveLength(1);
|
||||
expect(parentPrompts[0]).toContain(childId);
|
||||
expect(parentPrompts[0]).toContain("finished");
|
||||
});
|
||||
} finally {
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
}
|
||||
});
|
||||
|
||||
it("creates detached caller agents without a parent label", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
@@ -3814,11 +3770,6 @@ const HELD_TURN_CAPABILITIES = {
|
||||
supportsToolInvocations: false,
|
||||
} as const;
|
||||
|
||||
interface HeldTurnOptions {
|
||||
/** Run the whole turn before the turn-start request returns. */
|
||||
finishTurnsDuringStart?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider session that records every prompt it receives. With `holdTurns`, a started
|
||||
* turn stays running until `finishTurn()` so a caller's bounded wait can run out first.
|
||||
@@ -3835,7 +3786,6 @@ class HeldTurnAgentSession implements AgentSession {
|
||||
constructor(
|
||||
readonly provider: AgentProvider,
|
||||
private readonly holdTurns: boolean,
|
||||
private readonly options: HeldTurnOptions = {},
|
||||
) {}
|
||||
|
||||
/** Hold the next turn's acknowledgment, as a provider awaiting its turn-start request does. */
|
||||
@@ -3869,13 +3819,6 @@ class HeldTurnAgentSession implements AgentSession {
|
||||
throw error;
|
||||
}
|
||||
const turnId = randomUUID();
|
||||
if (this.options.finishTurnsDuringStart) {
|
||||
// The whole turn runs before the turn-start request returns, as a provider whose
|
||||
// start acknowledgment arrives together with the turn's completion does.
|
||||
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
||||
return { turnId };
|
||||
}
|
||||
this.activeTurnId = turnId;
|
||||
this.pushEvent({
|
||||
type: "timeline",
|
||||
@@ -3983,7 +3926,6 @@ class HeldTurnAgentClient implements AgentClient {
|
||||
constructor(
|
||||
readonly provider: AgentProvider,
|
||||
private readonly holdTurns: boolean,
|
||||
private readonly options: HeldTurnOptions = {},
|
||||
) {}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
@@ -3991,7 +3933,7 @@ class HeldTurnAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async createSession(): Promise<AgentSession> {
|
||||
const session = new HeldTurnAgentSession(this.provider, this.holdTurns, this.options);
|
||||
const session = new HeldTurnAgentSession(this.provider, this.holdTurns);
|
||||
this.sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -1750,7 +1750,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private fallbackAssistantMessageId: string | null = null;
|
||||
private closed = false;
|
||||
private historyPending = false;
|
||||
private historyRuntimeReleased = false;
|
||||
private replayingHistory = false;
|
||||
private bootstrapThreadEventPending = false;
|
||||
private readonly terminateProcess: ProcessTerminator;
|
||||
@@ -1868,38 +1867,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
await this.applyConfiguredOverrides();
|
||||
|
||||
if (this.resumePurpose === "history") {
|
||||
await this.releaseHistoryRuntime();
|
||||
}
|
||||
} catch (error) {
|
||||
await this.closeAfterInitializationFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
// History resumes exist to replay persisted events into the canonical
|
||||
// timeline; the provider process is released once replay finishes so
|
||||
// archived agents hold no resident runtime. Interactive work goes through
|
||||
// reloadAgentSession, which spawns a fresh process.
|
||||
private async releaseHistoryRuntime(): Promise<void> {
|
||||
const child = this.child;
|
||||
if (child) {
|
||||
const result = await this.terminateProcess(child, {
|
||||
gracefulTimeoutMs: 2_000,
|
||||
forceTimeoutMs: 2_000,
|
||||
});
|
||||
if (result === "kill-timeout") {
|
||||
// Keep the child registered so close() can retry the shutdown.
|
||||
this.logger.warn("ACP history resume could not release the provider process");
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.historyRuntimeReleased = true;
|
||||
this.connection = null;
|
||||
this.child = null;
|
||||
this.settleCommandsReady();
|
||||
}
|
||||
|
||||
private async closeAfterInitializationFailure(error: unknown): Promise<never> {
|
||||
try {
|
||||
await this.close();
|
||||
@@ -2869,7 +2841,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
});
|
||||
const spawnError = rejectOnSpawnError(child, stderrChunks);
|
||||
child.once("exit", (code, signal) => {
|
||||
if (this.closed || this.historyRuntimeReleased) {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
if (this.activeForegroundTurnId) {
|
||||
|
||||
+10
-17
@@ -1,5 +1,5 @@
|
||||
import { createServer } from "node:http";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { expect, test } from "vitest";
|
||||
@@ -13,18 +13,12 @@ test.each([
|
||||
{ placement: "delta", cacheTokens: 0 },
|
||||
{ placement: "start", cacheTokens: 1_000 },
|
||||
{ placement: "delta", cacheTokens: 1_000 },
|
||||
{ placement: "start", cacheTokens: 0, model: "glm-5.2[1m]" },
|
||||
{ placement: "start", cacheTokens: 0, model: "sonnet", resolvedModel: "glm-5.2[1m]" },
|
||||
])(
|
||||
"counts $placement input and $cacheTokens cached tokens through Claude Code ($model)",
|
||||
async ({ placement, cacheTokens, model = "claude-sonnet-4-6", resolvedModel = model }) => {
|
||||
"counts $placement input and $cacheTokens cached tokens through Claude Code",
|
||||
async ({ placement, cacheTokens }) => {
|
||||
const cwd = await mkdtemp(path.resolve(".claude-context-usage-"));
|
||||
const configDir = path.join(cwd, "config");
|
||||
await mkdir(configDir);
|
||||
await writeFile(
|
||||
path.join(configDir, "settings.json"),
|
||||
JSON.stringify({ env: { ANTHROPIC_DEFAULT_SONNET_MODEL: resolvedModel } }),
|
||||
);
|
||||
let requestCount = 0;
|
||||
const server = createServer(async (request, response) => {
|
||||
for await (const chunk of request) {
|
||||
@@ -51,7 +45,7 @@ test.each([
|
||||
id: `msg_context_usage_${requestCount}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: resolvedModel,
|
||||
model: "claude-sonnet-4-6",
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
@@ -92,9 +86,11 @@ test.each([
|
||||
},
|
||||
},
|
||||
});
|
||||
const session = await client.createSession({ provider: "claude", cwd, model }, undefined, {
|
||||
persistSession: false,
|
||||
});
|
||||
const session = await client.createSession(
|
||||
{ provider: "claude", cwd, model: "claude-sonnet-4-6" },
|
||||
undefined,
|
||||
{ persistSession: false },
|
||||
);
|
||||
try {
|
||||
for (let turn = 0; turn < 2; turn++) {
|
||||
const events = await collectSessionTurnEvents(session, "say hello", {
|
||||
@@ -115,10 +111,7 @@ test.each([
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "usage_updated",
|
||||
usage: expect.objectContaining({
|
||||
contextWindowUsedTokens: expectedTokens,
|
||||
contextWindowMaxTokens: resolvedModel.endsWith("[1m]") ? 1_000_000 : 200_000,
|
||||
}),
|
||||
usage: expect.objectContaining({ contextWindowUsedTokens: expectedTokens }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import type { ClaudeQueryFactory } from "./query.js";
|
||||
|
||||
// A query dependency that speaks the same stream interface as Claude Code.
|
||||
// Each request repeats init so a second turn exercises runtime-limit retention.
|
||||
function contextQuery(runtimeModel: string, reportedLimit: number): ClaudeQueryFactory {
|
||||
return ({ prompt }) => {
|
||||
let model = runtimeModel;
|
||||
const messages = (async function* () {
|
||||
if (typeof prompt === "string") throw new Error("Expected streaming input");
|
||||
for await (const _message of prompt) {
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "context-session",
|
||||
permissionMode: "default",
|
||||
model,
|
||||
};
|
||||
yield {
|
||||
type: "stream_event",
|
||||
session_id: "context-session",
|
||||
event: { type: "message_start", message: { usage: { input_tokens: 249_950 } } },
|
||||
};
|
||||
yield {
|
||||
type: "stream_event",
|
||||
session_id: "context-session",
|
||||
event: { type: "message_delta", usage: { output_tokens: 50 } },
|
||||
};
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: "context-session",
|
||||
duration_ms: 1,
|
||||
duration_api_ms: 1,
|
||||
is_error: false,
|
||||
num_turns: 1,
|
||||
result: "done",
|
||||
stop_reason: null,
|
||||
total_cost_usd: 0,
|
||||
usage: { input_tokens: 249_950, output_tokens: 50, cache_read_input_tokens: 0 },
|
||||
modelUsage: { [model]: { contextWindow: reportedLimit } },
|
||||
permission_denials: [],
|
||||
uuid: "context-result",
|
||||
};
|
||||
}
|
||||
})();
|
||||
return Object.assign(messages, {
|
||||
close: () => {
|
||||
void messages.return();
|
||||
},
|
||||
interrupt: async () => undefined,
|
||||
setModel: async (value: string) => {
|
||||
model = value;
|
||||
},
|
||||
setPermissionMode: async () => undefined,
|
||||
supportedModels: async () => [],
|
||||
supportedCommands: async () => [],
|
||||
}) as ReturnType<ClaudeQueryFactory>;
|
||||
};
|
||||
}
|
||||
|
||||
test.each([
|
||||
{ selected: "glm-5.2[1m]", runtime: "glm-5.2[1m]", expected: 1_000_000 },
|
||||
{ selected: "sonnet", runtime: "glm-5.2[1m]", expected: 1_000_000 },
|
||||
{ selected: "glm-5.2", runtime: "glm-5.2", expected: undefined },
|
||||
])(
|
||||
"streams the context limit for $selected resolved as $runtime",
|
||||
async ({ selected, runtime, expected }) => {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: contextQuery(runtime, 800_000),
|
||||
resolveBinary: async () => process.execPath,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: selected,
|
||||
});
|
||||
try {
|
||||
const first = await collectSessionTurnEvents(session, "first turn");
|
||||
const live = first.filter((event) => event.type === "usage_updated");
|
||||
expect(live.map((event) => event.usage.contextWindowMaxTokens)).toEqual([expected, expected]);
|
||||
expect(live.map((event) => event.usage.contextWindowUsedTokens)).toEqual([249_950, 250_000]);
|
||||
expect(first).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "turn_completed",
|
||||
usage: expect.objectContaining({ contextWindowMaxTokens: 800_000 }),
|
||||
}),
|
||||
);
|
||||
|
||||
const second = await collectSessionTurnEvents(session, "second turn");
|
||||
expect(
|
||||
second
|
||||
.filter((event) => event.type === "usage_updated")
|
||||
.map((event) => event.usage.contextWindowMaxTokens),
|
||||
).toEqual([800_000, 800_000]);
|
||||
|
||||
await session.setModel?.("another-custom-model");
|
||||
const switched = await collectSessionTurnEvents(session, "after model switch");
|
||||
expect(
|
||||
switched
|
||||
.filter((event) => event.type === "usage_updated")
|
||||
.map((event) => event.usage.contextWindowMaxTokens),
|
||||
).toEqual([undefined, undefined]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -2805,7 +2805,6 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 150,
|
||||
},
|
||||
}),
|
||||
@@ -2841,13 +2840,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
|
||||
test("selected 1M Claude models seed active context window usage from the catalog", async () => {
|
||||
const session = await createSessionForTurns(
|
||||
[
|
||||
[
|
||||
{ ...createInitMessage(), model: "claude-sonnet-5[1m]" },
|
||||
createMessageStartEvent(),
|
||||
createSuccessResult(),
|
||||
],
|
||||
],
|
||||
[[createInitMessage(), createMessageStartEvent(), createSuccessResult()]],
|
||||
{ model: "claude-sonnet-5[1m]" },
|
||||
);
|
||||
|
||||
@@ -2887,7 +2880,6 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 175,
|
||||
},
|
||||
}),
|
||||
@@ -2930,7 +2922,6 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 20_300,
|
||||
},
|
||||
}),
|
||||
@@ -2972,7 +2963,6 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 1_069,
|
||||
},
|
||||
}),
|
||||
@@ -3036,7 +3026,6 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 55,
|
||||
},
|
||||
}),
|
||||
@@ -3046,7 +3035,6 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 62,
|
||||
},
|
||||
}),
|
||||
@@ -3084,7 +3072,6 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 704,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
readTaskNotificationToolUseIdFromHistoryRecord,
|
||||
} from "./task-notification-tool-call.js";
|
||||
import {
|
||||
getClaudeContextWindowMaxTokens,
|
||||
findClaudeModel,
|
||||
getClaudeModelsWithSettings,
|
||||
normalizeClaudeRuntimeModelId,
|
||||
resolveConfiguredClaudeModel,
|
||||
@@ -2240,7 +2240,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.resolveBinary = options.resolveBinary;
|
||||
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
|
||||
this.contextUsage = new ClaudeContextUsageState(
|
||||
getClaudeContextWindowMaxTokens(this.config.model),
|
||||
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
|
||||
);
|
||||
const handle = options.handle;
|
||||
|
||||
@@ -2572,7 +2572,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
await this.applyFastModeFeature(false, activeQuery);
|
||||
}
|
||||
this.contextUsage.setInitialContextWindowMaxTokens(
|
||||
getClaudeContextWindowMaxTokens(this.config.model),
|
||||
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
|
||||
);
|
||||
this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel;
|
||||
this.lastRuntimeModel = null;
|
||||
@@ -2898,11 +2898,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.startFreshConversationSession();
|
||||
return;
|
||||
}
|
||||
const currentSessionId = this.claudeSessionId;
|
||||
await revertClaudeConversation({
|
||||
sdk: this.rewindSdk,
|
||||
sessionId: currentSessionId,
|
||||
transcriptPath: currentSessionId ? this.resolveHistoryPath(currentSessionId) : null,
|
||||
sessionId: this.claudeSessionId,
|
||||
messageId: target.messageId,
|
||||
resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId),
|
||||
setSessionId: (sessionId) => {
|
||||
@@ -4823,12 +4821,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.observePermissionMode(message.permissionMode);
|
||||
this.persistence = null;
|
||||
if (message.model) {
|
||||
const runtimeContextWindowMaxTokens = getClaudeContextWindowMaxTokens(message.model);
|
||||
// Seed usage before the first result, including settings-resolved aliases.
|
||||
// A repeated init for the same model must retain the SDK's reported limit.
|
||||
if (message.model !== this.lastRuntimeModel && runtimeContextWindowMaxTokens !== undefined) {
|
||||
this.contextUsage.setInitialContextWindowMaxTokens(runtimeContextWindowMaxTokens);
|
||||
}
|
||||
const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model);
|
||||
this.logger.debug(
|
||||
{ runtimeModel: message.model, normalizedRuntimeModel },
|
||||
|
||||
@@ -15,27 +15,7 @@ import {
|
||||
} from "./model-manifest.js";
|
||||
import { discoverClaudeModels } from "./model-discovery.js";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
findClaudeModel,
|
||||
getClaudeContextWindowMaxTokens,
|
||||
getClaudeModels,
|
||||
normalizeClaudeRuntimeModelId,
|
||||
} from "./models.js";
|
||||
|
||||
describe("getClaudeContextWindowMaxTokens", () => {
|
||||
it.each([
|
||||
["glm-5.2[1m]", 1_000_000],
|
||||
[" glm-5.2[1M] ", 1_000_000],
|
||||
["claude-sonnet-4-6", 200_000],
|
||||
["claude-sonnet-4-6[1m]", 1_000_000],
|
||||
["glm-5.2", undefined],
|
||||
["glm-5.2[1m]-other", undefined],
|
||||
[undefined, undefined],
|
||||
[null, undefined],
|
||||
])("resolves %s to %s without guessing unknown models", (model, expected) => {
|
||||
expect(getClaudeContextWindowMaxTokens(model)).toBe(expected);
|
||||
});
|
||||
});
|
||||
import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
|
||||
|
||||
const createdClaudeConfigDirs: string[] = [];
|
||||
|
||||
|
||||
@@ -48,15 +48,6 @@ export function findClaudeModel(
|
||||
return getClaudeModels().find((model) => model.id === normalizedModelId);
|
||||
}
|
||||
|
||||
export function getClaudeContextWindowMaxTokens(
|
||||
modelId: string | null | undefined,
|
||||
): number | undefined {
|
||||
if (modelId?.trim().toLowerCase().endsWith("[1m]")) {
|
||||
return 1_000_000;
|
||||
}
|
||||
return findClaudeModel(modelId)?.contextWindowMaxTokens;
|
||||
}
|
||||
|
||||
export async function getClaudeModelsWithSettings(
|
||||
logger: Logger,
|
||||
configDir: string,
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import {
|
||||
revertClaudeConversation,
|
||||
revertClaudeConversationAndFiles,
|
||||
revertClaudeFiles,
|
||||
realClaudeRewindSdk,
|
||||
} from "./rewind.js";
|
||||
import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js";
|
||||
|
||||
@@ -21,7 +16,6 @@ describe("Claude rewind", () => {
|
||||
await revertClaudeConversation({
|
||||
sdk: claude,
|
||||
sessionId,
|
||||
transcriptPath: "/claude/projects/repo/original-session.jsonl",
|
||||
messageId: "user-message-1",
|
||||
setSessionId: (nextSessionId) => {
|
||||
sessionId = nextSessionId;
|
||||
@@ -39,7 +33,6 @@ describe("Claude rewind", () => {
|
||||
await revertClaudeConversation({
|
||||
sdk: claude,
|
||||
sessionId,
|
||||
transcriptPath: "/claude/projects/repo/original-session.jsonl",
|
||||
messageId: "timeline-message-1",
|
||||
resolveMessageId: () => "claude-jsonl-message-1",
|
||||
setSessionId: (nextSessionId) => {
|
||||
@@ -83,7 +76,6 @@ describe("Claude rewind", () => {
|
||||
sdk: claude,
|
||||
query: claude.createQuery() as Query,
|
||||
sessionId,
|
||||
transcriptPath: "/claude/projects/repo/original-session.jsonl",
|
||||
messageId: "user-message-1",
|
||||
setSessionId: (nextSessionId) => {
|
||||
sessionId = nextSessionId;
|
||||
@@ -95,126 +87,3 @@ describe("Claude rewind", () => {
|
||||
expect(sessionId).toBe("forked-before-rehydrate");
|
||||
});
|
||||
});
|
||||
|
||||
interface TranscriptLine {
|
||||
type: "user" | "assistant";
|
||||
uuid: string;
|
||||
parentUuid: string | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function writeTranscript(
|
||||
transcriptPath: string,
|
||||
sessionId: string,
|
||||
lines: Array<TranscriptLine | string>,
|
||||
): void {
|
||||
const entries = lines.map((line) => {
|
||||
if (typeof line === "string") return line;
|
||||
const { type, uuid, parentUuid, text } = line;
|
||||
return JSON.stringify({
|
||||
type,
|
||||
uuid,
|
||||
parentUuid,
|
||||
sessionId,
|
||||
cwd: "/repo",
|
||||
timestamp: new Date().toISOString(),
|
||||
message: { role: type, content: text },
|
||||
});
|
||||
});
|
||||
writeFileSync(transcriptPath, `${entries.join("\n")}\n`, "utf8");
|
||||
}
|
||||
|
||||
function readMessageTexts(transcriptPath: string): string[] {
|
||||
const entries = readFileSync(transcriptPath, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as { message?: { content: string } });
|
||||
return entries.flatMap((entry) => (entry.message ? [entry.message.content] : []));
|
||||
}
|
||||
|
||||
describe("Claude rewind against the Claude SDK", () => {
|
||||
let tempRoot: string | null = null;
|
||||
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
afterEach(() => {
|
||||
if (tempRoot) rmSync(tempRoot, { recursive: true, force: true });
|
||||
tempRoot = null;
|
||||
if (previousConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
|
||||
}
|
||||
});
|
||||
|
||||
test("forks a session that lives in a provider's own CLAUDE_CONFIG_DIR", async () => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-config-dir-"));
|
||||
// The provider sets CLAUDE_CONFIG_DIR for Claude Code; the daemon's own environment does not.
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
const projectDir = path.join(tempRoot, "custom-claude-dir", "projects", "-repo");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const sessionId = randomUUID();
|
||||
const firstUser = randomUUID();
|
||||
const firstReply = randomUUID();
|
||||
const secondUser = randomUUID();
|
||||
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
writeTranscript(transcriptPath, sessionId, [
|
||||
{ type: "user", uuid: firstUser, parentUuid: null, text: "first" },
|
||||
{ type: "assistant", uuid: firstReply, parentUuid: firstUser, text: "first reply" },
|
||||
{ type: "user", uuid: secondUser, parentUuid: firstReply, text: "second" },
|
||||
{ type: "assistant", uuid: randomUUID(), parentUuid: secondUser, text: "second reply" },
|
||||
]);
|
||||
let currentSessionId = sessionId;
|
||||
|
||||
await revertClaudeConversation({
|
||||
sdk: realClaudeRewindSdk,
|
||||
sessionId,
|
||||
transcriptPath,
|
||||
messageId: firstReply,
|
||||
setSessionId: (nextSessionId) => {
|
||||
currentSessionId = nextSessionId;
|
||||
},
|
||||
});
|
||||
|
||||
expect(currentSessionId).not.toBe(sessionId);
|
||||
expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([
|
||||
"first",
|
||||
"first reply",
|
||||
]);
|
||||
});
|
||||
|
||||
test("skips transcript lines that are not entries when forking", async () => {
|
||||
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-config-dir-"));
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
const projectDir = path.join(tempRoot, "custom-claude-dir", "projects", "-repo");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const sessionId = randomUUID();
|
||||
const firstUser = randomUUID();
|
||||
const firstReply = randomUUID();
|
||||
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
writeTranscript(transcriptPath, sessionId, [
|
||||
"null",
|
||||
{ type: "user", uuid: firstUser, parentUuid: null, text: "first" },
|
||||
"[1, 2]",
|
||||
'"text"',
|
||||
"{}",
|
||||
{ type: "assistant", uuid: firstReply, parentUuid: firstUser, text: "first reply" },
|
||||
{ type: "user", uuid: randomUUID(), parentUuid: firstReply, text: "second" },
|
||||
]);
|
||||
let currentSessionId = sessionId;
|
||||
|
||||
await revertClaudeConversation({
|
||||
sdk: realClaudeRewindSdk,
|
||||
sessionId,
|
||||
transcriptPath,
|
||||
messageId: firstReply,
|
||||
setSessionId: (nextSessionId) => {
|
||||
currentSessionId = nextSessionId;
|
||||
},
|
||||
});
|
||||
|
||||
expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([
|
||||
"first",
|
||||
"first reply",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,90 +1,29 @@
|
||||
import { appendFile, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
forkSession as claudeForkSession,
|
||||
type Query,
|
||||
type SessionStore,
|
||||
type SessionStoreEntry,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { forkSession as claudeForkSession, type Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export interface ClaudeRewindSdk {
|
||||
forkSession(
|
||||
sessionId: string,
|
||||
options: { upToMessageId: string; transcriptPath: string },
|
||||
options: { upToMessageId: string },
|
||||
): Promise<{ sessionId: string }>;
|
||||
}
|
||||
|
||||
// On its own, the SDK looks for the session under the daemon's CLAUDE_CONFIG_DIR, while a
|
||||
// provider can point Claude Code at another one. Forking through a store rooted at the
|
||||
// transcript's folder reads the session where Claude Code wrote it and writes the fork beside it.
|
||||
export const realClaudeRewindSdk: ClaudeRewindSdk = {
|
||||
forkSession: (sessionId, options) =>
|
||||
claudeForkSession(sessionId, {
|
||||
upToMessageId: options.upToMessageId,
|
||||
sessionStore: projectFolderSessionStore(path.dirname(options.transcriptPath)),
|
||||
}),
|
||||
forkSession: claudeForkSession,
|
||||
};
|
||||
|
||||
function projectFolderSessionStore(projectFolder: string): SessionStore {
|
||||
const transcriptPath = (sessionId: string) => path.join(projectFolder, `${sessionId}.jsonl`);
|
||||
return {
|
||||
async load({ sessionId }) {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(transcriptPath(sessionId), "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
return parseTranscriptEntries(text);
|
||||
},
|
||||
async append({ sessionId }, entries) {
|
||||
const lines = entries.map((entry) => `${JSON.stringify(entry)}\n`).join("");
|
||||
await appendFile(transcriptPath(sessionId), lines, "utf8");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseTranscriptEntries(text: string): SessionStoreEntry[] {
|
||||
const entries: SessionStoreEntry[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
// Skip a line that is not JSON, as the SDK's own transcript reader does.
|
||||
continue;
|
||||
}
|
||||
if (isSessionStoreEntry(parsed)) entries.push(parsed);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function isSessionStoreEntry(value: unknown): value is SessionStoreEntry {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
typeof (value as { type?: unknown }).type === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export async function revertClaudeConversation(input: {
|
||||
sdk: ClaudeRewindSdk;
|
||||
sessionId: string | null;
|
||||
transcriptPath: string | null;
|
||||
messageId: string;
|
||||
resolveMessageId?: (messageId: string) => string | Promise<string>;
|
||||
setSessionId: (sessionId: string) => void;
|
||||
}): Promise<void> {
|
||||
if (!input.sessionId || !input.transcriptPath) {
|
||||
if (!input.sessionId) {
|
||||
throw new Error("Claude session is not ready for rewind");
|
||||
}
|
||||
const messageId = (await input.resolveMessageId?.(input.messageId)) ?? input.messageId;
|
||||
const fork = await input.sdk.forkSession(input.sessionId, {
|
||||
upToMessageId: messageId,
|
||||
transcriptPath: input.transcriptPath,
|
||||
});
|
||||
input.setSessionId(fork.sessionId);
|
||||
}
|
||||
@@ -105,7 +44,6 @@ export async function revertClaudeConversationAndFiles(input: {
|
||||
sdk: ClaudeRewindSdk;
|
||||
query: Query;
|
||||
sessionId: string | null;
|
||||
transcriptPath: string | null;
|
||||
messageId: string;
|
||||
resolveMessageId?: (messageId: string) => string | Promise<string>;
|
||||
setSessionId: (sessionId: string) => void;
|
||||
|
||||
@@ -12,7 +12,7 @@ export class FakeClaudeSdk implements ClaudeRewindSdk {
|
||||
|
||||
async forkSession(
|
||||
_sessionId: string,
|
||||
options: { upToMessageId: string; transcriptPath: string },
|
||||
options: { upToMessageId: string },
|
||||
): Promise<{ sessionId: string }> {
|
||||
this.recordedForks.push({ upToMessageId: options.upToMessageId });
|
||||
return { sessionId: this.nextSessionId };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user