mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d86cb4cb2b | ||
|
|
fc8f085b9e | ||
|
|
019e5279e9 | ||
|
|
4a3ff87eec | ||
|
|
a4f1df3aed | ||
|
|
a4b15455b7 | ||
|
|
bd9af256bd | ||
|
|
b1a638482d | ||
|
|
12aaded6c5 | ||
|
|
0614e96ee0 | ||
|
|
e135e9c101 | ||
|
|
335a11eb82 | ||
|
|
17562fcb66 | ||
|
|
5805b776c2 | ||
|
|
4400445edb | ||
|
|
337e81a9c6 | ||
|
|
b923cde6cf | ||
|
|
058375dac8 | ||
|
|
eb051ea81e | ||
|
|
1839de5b1d | ||
|
|
ec6393f31f | ||
|
|
b5e51cf839 | ||
|
|
b0f054de90 | ||
|
|
e8108d9092 | ||
|
|
9eaf7ad0c1 | ||
|
|
be0acb2935 |
@@ -93,3 +93,6 @@ app.json
|
||||
|
||||
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
|
||||
*.heapsnapshot
|
||||
|
||||
# Project-local Serena configuration and memories.
|
||||
.serena/
|
||||
|
||||
@@ -54,7 +54,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||
| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar and ordinary side-pane host contracts, lifecycle, placement, and routing preferences |
|
||||
| [docs/explorer-sidebar.md](docs/explorer-sidebar.md) | Explorer sidebar, side-pane, and compact workspace sheet host contracts, lifecycle, placement, routing preferences |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
|
||||
|
||||
@@ -151,11 +151,12 @@ 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. 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
|
||||
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
|
||||
outside Paseo while its Paseo agent is active.
|
||||
|
||||
Provider session connection owns every process it spawns until the session is registered with
|
||||
@@ -211,7 +212,7 @@ parentAgentId === thisAgent.id AND !archivedAt
|
||||
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
|
||||
|
||||
Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in the [workspace sheet](explorer-sidebar.md#workspace-sheet) instead; the sheet hides the composer, and a Paseo subagent's sheet offers **Open as tab** to get it back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ contracts.
|
||||
| ---------------- | ---------------------------- | ------------------------------------------ |
|
||||
| Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock |
|
||||
| Side pane | Ordinary workspace content | Created and closed like any workspace pane |
|
||||
| Workspace sheet | Compact look-ups | Holds one tab; closes or becomes a tab |
|
||||
|
||||
## Panel host contract
|
||||
|
||||
@@ -22,6 +23,24 @@ can move between Explorer and main panes.
|
||||
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
|
||||
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
|
||||
|
||||
## Workspace sheet
|
||||
|
||||
On compact layouts, files and subagents open in a closable sheet over the workspace, so the tab
|
||||
switcher keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest
|
||||
(`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between
|
||||
sheet and tab, so callers open targets the same way on every layout. The sheet holds one ordinary
|
||||
`WorkspaceTab`, which is not persisted.
|
||||
|
||||
The sheet is modal and covers the whole compact column, header included. It renders in the
|
||||
workspace tree rather than through `IsolatedBottomSheetModal`: its panel needs the navigator and
|
||||
every workspace provider an ordinary pane has, and a portal drops them
|
||||
([floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site)).
|
||||
|
||||
A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
|
||||
Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the
|
||||
mounted view, so the move would drop it. Widening the layout is the known exception: it still moves
|
||||
the sheet's tab into a pane, and edits the autosave has not started writing are lost.
|
||||
|
||||
## Explorer sidebar
|
||||
|
||||
`packages/app/src/workspace-tabs/explorer-sidebar.ts` owns show, hide, toggle, and view selection.
|
||||
@@ -62,7 +81,9 @@ lifecycle.
|
||||
|
||||
`packages/app/src/workspace-tabs/open-beside.ts` owns content opened beside the user's work. The
|
||||
layout store remembers one ordinary pane per workspace. The first side open creates a full-height
|
||||
right split around the workspace root; later side opens reuse it.
|
||||
right split around the workspace root; later side opens reuse it. Only the `split-panes` layout
|
||||
mode opens beside. Compact and single-pane layouts (touch tablets) open the same target in the main
|
||||
pane, so callers never check whether splits render.
|
||||
|
||||
Removing a pane clears its remembered id; a later side open creates a new pane. The last visible
|
||||
ordinary pane stays when its final tab closes and shows the New launcher. An empty workspace does
|
||||
|
||||
@@ -304,7 +304,9 @@ track phase.
|
||||
|
||||
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This
|
||||
includes native centered dialogs: separate React Native Modals cannot present a root-owned
|
||||
sibling dialog over an existing iOS presenter.
|
||||
sibling dialog over an existing iOS presenter. The one exception is the
|
||||
[workspace sheet](explorer-sidebar.md#workspace-sheet), an in-tree Gorhom `BottomSheet` that hosts a
|
||||
whole workspace panel.
|
||||
|
||||
The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary
|
||||
but stores it in the modal queue and later reads `.current`; provider dismissal, replacement,
|
||||
|
||||
@@ -21,6 +21,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
|
||||
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
|
||||
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
|
||||
- **Workspace sheet** — The compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `sheetByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `WorkspaceSheetHost` (`packages/app/src/screens/workspace/workspace-sheet-host.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#workspace-sheet).
|
||||
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
|
||||
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
|
||||
@@ -4,7 +4,7 @@ Paseo proxies HTTP traffic to services running inside your workspaces. Localhost
|
||||
|
||||
## How it works
|
||||
|
||||
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port.
|
||||
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port. HTTP requests, WebSocket upgrades, and service health checks select the reachable IPv4 or IPv6 loopback address. Services can bind to `127.0.0.1` or `::1` without changing their proxy URL.
|
||||
|
||||
The generated hostname is built from the script name, branch, and project:
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function changesTree(page: Page) {
|
||||
return page.getByTestId("changes-tree-panel").filter({ visible: true });
|
||||
}
|
||||
|
||||
async function expectEmptyChanges(page: Page) {
|
||||
await expect(changesTree(page).getByText("No changes to display", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function editReadme(repoPath: string, text: string) {
|
||||
await appendFile(path.join(repoPath, "README.md"), text);
|
||||
}
|
||||
|
||||
async function expectReadmeChanges(page: Page, additions: number) {
|
||||
await expect(changesTree(page).getByText("README.md", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(changesTree(page)).toContainText(`+${additions}`);
|
||||
}
|
||||
|
||||
async function openEmptyDiff(page: Page) {
|
||||
await changesTree(page).getByTestId("changes-options-menu").click();
|
||||
await page.getByTestId("changes-open-tab").click();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("working-diff-panel")
|
||||
.filter({ visible: true })
|
||||
.getByText("No changes to display", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectReadmeDiff(page: Page, additions: number) {
|
||||
await expect(
|
||||
page.getByTestId("working-diff-panel").filter({ visible: true }).getByTestId("diff-file-0"),
|
||||
).toHaveAccessibleName(`README.md, +${additions}, -0`, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function openDiffFromNewTabMenu(page: Page) {
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first().click();
|
||||
await page.getByTestId("workspace-new-tab-menu-diff").filter({ visible: true }).first().click();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("working-diff-panel")
|
||||
.filter({ visible: true })
|
||||
.getByText("No changes to display", { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function refreshDiff(page: Page) {
|
||||
await page
|
||||
.getByTestId("working-diff-panel")
|
||||
.filter({ visible: true })
|
||||
.getByTestId("changes-refresh")
|
||||
.click();
|
||||
}
|
||||
|
||||
async function reloadWorkspace(page: Page) {
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
test("Changes follows the first edit to a clean workspace without reloading", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace();
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
await openChangesTreePanel(page);
|
||||
await expectEmptyChanges(page);
|
||||
|
||||
await editReadme(workspace.repoPath, "first edit\n");
|
||||
await expectReadmeChanges(page, 1);
|
||||
|
||||
await editReadme(workspace.repoPath, "second edit\n");
|
||||
await expectReadmeChanges(page, 2);
|
||||
});
|
||||
|
||||
test("the full diff follows the first edit to a clean workspace without reloading", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace();
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
await openChangesTreePanel(page);
|
||||
await expectEmptyChanges(page);
|
||||
await openEmptyDiff(page);
|
||||
|
||||
await editReadme(workspace.repoPath, "first edit\n");
|
||||
await expectReadmeDiff(page, 1);
|
||||
|
||||
await editReadme(workspace.repoPath, "second edit\n");
|
||||
await expectReadmeDiff(page, 2);
|
||||
});
|
||||
|
||||
test("a directly opened Diff tab follows the first edit without an active Changes tree", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace();
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await workspace.navigateTo();
|
||||
await openDiffFromNewTabMenu(page);
|
||||
|
||||
await editReadme(workspace.repoPath, "first edit\n");
|
||||
await expectReadmeDiff(page, 1);
|
||||
|
||||
await editReadme(workspace.repoPath, "second edit\n");
|
||||
await expectReadmeDiff(page, 2);
|
||||
|
||||
await refreshDiff(page);
|
||||
await expectReadmeDiff(page, 2);
|
||||
await reloadWorkspace(page);
|
||||
await expectReadmeDiff(page, 2);
|
||||
await editReadme(workspace.repoPath, "third edit\n");
|
||||
await expectReadmeDiff(page, 3);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { openSubagentFromCompactTrack, seedSubagent } from "../support/helpers/subagents";
|
||||
import {
|
||||
closeWorkspaceSheet,
|
||||
expectOpenTabCount,
|
||||
openWorkspaceSheetAsTab,
|
||||
expectDeclinedDiscardPrompt,
|
||||
expectWorkspaceSheetAtSnap,
|
||||
replaceWorkspaceSheetFileText,
|
||||
selectTabFromSwitcher,
|
||||
tapWorkspaceSheetBackdrop,
|
||||
workspaceSheet,
|
||||
} from "../support/helpers/workspace-sheet";
|
||||
|
||||
const FILE_LINK = "linked.ts:3";
|
||||
|
||||
function promptMentioning(target: string): string {
|
||||
return [
|
||||
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||
"Return JSON only with fields 'title' and 'branch'.",
|
||||
"",
|
||||
"<user-prompt>",
|
||||
`Open \`${target}\` now`,
|
||||
"</user-prompt>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
test("compact opens files and subagents in a sheet, leaving the tabs for agents", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "compact-workspace-sheet-",
|
||||
title: "Sheet parent",
|
||||
initialPrompt: promptMentioning(FILE_LINK),
|
||||
});
|
||||
try {
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const linkedPath = path.join(workspace.cwd, "linked.ts");
|
||||
await writeFile(linkedPath, "one\ntwo\nthree\n", "utf8");
|
||||
const child = await seedSubagent(
|
||||
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||
{ parentId: workspace.agentId, title: "Sheet child" },
|
||||
);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||
await expectOpenTabCount(page, 1);
|
||||
|
||||
await test.step("a chat file link opens the file in a sheet", async () => {
|
||||
const fileLink = page.getByText(FILE_LINK, { exact: true });
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.click();
|
||||
|
||||
const sheet = workspaceSheet(page, "linked.ts");
|
||||
await expect(sheet).toBeVisible();
|
||||
await expect(sheet.getByText("three")).toBeVisible();
|
||||
await expectWorkspaceSheetAtSnap(page, "linked.ts");
|
||||
await expect(sheet.getByRole("button", { name: "Open as tab" })).toHaveCount(0);
|
||||
await expectOpenTabCount(page, 1);
|
||||
await closeWorkspaceSheet(page, "linked.ts");
|
||||
});
|
||||
|
||||
await test.step("a subagent opens in a sheet without its composer", async () => {
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
|
||||
const sheet = workspaceSheet(page, child.title);
|
||||
await expect(sheet).toBeVisible();
|
||||
await expectWorkspaceSheetAtSnap(page, child.title);
|
||||
await expect(sheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("Open as tab promotes the subagent to a full tab", async () => {
|
||||
await openWorkspaceSheetAsTab(page, child.title);
|
||||
await expectOpenTabCount(page, 2);
|
||||
await expectComposerVisible(page);
|
||||
});
|
||||
|
||||
await test.step("a sheet with unsaved edits closes only through the guarded close", async () => {
|
||||
await selectTabFromSwitcher(page, "Sheet parent");
|
||||
await page.getByText(FILE_LINK, { exact: true }).click();
|
||||
const sheet = workspaceSheet(page, "linked.ts");
|
||||
await expect(sheet).toBeVisible();
|
||||
// Hold the autosave so the edit stays unsaved.
|
||||
gate.holdNextClientRequest("fs.file.write.request");
|
||||
await replaceWorkspaceSheetFileText(page, "linked.ts", "edited in the sheet");
|
||||
await gate.waitForHeldClientRequest();
|
||||
|
||||
await expectDeclinedDiscardPrompt(page, () => tapWorkspaceSheetBackdrop(page));
|
||||
await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet");
|
||||
await expectDeclinedDiscardPrompt(page, () =>
|
||||
sheet.getByRole("button", { name: "Close" }).click(),
|
||||
);
|
||||
await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet");
|
||||
|
||||
gate.releaseHeldClientRequest();
|
||||
await expect.poll(() => readFile(linkedPath, "utf8")).toBe("edited in the sheet");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
expandFolder,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
expectFileTabOpen,
|
||||
@@ -70,6 +71,14 @@ async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<v
|
||||
await expect(option).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
async function expectMarkdownImageLoaded(page: Page, name: string): Promise<void> {
|
||||
const image = page.getByRole("img", { name, exact: true });
|
||||
await expect(image).toBeVisible();
|
||||
await expect
|
||||
.poll(() => image.locator("img").evaluate((element: HTMLImageElement) => element.naturalWidth))
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
function watchRequestsTo(page: Page, origin: string): string[] {
|
||||
const requests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
@@ -684,6 +693,40 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
});
|
||||
|
||||
test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-md-relative-image-" });
|
||||
await mkdir(path.join(workspace.repoPath, "docs"), { recursive: true });
|
||||
await mkdir(path.join(workspace.repoPath, "data", "cache"), { recursive: true });
|
||||
await writeFile(path.join(workspace.repoPath, "docs", "sibling.png"), RED_PIXEL);
|
||||
await writeFile(path.join(workspace.repoPath, "data", "cache", "hero.png"), RED_PIXEL);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "docs", "guide.md"),
|
||||
[
|
||||
"# Guide",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "docs");
|
||||
await openFileFromExplorer(page, "guide.md");
|
||||
await expectFileTabOpen(page, "docs/guide.md");
|
||||
|
||||
const visibleFilePane = page.getByTestId("workspace-file-pane").filter({ visible: true });
|
||||
await expect(visibleFilePane.getByText("Guide", { exact: true })).toBeVisible();
|
||||
await expectMarkdownImageLoaded(page, "sibling");
|
||||
await expectMarkdownImageLoaded(page, "hero");
|
||||
await visibleFilePane.screenshot({
|
||||
path: test.info().outputPath("markdown-relative-images.png"),
|
||||
});
|
||||
});
|
||||
|
||||
test("previews and refreshes an HTML plan while preserving source access", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import {
|
||||
expectPairingOffer,
|
||||
expectCompletePairingQr,
|
||||
expectRelayConsent,
|
||||
openPairDeviceModal,
|
||||
preparePairingHost,
|
||||
@@ -18,3 +20,20 @@ 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,6 +241,32 @@ 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);
|
||||
|
||||
@@ -10,16 +10,29 @@ import {
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "../support/helpers/settings";
|
||||
import { copyPluginExample } from "../support/helpers/plugin-fixture";
|
||||
|
||||
const CUSTOM_PROVIDER = {
|
||||
id: "junie",
|
||||
name: "Junie",
|
||||
} as const;
|
||||
|
||||
const PLUGIN_PROVIDER = {
|
||||
id: "direct-example",
|
||||
name: "Direct provider example",
|
||||
pluginId: "provider-direct-example",
|
||||
} as const;
|
||||
|
||||
interface ProviderRemovalDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
patchDaemonConfig(config: { removeProviders?: string[] }): Promise<unknown>;
|
||||
getDaemonConfig(): Promise<{ config: { pluginsEnabled?: boolean } }>;
|
||||
patchDaemonConfig(config: {
|
||||
removeProviders?: string[];
|
||||
pluginsEnabled?: boolean;
|
||||
}): Promise<unknown>;
|
||||
installDirectoryPlugin(path: string): Promise<unknown>;
|
||||
removePlugin(pluginId: string): Promise<void>;
|
||||
getProvidersSnapshot(): Promise<{
|
||||
entries: Array<{ provider: string; source?: "builtin" | "custom" }>;
|
||||
}>;
|
||||
@@ -93,4 +106,45 @@ test.describe("provider removal", () => {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not offer Remove for a plugin-registered provider", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const client = await connectDaemonClient<ProviderRemovalDaemonClient>({
|
||||
clientIdPrefix: "provider-removal-e2e",
|
||||
});
|
||||
|
||||
const plugin = await copyPluginExample("provider-direct");
|
||||
try {
|
||||
const previous = await client.getDaemonConfig();
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(plugin.directory);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
|
||||
const pluginProviderRow = page.getByRole("button", {
|
||||
name: `${PLUGIN_PROVIDER.name} provider details`,
|
||||
exact: true,
|
||||
});
|
||||
await expect(pluginProviderRow).toBeVisible();
|
||||
await expect(page.getByTestId(`provider-actions-${PLUGIN_PROVIDER.id}`)).toHaveCount(0);
|
||||
|
||||
// The provider survives a removeProviders patch pointed at it: the
|
||||
// plugin, not config.json, owns the definition.
|
||||
await client.patchDaemonConfig({ removeProviders: [PLUGIN_PROVIDER.id] });
|
||||
await expect(pluginProviderRow).toBeVisible();
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_PROVIDER.pluginId).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await plugin.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
expectQuestionDismissEnabled,
|
||||
expectQuestionHidden,
|
||||
expectQuestionNavigationEnabled,
|
||||
expectQuestionOptionPreview,
|
||||
expectQuestionOptionSelected,
|
||||
expectQuestionPrimaryActionDisabled,
|
||||
expectQuestionPrimaryActionEnabled,
|
||||
@@ -61,6 +62,10 @@ 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,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
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
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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");
|
||||
@@ -220,3 +220,24 @@ 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,6 +43,15 @@ 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,31 +49,37 @@ export async function seedParentWithSubagent(
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const child = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: input.childTitle,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
},
|
||||
});
|
||||
const child = await seedSubagent(workspace, { parentId: parent.id, title: input.childTitle });
|
||||
|
||||
return {
|
||||
parent: {
|
||||
id: parent.id,
|
||||
title: input.parentTitle,
|
||||
},
|
||||
child: {
|
||||
id: child.id,
|
||||
title: input.childTitle,
|
||||
},
|
||||
child,
|
||||
workspaceId: workspace.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Seeds an idle Paseo subagent of an existing agent in the same workspace. */
|
||||
export async function seedSubagent(
|
||||
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
|
||||
input: { parentId: string; title: string },
|
||||
): Promise<{ id: string; title: string }> {
|
||||
const child = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: input.title,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: input.parentId,
|
||||
},
|
||||
});
|
||||
return { id: child.id, title: input.title };
|
||||
}
|
||||
|
||||
export async function seedParentWithCrossWorkspaceSubagent(
|
||||
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId" | "projectId">,
|
||||
input: { parentTitle: string; childTitle: string },
|
||||
@@ -141,6 +147,12 @@ export async function openSubagentsTrack(page: Page): Promise<void> {
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
/** On compact the track opens as a sheet, which closes once a row is chosen. */
|
||||
export async function openSubagentFromCompactTrack(page: Page, childId: string): Promise<void> {
|
||||
await page.getByTestId("subagents-track-header").click();
|
||||
await page.getByTestId(`subagents-track-row-${childId}`).click();
|
||||
}
|
||||
|
||||
export async function expectSubagentRowVisible(page: Page, childId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
/** The compact workspace sheet, named after the tab it hosts. */
|
||||
export function workspaceSheet(page: Page, label: string): Locator {
|
||||
return page.getByRole("dialog", { name: label });
|
||||
}
|
||||
|
||||
/** Sheet height as a fraction of the viewport; mirrors the host's 92% snap point. */
|
||||
const SHEET_SNAP_FRACTION = 0.92;
|
||||
/** The dialog starts below the sheet's drag handle. */
|
||||
const SHEET_HANDLE_MAX_HEIGHT = 32;
|
||||
|
||||
/**
|
||||
* The sheet rests at its snap point, docked to the bottom edge. A sheet pushed up past it
|
||||
* covers the workspace header; a sheet sunk below it hides content.
|
||||
*/
|
||||
export async function expectWorkspaceSheetAtSnap(page: Page, label: string): Promise<void> {
|
||||
const viewportHeight = page.viewportSize()?.height ?? 0;
|
||||
const snapTop = Math.round(viewportHeight * (1 - SHEET_SNAP_FRACTION));
|
||||
const sheet = workspaceSheet(page, label);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await sheet.boundingBox();
|
||||
if (!box) return null;
|
||||
const top = Math.round(box.y);
|
||||
return {
|
||||
dockedToBottom: Math.round(box.y + box.height) === viewportHeight,
|
||||
topAtSnap: top >= snapTop && top <= snapTop + SHEET_HANDLE_MAX_HEIGHT,
|
||||
};
|
||||
})
|
||||
.toEqual({ dockedToBottom: true, topAtSnap: true });
|
||||
}
|
||||
|
||||
export async function expectOpenTabCount(page: Page, count: number): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Switch tabs (${count} open)` }).filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function closeWorkspaceSheet(page: Page, label: string): Promise<void> {
|
||||
const sheet = workspaceSheet(page, label);
|
||||
await sheet.getByRole("button", { name: "Close" }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function openWorkspaceSheetAsTab(page: Page, label: string): Promise<void> {
|
||||
const sheet = workspaceSheet(page, label);
|
||||
await sheet.getByRole("button", { name: "Open as tab" }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function replaceWorkspaceSheetFileText(
|
||||
page: Page,
|
||||
label: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const editor = workspaceSheet(page, label)
|
||||
.getByTestId("file-source-editor")
|
||||
.locator(".cm-content");
|
||||
await editor.click();
|
||||
await editor.press("ControlOrMeta+A");
|
||||
await editor.pressSequentially(content);
|
||||
}
|
||||
|
||||
export async function selectTabFromSwitcher(page: Page, label: string): Promise<void> {
|
||||
await page
|
||||
.getByRole("button", { name: /Switch tabs/ })
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
const option = page.getByText(label, { exact: true }).filter({ visible: true }).last();
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function tapWorkspaceSheetBackdrop(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop" })
|
||||
.click({ position: { x: 10, y: 10 } });
|
||||
}
|
||||
|
||||
/** Runs a close that hits the unsaved-changes guard, and declines the discard. */
|
||||
export async function expectDeclinedDiscardPrompt(
|
||||
page: Page,
|
||||
close: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const prompt = page.waitForEvent("dialog").then(async (dialog) => {
|
||||
const message = dialog.message();
|
||||
await dialog.dismiss();
|
||||
return message;
|
||||
});
|
||||
await close();
|
||||
expect(await prompt).toContain("discard the draft");
|
||||
}
|
||||
@@ -29,4 +29,15 @@ 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,10 +34,9 @@ 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 (compactId === "xhigh" || compactLabel === "xhigh") {
|
||||
if (compactLabel === "xhigh" || compactLabel === "extrahigh") {
|
||||
return i18n.t("agentControls.thinking.extraHigh");
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||
import { getNextThemePreference, ICON_STROKE_WIDTH } from "@/styles/theme";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { WorkspaceLayoutModePublisher } from "@/workspace-tabs/layout-mode-publisher";
|
||||
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import {
|
||||
@@ -549,6 +550,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
}
|
||||
const workspaceChrome = (
|
||||
<View style={rowStyle}>
|
||||
<WorkspaceLayoutModePublisher />
|
||||
{!isCompactLayout ? (
|
||||
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
|
||||
{themedSidebarChrome}
|
||||
|
||||
@@ -147,4 +147,29 @@ 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,6 +1,7 @@
|
||||
export interface QuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export interface QuestionFormQuestion {
|
||||
@@ -46,6 +47,7 @@ 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,6 +10,7 @@ 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,
|
||||
@@ -29,6 +30,9 @@ 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,
|
||||
@@ -133,6 +137,20 @@ 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>
|
||||
@@ -704,6 +722,10 @@ 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,8 +125,7 @@ function buildCreateAgentOptions({
|
||||
provider,
|
||||
}: {
|
||||
composerState: {
|
||||
modeOptions: { id: string }[];
|
||||
selectedMode: string;
|
||||
effectiveModeId: string;
|
||||
effectiveModelId: string | null;
|
||||
effectiveThinkingOptionId: string | null;
|
||||
};
|
||||
@@ -137,20 +136,11 @@ 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,
|
||||
...(reconciledMode !== "" ? { modeId: reconciledMode } : {}),
|
||||
...(composerState.effectiveModeId ? { modeId: composerState.effectiveModeId } : {}),
|
||||
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
|
||||
...(composerState.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/composer/draft/input-draft-core";
|
||||
import {
|
||||
buildDraftCommandTarget,
|
||||
resolveEffectiveComposerModeId,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
type ProviderSelectionState,
|
||||
@@ -46,6 +47,7 @@ interface UseAgentInputDraftInput {
|
||||
|
||||
type DraftComposerState = UseAgentFormStateResult & {
|
||||
workingDir: string;
|
||||
effectiveModeId: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
@@ -256,6 +258,11 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
],
|
||||
);
|
||||
|
||||
const effectiveModeId = useMemo(
|
||||
() => resolveEffectiveComposerModeId(providerSelection, formState.isModelLoading),
|
||||
[formState.isModelLoading, providerSelection],
|
||||
);
|
||||
|
||||
const effectiveModelId = useMemo(
|
||||
() => resolveEffectiveComposerModelId(providerSelection),
|
||||
[providerSelection],
|
||||
@@ -315,6 +322,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
return {
|
||||
...formState,
|
||||
workingDir,
|
||||
effectiveModeId,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
featureValues: draftFeatureValues,
|
||||
@@ -329,6 +337,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
}, [
|
||||
commandDraft,
|
||||
composerOptions,
|
||||
effectiveModeId,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
draftFeatures,
|
||||
@@ -355,6 +364,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
|
||||
export const __private__ = {
|
||||
resolveDraftKey,
|
||||
resolveEffectiveComposerModeId,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
buildDraftCommandTarget,
|
||||
|
||||
@@ -86,46 +86,30 @@ 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;
|
||||
modeOptionIds: readonly string[];
|
||||
selectedMode: string;
|
||||
effectiveModeId: string;
|
||||
}): { modeId: string } | Record<string, never> {
|
||||
const { autoSubmitConfig, modeOptionIds, selectedMode } = input;
|
||||
const { autoSubmitConfig, effectiveModeId } = input;
|
||||
if (autoSubmitConfig?.modeId) {
|
||||
return { modeId: autoSubmitConfig.modeId };
|
||||
}
|
||||
const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode);
|
||||
if (reconciled !== "") {
|
||||
return { modeId: reconciled };
|
||||
if (effectiveModeId !== "") {
|
||||
return { modeId: effectiveModeId };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function resolveDraftModeId(input: {
|
||||
autoSubmitConfig: AutoSubmitConfig | null;
|
||||
modeOptionIds: readonly string[];
|
||||
selectedMode: string;
|
||||
effectiveModeId: string;
|
||||
}): string | null {
|
||||
const { autoSubmitConfig, modeOptionIds, selectedMode } = input;
|
||||
const { autoSubmitConfig, effectiveModeId } = input;
|
||||
if (autoSubmitConfig?.modeId !== undefined) {
|
||||
return autoSubmitConfig.modeId;
|
||||
}
|
||||
const reconciled = reconcileSelectedMode(modeOptionIds, selectedMode);
|
||||
if (reconciled !== "") {
|
||||
return reconciled;
|
||||
if (effectiveModeId !== "") {
|
||||
return effectiveModeId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -143,8 +127,7 @@ async function submitDraftCreateRequest(input: {
|
||||
autoSubmitConfig: AutoSubmitConfig | null;
|
||||
composerState: {
|
||||
selectedProvider: string | null;
|
||||
selectedMode: string;
|
||||
modeOptions: readonly { id: string }[];
|
||||
effectiveModeId: string;
|
||||
effectiveModelId: string | null;
|
||||
effectiveThinkingOptionId: string | null;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
@@ -177,8 +160,7 @@ async function submitDraftCreateRequest(input: {
|
||||
}
|
||||
const modeIdOverride = resolveDraftModeIdOverride({
|
||||
autoSubmitConfig,
|
||||
modeOptionIds: composerState.modeOptions.map((mode) => mode.id),
|
||||
selectedMode: composerState.selectedMode,
|
||||
effectiveModeId: composerState.effectiveModeId,
|
||||
});
|
||||
const config = buildWorkspaceDraftAgentConfig({
|
||||
provider,
|
||||
@@ -219,8 +201,7 @@ function buildDraftAgentSnapshot(input: {
|
||||
composerState: {
|
||||
effectiveModelId: string | null;
|
||||
effectiveThinkingOptionId: string | null;
|
||||
modeOptions: readonly { id: string }[];
|
||||
selectedMode: string;
|
||||
effectiveModeId: string;
|
||||
selectedProvider: string | null;
|
||||
agentControls: { features?: Agent["features"] };
|
||||
};
|
||||
@@ -234,8 +215,7 @@ function buildDraftAgentSnapshot(input: {
|
||||
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || null);
|
||||
const modeId = resolveDraftModeId({
|
||||
autoSubmitConfig,
|
||||
modeOptionIds: composerState.modeOptions.map((mode) => mode.id),
|
||||
selectedMode: composerState.selectedMode,
|
||||
effectiveModeId: composerState.effectiveModeId,
|
||||
});
|
||||
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
|
||||
if (!provider) {
|
||||
|
||||
@@ -199,6 +199,71 @@ function providerUpdate(generatedAt: string): ProvidersSnapshotUpdateMessage {
|
||||
}
|
||||
|
||||
describe("server data push router", () => {
|
||||
it("keeps a visible diff subscribed when a hidden panel switches to the same comparison", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const fake = createFakeClient();
|
||||
const serverId = "server-1";
|
||||
const cwd = "/repo";
|
||||
const options = (mode: "base" | "uncommitted", enabled: boolean) => {
|
||||
const queryKey = checkoutDiffQueryKey(
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
mode === "base" ? "main" : undefined,
|
||||
);
|
||||
return {
|
||||
queryKey,
|
||||
queryFn: skipToken as typeof skipToken,
|
||||
gcTime: Infinity,
|
||||
meta: checkoutDiffPushRoute({
|
||||
enabled,
|
||||
serverId,
|
||||
cwd,
|
||||
subscriptionId: `checkoutDiff:${JSON.stringify(queryKey)}`,
|
||||
compare: { mode, ...(mode === "base" ? { baseRef: "main" } : {}) },
|
||||
}),
|
||||
};
|
||||
};
|
||||
const visible = new QueryObserver(queryClient, options("base", true));
|
||||
const hidden = new QueryObserver(queryClient, options("base", false));
|
||||
const unsubscribeVisible = visible.subscribe(() => {});
|
||||
const unsubscribeHidden = hidden.subscribe(() => {});
|
||||
const unmount = mountServerDataPushRouter({ queryClient, client: fake.client, serverId });
|
||||
try {
|
||||
visible.setOptions(options("uncommitted", true));
|
||||
hidden.setOptions(options("uncommitted", false));
|
||||
const subscription = fake.subscribeCheckoutDiffCalls.findLast(
|
||||
(call) => call.compare.mode === "uncommitted",
|
||||
)!;
|
||||
const files = [
|
||||
{
|
||||
path: "README.md",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
hunks: [],
|
||||
},
|
||||
];
|
||||
fake.emit({
|
||||
type: "checkout_diff_update",
|
||||
payload: { subscriptionId: subscription.subscriptionId, cwd, files, error: null },
|
||||
});
|
||||
expect(queryClient.getQueryData(options("uncommitted", true).queryKey)).toMatchObject({
|
||||
files,
|
||||
});
|
||||
expect(fake.unsubscribeCheckoutDiffCalls).not.toContain(subscription.subscriptionId);
|
||||
|
||||
unsubscribeVisible();
|
||||
expect(fake.unsubscribeCheckoutDiffCalls).toContain(subscription.subscriptionId);
|
||||
} finally {
|
||||
unsubscribeVisible();
|
||||
unsubscribeHidden();
|
||||
unmount();
|
||||
queryClient.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains equal diff files without reconstructing changed file bodies", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const fake = createFakeClient();
|
||||
|
||||
@@ -494,6 +494,14 @@ function getActiveServerDataRoute(
|
||||
if (query.getObserversCount() === 0) {
|
||||
return null;
|
||||
}
|
||||
// Query metadata belongs to the last observer that updated its options. A
|
||||
// retained hidden panel must not cancel demand from another visible panel.
|
||||
const observerRoutes = query.observers
|
||||
.map((observer) => getServerDataRoute({ meta: observer.options.meta }))
|
||||
.filter((route): route is ServerDataRoute => route !== null);
|
||||
if (observerRoutes.length > 0) {
|
||||
return observerRoutes.find((route) => route.enabled && route.serverId === serverId) ?? null;
|
||||
}
|
||||
const route = getServerDataRoute(query);
|
||||
if (route) {
|
||||
return route.enabled && route.serverId === serverId ? route : null;
|
||||
@@ -590,7 +598,7 @@ function canEventChangeDesiredSubscriptions(type: QueryCacheNotifyEvent["type"])
|
||||
);
|
||||
}
|
||||
|
||||
function getServerDataRoute(query: Query): ServerDataRoute | null {
|
||||
function getServerDataRoute(query: Pick<Query, "meta">): ServerDataRoute | null {
|
||||
const meta = query.meta;
|
||||
if (!isRecord(meta) || !isRecord(meta.serverData)) {
|
||||
return null;
|
||||
|
||||
@@ -80,7 +80,6 @@ export function PairDeviceSection({ serverId, onClose }: PairDeviceSectionProps)
|
||||
type: "svg",
|
||||
errorCorrectionLevel: "M",
|
||||
margin: 1,
|
||||
width: 480,
|
||||
}),
|
||||
enabled: Boolean(pairingQuery.data?.url),
|
||||
dataShape: "value",
|
||||
@@ -275,7 +274,8 @@ function PairingQr({ svg, isError }: { svg: string | null; isError: boolean }) {
|
||||
return (
|
||||
<SvgXml
|
||||
xml={svg}
|
||||
style={styles.qrImage}
|
||||
width="100%"
|
||||
height="100%"
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={t("pairing.device.qrAccessibility")}
|
||||
/>
|
||||
@@ -365,10 +365,6 @@ 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,4 +165,7 @@ const FRAME_STYLE = {
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
} as const;
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const;
|
||||
// CodeMirror's scrollIntoView scrolls every overflowing ancestor and stops at a fixed or sticky
|
||||
// one. Sticky (with no offsets it lays out like relative) keeps line reveals inside the editor, so
|
||||
// they don't shift the host around it, such as the compact workspace sheet.
|
||||
const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden", position: "sticky" } as const;
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveMarkdownFileDirectory, resolveMarkdownPreviewImageSource } from "./image-source";
|
||||
|
||||
describe("resolveMarkdownFileDirectory", () => {
|
||||
it("uses the parent of an absolute markdown path", () => {
|
||||
expect(
|
||||
resolveMarkdownFileDirectory({
|
||||
markdownPath: "/Users/test/project/docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("/Users/test/project/docs");
|
||||
});
|
||||
|
||||
it("joins a workspace-relative markdown path to the workspace root", () => {
|
||||
expect(
|
||||
resolveMarkdownFileDirectory({
|
||||
markdownPath: "docs/星谷云/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("/Users/test/project/docs/星谷云");
|
||||
});
|
||||
|
||||
it("preserves the host home directory for home-relative markdown paths", () => {
|
||||
expect(
|
||||
resolveMarkdownFileDirectory({
|
||||
markdownPath: "~/.paseo/plans/note.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("~/.paseo/plans");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMarkdownPreviewImageSource", () => {
|
||||
it("passes through remote and data URIs", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "https://example.com/image.png",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("https://example.com/image.png");
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "data:image/png;base64,abc",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("data:image/png;base64,abc");
|
||||
});
|
||||
|
||||
it("resolves a sibling image against the markdown file", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "./images/hero.png",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("/Users/test/project/docs/images/hero.png");
|
||||
});
|
||||
|
||||
it("ignores local URL query strings and fragments", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "./images/hero.png?v=2#preview",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/workspace",
|
||||
}),
|
||||
).toBe("/workspace/docs/images/hero.png");
|
||||
});
|
||||
|
||||
it("preserves encoded filename characters when stripping URL suffixes", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "./hero%23draft%3Fv.png?v=2#preview",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/workspace",
|
||||
}),
|
||||
).toBe("/workspace/docs/hero#draft?v.png");
|
||||
});
|
||||
|
||||
it("preserves remote URL query strings and fragments", () => {
|
||||
const source = "https://example.com/hero.png?v=2#preview";
|
||||
expect(resolveMarkdownPreviewImageSource({ source, markdownPath: "docs/guide.md" })).toBe(
|
||||
source,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves parent-relative images that stay inside the workspace", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "../../data/cache/xinggu_docs/12-linkedin/token.png",
|
||||
markdownPath: "docs/星谷云/12-LinkedIn.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("/Users/test/project/data/cache/xinggu_docs/12-linkedin/token.png");
|
||||
});
|
||||
|
||||
it("keeps absolute and home-relative image paths", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "/tmp/screenshot.png",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("/tmp/screenshot.png");
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "~/.paseo/screenshots/output.png",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("~/.paseo/screenshots/output.png");
|
||||
});
|
||||
|
||||
it("normalizes file URIs into host paths", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "file:///tmp/paseo-preview.png",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("/tmp/paseo-preview.png");
|
||||
});
|
||||
|
||||
it("resolves images beside a home-relative document without using the workspace root", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "screenshots/output.png",
|
||||
markdownPath: "~/.paseo/plans/note.md",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toBe("~/.paseo/plans/screenshots/output.png");
|
||||
});
|
||||
|
||||
it("decodes a file URI exactly once", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "file:///tmp/hero%2520image.png",
|
||||
markdownPath: "docs/guide.md",
|
||||
workspaceRoot: "/workspace",
|
||||
}),
|
||||
).toBe("/tmp/hero%20image.png");
|
||||
});
|
||||
|
||||
it("resolves parent segments from a home-relative document", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "../images/hero.png",
|
||||
markdownPath: "~/docs/guide.md",
|
||||
workspaceRoot: "/workspace",
|
||||
}),
|
||||
).toBe("~/images/hero.png");
|
||||
});
|
||||
|
||||
it("resolves Windows relative images against a drive-rooted markdown file", () => {
|
||||
expect(
|
||||
resolveMarkdownPreviewImageSource({
|
||||
source: "..\\images\\hero.png",
|
||||
markdownPath: "C:\\Users\\test\\repo\\docs\\guide.md",
|
||||
workspaceRoot: "C:\\Users\\test\\repo",
|
||||
}),
|
||||
).toBe("C:/Users/test/repo/images/hero.png");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { localFileSourceToPath } from "@/attachments/utils";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
function trimTrailingSeparators(value: string): string {
|
||||
if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
return value.replace(/[\\/]+$/, "");
|
||||
}
|
||||
|
||||
function normalizePathSegments(value: string): string {
|
||||
const segments: string[] = [];
|
||||
for (const segment of value.split("/")) {
|
||||
if (!segment || segment === ".") {
|
||||
continue;
|
||||
}
|
||||
if (segment === "..") {
|
||||
if (segments.length > 0) {
|
||||
segments.pop();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
function normalizeAbsolutePath(value: string): string | null {
|
||||
const normalizedInput = trimTrailingSeparators(value.replace(/\\/g, "/"));
|
||||
if (isHomeRelativePath(normalizedInput)) {
|
||||
return `~/${normalizePathSegments(normalizedInput.slice(2))}`;
|
||||
}
|
||||
if (!isAbsolutePath(normalizedInput)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const drivePath = /^([A-Za-z]:)\/(.*)$/.exec(normalizedInput);
|
||||
if (drivePath) {
|
||||
return trimTrailingSeparators(`${drivePath[1]}/${normalizePathSegments(drivePath[2])}`);
|
||||
}
|
||||
|
||||
const prefix = normalizedInput.startsWith("//") ? "//" : "/";
|
||||
return trimTrailingSeparators(
|
||||
`${prefix}${normalizePathSegments(normalizedInput.replace(/^\/+/, ""))}`,
|
||||
);
|
||||
}
|
||||
|
||||
function parentDirectory(path: string): string | null {
|
||||
const normalized = trimTrailingSeparators(path.replace(/\\/g, "/"));
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (normalized === "/" || /^[A-Za-z]:\/?$/.test(normalized)) {
|
||||
return normalized.endsWith("/") ? normalized : `${normalized}/`;
|
||||
}
|
||||
|
||||
const index = normalized.lastIndexOf("/");
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
if (index === 0) {
|
||||
return "/";
|
||||
}
|
||||
const parent = normalized.slice(0, index);
|
||||
if (/^[A-Za-z]:$/.test(parent)) {
|
||||
return `${parent}/`;
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
function joinAbsolute(baseDirectory: string, relativePath: string): string | null {
|
||||
const directory = trimTrailingSeparators(baseDirectory.replace(/\\/g, "/"));
|
||||
const relative = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (!directory) {
|
||||
return null;
|
||||
}
|
||||
return normalizeAbsolutePath(`${directory}/${relative}`);
|
||||
}
|
||||
|
||||
function isHomeRelativePath(value: string): boolean {
|
||||
return value === "~" || value.startsWith("~/") || value.startsWith("~\\");
|
||||
}
|
||||
|
||||
function decodeRelativeSource(source: string): string {
|
||||
try {
|
||||
return decodeURIComponent(source);
|
||||
} catch {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMarkdownFileDirectory(input: {
|
||||
markdownPath: string;
|
||||
workspaceRoot?: string;
|
||||
}): string | null {
|
||||
const markdownPath = input.markdownPath.trim().replace(/\\/g, "/");
|
||||
if (!markdownPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAbsolutePath(markdownPath) || isHomeRelativePath(markdownPath)) {
|
||||
const absolute = normalizeAbsolutePath(markdownPath);
|
||||
return absolute ? parentDirectory(absolute) : null;
|
||||
}
|
||||
|
||||
const workspaceRoot = input.workspaceRoot?.trim();
|
||||
if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const absoluteMarkdown = joinAbsolute(workspaceRoot, markdownPath);
|
||||
return absoluteMarkdown ? parentDirectory(absoluteMarkdown) : null;
|
||||
}
|
||||
|
||||
export function resolveMarkdownPreviewImageSource(input: {
|
||||
source: string;
|
||||
markdownPath: string;
|
||||
workspaceRoot?: string;
|
||||
}): string | null {
|
||||
const source = input.source.trim();
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^(https?:|data:|blob:)/i.test(source)) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const pathSource = source.replace(/[?#].*$/, "");
|
||||
const path = localFileSourceToPath(
|
||||
pathSource.startsWith("file://") ? pathSource : decodeRelativeSource(pathSource),
|
||||
);
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isHomeRelativePath(path) || isAbsolutePath(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const directory = resolveMarkdownFileDirectory({
|
||||
markdownPath: input.markdownPath,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
});
|
||||
if (!directory) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return joinAbsolute(directory, path);
|
||||
}
|
||||
@@ -1,11 +1,59 @@
|
||||
import { useMemo } from "react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ASTNode, RenderRules } from "react-native-markdown-display";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import {
|
||||
createSharedMarkdownRules,
|
||||
MarkdownRenderer,
|
||||
type MarkdownStyles,
|
||||
} from "@/components/markdown/renderer";
|
||||
import { parseMarkdownPreviewDocument } from "./document";
|
||||
import { resolveMarkdownPreviewImageSource } from "./image-source";
|
||||
import { FileMarkdownPreviewImage } from "./preview-image";
|
||||
|
||||
export function FileMarkdownPreview({ source }: { source: string }) {
|
||||
export function FileMarkdownPreview({
|
||||
source,
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
client,
|
||||
serverId,
|
||||
}: {
|
||||
source: string;
|
||||
filePath: string;
|
||||
workspaceRoot?: string;
|
||||
client?: DaemonClient | null;
|
||||
serverId?: string;
|
||||
}) {
|
||||
const document = useMemo(() => parseMarkdownPreviewDocument(source), [source]);
|
||||
const rules = useMemo<RenderRules>(
|
||||
() => ({
|
||||
...createSharedMarkdownRules(),
|
||||
image: (node: ASTNode, _children, _parent: ASTNode[], _styles: MarkdownStyles) => {
|
||||
const resolved = resolveMarkdownPreviewImageSource({
|
||||
source: String(node.attributes?.src ?? ""),
|
||||
markdownPath: filePath,
|
||||
workspaceRoot,
|
||||
});
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FileMarkdownPreviewImage
|
||||
key={node.key}
|
||||
source={resolved}
|
||||
occurrenceKey={`${filePath}:${node.key}`}
|
||||
alt={typeof node.attributes?.alt === "string" ? node.attributes.alt : undefined}
|
||||
client={client}
|
||||
workspaceRoot={workspaceRoot}
|
||||
serverId={serverId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[client, filePath, serverId, workspaceRoot],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.outerGutter}>
|
||||
@@ -31,7 +79,7 @@ export function FileMarkdownPreview({ source }: { source: string }) {
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
<MarkdownRenderer text={document.body} />
|
||||
<MarkdownRenderer text={document.body} rules={rules} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Image, Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useAssistantImage } from "@/assistant-image/use-assistant-image";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const PREVIEW_IMAGE_MIN_HEIGHT = 160;
|
||||
|
||||
export function FileMarkdownPreviewImage({
|
||||
source,
|
||||
occurrenceKey,
|
||||
alt,
|
||||
client,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
}: {
|
||||
source: string;
|
||||
occurrenceKey: string;
|
||||
alt?: string;
|
||||
client?: DaemonClient | null;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [viewerOpen, setViewerOpen] = useState(false);
|
||||
const openViewer = useCallback(() => setViewerOpen(true), []);
|
||||
const closeViewer = useCallback(() => setViewerOpen(false), []);
|
||||
const image = useAssistantImage({
|
||||
source,
|
||||
occurrenceKey,
|
||||
client,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
});
|
||||
const binding = image.status === "failed" ? null : image.binding;
|
||||
const aspectRatio = image.status === "failed" ? null : image.aspectRatio;
|
||||
const imageUri = binding?.uri ?? "";
|
||||
const imageSource = useMemo(() => ({ uri: imageUri }), [imageUri]);
|
||||
const imageSizeStyle = useMemo<ViewStyle>(() => {
|
||||
if (aspectRatio) {
|
||||
return { aspectRatio };
|
||||
}
|
||||
return { height: PREVIEW_IMAGE_MIN_HEIGHT };
|
||||
}, [aspectRatio]);
|
||||
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.surface, imageSizeStyle],
|
||||
[imageSizeStyle],
|
||||
);
|
||||
const lightboxSource = useMemo<ImageLightboxSource | null>(() => {
|
||||
if (!viewerOpen || !imageUri) return null;
|
||||
return {
|
||||
type: "uri",
|
||||
uri: imageUri,
|
||||
contentSize: aspectRatio ? { width: aspectRatio, height: 1 } : undefined,
|
||||
};
|
||||
}, [aspectRatio, imageUri, viewerOpen]);
|
||||
|
||||
if (image.status === "failed") {
|
||||
return (
|
||||
<View style={[styles.frame, styles.state]} testID="markdown-preview-image-failed">
|
||||
<Text style={styles.errorText}>{image.message}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!binding) {
|
||||
return (
|
||||
<View style={[styles.frame, styles.state]} testID="markdown-preview-image-loading">
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.frame} testID="markdown-preview-image">
|
||||
<Pressable
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
accessibilityRole="button"
|
||||
disabled={image.status !== "loaded"}
|
||||
onPress={openViewer}
|
||||
style={surfaceStyle}
|
||||
>
|
||||
<View style={styles.image} accessibilityRole="image" accessibilityLabel={alt}>
|
||||
<Image
|
||||
ref={binding.onRef}
|
||||
source={imageSource}
|
||||
style={styles.image}
|
||||
resizeMode="contain"
|
||||
onLoad={binding.onLoad}
|
||||
onError={binding.onError}
|
||||
/>
|
||||
{image.status === "loading" ? (
|
||||
<View pointerEvents="none" style={styles.loadingOverlay}>
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
<AttachmentLightbox source={lightboxSource} onClose={closeViewer} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
frame: {
|
||||
width: "100%",
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
surface: {
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
image: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
state: {
|
||||
minHeight: PREVIEW_IMAGE_MIN_HEIGHT,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[6],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
loadingOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
@@ -54,6 +54,9 @@ interface FilePreviewBodyProps {
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
imagePreviewUri: string | null;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient | null;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
type TextExplorerFile = ExplorerFile & { kind: "text" };
|
||||
@@ -135,6 +138,9 @@ function FilePreviewBody({
|
||||
location,
|
||||
navigationRevision,
|
||||
imagePreviewUri,
|
||||
workspaceRoot,
|
||||
client,
|
||||
serverId,
|
||||
}: FilePreviewBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const filePath = location.path;
|
||||
@@ -182,7 +188,13 @@ function FilePreviewBody({
|
||||
style={styles.previewContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<FileMarkdownPreview source={preview.content ?? ""} />
|
||||
<FileMarkdownPreview
|
||||
source={preview.content ?? ""}
|
||||
filePath={filePath}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
/>
|
||||
</RNScrollView>
|
||||
</View>
|
||||
);
|
||||
@@ -297,6 +309,7 @@ export function FilePane({
|
||||
return (
|
||||
<FilePanePresentation
|
||||
serverId={serverId}
|
||||
workspaceRoot={normalizedWorkspaceRoot}
|
||||
client={client}
|
||||
readTarget={readTarget}
|
||||
preview={preview}
|
||||
@@ -338,6 +351,7 @@ function isEditableTextFile(input: {
|
||||
|
||||
function FilePanePresentation({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
client,
|
||||
readTarget,
|
||||
preview,
|
||||
@@ -359,6 +373,7 @@ function FilePanePresentation({
|
||||
imagePreviewUri,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient | null;
|
||||
readTarget: { cwd: string; path: string } | null;
|
||||
preview: ExplorerFile | null;
|
||||
@@ -393,6 +408,8 @@ function FilePanePresentation({
|
||||
return (
|
||||
<EditableFilePane
|
||||
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
|
||||
serverId={serverId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
cwd={readTarget.cwd}
|
||||
path={readTarget.path}
|
||||
@@ -449,12 +466,17 @@ function FilePanePresentation({
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
imagePreviewUri={imagePreviewUri}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableFilePane({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
client,
|
||||
cwd,
|
||||
path,
|
||||
@@ -470,6 +492,8 @@ function EditableFilePane({
|
||||
location,
|
||||
navigationRevision,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceRoot: string;
|
||||
client: DaemonClient;
|
||||
cwd: string;
|
||||
path: string;
|
||||
@@ -622,6 +646,9 @@ function EditableFilePane({
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
imagePreviewUri={null}
|
||||
workspaceRoot={workspaceRoot}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -613,6 +613,9 @@ export const ar: TranslationResources = {
|
||||
updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.",
|
||||
unableToSubscribe: "غير قادر على الاشتراك في المحطة",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "فتح كعلامة تبويب",
|
||||
},
|
||||
tabs: {
|
||||
loading: "تحميل...",
|
||||
modified: "تغييرات غير محفوظة",
|
||||
|
||||
@@ -610,6 +610,9 @@ export const en = {
|
||||
updateHost: "Update the host to use the native terminal renderer.",
|
||||
unableToSubscribe: "Unable to subscribe to terminal",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "Open as tab",
|
||||
},
|
||||
tabs: {
|
||||
loading: "Loading...",
|
||||
modified: "Unsaved changes",
|
||||
|
||||
@@ -619,6 +619,9 @@ export const es: TranslationResources = {
|
||||
updateHost: "Actualiza el host para usar el renderizador de terminal nativo.",
|
||||
unableToSubscribe: "No se puede suscribir al terminal",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "Abrir como pestaña",
|
||||
},
|
||||
tabs: {
|
||||
loading: "Cargando...",
|
||||
modified: "Cambios sin guardar",
|
||||
|
||||
@@ -618,6 +618,9 @@ export const fr: TranslationResources = {
|
||||
updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.",
|
||||
unableToSubscribe: "Impossible de s’abonner au terminal",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "Ouvrir dans un onglet",
|
||||
},
|
||||
tabs: {
|
||||
loading: "Chargement…",
|
||||
modified: "Modifications non enregistrées",
|
||||
|
||||
@@ -619,6 +619,9 @@ export const ja: TranslationResources = {
|
||||
updateHost: "ネイティブターミナルを使用するにはホストを更新してください。",
|
||||
unableToSubscribe: "ターミナルに接続できません",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "タブで開く",
|
||||
},
|
||||
tabs: {
|
||||
loading: "読み込み中...",
|
||||
modified: "未保存の変更",
|
||||
|
||||
@@ -614,6 +614,9 @@ export const ko: TranslationResources = {
|
||||
updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.",
|
||||
unableToSubscribe: "터미널을 구독할 수 없습니다",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "탭으로 열기",
|
||||
},
|
||||
tabs: {
|
||||
loading: "불러오는 중...",
|
||||
modified: "저장되지 않은 변경사항",
|
||||
|
||||
@@ -618,6 +618,9 @@ export const ptBR: TranslationResources = {
|
||||
updateHost: "Atualize o host para usar o renderizador de terminal nativo.",
|
||||
unableToSubscribe: "Não foi possível assinar o terminal",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "Abrir como aba",
|
||||
},
|
||||
tabs: {
|
||||
loading: "Carregando...",
|
||||
modified: "Alterações não salvas",
|
||||
|
||||
@@ -620,6 +620,9 @@ export const ru: TranslationResources = {
|
||||
updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.",
|
||||
unableToSubscribe: "Не удалось подключиться к терминалу",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "Открыть во вкладке",
|
||||
},
|
||||
tabs: {
|
||||
loading: "Загрузка...",
|
||||
modified: "Несохранённые изменения",
|
||||
|
||||
@@ -613,6 +613,9 @@ export const zhCN: TranslationResources = {
|
||||
updateHost: "请更新主机以使用原生终端渲染器。",
|
||||
unableToSubscribe: "无法订阅 Terminal",
|
||||
},
|
||||
sheet: {
|
||||
openAsTab: "作为标签页打开",
|
||||
},
|
||||
tabs: {
|
||||
loading: "正在加载...",
|
||||
modified: "未保存的更改",
|
||||
|
||||
@@ -1027,6 +1027,71 @@ 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,6 +1285,12 @@ 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,6 +1153,8 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// The workspace sheet is for looking; Open as tab brings the agent back with its composer.
|
||||
const showsComposer = usePaneContext().host !== "sheet";
|
||||
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
|
||||
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
|
||||
state.sessions[serverId]?.agentTasks.get(agentId),
|
||||
@@ -1216,7 +1218,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
composerState,
|
||||
],
|
||||
);
|
||||
const composerSection = (
|
||||
const composerSection = showsComposer ? (
|
||||
<RenderProfile id={`AgentComposerSection:${agentId}`}>
|
||||
<AgentComposerSection
|
||||
agentId={agentId}
|
||||
@@ -1233,7 +1235,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
onMessageSent={handleMessageSent}
|
||||
/>
|
||||
</RenderProfile>
|
||||
);
|
||||
) : null;
|
||||
const streamContent = (
|
||||
<View style={animatedStaticStyles.content}>
|
||||
<RenderProfile id={`AgentStreamSection:${agentId}`}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill";
|
||||
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
|
||||
import { AgentTaskList } from "@/composer/task-list";
|
||||
import { ComposerTrackBar } from "@/composer/tracks";
|
||||
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import { PluginComposerPills } from "@/plugins";
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
import type { TodoEntry } from "@/types/stream";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { openPreferredWorkspaceTarget } from "@/workspace-tabs/open-beside";
|
||||
import { openComposerChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
|
||||
@@ -49,10 +49,9 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
onArchiveFinished: () => void;
|
||||
hasPluginComposerPills: boolean;
|
||||
}): ReactElement | null {
|
||||
const { tabId, openTab } = usePaneContext();
|
||||
const { tabId } = usePaneContext();
|
||||
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const canSplit = supportsDesktopPaneSplits() && !isCompact;
|
||||
const openInSidePane = useSettings((settings) => settings.openInSidePane);
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const canDetachSubagents = useSessionStore(
|
||||
@@ -60,45 +59,36 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
);
|
||||
const archiveSubagent = useArchiveSubagent({ serverId });
|
||||
const detachSubagent = useDetachSubagent({ serverId });
|
||||
const openSubagentTarget = useCallback(
|
||||
(target: WorkspaceTabTarget) => {
|
||||
openPreferredWorkspaceTarget({
|
||||
workspaceKey,
|
||||
target,
|
||||
source: "subagents",
|
||||
preferences: openInSidePane,
|
||||
parentTabId: tabId,
|
||||
});
|
||||
},
|
||||
[openInSidePane, tabId, workspaceKey],
|
||||
);
|
||||
const handleOpenSubagent = useCallback(
|
||||
(subagentId: string) => {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const agent = session?.agents.get(subagentId) ?? session?.agentDetails.get(subagentId);
|
||||
// A subagent in another workspace lives in that workspace's tabs.
|
||||
if (agent?.workspaceId && agent.workspaceId !== workspaceId) {
|
||||
navigateToAgent({ serverId, agentId: subagentId });
|
||||
return;
|
||||
}
|
||||
if (canSplit && workspaceKey) {
|
||||
openPreferredWorkspaceTarget({
|
||||
isCompact,
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: subagentId },
|
||||
source: "subagents",
|
||||
preferences: openInSidePane,
|
||||
parentTabId: tabId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigateToAgent({ serverId, agentId: subagentId });
|
||||
openSubagentTarget({ kind: "agent", agentId: subagentId });
|
||||
},
|
||||
[canSplit, isCompact, openInSidePane, serverId, tabId, workspaceId, workspaceKey],
|
||||
[openSubagentTarget, serverId, workspaceId],
|
||||
);
|
||||
const handleOpenProviderSubagent = useCallback(
|
||||
(parentAgentId: string, subagentId: string) => {
|
||||
if (canSplit && workspaceKey) {
|
||||
openPreferredWorkspaceTarget({
|
||||
isCompact,
|
||||
workspaceKey,
|
||||
target: { kind: "provider_subagent", parentAgentId, subagentId },
|
||||
source: "subagents",
|
||||
preferences: openInSidePane,
|
||||
parentTabId: tabId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
|
||||
openSubagentTarget({ kind: "provider_subagent", parentAgentId, subagentId });
|
||||
},
|
||||
[canSplit, isCompact, openInSidePane, openTab, tabId, workspaceKey],
|
||||
[openSubagentTarget],
|
||||
);
|
||||
const handleOpenChanges = useCallback(() => {
|
||||
if (!workspaceKey) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
|
||||
export interface PanelInstanceIdentity {
|
||||
serverId: string;
|
||||
@@ -18,8 +19,20 @@ const listenersByPanel = new Map<string, Set<() => void>>();
|
||||
const allListeners = new Set<() => void>();
|
||||
let attributesRevision = 0;
|
||||
|
||||
function buildWorkspacePanelKey(workspaceKey: string, tabId: string): string {
|
||||
return `${workspaceKey}:${tabId}`;
|
||||
}
|
||||
|
||||
export function buildPanelInstanceKey(identity: PanelInstanceIdentity): string {
|
||||
return `${identity.serverId}:${identity.workspaceId}:${identity.tabId}`;
|
||||
return buildWorkspacePanelKey(buildWorkspaceTabPersistenceKey(identity) ?? "", identity.tabId);
|
||||
}
|
||||
|
||||
/** Looks a panel up by the workspace key the layout store uses. */
|
||||
export function getWorkspacePanelAttributes(
|
||||
workspaceKey: string,
|
||||
tabId: string,
|
||||
): PanelInstanceAttributes {
|
||||
return attributesByPanel.get(buildWorkspacePanelKey(workspaceKey, tabId)) ?? DEFAULT_ATTRIBUTES;
|
||||
}
|
||||
|
||||
export function getPanelInstanceAttributes(
|
||||
|
||||
@@ -18,6 +18,14 @@ describe("panel manifest", () => {
|
||||
expect(panelSupportsHost("setup", "explorer")).toBe(false);
|
||||
});
|
||||
|
||||
it("supports the sheet exactly for kinds with a compact sheet rule", () => {
|
||||
expect(panelSupportsHost("file", "sheet")).toBe(true);
|
||||
expect(panelSupportsHost("agent", "sheet")).toBe(true);
|
||||
expect(panelSupportsHost("provider_subagent", "sheet")).toBe(true);
|
||||
expect(panelSupportsHost("terminal", "sheet")).toBe(false);
|
||||
expect(panelSupportsHost("plugin", "sheet")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets navigation tabs hide their close button without losing their close action", () => {
|
||||
expect(getPanelManifest("files").showCloseButton).toBe(false);
|
||||
expect(getPanelManifest("changes_tree").showCloseButton).toBe(false);
|
||||
|
||||
@@ -1,12 +1,38 @@
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
export type PaneHost = "main" | "explorer";
|
||||
/** Where a tab renders: a split pane, the Explorer dock, or the compact workspace sheet. */
|
||||
export type PaneHost = "main" | "explorer" | "sheet";
|
||||
|
||||
/** How a target behaves while it is shown in the compact workspace sheet. */
|
||||
export interface CompactSheetPolicy {
|
||||
/** The sheet offers Open as tab, which moves the same tab into the focused pane. */
|
||||
openAsTab: boolean;
|
||||
}
|
||||
|
||||
/** Facts about a target that live outside the layout, resolved lazily by the workspace. */
|
||||
export interface PanelTargetFacts {
|
||||
isWorkspaceRootAgent(agentId: string): boolean;
|
||||
}
|
||||
|
||||
type CompactSheetRule<K extends WorkspaceTabTarget["kind"]> =
|
||||
| CompactSheetPolicy
|
||||
| null
|
||||
| ((
|
||||
target: Extract<WorkspaceTabTarget, { kind: K }>,
|
||||
facts: PanelTargetFacts,
|
||||
) => CompactSheetPolicy | null);
|
||||
|
||||
export interface PanelManifest<K extends WorkspaceTabTarget["kind"] = WorkspaceTabTarget["kind"]> {
|
||||
kind: K;
|
||||
supportedHosts: readonly PaneHost[];
|
||||
/** The sheet is never listed: a kind renders there exactly when it has a `compactSheet` rule. */
|
||||
supportedHosts: readonly Exclude<PaneHost, "sheet">[];
|
||||
showCloseButton: boolean;
|
||||
singleton: boolean;
|
||||
/**
|
||||
* On compact layouts, revealing this target opens it in the workspace sheet over the current
|
||||
* tab instead of adding a tab. `null` keeps it in the tab strip.
|
||||
*/
|
||||
compactSheet: CompactSheetRule<K>;
|
||||
resourceKey(target: Extract<WorkspaceTabTarget, { kind: K }>): string;
|
||||
}
|
||||
|
||||
@@ -20,6 +46,7 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: () => "new_tab",
|
||||
},
|
||||
draft: {
|
||||
@@ -27,6 +54,7 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: (target) => target.draftId,
|
||||
},
|
||||
agent: {
|
||||
@@ -34,6 +62,8 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: (target, facts) =>
|
||||
facts.isWorkspaceRootAgent(target.agentId) ? null : { openAsTab: true },
|
||||
resourceKey: (target) => target.agentId,
|
||||
},
|
||||
provider_subagent: {
|
||||
@@ -41,6 +71,7 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: { openAsTab: false },
|
||||
resourceKey: (target) => `${target.parentAgentId}:${target.subagentId}`,
|
||||
},
|
||||
terminal: {
|
||||
@@ -48,6 +79,7 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: (target) => target.terminalId,
|
||||
},
|
||||
browser: {
|
||||
@@ -55,6 +87,7 @@ const manifests = {
|
||||
supportedHosts: ["main"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: (target) => target.browserId,
|
||||
},
|
||||
changes_tree: {
|
||||
@@ -62,6 +95,7 @@ const manifests = {
|
||||
supportedHosts: ["explorer"],
|
||||
showCloseButton: false,
|
||||
singleton: true,
|
||||
compactSheet: null,
|
||||
resourceKey: () => "changes_tree",
|
||||
},
|
||||
files: {
|
||||
@@ -69,6 +103,7 @@ const manifests = {
|
||||
supportedHosts: ["explorer"],
|
||||
showCloseButton: false,
|
||||
singleton: true,
|
||||
compactSheet: null,
|
||||
resourceKey: () => "files",
|
||||
},
|
||||
pull_request: {
|
||||
@@ -76,6 +111,7 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: () => "pull_request",
|
||||
},
|
||||
file: {
|
||||
@@ -83,6 +119,7 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: { openAsTab: false },
|
||||
resourceKey: (target) => target.path,
|
||||
},
|
||||
working_diff: {
|
||||
@@ -90,6 +127,7 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: () => "working_diff",
|
||||
},
|
||||
plugin: {
|
||||
@@ -98,6 +136,7 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: (target) =>
|
||||
target.context === "agent"
|
||||
? `${target.pluginId}:${target.panelId}:agent:${target.agentId}`
|
||||
@@ -108,6 +147,7 @@ const manifests = {
|
||||
supportedHosts: ["main"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: (target) => target.workspaceId,
|
||||
},
|
||||
commit_diff: {
|
||||
@@ -115,6 +155,7 @@ const manifests = {
|
||||
supportedHosts: ["main", "explorer"],
|
||||
showCloseButton: true,
|
||||
singleton: false,
|
||||
compactSheet: null,
|
||||
resourceKey: (target) => target.sha,
|
||||
},
|
||||
} satisfies PanelManifestByKind;
|
||||
@@ -124,7 +165,17 @@ export function getPanelManifest<K extends WorkspaceTabTarget["kind"]>(kind: K):
|
||||
}
|
||||
|
||||
export function panelSupportsHost(kind: WorkspaceTabTarget["kind"], host: PaneHost): boolean {
|
||||
return getPanelManifest(kind).supportedHosts.includes(host);
|
||||
const manifest = getPanelManifest(kind);
|
||||
if (host === "sheet") return manifest.compactSheet !== null;
|
||||
return manifest.supportedHosts.includes(host);
|
||||
}
|
||||
|
||||
export function panelCompactSheetPolicy(
|
||||
target: WorkspaceTabTarget,
|
||||
facts: PanelTargetFacts,
|
||||
): CompactSheetPolicy | null {
|
||||
const rule = getPanelManifest(target.kind).compactSheet as CompactSheetRule<typeof target.kind>;
|
||||
return typeof rule === "function" ? rule(target as never, facts) : rule;
|
||||
}
|
||||
|
||||
export function panelCanLaunchInPane(
|
||||
|
||||
@@ -8,6 +8,7 @@ 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";
|
||||
@@ -172,6 +173,7 @@ 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") return panelSupportsHost(target.kind, host);
|
||||
if (target.kind !== "plugin" || host === "sheet") return panelSupportsHost(target.kind, host);
|
||||
const panel = findPluginPanel(serverId, target);
|
||||
// Preserve a persisted unavailable panel where the user left it until its plugin loads again.
|
||||
if (!panel) return true;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildSelectedTriggerLabel,
|
||||
filterAndRankModelRows,
|
||||
matchesModelSearch,
|
||||
resolveEffectiveComposerModeId,
|
||||
resolveSelectedModelLabel,
|
||||
resolveSubmissionReadiness,
|
||||
} from "./provider-selection";
|
||||
@@ -441,3 +442,56 @@ 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,6 +272,23 @@ 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.selectedMode || null,
|
||||
modeId: input.composerState.effectiveModeId || 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.selectedMode || undefined,
|
||||
modeId: composerState.effectiveModeId || 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.selectedMode || null,
|
||||
modeId: composerState.effectiveModeId || null,
|
||||
model: composerState.effectiveModelId || null,
|
||||
thinkingOptionId: composerState.effectiveThinkingOptionId || null,
|
||||
featureValues: composerState.featureValues,
|
||||
|
||||
@@ -328,7 +328,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval");
|
||||
const { entries, isLoading, refresh } = useProvidersSnapshot(serverId);
|
||||
const { patchConfig } = useDaemonConfig(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const openProviderSettings = useProviderSettingsStore((state) => state.open);
|
||||
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
|
||||
const [removingProviderId, setRemovingProviderId] = useState<string | null>(null);
|
||||
@@ -338,6 +338,12 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
|
||||
const hasServer = serverId.length > 0;
|
||||
|
||||
/** Custom providers are deletable only when config.json holds their `extends` definition. */
|
||||
const isConfigOwnedProvider = useCallback(
|
||||
(providerId: string) => Boolean(config?.providers?.[providerId]?.extends),
|
||||
[config],
|
||||
);
|
||||
|
||||
const handleOpenProviderSettings = useCallback(
|
||||
(providerId: string) => {
|
||||
openProviderSettings({ serverId, provider: providerId });
|
||||
@@ -444,7 +450,11 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
||||
enabled={entry.enabled ?? true}
|
||||
isToggling={pendingProviderId === def.id}
|
||||
isRemoving={removingProviderId === def.id}
|
||||
canRemove={supportsProviderRemoval && entry.source === "custom"}
|
||||
canRemove={
|
||||
supportsProviderRemoval &&
|
||||
entry.source === "custom" &&
|
||||
isConfigOwnedProvider(def.id)
|
||||
}
|
||||
isFirst={index === 0}
|
||||
onPress={handleOpenProviderSettings}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
|
||||
@@ -110,3 +110,25 @@ test("pane retargeting replaces the viewed agent and duplicate panes collapse to
|
||||
}),
|
||||
}).toEqual({ duplicate: ["agent-a"], retargeted: ["agent-a", "agent-b"] });
|
||||
});
|
||||
|
||||
test("the agent shown in the workspace sheet is viewed alongside the pane under it", () => {
|
||||
const layout: WorkspaceLayout = {
|
||||
focusedPaneId: "main",
|
||||
root: { kind: "pane", pane: { id: "main", tabIds: ["a"], focusedTabId: "a" } },
|
||||
};
|
||||
const tabs: WorkspaceTab[] = [
|
||||
{ tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
];
|
||||
const sheetTab: WorkspaceTab = {
|
||||
tabId: "sheet",
|
||||
target: { kind: "agent", agentId: "agent-child" },
|
||||
createdAt: 2,
|
||||
};
|
||||
|
||||
expect(
|
||||
selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: true, focusedPaneOnly: true }),
|
||||
).toEqual(["agent-a", "agent-child"]);
|
||||
expect(
|
||||
selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: false, focusedPaneOnly: true }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import { deriveWorkspacePaneState } from "./workspace-pane-state";
|
||||
export function selectVisibleAgentIds(input: {
|
||||
layout: WorkspaceLayout | null;
|
||||
tabs: WorkspaceTab[];
|
||||
/** The workspace sheet's tab, shown over the panes. */
|
||||
sheetTab?: WorkspaceTab | null;
|
||||
routeFocused: boolean;
|
||||
focusedPaneOnly: boolean;
|
||||
}): string[] {
|
||||
@@ -16,20 +18,21 @@ export function selectVisibleAgentIds(input: {
|
||||
? collectAllPanes(input.layout.root).filter((pane) => pane.id === input.layout?.focusedPaneId)
|
||||
: collectAllPanes(input.layout.root);
|
||||
|
||||
const targets = panes.flatMap((pane) => {
|
||||
const target = deriveWorkspacePaneState({ pane, tabs: input.tabs }).activeTab?.descriptor
|
||||
.target;
|
||||
return target ? [target] : [];
|
||||
});
|
||||
if (input.sheetTab) targets.push(input.sheetTab.target);
|
||||
return [
|
||||
...new Set(
|
||||
panes.flatMap((pane) => {
|
||||
const target = deriveWorkspacePaneState({ pane, tabs: input.tabs }).activeTab?.descriptor
|
||||
.target;
|
||||
return target?.kind === "agent" ? [target.agentId] : [];
|
||||
}),
|
||||
),
|
||||
...new Set(targets.flatMap((target) => (target.kind === "agent" ? [target.agentId] : []))),
|
||||
].sort();
|
||||
}
|
||||
|
||||
export function useVisibleAgentIds(input: {
|
||||
layout: WorkspaceLayout | null;
|
||||
tabs: WorkspaceTab[];
|
||||
sheetTab: WorkspaceTab | null;
|
||||
routeFocused: boolean;
|
||||
focusedPaneOnly: boolean;
|
||||
}): string[] {
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
getFocusedBrowserId,
|
||||
FOCUSED_PANE_PLACEMENT,
|
||||
selectExplorerSidebarPaneId,
|
||||
selectPaneFocusSuspended,
|
||||
type WorkspaceLayout,
|
||||
type WorkspaceTabPlacement,
|
||||
useWorkspaceLayoutStore,
|
||||
@@ -160,6 +161,8 @@ import {
|
||||
type WorkspacePaneContentModel,
|
||||
} from "@/screens/workspace/workspace-pane-content";
|
||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||
import { WorkspaceSheetHost } from "@/screens/workspace/workspace-sheet-host";
|
||||
import type { PaneHost } from "@/panels/panel-manifest";
|
||||
import { WorkspaceFocusProvider } from "@/workspace/focus";
|
||||
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
|
||||
import type { NewTabSelection } from "@/workspace-tabs/new-tab";
|
||||
@@ -751,22 +754,20 @@ function WorkspaceContentProviders({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspacePanelContent({
|
||||
function WorkspacePanelProviders({
|
||||
launcher,
|
||||
content,
|
||||
children,
|
||||
tabIds,
|
||||
workspaceKey,
|
||||
}: {
|
||||
workspaceKey: string;
|
||||
launcher: NewTabLauncher;
|
||||
content: ReactNode;
|
||||
children: ReactNode;
|
||||
tabIds: readonly string[];
|
||||
}) {
|
||||
return (
|
||||
<WorkspaceComposerEditingSessionsProvider workspaceKey={workspaceKey} tabIds={tabIds}>
|
||||
<NewTabLauncherProvider value={launcher}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
</NewTabLauncherProvider>
|
||||
<NewTabLauncherProvider value={launcher}>{children}</NewTabLauncherProvider>
|
||||
</WorkspaceComposerEditingSessionsProvider>
|
||||
);
|
||||
}
|
||||
@@ -1267,6 +1268,9 @@ function WorkspaceScreenContent({
|
||||
const explorerSidebarPaneId = useWorkspaceLayoutStore((state) =>
|
||||
persistenceKey ? selectExplorerSidebarPaneId(state, persistenceKey) : null,
|
||||
);
|
||||
const paneFocusSuspended = useWorkspaceLayoutStore((state) =>
|
||||
persistenceKey ? selectPaneFocusSuspended(state, persistenceKey) : false,
|
||||
);
|
||||
const lastMainPaneRef = useLastMainPane({
|
||||
workspaceKey: persistenceKey,
|
||||
layout: workspaceLayout,
|
||||
@@ -1305,6 +1309,9 @@ function WorkspaceScreenContent({
|
||||
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
const selectWorkspaceTabInPane = useWorkspaceLayoutStore((state) => state.selectTabInPane);
|
||||
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
||||
const sheetTab = useWorkspaceLayoutStore((state) =>
|
||||
persistenceKey ? (state.sheetByWorkspace[persistenceKey]?.tab ?? null) : null,
|
||||
);
|
||||
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
||||
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
||||
const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState);
|
||||
@@ -1377,6 +1384,7 @@ function WorkspaceScreenContent({
|
||||
const visibleAgentIds = useVisibleAgentIds({
|
||||
layout: workspaceLayout,
|
||||
tabs: uiTabs,
|
||||
sheetTab,
|
||||
routeFocused: isRouteFocused,
|
||||
focusedPaneOnly: syncFocusedPaneOnly,
|
||||
});
|
||||
@@ -1678,7 +1686,6 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
|
||||
const tabId = openPreferredWorkspaceTarget({
|
||||
isCompact: isMobile,
|
||||
workspaceKey: persistenceKey,
|
||||
target: createWorkspaceFileTabTarget(location),
|
||||
source: "chatFiles",
|
||||
@@ -2062,6 +2069,12 @@ function WorkspaceScreenContent({
|
||||
|
||||
const handleCloseTabById = useCallback(
|
||||
async (tabId: string) => {
|
||||
if (persistenceKey && sheetTab?.tabId === tabId) {
|
||||
if (await confirmDiscardModifiedTab(tabId)) {
|
||||
closeWorkspaceTab(persistenceKey, tabId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const tab = allTabDescriptorsById.get(tabId);
|
||||
if (!tab) {
|
||||
return;
|
||||
@@ -2081,10 +2094,13 @@ function WorkspaceScreenContent({
|
||||
},
|
||||
[
|
||||
allTabDescriptorsById,
|
||||
closeWorkspaceTab,
|
||||
confirmDiscardModifiedTab,
|
||||
handleCloseAgentTab,
|
||||
handleClosePassiveTab,
|
||||
handleCloseTerminalTab,
|
||||
persistenceKey,
|
||||
sheetTab,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2918,17 +2934,19 @@ function WorkspaceScreenContent({
|
||||
tab: WorkspaceTabDescriptor;
|
||||
paneId?: string | null;
|
||||
focusPaneBeforeOpen?: boolean;
|
||||
host?: PaneHost;
|
||||
}) =>
|
||||
buildWorkspacePaneContentModel({
|
||||
tab: input.tab,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
host:
|
||||
canRenderDesktopPaneSplits &&
|
||||
input.host ??
|
||||
(canRenderDesktopPaneSplits &&
|
||||
input.paneId !== null &&
|
||||
input.paneId === explorerSidebarPaneId
|
||||
? "explorer"
|
||||
: "main",
|
||||
: "main"),
|
||||
fileNavigationRevision: fileNavigationRevisionByTabId[input.tab.tabId] ?? 0,
|
||||
onOpenTab: (target) => {
|
||||
if (!persistenceKey) {
|
||||
@@ -2947,7 +2965,6 @@ function WorkspaceScreenContent({
|
||||
onOpenPreferredTarget: (target, source) => {
|
||||
if (!persistenceKey) return;
|
||||
const tabId = openPreferredWorkspacePreview({
|
||||
isCompact: isMobile,
|
||||
workspaceKey: persistenceKey,
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
@@ -3009,7 +3026,6 @@ function WorkspaceScreenContent({
|
||||
canRenderDesktopPaneSplits,
|
||||
openImportSheet,
|
||||
openInSidePane,
|
||||
isMobile,
|
||||
requestFileNavigation,
|
||||
revealWorkspaceChildTab,
|
||||
persistenceKey,
|
||||
@@ -3053,6 +3069,17 @@ function WorkspaceScreenContent({
|
||||
},
|
||||
[buildPaneContentModel],
|
||||
);
|
||||
const buildSheetPaneContentModel = useCallback(
|
||||
function buildSheetPaneContentModel(tab: WorkspaceTabDescriptor) {
|
||||
return buildPaneContentModel({
|
||||
tab,
|
||||
paneId: null,
|
||||
focusPaneBeforeOpen: false,
|
||||
host: "sheet",
|
||||
});
|
||||
},
|
||||
[buildPaneContentModel],
|
||||
);
|
||||
const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) {
|
||||
if (!persistenceKey || paneFocusSuppressedRef.current) {
|
||||
return;
|
||||
@@ -3068,7 +3095,8 @@ function WorkspaceScreenContent({
|
||||
focusedPaneTabDescriptorMap,
|
||||
isRouteFocused,
|
||||
focusedPaneId,
|
||||
paneFocusSuspended: Boolean(unfocusedPaneId),
|
||||
// The sheet owns input while it covers the tab.
|
||||
paneFocusSuspended,
|
||||
onFocusPane: handleFocusPane,
|
||||
buildMobilePaneContentModel,
|
||||
});
|
||||
@@ -3495,28 +3523,42 @@ function WorkspaceScreenContent({
|
||||
const desktopContent = desktopSplitContent ?? content;
|
||||
const rendersDesktopSplitContent = !isMobile && desktopSplitContent !== null;
|
||||
|
||||
const workspacePanelContent = (
|
||||
<WorkspacePanelContent
|
||||
launcher={newTabLauncher}
|
||||
workspaceKey={`${normalizedServerId}:${normalizedWorkspaceId}`}
|
||||
tabIds={uiTabs.map((tab) => tab.tabId)}
|
||||
content={isMobile ? content : desktopContent}
|
||||
/>
|
||||
const workspacePanelTabIds = useMemo(
|
||||
() => [...uiTabs.map((tab) => tab.tabId), ...(sheetTab ? [sheetTab.tabId] : [])],
|
||||
[uiTabs, sheetTab],
|
||||
);
|
||||
const workspacePanelKey = `${normalizedServerId}:${normalizedWorkspaceId}`;
|
||||
|
||||
// The sheet is the column's last child, so it covers the compact header as a modal.
|
||||
const workspaceCenterColumn = isMobile ? (
|
||||
<View style={styles.centerColumn}>
|
||||
<FloatingHeaderLayout
|
||||
header={compactWorkspaceHeader}
|
||||
floating={floatsHeaderOverContent(activeTabDescriptor)}
|
||||
>
|
||||
{(topOverlayInset) => (
|
||||
<StreamTopOverlayProvider value={topOverlayInset}>
|
||||
{workspacePanelContent}
|
||||
</StreamTopOverlayProvider>
|
||||
)}
|
||||
</FloatingHeaderLayout>
|
||||
</View>
|
||||
<WorkspacePanelProviders
|
||||
launcher={newTabLauncher}
|
||||
workspaceKey={workspacePanelKey}
|
||||
tabIds={workspacePanelTabIds}
|
||||
>
|
||||
<View style={styles.centerColumn}>
|
||||
<FloatingHeaderLayout
|
||||
header={compactWorkspaceHeader}
|
||||
floating={floatsHeaderOverContent(activeTabDescriptor)}
|
||||
>
|
||||
{(topOverlayInset) => (
|
||||
<StreamTopOverlayProvider value={topOverlayInset}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
</StreamTopOverlayProvider>
|
||||
)}
|
||||
</FloatingHeaderLayout>
|
||||
<StreamTopOverlayProvider value={0}>
|
||||
<WorkspaceSheetHost
|
||||
workspaceKey={persistenceKey}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isRouteFocused}
|
||||
buildContent={buildSheetPaneContentModel}
|
||||
onCloseTab={handleCloseTabById}
|
||||
/>
|
||||
</StreamTopOverlayProvider>
|
||||
</View>
|
||||
</WorkspacePanelProviders>
|
||||
) : (
|
||||
<View style={styles.centerColumn}>
|
||||
{rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()}
|
||||
@@ -3549,7 +3591,15 @@ function WorkspaceScreenContent({
|
||||
</NewTabLauncherProvider>
|
||||
) : null}
|
||||
|
||||
<View style={styles.centerContent}>{workspacePanelContent}</View>
|
||||
<View style={styles.centerContent}>
|
||||
<WorkspacePanelProviders
|
||||
launcher={newTabLauncher}
|
||||
workspaceKey={workspacePanelKey}
|
||||
tabIds={workspacePanelTabIds}
|
||||
>
|
||||
<View style={styles.content}>{desktopContent}</View>
|
||||
</WorkspacePanelProviders>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import BottomSheet, {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { type ComponentType, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { BackHandler, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { SheetHeaderView } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import type { PanelIconProps } from "@/panels/panel-registry";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
WorkspacePaneContent,
|
||||
type WorkspacePaneContentModel,
|
||||
} from "@/screens/workspace/workspace-pane-content";
|
||||
import { useWorkspaceLayoutStore, type WorkspaceSheet } from "@/stores/workspace-layout-store";
|
||||
|
||||
const SNAP_POINTS = ["92%"];
|
||||
|
||||
const ThemedBottomSheet = withUnistyles(BottomSheet, (theme) => ({
|
||||
backgroundStyle: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
handleIndicatorStyle: {
|
||||
backgroundColor: theme.colors.palette.zinc[600],
|
||||
},
|
||||
}));
|
||||
|
||||
function PanelIcon({
|
||||
icon: Icon,
|
||||
size,
|
||||
color,
|
||||
}: { icon: ComponentType<PanelIconProps> } & PanelIconProps) {
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
function renderBackdrop(props: BottomSheetBackdropProps) {
|
||||
return (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
appearsOnIndex={0}
|
||||
disappearsOnIndex={-1}
|
||||
opacity={0.45}
|
||||
pressBehavior="close"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WorkspaceSheetHostProps {
|
||||
workspaceKey: string | null;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
isWorkspaceFocused: boolean;
|
||||
buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
|
||||
/** The workspace's ordinary tab close, including its unsaved-changes guard. */
|
||||
onCloseTab: (tabId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface WorkspaceSheetPresentationProps extends Omit<
|
||||
WorkspaceSheetHostProps,
|
||||
"workspaceKey" | "onCloseTab"
|
||||
> {
|
||||
sheet: WorkspaceSheet;
|
||||
onClose: () => Promise<void>;
|
||||
onOpenAsTab: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts the workspace's sheet tab over the compact tab strip. It renders in the workspace tree,
|
||||
* not through a portal, so the panel keeps every context an ordinary pane has.
|
||||
*/
|
||||
export function WorkspaceSheetHost({ workspaceKey, onCloseTab, ...rest }: WorkspaceSheetHostProps) {
|
||||
const sheet = useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? (state.sheetByWorkspace[workspaceKey] ?? null) : null,
|
||||
);
|
||||
const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab);
|
||||
const sheetTabId = sheet?.tab.tabId ?? null;
|
||||
const handleClose = useCallback(async () => {
|
||||
if (sheetTabId) await onCloseTab(sheetTabId);
|
||||
}, [onCloseTab, sheetTabId]);
|
||||
const handleOpenAsTab = useCallback(() => {
|
||||
if (workspaceKey) openSheetAsTab(workspaceKey);
|
||||
}, [openSheetAsTab, workspaceKey]);
|
||||
if (!sheet) return null;
|
||||
return (
|
||||
<WorkspaceSheetPresentation
|
||||
{...rest}
|
||||
sheet={sheet}
|
||||
onClose={handleClose}
|
||||
onOpenAsTab={handleOpenAsTab}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceSheetPresentation({
|
||||
sheet,
|
||||
serverId,
|
||||
workspaceId,
|
||||
isWorkspaceFocused,
|
||||
buildContent,
|
||||
onClose,
|
||||
onOpenAsTab,
|
||||
}: WorkspaceSheetPresentationProps) {
|
||||
const sheetRef = useRef<BottomSheet>(null);
|
||||
const isScreenFocused = useIsFocused();
|
||||
const ownsBackPress = isNative && isWorkspaceFocused && isScreenFocused;
|
||||
const handleChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index !== -1) return;
|
||||
// A declined close (unsaved changes) leaves the tab in the sheet, so bring it back up.
|
||||
void onClose().then(() => sheetRef.current?.snapToIndex(0));
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
useEffect(() => {
|
||||
// Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns back.
|
||||
if (!ownsBackPress) return;
|
||||
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||
void onClose();
|
||||
return true;
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, [onClose, ownsBackPress]);
|
||||
|
||||
return (
|
||||
<View style={styles.host} pointerEvents="box-none">
|
||||
<ThemedBottomSheet
|
||||
ref={sheetRef}
|
||||
index={0}
|
||||
snapPoints={SNAP_POINTS}
|
||||
enableDynamicSizing={false}
|
||||
enablePanDownToClose
|
||||
enableContentPanningGesture={false}
|
||||
backdropComponent={renderBackdrop}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<WorkspaceSheetBody
|
||||
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
|
||||
sheet={sheet}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
buildContent={buildContent}
|
||||
onClose={onClose}
|
||||
onOpenAsTab={onOpenAsTab}
|
||||
/>
|
||||
</ThemedBottomSheet>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceSheetBody({
|
||||
sheet,
|
||||
serverId,
|
||||
workspaceId,
|
||||
isWorkspaceFocused,
|
||||
buildContent,
|
||||
onClose,
|
||||
onOpenAsTab,
|
||||
}: WorkspaceSheetPresentationProps) {
|
||||
const { t } = useTranslation();
|
||||
const { tab } = sheet;
|
||||
ensurePanelsRegistered();
|
||||
const registration = getPanelRegistration(tab.target.kind);
|
||||
if (!registration) {
|
||||
throw new Error(`No panel registration for kind: ${tab.target.kind}`);
|
||||
}
|
||||
// The body is keyed by tab id and kind, so the registration and its descriptor hook stay fixed.
|
||||
const descriptor = registration.useDescriptor(tab.target as never, {
|
||||
serverId,
|
||||
workspaceId,
|
||||
tabId: tab.tabId,
|
||||
});
|
||||
const tabDescriptor = useMemo<WorkspaceTabDescriptor>(
|
||||
() => ({
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: tab.target.kind,
|
||||
target: tab.target,
|
||||
state: tab.state,
|
||||
}),
|
||||
[tab],
|
||||
);
|
||||
const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]);
|
||||
const handleClose = useCallback(() => {
|
||||
void onClose();
|
||||
}, [onClose]);
|
||||
// The sheet reaches the bottom of the screen, so it owns the system navigation inset.
|
||||
const { bottom: bottomInset } = useSafeAreaInsets();
|
||||
const contentStyle = useMemo(
|
||||
() => [styles.content, { paddingBottom: bottomInset }],
|
||||
[bottomInset],
|
||||
);
|
||||
const header = useMemo(
|
||||
() => ({
|
||||
title: descriptor.label,
|
||||
leading: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
|
||||
actions: sheet.policy.openAsTab ? (
|
||||
<Button size="sm" variant="secondary" onPress={onOpenAsTab}>
|
||||
{t("workspace.sheet.openAsTab")}
|
||||
</Button>
|
||||
) : undefined,
|
||||
}),
|
||||
[descriptor.icon, descriptor.label, onOpenAsTab, sheet.policy.openAsTab, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.body} role="dialog" aria-label={descriptor.label}>
|
||||
<SheetHeaderView header={header} onClose={handleClose} />
|
||||
<View style={contentStyle}>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isWorkspaceFocused}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
host: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
@@ -16,6 +16,9 @@ vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
});
|
||||
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { setPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||
import { createViewedTimelineSync } from "@/timeline/viewed-timeline-sync";
|
||||
import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import { defaultChangesState, type ChangesState } from "@/panels/changes/state";
|
||||
@@ -24,6 +27,7 @@ import {
|
||||
canDismissPaneInLayout,
|
||||
collectAllPanes,
|
||||
collectAllTabs,
|
||||
selectPaneFocusSuspended,
|
||||
createWorkspaceLayoutStore,
|
||||
observeOpenWorkspaceAgentIds,
|
||||
createDefaultLayout,
|
||||
@@ -4438,3 +4442,580 @@ it("persists the once-only PR add after closing, and clears it when purging the
|
||||
expect(restored.getState().pullRequestTabAutoOpenedByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(restored.getState().autoOpenPullRequestTab(workspaceKey, placement)).toBe("pull_request");
|
||||
});
|
||||
|
||||
describe("compact workspace sheet", () => {
|
||||
const workspaceKey = "server-1:sheet-ws";
|
||||
const fileTarget = { kind: "file" as const, path: "src/index.ts" };
|
||||
|
||||
function createCompactStore() {
|
||||
const store = createWorkspaceLayoutStore(workspaceLayoutIds);
|
||||
store.setState({ layoutByWorkspace: {}, sheetByWorkspace: {} });
|
||||
store.getState().setLayoutMode("compact");
|
||||
store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "terminal", terminalId: "term-1" },
|
||||
intent: "reveal",
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
function tabKinds(store: ReturnType<typeof createCompactStore>): string[] {
|
||||
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||
const tabsById = new Map(collectAllTabs(layout.root).map((tab) => [tab.tabId, tab]));
|
||||
return (findPaneById(layout.root, "main")?.tabIds ?? []).map(
|
||||
(tabId) => tabsById.get(tabId)?.target.kind ?? "missing",
|
||||
);
|
||||
}
|
||||
|
||||
function seedAgents(agents: Array<Pick<Agent, "id" | "parentAgentId" | "workspaceId">>): void {
|
||||
const timestamp = new Date("2026-04-21T10:00:00.000Z");
|
||||
useSessionStore.getState().initializeSession("server-1", null as unknown as DaemonClient);
|
||||
useSessionStore.getState().setAgents(
|
||||
"server-1",
|
||||
new Map(
|
||||
agents.map((agent) => [
|
||||
agent.id,
|
||||
{
|
||||
serverId: "server-1",
|
||||
provider: "codex",
|
||||
status: "idle",
|
||||
turn: { phase: "idle", cancellationRequestId: null },
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: timestamp,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: undefined,
|
||||
lastUsage: undefined,
|
||||
lastError: null,
|
||||
title: agent.id,
|
||||
cwd: "/repo",
|
||||
model: null,
|
||||
features: undefined,
|
||||
thinkingOptionId: undefined,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: null,
|
||||
labels: {},
|
||||
projectPlacement: null,
|
||||
...agent,
|
||||
} satisfies Agent,
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
it("reveals a file in the sheet and leaves the tab strip unchanged", () => {
|
||||
const store = createCompactStore();
|
||||
const before = tabKinds(store);
|
||||
|
||||
const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab).toEqual({
|
||||
tabId,
|
||||
target: fileTarget,
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
expect(tabKinds(store)).toEqual(before);
|
||||
});
|
||||
|
||||
it("focuses a target that is already open as a tab instead of using the sheet", () => {
|
||||
const store = createCompactStore();
|
||||
store.getState().setLayoutMode("split-panes");
|
||||
const fileTabId = store
|
||||
.getState()
|
||||
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||
store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "terminal", terminalId: "term-1" },
|
||||
intent: "reveal",
|
||||
});
|
||||
store.getState().setLayoutMode("compact");
|
||||
|
||||
const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||
|
||||
expect(tabId).toBe(fileTabId);
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(findPaneContainingTab(layout.root, fileTabId ?? "")?.focusedTabId).toBe(fileTabId);
|
||||
});
|
||||
|
||||
it("replaces the sheet content with the next sheet target", () => {
|
||||
const store = createCompactStore();
|
||||
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||
|
||||
const nextTarget = { kind: "file" as const, path: "src/other.ts" };
|
||||
store.getState().openTab({ workspaceKey, target: nextTarget, intent: "reveal" });
|
||||
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual(nextTarget);
|
||||
expect(tabKinds(store)).toEqual(["terminal"]);
|
||||
});
|
||||
|
||||
it("closes the sheet when a tab target is revealed, and keeps it for background opens", () => {
|
||||
const store = createCompactStore();
|
||||
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||
|
||||
store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "file", path: "src/background.ts" },
|
||||
intent: "background",
|
||||
});
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual(fileTarget);
|
||||
expect(tabKinds(store)).toEqual(["terminal", "file"]);
|
||||
|
||||
store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "terminal", terminalId: "term-2" },
|
||||
intent: "reveal",
|
||||
});
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("moves the sheet tab into the focused pane with the same tab id and state", () => {
|
||||
const store = createCompactStore();
|
||||
const sheetTabId = store
|
||||
.getState()
|
||||
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||
if (!sheetTabId) throw new Error("Expected a sheet tab");
|
||||
store.getState().setTabState(workspaceKey, sheetTabId, { scrollTop: 120 });
|
||||
|
||||
const tabId = store.getState().openSheetAsTab(workspaceKey);
|
||||
|
||||
expect(tabId).toBe(sheetTabId);
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||
const pane = findPaneContainingTab(layout.root, sheetTabId);
|
||||
expect(pane?.id).toBe(layout.focusedPaneId);
|
||||
expect(pane?.focusedTabId).toBe(sheetTabId);
|
||||
expect(collectAllTabs(layout.root).find((tab) => tab.tabId === sheetTabId)?.state).toEqual({
|
||||
scrollTop: 120,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the sheet through the ordinary close and ignores focus requests for it", () => {
|
||||
const store = createCompactStore();
|
||||
const sheetTabId = store
|
||||
.getState()
|
||||
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||
if (!sheetTabId) throw new Error("Expected a sheet tab");
|
||||
const layoutBefore = store.getState().layoutByWorkspace[workspaceKey];
|
||||
|
||||
store.getState().focusTab(workspaceKey, sheetTabId);
|
||||
expect(store.getState().layoutByWorkspace[workspaceKey]).toBe(layoutBefore);
|
||||
|
||||
store.getState().closeTab(workspaceKey, sheetTabId);
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(tabKinds(store)).toEqual(["terminal"]);
|
||||
});
|
||||
|
||||
it("suspends pane focus while the sheet covers the panes", () => {
|
||||
const store = createCompactStore();
|
||||
expect(selectPaneFocusSuspended(store.getState(), workspaceKey)).toBe(false);
|
||||
|
||||
const sheetTabId = store
|
||||
.getState()
|
||||
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||
expect(selectPaneFocusSuspended(store.getState(), workspaceKey)).toBe(true);
|
||||
|
||||
store.getState().closeTab(workspaceKey, sheetTabId ?? "");
|
||||
expect(selectPaneFocusSuspended(store.getState(), workspaceKey)).toBe(false);
|
||||
});
|
||||
|
||||
it("never uses the sheet on wide layouts", () => {
|
||||
const store = createCompactStore();
|
||||
store.getState().setLayoutMode("split-panes");
|
||||
|
||||
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||
|
||||
expect(store.getState().sheetByWorkspace).toEqual({});
|
||||
expect(tabKinds(store)).toEqual(["terminal", "file"]);
|
||||
});
|
||||
|
||||
it("keeps the sheet content as a tab when the layout widens", () => {
|
||||
const store = createCompactStore();
|
||||
const sheetTabId = store
|
||||
.getState()
|
||||
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||
|
||||
store.getState().setLayoutMode("split-panes");
|
||||
|
||||
expect(store.getState().sheetByWorkspace).toEqual({});
|
||||
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(findPaneContainingTab(layout.root, sheetTabId ?? "")?.focusedTabId).toBe(sheetTabId);
|
||||
});
|
||||
|
||||
it("opens subagents and provider subagents in the sheet but keeps root agents as tabs", () => {
|
||||
seedAgents([
|
||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||
{ id: "other-workspace-child", parentAgentId: "root", workspaceId: "other-ws" },
|
||||
]);
|
||||
const store = createCompactStore();
|
||||
|
||||
store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: "child" },
|
||||
intent: "reveal",
|
||||
});
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({
|
||||
tab: { target: { kind: "agent", agentId: "child" } },
|
||||
policy: { openAsTab: true },
|
||||
});
|
||||
|
||||
store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "provider_subagent", parentAgentId: "root", subagentId: "task-1" },
|
||||
intent: "reveal",
|
||||
});
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({
|
||||
tab: { target: { kind: "provider_subagent", subagentId: "task-1" } },
|
||||
policy: { openAsTab: false },
|
||||
});
|
||||
|
||||
for (const agentId of ["root", "other-workspace-child"]) {
|
||||
store
|
||||
.getState()
|
||||
.openTab({ workspaceKey, target: { kind: "agent", agentId }, intent: "reveal" });
|
||||
}
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]);
|
||||
});
|
||||
|
||||
it("gives a sheet target opened from inside the sheet the sheet's own parent", () => {
|
||||
seedAgents([
|
||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||
]);
|
||||
const store = createCompactStore();
|
||||
const rootTabId = store
|
||||
.getState()
|
||||
.openTab({ workspaceKey, target: { kind: "agent", agentId: "root" }, intent: "reveal" });
|
||||
const childSheetTabId = store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: "child" },
|
||||
intent: "reveal",
|
||||
parentTabId: rootTabId ?? undefined,
|
||||
});
|
||||
|
||||
const fileSheetTabId = store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: fileTarget,
|
||||
intent: "reveal",
|
||||
parentTabId: childSheetTabId ?? undefined,
|
||||
});
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]?.parentTabId).toBe(rootTabId);
|
||||
|
||||
store.getState().openSheetAsTab(workspaceKey);
|
||||
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(layout.parentTabIdByTabId?.[fileSheetTabId ?? ""]).toBe(rootTabId);
|
||||
});
|
||||
|
||||
it("closes the sheet and reveals the tab when a tab target is opened from inside the sheet", () => {
|
||||
seedAgents([
|
||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||
{ id: "second-root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||
]);
|
||||
const store = createCompactStore();
|
||||
const rootTabId = store
|
||||
.getState()
|
||||
.openTab({ workspaceKey, target: { kind: "agent", agentId: "root" }, intent: "reveal" });
|
||||
const openChildSheet = () =>
|
||||
store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: "child" },
|
||||
intent: "reveal",
|
||||
parentTabId: rootTabId ?? undefined,
|
||||
}) ?? undefined;
|
||||
|
||||
const terminalTabId = store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "terminal", terminalId: "term-1" },
|
||||
intent: "reveal",
|
||||
parentTabId: openChildSheet(),
|
||||
});
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||
let layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(terminalTabId);
|
||||
|
||||
const secondRootTabId = store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: "second-root" },
|
||||
intent: "reveal",
|
||||
parentTabId: openChildSheet(),
|
||||
});
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||
layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(secondRootTabId);
|
||||
expect(layout.parentTabIdByTabId?.[secondRootTabId ?? ""]).toBe(rootTabId);
|
||||
expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]);
|
||||
});
|
||||
|
||||
describe("unsaved sheet work", () => {
|
||||
const sheetPanel = (tabId: string) => ({
|
||||
serverId: "server-1",
|
||||
workspaceId: "sheet-ws",
|
||||
tabId,
|
||||
});
|
||||
let modifiedTabId: string | null = null;
|
||||
|
||||
function openModifiedFileSheet(store: ReturnType<typeof createCompactStore>): string {
|
||||
const tabId = store
|
||||
.getState()
|
||||
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||
if (!tabId) throw new Error("Expected a sheet tab");
|
||||
store.getState().setTabState(workspaceKey, tabId, { scrollTop: 40 });
|
||||
setPanelInstanceAttributes(sheetPanel(tabId), { modified: true });
|
||||
modifiedTabId = tabId;
|
||||
return tabId;
|
||||
}
|
||||
|
||||
function stripTab(store: ReturnType<typeof createCompactStore>, tabId: string) {
|
||||
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||
return collectAllTabs(layout.root).find((tab) => tab.tabId === tabId) ?? null;
|
||||
}
|
||||
|
||||
function focusedTabId(store: ReturnType<typeof createCompactStore>): string | null {
|
||||
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||
return findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId ?? null;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (modifiedTabId) setPanelInstanceAttributes(sheetPanel(modifiedTabId), { modified: false });
|
||||
modifiedTabId = null;
|
||||
});
|
||||
|
||||
function sheetTabId(store: ReturnType<typeof createCompactStore>): string | null {
|
||||
return store.getState().sheetByWorkspace[workspaceKey]?.tab.tabId ?? null;
|
||||
}
|
||||
|
||||
it("opens the next sheet target as a tab under a modified sheet", () => {
|
||||
const store = createCompactStore();
|
||||
const modifiedSheetTabId = openModifiedFileSheet(store);
|
||||
|
||||
const nextTarget = { kind: "file" as const, path: "src/other.ts" };
|
||||
const nextTabId = store
|
||||
.getState()
|
||||
.openTab({ workspaceKey, target: nextTarget, intent: "reveal" });
|
||||
|
||||
expect(sheetTabId(store)).toBe(modifiedSheetTabId);
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.state).toEqual({ scrollTop: 40 });
|
||||
expect(stripTab(store, nextTabId ?? "")).toMatchObject({ target: nextTarget });
|
||||
expect(focusedTabId(store)).toBe(nextTabId);
|
||||
});
|
||||
|
||||
it("opens a tab target under a modified sheet without closing it", () => {
|
||||
const store = createCompactStore();
|
||||
const modifiedSheetTabId = openModifiedFileSheet(store);
|
||||
|
||||
const terminalTabId = store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "terminal", terminalId: "term-2" },
|
||||
intent: "reveal",
|
||||
});
|
||||
|
||||
expect(sheetTabId(store)).toBe(modifiedSheetTabId);
|
||||
expect(focusedTabId(store)).toBe(terminalTabId);
|
||||
});
|
||||
|
||||
it("keeps a modified sheet open when another tab is chosen", () => {
|
||||
const store = createCompactStore();
|
||||
const secondTabId = store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "terminal", terminalId: "term-2" },
|
||||
intent: "reveal",
|
||||
});
|
||||
const firstTabId = store.getState().getWorkspaceTabs(workspaceKey)[0]?.tabId ?? "";
|
||||
const modifiedSheetTabId = openModifiedFileSheet(store);
|
||||
|
||||
store.getState().focusTab(workspaceKey, firstTabId);
|
||||
expect(sheetTabId(store)).toBe(modifiedSheetTabId);
|
||||
expect(focusedTabId(store)).toBe(firstTabId);
|
||||
|
||||
store.getState().selectTabInPane(workspaceKey, "main", secondTabId ?? "");
|
||||
expect(sheetTabId(store)).toBe(modifiedSheetTabId);
|
||||
expect(focusedTabId(store)).toBe(secondTabId);
|
||||
|
||||
store.getState().closeTab(workspaceKey, modifiedSheetTabId);
|
||||
expect(sheetTabId(store)).toBeNull();
|
||||
expect(tabKinds(store)).toEqual(["terminal", "terminal"]);
|
||||
});
|
||||
|
||||
it("replaces an unmodified sheet with the next sheet target", () => {
|
||||
const store = createCompactStore();
|
||||
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||
|
||||
store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "file", path: "src/other.ts" },
|
||||
intent: "reveal",
|
||||
});
|
||||
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual({
|
||||
kind: "file",
|
||||
path: "src/other.ts",
|
||||
});
|
||||
expect(tabKinds(store)).toEqual(["terminal"]);
|
||||
});
|
||||
|
||||
it("closes an unmodified sheet when another tab is chosen", () => {
|
||||
const store = createCompactStore();
|
||||
const secondTabId = store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "terminal", terminalId: "term-2" },
|
||||
intent: "reveal",
|
||||
});
|
||||
const firstTabId = store.getState().getWorkspaceTabs(workspaceKey)[0]?.tabId ?? "";
|
||||
|
||||
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||
store.getState().focusTab(workspaceKey, firstTabId);
|
||||
expect(sheetTabId(store)).toBeNull();
|
||||
expect(focusedTabId(store)).toBe(firstTabId);
|
||||
|
||||
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||
store.getState().selectTabInPane(workspaceKey, "main", secondTabId ?? "");
|
||||
expect(sheetTabId(store)).toBeNull();
|
||||
expect(focusedTabId(store)).toBe(secondTabId);
|
||||
expect(tabKinds(store)).toEqual(["terminal", "terminal"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the sheet when its agent leaves the workspace", () => {
|
||||
seedAgents([
|
||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||
]);
|
||||
const store = createCompactStore();
|
||||
store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: "child" },
|
||||
intent: "reveal",
|
||||
});
|
||||
const snapshot = {
|
||||
terminalsHydrated: false,
|
||||
activeAgentIds: ["root"],
|
||||
autoOpenAgentIds: [],
|
||||
knownTerminalIds: ["term-1"],
|
||||
standaloneTerminalIds: ["term-1"],
|
||||
hasActivePendingTerminalCreate: false,
|
||||
};
|
||||
|
||||
store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: false });
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeDefined();
|
||||
|
||||
store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: true });
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("closes the sheet when its agent stops being a subagent, leaving one tab for it", () => {
|
||||
seedAgents([
|
||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||
]);
|
||||
const store = createCompactStore();
|
||||
const sheetTabId = store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: "child" },
|
||||
intent: "reveal",
|
||||
});
|
||||
const snapshot = {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: false,
|
||||
activeAgentIds: ["root", "child"],
|
||||
autoOpenAgentIds: ["child"],
|
||||
knownTerminalIds: ["term-1"],
|
||||
standaloneTerminalIds: ["term-1"],
|
||||
hasActivePendingTerminalCreate: false,
|
||||
};
|
||||
|
||||
store.getState().reconcileTabs(workspaceKey, { ...snapshot, autoOpenAgentIds: [] });
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.tabId).toBe(sheetTabId);
|
||||
|
||||
seedAgents([
|
||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||
{ id: "child", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||
]);
|
||||
store.getState().reconcileTabs(workspaceKey, snapshot);
|
||||
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(
|
||||
collectAllTabs(layout.root).filter(
|
||||
(tab) => tab.target.kind === "agent" && tab.target.agentId === "child",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("re-resolves the sheet policy when the sheet tab gets another target", () => {
|
||||
seedAgents([
|
||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||
]);
|
||||
const store = createCompactStore();
|
||||
const sheetTabId = store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "provider_subagent", parentAgentId: "root", subagentId: "task-1" },
|
||||
intent: "reveal",
|
||||
});
|
||||
|
||||
const replacedTabId = store
|
||||
.getState()
|
||||
.replaceTab(workspaceKey, sheetTabId ?? "", { kind: "agent", agentId: "child" });
|
||||
|
||||
expect(replacedTabId).toBe(sheetTabId);
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({
|
||||
tab: { tabId: sheetTabId, target: { kind: "agent", agentId: "child" } },
|
||||
policy: { openAsTab: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("moves the sheet tab into the tab strip when it gets a target the sheet does not host", () => {
|
||||
const store = createCompactStore();
|
||||
const sheetTabId = store
|
||||
.getState()
|
||||
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
|
||||
|
||||
const replacedTabId = store.getState().replaceTab(workspaceKey, sheetTabId ?? "", {
|
||||
kind: "terminal",
|
||||
terminalId: "term-2",
|
||||
});
|
||||
|
||||
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
|
||||
expect(replacedTabId).toBe(sheetTabId);
|
||||
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(sheetTabId);
|
||||
expect(tabKinds(store)).toEqual(["terminal", "terminal"]);
|
||||
});
|
||||
|
||||
it("keeps a chat shown in the sheet open for timeline sync until the sheet closes", () => {
|
||||
const store = createCompactStore();
|
||||
seedAgents([
|
||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||
]);
|
||||
const received: string[][] = [];
|
||||
const stop = observeOpenWorkspaceAgentIds("server-1", (ids) => received.push(ids), store);
|
||||
|
||||
store.getState().openTab({
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: "child" },
|
||||
intent: "reveal",
|
||||
});
|
||||
store.getState().closeSheet(workspaceKey);
|
||||
stop();
|
||||
|
||||
expect(received).toEqual([[], ["child"], []]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,12 @@ import {
|
||||
type WorkspaceTabSnapshot,
|
||||
type WorkspaceLayout,
|
||||
} from "@/stores/workspace-layout-actions";
|
||||
import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
normalizeWorkspaceTabTarget,
|
||||
} from "@/workspace-tabs/identity";
|
||||
import { isSheetTabModified, resolveCompactSheetPolicy } from "@/workspace-tabs/compact-sheet";
|
||||
import { panelResourceKey, type CompactSheetPolicy } from "@/panels/panel-manifest";
|
||||
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
|
||||
import { panelTargetSupportsHostForWorkspaceKey } from "@/plugins/workspace-panels/locations";
|
||||
|
||||
@@ -105,7 +110,24 @@ export interface OpenWorkspaceTabInput {
|
||||
state?: JsonValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* How the app shell lays out a workspace: compact (one tab at a time, plus the sheet), a single
|
||||
* wide pane (touch tablets, which cannot render splits), or split panes.
|
||||
*/
|
||||
export type WorkspaceLayoutMode = "compact" | "single-pane" | "split-panes";
|
||||
|
||||
/** A tab hosted by the compact workspace sheet instead of a pane. */
|
||||
export interface WorkspaceSheet {
|
||||
tab: WorkspaceTab;
|
||||
policy: CompactSheetPolicy;
|
||||
parentTabId: string | null;
|
||||
}
|
||||
|
||||
interface WorkspaceLayoutStore {
|
||||
/** Published once by the app shell; callers never pass a form factor to open a tab. */
|
||||
layoutMode: WorkspaceLayoutMode;
|
||||
/** At most one sheet tab per workspace. Not persisted: a reload returns to the tab strip. */
|
||||
sheetByWorkspace: Record<string, WorkspaceSheet>;
|
||||
layoutByWorkspace: Record<string, WorkspaceLayout>;
|
||||
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
||||
explorerSidebarWidthByWorkspace: Record<string, number>;
|
||||
@@ -116,7 +138,19 @@ interface WorkspaceLayoutStore {
|
||||
sidePaneIdByWorkspace: Record<string, string | null>;
|
||||
/** Workspaces where PR detection already added its tab once; a closed tab never returns. */
|
||||
pullRequestTabAutoOpenedByWorkspace: Record<string, true>;
|
||||
/**
|
||||
* The one entry point for showing a target. On compact layouts a target whose panel declares
|
||||
* `compactSheet` opens in the workspace sheet; the returned tab id is the sheet tab's id.
|
||||
*/
|
||||
openTab: (input: OpenWorkspaceTabInput) => string | null;
|
||||
/** Whether `openTab` with this input would show the target in the sheet. */
|
||||
opensInSheet: (
|
||||
input: Pick<OpenWorkspaceTabInput, "workspaceKey" | "target" | "intent">,
|
||||
) => boolean;
|
||||
setLayoutMode: (mode: WorkspaceLayoutMode) => void;
|
||||
closeSheet: (workspaceKey: string) => void;
|
||||
/** Moves the sheet's tab, keeping its tab id, into the focused pane. */
|
||||
openSheetAsTab: (workspaceKey: string) => string | null;
|
||||
/** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */
|
||||
autoOpenPullRequestTab: (
|
||||
workspaceKey: string,
|
||||
@@ -472,6 +506,17 @@ export function selectExplorerSidebarPaneId(
|
||||
return resolveExplorerSidebarPaneId(layout, state.explorerSidebarPaneIdByWorkspace[workspaceKey]);
|
||||
}
|
||||
|
||||
/** A workspace's panes give up keyboard focus while focus is parked or the sheet covers them. */
|
||||
export function selectPaneFocusSuspended(
|
||||
state: Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace" | "sheetByWorkspace">,
|
||||
workspaceKey: string,
|
||||
): boolean {
|
||||
return (
|
||||
Boolean(state.focusRestorationByWorkspace[workspaceKey]?.restorePaneId) ||
|
||||
workspaceKey in state.sheetByWorkspace
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the Explorer sidebar pane is currently on screen. */
|
||||
export function selectIsExplorerSidebarVisible(
|
||||
state: ExplorerSidebarState,
|
||||
@@ -637,6 +682,189 @@ function attachParentTab(input: {
|
||||
});
|
||||
}
|
||||
|
||||
function withoutSheet(
|
||||
state: Pick<WorkspaceLayoutStore, "sheetByWorkspace">,
|
||||
workspaceKey: string,
|
||||
): Pick<WorkspaceLayoutStore, "sheetByWorkspace"> {
|
||||
if (!(workspaceKey in state.sheetByWorkspace)) {
|
||||
return { sheetByWorkspace: state.sheetByWorkspace };
|
||||
}
|
||||
const { [workspaceKey]: _removed, ...sheetByWorkspace } = state.sheetByWorkspace;
|
||||
return { sheetByWorkspace };
|
||||
}
|
||||
|
||||
function isTargetOpenAsTab(layout: WorkspaceLayout | undefined, target: WorkspaceTabTarget) {
|
||||
if (!layout) return false;
|
||||
const resourceKey = panelResourceKey(target);
|
||||
return collectAllTabs(layout.root).some((tab) => panelResourceKey(tab.target) === resourceKey);
|
||||
}
|
||||
|
||||
function resolveSheetPolicy(
|
||||
state: Pick<WorkspaceLayoutStore, "layoutMode" | "layoutByWorkspace">,
|
||||
workspaceKey: string,
|
||||
target: WorkspaceTabTarget,
|
||||
intent: WorkspaceTabOpenIntent,
|
||||
): CompactSheetPolicy | null {
|
||||
if (state.layoutMode !== "compact" || intent === "background") return null;
|
||||
if (intent === "reveal" && isTargetOpenAsTab(state.layoutByWorkspace[workspaceKey], target)) {
|
||||
return null;
|
||||
}
|
||||
return resolveCompactSheetPolicy(workspaceKey, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent a newly opened tab links to. A target opened from inside the sheet names the sheet's
|
||||
* tab, which the open replaces or closes, so it inherits the sheet's own parent instead.
|
||||
*/
|
||||
function resolveOpenParentTabId(
|
||||
state: Pick<WorkspaceLayoutStore, "sheetByWorkspace">,
|
||||
workspaceKey: string,
|
||||
parentTabId: string | undefined,
|
||||
): string | null {
|
||||
const sheet = state.sheetByWorkspace[workspaceKey];
|
||||
if (sheet && parentTabId === sheet.tab.tabId) return sheet.parentTabId;
|
||||
return parentTabId ?? null;
|
||||
}
|
||||
|
||||
/** A reveal of the resource the sheet already shows keeps the sheet's tab. */
|
||||
function sheetKeepsTabFor(
|
||||
sheet: WorkspaceSheet | undefined,
|
||||
target: WorkspaceTabTarget,
|
||||
intent: WorkspaceTabOpenIntent,
|
||||
): sheet is WorkspaceSheet {
|
||||
return (
|
||||
intent === "reveal" &&
|
||||
sheet !== undefined &&
|
||||
panelResourceKey(sheet.tab.target) === panelResourceKey(target)
|
||||
);
|
||||
}
|
||||
|
||||
function placeTargetInSheet(input: {
|
||||
/** The sheet whose tab the target keeps, if any. */
|
||||
kept: WorkspaceSheet | undefined;
|
||||
target: WorkspaceTabTarget;
|
||||
intent: WorkspaceTabOpenIntent;
|
||||
policy: CompactSheetPolicy;
|
||||
parentTabId: string | null;
|
||||
state: JsonValue | undefined;
|
||||
}): WorkspaceSheet {
|
||||
if (input.kept) {
|
||||
return { ...input.kept, tab: { ...input.kept.tab, target: input.target } };
|
||||
}
|
||||
return {
|
||||
tab: {
|
||||
tabId:
|
||||
input.intent === "reveal"
|
||||
? buildDeterministicWorkspaceTabId(input.target)
|
||||
: createWorkspaceTabInstanceId(),
|
||||
target: input.target,
|
||||
createdAt: Date.now(),
|
||||
...(input.state !== undefined ? { state: input.state } : {}),
|
||||
},
|
||||
policy: input.policy,
|
||||
parentTabId: input.parentTabId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Moves a workspace's sheet tab into its focused pane, keeping its tab id and state. */
|
||||
function promoteSheetToTab(
|
||||
state: WorkspaceLayoutStore,
|
||||
workspaceKey: string,
|
||||
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
|
||||
const sheet = state.sheetByWorkspace[workspaceKey];
|
||||
if (!sheet) return null;
|
||||
const { tab } = sheet;
|
||||
const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT);
|
||||
const input = {
|
||||
...placement,
|
||||
target: tab.target,
|
||||
now: tab.createdAt,
|
||||
createTabId: () => tab.tabId,
|
||||
state: tab.state,
|
||||
};
|
||||
const result = isTargetOpenAsTab(placement.layout, tab.target)
|
||||
? revealTargetInLayout(input)
|
||||
: createTabInLayout(input);
|
||||
if (!result) return null;
|
||||
const nextLayout = attachParentTab({
|
||||
layout: keepWorkspaceFocusOutOfExplorerSidebar(
|
||||
result.layout,
|
||||
placement.explorerSidebarPaneId,
|
||||
placement.layout.focusedPaneId,
|
||||
),
|
||||
childTabId: result.tabId,
|
||||
parentTabId: sheet.parentTabId,
|
||||
});
|
||||
return {
|
||||
tabId: result.tabId,
|
||||
patch: {
|
||||
...withoutSheet(state, workspaceKey),
|
||||
...withoutFocusRestoration(state, workspaceKey),
|
||||
hiddenAgentIdsByWorkspace:
|
||||
tab.target.kind === "agent"
|
||||
? removeAgentIdFromWorkspaceSet(
|
||||
state.hiddenAgentIdsByWorkspace,
|
||||
workspaceKey,
|
||||
tab.target.agentId,
|
||||
)
|
||||
: state.hiddenAgentIdsByWorkspace,
|
||||
layoutByWorkspace: { ...state.layoutByWorkspace, [workspaceKey]: nextLayout },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A sheet holding unsaved work is never implicitly displaced: moving its tab would remount the
|
||||
* panel and drop the work. Only the explicit, guarded close removes it.
|
||||
*/
|
||||
function sheetHoldsUnsavedWork(
|
||||
state: Pick<WorkspaceLayoutStore, "sheetByWorkspace">,
|
||||
workspaceKey: string,
|
||||
): boolean {
|
||||
const sheet = state.sheetByWorkspace[workspaceKey];
|
||||
return sheet !== undefined && isSheetTabModified(workspaceKey, sheet.tab.tabId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an open lands in the sheet, or null when it opens as a tab. A reveal of the resource the
|
||||
* sheet shows keeps its tab; any other target never displaces unsaved work and opens as a tab.
|
||||
*/
|
||||
function resolveSheetPlacement(
|
||||
state: WorkspaceLayoutStore,
|
||||
workspaceKey: string,
|
||||
target: WorkspaceTabTarget,
|
||||
intent: WorkspaceTabOpenIntent,
|
||||
): { policy: CompactSheetPolicy; kept: WorkspaceSheet | undefined } | null {
|
||||
const policy = resolveSheetPolicy(state, workspaceKey, target, intent);
|
||||
if (!policy) return null;
|
||||
const sheet = state.sheetByWorkspace[workspaceKey];
|
||||
if (sheetKeepsTabFor(sheet, target, intent)) return { policy, kept: sheet };
|
||||
return sheetHoldsUnsavedWork(state, workspaceKey) ? null : { policy, kept: undefined };
|
||||
}
|
||||
|
||||
/** Closes the sheet because navigation moved on underneath it, unless it holds unsaved work. */
|
||||
function dismissSheet(
|
||||
state: WorkspaceLayoutStore,
|
||||
workspaceKey: string,
|
||||
): Partial<WorkspaceLayoutStore> {
|
||||
return sheetHoldsUnsavedWork(state, workspaceKey) ? {} : withoutSheet(state, workspaceKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* A sheet agent closes once the workspace no longer has it (archived or gone), like its tab would,
|
||||
* and once it stops being a subagent: as a root agent, reconcile gives it its own tab, and the sheet
|
||||
* would hold a second tab with the same id.
|
||||
*/
|
||||
function sheetAgentNoLongerFits(
|
||||
workspaceKey: string,
|
||||
sheet: WorkspaceSheet | undefined,
|
||||
snapshot: WorkspaceTabSnapshot,
|
||||
): boolean {
|
||||
if (sheet?.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false;
|
||||
if (!new Set(snapshot.activeAgentIds).has(sheet.tab.target.agentId)) return true;
|
||||
return resolveCompactSheetPolicy(workspaceKey, sheet.tab.target) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits an Explorer sidebar out of the pane the user is in. Only reached by layouts saved
|
||||
* before the Explorer became part of the default tree; new ones are born with it.
|
||||
@@ -676,6 +904,8 @@ export function createWorkspaceLayoutStore(
|
||||
}
|
||||
return tabId;
|
||||
},
|
||||
layoutMode: "split-panes",
|
||||
sheetByWorkspace: {},
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
explorerSidebarWidthByWorkspace: {},
|
||||
@@ -691,6 +921,31 @@ export function createWorkspaceLayoutStore(
|
||||
if (!normalizedWorkspaceKey || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
const parentTabId = resolveOpenParentTabId(
|
||||
get(),
|
||||
normalizedWorkspaceKey,
|
||||
input.parentTabId,
|
||||
);
|
||||
const sheetPlacement = resolveSheetPlacement(
|
||||
get(),
|
||||
normalizedWorkspaceKey,
|
||||
normalizedTarget,
|
||||
input.intent,
|
||||
);
|
||||
if (sheetPlacement) {
|
||||
const sheet = placeTargetInSheet({
|
||||
kept: sheetPlacement.kept,
|
||||
target: normalizedTarget,
|
||||
intent: input.intent,
|
||||
policy: sheetPlacement.policy,
|
||||
parentTabId,
|
||||
state: input.state,
|
||||
});
|
||||
set((state) => ({
|
||||
sheetByWorkspace: { ...state.sheetByWorkspace, [normalizedWorkspaceKey]: sheet },
|
||||
}));
|
||||
return sheet.tab.tabId;
|
||||
}
|
||||
const placement = getOpenTabPlacement(
|
||||
get(),
|
||||
normalizedWorkspaceKey,
|
||||
@@ -734,6 +989,8 @@ export function createWorkspaceLayoutStore(
|
||||
const shouldPinAgent = input.pin === true && normalizedTarget.kind === "agent";
|
||||
set((state) => ({
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
// A tab brought to the front dismisses the sheet over it.
|
||||
...(input.intent === "background" ? {} : dismissSheet(state, normalizedWorkspaceKey)),
|
||||
hiddenAgentIdsByWorkspace:
|
||||
normalizedTarget.kind !== "agent"
|
||||
? state.hiddenAgentIdsByWorkspace
|
||||
@@ -751,17 +1008,52 @@ export function createWorkspaceLayoutStore(
|
||||
: state.pinnedAgentIdsByWorkspace,
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: input.parentTabId
|
||||
[normalizedWorkspaceKey]: parentTabId
|
||||
? attachParentTab({
|
||||
layout: nextLayout,
|
||||
childTabId: result.tabId,
|
||||
parentTabId: input.parentTabId,
|
||||
parentTabId,
|
||||
})
|
||||
: nextLayout,
|
||||
},
|
||||
}));
|
||||
return result.tabId;
|
||||
},
|
||||
opensInSheet: (input) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
|
||||
if (!normalizedWorkspaceKey || !normalizedTarget) return false;
|
||||
return (
|
||||
resolveSheetPlacement(get(), normalizedWorkspaceKey, normalizedTarget, input.intent) !==
|
||||
null
|
||||
);
|
||||
},
|
||||
setLayoutMode: (mode) => {
|
||||
set((state) => {
|
||||
if (state.layoutMode === mode) return state;
|
||||
if (mode === "compact") return { layoutMode: mode };
|
||||
// Widening keeps whatever the user had open in the sheet as an ordinary tab.
|
||||
const next: WorkspaceLayoutStore = { ...state, layoutMode: mode };
|
||||
for (const workspaceKey of Object.keys(state.sheetByWorkspace)) {
|
||||
const promoted = promoteSheetToTab(next, workspaceKey);
|
||||
Object.assign(next, promoted?.patch ?? withoutSheet(next, workspaceKey));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
closeSheet: (workspaceKey) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) return;
|
||||
set((state) => withoutSheet(state, normalizedWorkspaceKey));
|
||||
},
|
||||
openSheetAsTab: (workspaceKey) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) return null;
|
||||
const promoted = promoteSheetToTab(get(), normalizedWorkspaceKey);
|
||||
if (!promoted) return null;
|
||||
set(promoted.patch);
|
||||
return promoted.tabId;
|
||||
},
|
||||
showExplorerSidebar: (workspaceKey) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
@@ -881,6 +1173,9 @@ export function createWorkspaceLayoutStore(
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
if (state.sheetByWorkspace[normalizedWorkspaceKey]?.tab.tabId === normalizedTabId) {
|
||||
return withoutSheet(state, normalizedWorkspaceKey);
|
||||
}
|
||||
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
||||
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
|
||||
layout,
|
||||
@@ -988,6 +1283,8 @@ export function createWorkspaceLayoutStore(
|
||||
|
||||
return {
|
||||
...withoutFocusRestoration(state, normalizedWorkspaceKey),
|
||||
// Choosing a tab dismisses the sheet that covers it.
|
||||
...dismissSheet(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
@@ -1011,6 +1308,8 @@ export function createWorkspaceLayoutStore(
|
||||
});
|
||||
return nextLayout
|
||||
? {
|
||||
// Choosing a tab dismisses the sheet that covers it.
|
||||
...dismissSheet(state, normalizedWorkspaceKey),
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextLayout,
|
||||
@@ -1024,6 +1323,31 @@ export function createWorkspaceLayoutStore(
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null;
|
||||
const sheet = get().sheetByWorkspace[normalizedWorkspaceKey];
|
||||
if (sheet?.tab.tabId === normalizedTabId) {
|
||||
const policy = resolveCompactSheetPolicy(normalizedWorkspaceKey, normalizedTarget);
|
||||
const replaced: WorkspaceSheet = {
|
||||
...sheet,
|
||||
policy: policy ?? sheet.policy,
|
||||
tab: {
|
||||
...sheet.tab,
|
||||
target: normalizedTarget,
|
||||
...(tabState !== undefined ? { state: tabState } : {}),
|
||||
},
|
||||
};
|
||||
const next: WorkspaceLayoutStore = {
|
||||
...get(),
|
||||
sheetByWorkspace: { ...get().sheetByWorkspace, [normalizedWorkspaceKey]: replaced },
|
||||
};
|
||||
if (policy) {
|
||||
set({ sheetByWorkspace: next.sheetByWorkspace });
|
||||
return normalizedTabId;
|
||||
}
|
||||
// A target the sheet does not host becomes the tab it would have opened as.
|
||||
const promoted = promoteSheetToTab(next, normalizedWorkspaceKey);
|
||||
set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey));
|
||||
return promoted?.tabId ?? null;
|
||||
}
|
||||
const result = replaceTabTargetInLayout({
|
||||
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
||||
tabId: normalizedTabId,
|
||||
@@ -1054,6 +1378,19 @@ export function createWorkspaceLayoutStore(
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
if (!normalizedWorkspaceKey || !normalizedTabId) return;
|
||||
set((state) => {
|
||||
const sheet = state.sheetByWorkspace[normalizedWorkspaceKey];
|
||||
if (sheet?.tab.tabId === normalizedTabId) {
|
||||
const { state: _previous, ...tab } = sheet.tab;
|
||||
return {
|
||||
sheetByWorkspace: {
|
||||
...state.sheetByWorkspace,
|
||||
[normalizedWorkspaceKey]: {
|
||||
...sheet,
|
||||
tab: tabState === undefined ? tab : { ...tab, state: tabState },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const layout = setTabStateInLayout({
|
||||
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
|
||||
tabId: normalizedTabId,
|
||||
@@ -1148,15 +1485,22 @@ export function createWorkspaceLayoutStore(
|
||||
);
|
||||
}
|
||||
}
|
||||
const sheetAgentGone = sheetAgentNoLongerFits(
|
||||
normalizedWorkspaceKey,
|
||||
state.sheetByWorkspace[normalizedWorkspaceKey],
|
||||
snapshot,
|
||||
);
|
||||
if (
|
||||
state.layoutByWorkspace[normalizedWorkspaceKey] &&
|
||||
nextLayout === rawLayout &&
|
||||
pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace
|
||||
pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace &&
|
||||
!sheetAgentGone
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...(sheetAgentGone ? withoutSheet(state, normalizedWorkspaceKey) : {}),
|
||||
pinnedAgentIdsByWorkspace,
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
@@ -1661,7 +2005,8 @@ export function createWorkspaceLayoutStore(
|
||||
normalizedWorkspaceKey in state.focusRestorationByWorkspace ||
|
||||
normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace ||
|
||||
normalizedWorkspaceKey in state.sidePaneIdByWorkspace ||
|
||||
normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace;
|
||||
normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace ||
|
||||
normalizedWorkspaceKey in state.sheetByWorkspace;
|
||||
if (!hasAny) {
|
||||
return state;
|
||||
}
|
||||
@@ -1690,6 +2035,7 @@ export function createWorkspaceLayoutStore(
|
||||
const { [normalizedWorkspaceKey]: _sidePane, ...sidePaneIdByWorkspace } =
|
||||
state.sidePaneIdByWorkspace;
|
||||
return {
|
||||
...withoutSheet(state, normalizedWorkspaceKey),
|
||||
pullRequestTabAutoOpenedByWorkspace,
|
||||
layoutByWorkspace,
|
||||
splitSizesByWorkspace,
|
||||
@@ -1795,10 +2141,10 @@ export function createWorkspaceLayoutStore(
|
||||
export const useWorkspaceLayoutStore = createWorkspaceLayoutStore();
|
||||
|
||||
/**
|
||||
* The agent tabs that exist right now, across every workspace of this host, independently
|
||||
* of which workspace views are mounted. Timeline sync uses this to release a chat when its
|
||||
* tab closes. It is not a subscription source: this layout is restored from disk at launch,
|
||||
* so subscribing to it would resume every agent the user has ever opened.
|
||||
* The agent tabs that exist right now, including sheet tabs, across every workspace of this host,
|
||||
* independently of which workspace views are mounted. Timeline sync uses this to release a chat
|
||||
* when its tab closes. It is not a subscription source: this layout is restored from disk at
|
||||
* launch, so subscribing to it would resume every agent the user has ever opened.
|
||||
*/
|
||||
export function observeOpenWorkspaceAgentIds(
|
||||
serverId: string,
|
||||
@@ -1806,11 +2152,19 @@ export function observeOpenWorkspaceAgentIds(
|
||||
store = useWorkspaceLayoutStore,
|
||||
): () => void {
|
||||
let previous: string[] | undefined;
|
||||
const publish = (state: Pick<WorkspaceLayoutStore, "layoutByWorkspace">) => {
|
||||
const publish = (state: Pick<WorkspaceLayoutStore, "layoutByWorkspace" | "sheetByWorkspace">) => {
|
||||
const ids = new Set<string>();
|
||||
for (const [workspaceKey, layout] of Object.entries(state.layoutByWorkspace)) {
|
||||
const tabsByWorkspace = [
|
||||
...Object.entries(state.layoutByWorkspace).map(
|
||||
([workspaceKey, layout]) => [workspaceKey, collectAllTabs(layout.root)] as const,
|
||||
),
|
||||
...Object.entries(state.sheetByWorkspace).map(
|
||||
([workspaceKey, sheet]) => [workspaceKey, [sheet.tab]] as const,
|
||||
),
|
||||
];
|
||||
for (const [workspaceKey, tabs] of tabsByWorkspace) {
|
||||
if (!workspaceKey.startsWith(`${serverId}:`)) continue;
|
||||
for (const tab of collectAllTabs(layout.root)) {
|
||||
for (const tab of tabs) {
|
||||
if (tab.target.kind === "agent") ids.add(tab.target.agentId);
|
||||
}
|
||||
}
|
||||
@@ -1821,7 +2175,12 @@ export function observeOpenWorkspaceAgentIds(
|
||||
listener(next);
|
||||
};
|
||||
const unsubscribe = store.subscribe((state, before) => {
|
||||
if (state.layoutByWorkspace !== before.layoutByWorkspace) publish(state);
|
||||
if (
|
||||
state.layoutByWorkspace !== before.layoutByWorkspace ||
|
||||
state.sheetByWorkspace !== before.sheetByWorkspace
|
||||
) {
|
||||
publish(state);
|
||||
}
|
||||
});
|
||||
publish(store.getState());
|
||||
return unsubscribe;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getWorkspacePanelAttributes } from "@/panels/panel-instance-attributes";
|
||||
import { panelCompactSheetPolicy, type CompactSheetPolicy } from "@/panels/panel-manifest";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { isWorkspaceRootAgent } from "@/subagents/policies";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
|
||||
function workspaceAgentLookup(workspaceKey: string): (agentId: string) => Agent | null {
|
||||
const sessions = useSessionStore.getState().sessions;
|
||||
const serverId = Object.keys(sessions)
|
||||
.filter((candidate) => workspaceKey.startsWith(`${candidate}:`))
|
||||
.sort((left, right) => right.length - left.length)[0];
|
||||
const session = serverId ? sessions[serverId] : undefined;
|
||||
return (agentId) => session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single target-aware boundary deciding whether a compact reveal lands in the workspace
|
||||
* sheet. An agent the client has not loaded yet counts as a root agent and stays a tab.
|
||||
*/
|
||||
export function resolveCompactSheetPolicy(
|
||||
workspaceKey: string,
|
||||
target: WorkspaceTabTarget,
|
||||
): CompactSheetPolicy | null {
|
||||
return panelCompactSheetPolicy(target, {
|
||||
isWorkspaceRootAgent: (agentId) => {
|
||||
const getAgent = workspaceAgentLookup(workspaceKey);
|
||||
const agent = getAgent(agentId);
|
||||
if (!agent) return true;
|
||||
const parent = agent.parentAgentId ? (getAgent(agent.parentAgentId) ?? undefined) : undefined;
|
||||
return isWorkspaceRootAgent(agent, parent);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether the sheet's tab holds unsaved work, which must never be discarded with the sheet. */
|
||||
export function isSheetTabModified(workspaceKey: string, tabId: string): boolean {
|
||||
return getWorkspacePanelAttributes(workspaceKey, tabId).modified;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useWorkspaceLayoutStore, type WorkspaceLayoutMode } from "@/stores/workspace-layout-store";
|
||||
|
||||
function resolveWorkspaceLayoutMode(isCompact: boolean): WorkspaceLayoutMode {
|
||||
if (isCompact) return "compact";
|
||||
return supportsDesktopPaneSplits() ? "split-panes" : "single-pane";
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the shell's workspace layout to the layout store, which decides where opened targets
|
||||
* land. Render it before the routes: React runs an earlier sibling's layout effects before a later
|
||||
* sibling's subtree, so a route sees the mode even from its first layout effect.
|
||||
*/
|
||||
export function WorkspaceLayoutModePublisher(): null {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
useLayoutEffect(() => {
|
||||
useWorkspaceLayoutStore.getState().setLayoutMode(resolveWorkspaceLayoutMode(isCompact));
|
||||
}, [isCompact]);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async () => null),
|
||||
setItem: vi.fn(async () => undefined),
|
||||
removeItem: vi.fn(async () => undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
import { DEFAULT_APP_SETTINGS } from "@/hooks/use-settings";
|
||||
import { setPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||
import {
|
||||
collectAllPanes,
|
||||
collectAllTabs,
|
||||
findPaneById,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
openPreferredWorkspacePreview,
|
||||
openPreferredWorkspaceTarget,
|
||||
openWorkspaceTargetBeside,
|
||||
} from "@/workspace-tabs/open-beside";
|
||||
|
||||
const WORKSPACE_KEY = "server-1:workspace-1";
|
||||
|
||||
beforeEach(() => {
|
||||
useWorkspaceLayoutStore.setState({
|
||||
layoutMode: "split-panes",
|
||||
sheetByWorkspace: {},
|
||||
layoutByWorkspace: {},
|
||||
explorerSidebarPaneIdByWorkspace: {},
|
||||
sidePaneIdByWorkspace: {},
|
||||
});
|
||||
});
|
||||
|
||||
describe("openPreferredWorkspacePreview", () => {
|
||||
it("opens a compact file preview in the sheet instead of replacing the active file tab", () => {
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const fileTabId = store.openTab({
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
target: { kind: "file", path: "src/open.ts" },
|
||||
intent: "reveal",
|
||||
});
|
||||
store.setLayoutMode("compact");
|
||||
|
||||
const tabId = openPreferredWorkspacePreview({
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-1",
|
||||
explorerSidebarPaneId: "explorer",
|
||||
lastMainPaneId: "main",
|
||||
target: { kind: "file", path: "src/preview.ts" },
|
||||
source: "explorerFiles",
|
||||
preferences: DEFAULT_APP_SETTINGS.openInSidePane,
|
||||
});
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
expect(state.sheetByWorkspace[WORKSPACE_KEY]?.tab).toMatchObject({
|
||||
tabId,
|
||||
target: { kind: "file", path: "src/preview.ts" },
|
||||
});
|
||||
const root = state.layoutByWorkspace[WORKSPACE_KEY].root;
|
||||
expect(findPaneById(root, "main")?.tabIds).toEqual([fileTabId]);
|
||||
expect(collectAllTabs(root).find((tab) => tab.tabId === fileTabId)?.target).toEqual({
|
||||
kind: "file",
|
||||
path: "src/open.ts",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("previews under a sheet with unsaved work", () => {
|
||||
const sheetTarget = { kind: "file" as const, path: "src/draft.ts" };
|
||||
let sheetTabId: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (sheetTabId) {
|
||||
setPanelInstanceAttributes(
|
||||
{ serverId: "server-1", workspaceId: "workspace-1", tabId: sheetTabId },
|
||||
{ modified: false },
|
||||
);
|
||||
}
|
||||
sheetTabId = null;
|
||||
});
|
||||
|
||||
function previewFile(path: string): string | null {
|
||||
return openPreferredWorkspacePreview({
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-1",
|
||||
explorerSidebarPaneId: "explorer",
|
||||
lastMainPaneId: "main",
|
||||
target: { kind: "file", path },
|
||||
source: "explorerFiles",
|
||||
preferences: DEFAULT_APP_SETTINGS.openInSidePane,
|
||||
});
|
||||
}
|
||||
|
||||
it("reuses one preview tab instead of adding a tab per link", () => {
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
store.openTab({
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
target: { kind: "terminal", terminalId: "term-1" },
|
||||
intent: "reveal",
|
||||
});
|
||||
store.setLayoutMode("compact");
|
||||
sheetTabId = store.openTab({
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
target: sheetTarget,
|
||||
intent: "reveal",
|
||||
});
|
||||
setPanelInstanceAttributes(
|
||||
{ serverId: "server-1", workspaceId: "workspace-1", tabId: sheetTabId ?? "" },
|
||||
{ modified: true },
|
||||
);
|
||||
|
||||
const firstPreviewTabId = previewFile("src/first.ts");
|
||||
const secondPreviewTabId = previewFile("src/second.ts");
|
||||
|
||||
const state = useWorkspaceLayoutStore.getState();
|
||||
expect(state.sheetByWorkspace[WORKSPACE_KEY]?.tab.target).toEqual(sheetTarget);
|
||||
expect(secondPreviewTabId).toBe(firstPreviewTabId);
|
||||
const root = state.layoutByWorkspace[WORKSPACE_KEY].root;
|
||||
const tabsById = new Map(collectAllTabs(root).map((tab) => [tab.tabId, tab.target]));
|
||||
expect(findPaneById(root, "main")?.tabIds.map((tabId) => tabsById.get(tabId))).toEqual([
|
||||
{ kind: "terminal", terminalId: "term-1" },
|
||||
{ kind: "file", path: "src/second.ts" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("opening beside", () => {
|
||||
const sidePreference = { ...DEFAULT_APP_SETTINGS.openInSidePane, subagents: true };
|
||||
|
||||
function openSubagentWithSidePreference(): string | null {
|
||||
useWorkspaceLayoutStore.getState().openTab({
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
target: { kind: "file", path: "src/open.ts" },
|
||||
intent: "reveal",
|
||||
});
|
||||
return openPreferredWorkspaceTarget({
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
target: { kind: "file", path: "src/subagent.ts" },
|
||||
source: "subagents",
|
||||
preferences: sidePreference,
|
||||
});
|
||||
}
|
||||
|
||||
function paneIdHolding(tabId: string | null): string | undefined {
|
||||
const root = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY].root;
|
||||
return collectAllPanes(root).find((pane) => pane.tabIds.includes(tabId ?? ""))?.id;
|
||||
}
|
||||
|
||||
it("follows the side preference where the workspace renders split panes", () => {
|
||||
useWorkspaceLayoutStore.getState().setLayoutMode("split-panes");
|
||||
|
||||
const tabId = openSubagentWithSidePreference();
|
||||
|
||||
const sidePaneId = useWorkspaceLayoutStore.getState().sidePaneIdByWorkspace[WORKSPACE_KEY];
|
||||
expect(sidePaneId).toEqual(expect.any(String));
|
||||
expect(paneIdHolding(tabId)).toBe(sidePaneId);
|
||||
});
|
||||
|
||||
it("keeps a single-pane workspace in one pane despite the side preference", () => {
|
||||
useWorkspaceLayoutStore.getState().setLayoutMode("single-pane");
|
||||
|
||||
const preferredTabId = openSubagentWithSidePreference();
|
||||
const besideTabId = openWorkspaceTargetBeside({
|
||||
workspaceKey: WORKSPACE_KEY,
|
||||
target: { kind: "file", path: "src/beside.ts" },
|
||||
});
|
||||
|
||||
expect(useWorkspaceLayoutStore.getState().sidePaneIdByWorkspace[WORKSPACE_KEY]).toBeFalsy();
|
||||
expect(paneIdHolding(preferredTabId)).toBe("main");
|
||||
expect(paneIdHolding(besideTabId)).toBe("main");
|
||||
});
|
||||
});
|
||||
@@ -22,13 +22,11 @@ interface OpenWorkspaceTargetInput {
|
||||
}
|
||||
|
||||
export interface OpenPreferredWorkspaceTargetInput extends OpenWorkspaceTargetInput {
|
||||
isCompact: boolean;
|
||||
source: OpenInSidePaneSource;
|
||||
preferences: OpenInSidePanePreferences;
|
||||
}
|
||||
|
||||
interface OpenWorkspaceTargetAtLocationInput extends OpenWorkspaceTargetInput {
|
||||
isCompact: boolean;
|
||||
location: WorkspaceTargetOpenLocation;
|
||||
}
|
||||
|
||||
@@ -39,6 +37,11 @@ interface OpenPreferredWorkspacePreviewInput extends OpenPreferredWorkspaceTarge
|
||||
lastMainPaneId: string | null;
|
||||
}
|
||||
|
||||
/** A side pane exists only where the workspace renders split panes. */
|
||||
function canOpenBeside(): boolean {
|
||||
return useWorkspaceLayoutStore.getState().layoutMode === "split-panes";
|
||||
}
|
||||
|
||||
function resolveMainPane(input: {
|
||||
workspaceKey: string;
|
||||
explorerSidebarPaneId: string | null;
|
||||
@@ -86,7 +89,6 @@ export function openPreferredWorkspaceTarget(
|
||||
input: OpenPreferredWorkspaceTargetInput,
|
||||
): string | null {
|
||||
return openWorkspaceTargetAtLocation({
|
||||
isCompact: input.isCompact,
|
||||
workspaceKey: input.workspaceKey,
|
||||
target: input.target,
|
||||
location: input.preferences[input.source] ? "side" : "main",
|
||||
@@ -105,7 +107,7 @@ export function openWorkspaceTargetAtLocation(
|
||||
layout &&
|
||||
collectAllTabs(layout.root).some((tab) => workspaceTabTargetsEqual(tab.target, input.target)),
|
||||
);
|
||||
const shouldOpenBeside = !input.isCompact && input.location === "side";
|
||||
const shouldOpenBeside = canOpenBeside() && input.location === "side";
|
||||
let placement: WorkspaceTabPlacement | undefined;
|
||||
if (shouldOpenBeside && !targetAlreadyExists) {
|
||||
const paneId = store.ensureSidePane(input.workspaceKey);
|
||||
@@ -135,7 +137,13 @@ export function openPreferredWorkspacePreview(
|
||||
const existing = collectAllTabs(layout.root).some((tab) =>
|
||||
workspaceTabTargetsEqual(tab.target, input.target),
|
||||
);
|
||||
if (existing) return openPreferredWorkspaceTarget(input);
|
||||
// The sheet is already a single replaceable slot, so it needs no preview tab.
|
||||
if (
|
||||
existing ||
|
||||
store.opensInSheet({ workspaceKey: input.workspaceKey, target: input.target, intent: "reveal" })
|
||||
) {
|
||||
return openPreferredWorkspaceTarget(input);
|
||||
}
|
||||
|
||||
const mainPane = resolveMainPane({
|
||||
workspaceKey: input.workspaceKey,
|
||||
@@ -143,7 +151,7 @@ export function openPreferredWorkspacePreview(
|
||||
lastMainPaneId: input.lastMainPaneId,
|
||||
});
|
||||
const destinationPaneId =
|
||||
!input.isCompact && input.preferences[input.source]
|
||||
canOpenBeside() && input.preferences[input.source]
|
||||
? store.ensureSidePane(input.workspaceKey)
|
||||
: mainPane?.id;
|
||||
if (!destinationPaneId) return null;
|
||||
@@ -173,9 +181,15 @@ export function openPreferredWorkspacePreview(
|
||||
});
|
||||
}
|
||||
|
||||
/** Explicit Open to Side: creates the side pane and places the target there. */
|
||||
/**
|
||||
* Explicit Open to Side: creates the side pane and places the target there. Without split panes
|
||||
* the target opens like any other.
|
||||
*/
|
||||
export function openWorkspaceTargetBeside(input: OpenWorkspaceTargetInput): string | null {
|
||||
if (!input.workspaceKey) return null;
|
||||
if (!canOpenBeside()) {
|
||||
return openWorkspaceTargetAtLocation({ ...input, location: "main" });
|
||||
}
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
const paneId = store.ensureSidePane(input.workspaceKey);
|
||||
return store.openTab({
|
||||
|
||||
@@ -46,7 +46,6 @@ export function openWorkspaceChanges(input: OpenWorkspaceChangesInput): string |
|
||||
return null;
|
||||
}
|
||||
return openPreferredWorkspaceTarget({
|
||||
isCompact: input.isCompact,
|
||||
workspaceKey: input.workspaceKey,
|
||||
target: { kind: "working_diff" },
|
||||
source: "diffs",
|
||||
@@ -70,7 +69,6 @@ export function openWorkspacePullRequest(input: OpenWorkspacePullRequestInput):
|
||||
return null;
|
||||
}
|
||||
return openWorkspaceTargetAtLocation({
|
||||
isCompact: input.isCompact,
|
||||
workspaceKey: input.workspaceKey,
|
||||
target: { kind: "pull_request" },
|
||||
location: input.destination,
|
||||
|
||||
@@ -1,5 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentLsFetchOptions } from "./ls.js";
|
||||
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 () => {} };
|
||||
}
|
||||
|
||||
describe("buildAgentLsFetchOptions", () => {
|
||||
it("fetches active agents by default", () => {
|
||||
@@ -66,3 +121,13 @@ 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,6 +165,14 @@ 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
|
||||
@@ -176,7 +184,16 @@ export async function runLsCommand(
|
||||
options: AgentLsOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentLsResult> {
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
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 });
|
||||
|
||||
try {
|
||||
const normalizedThinkingOptionId = options.thinking?.trim();
|
||||
@@ -189,8 +206,19 @@ export async function runLsCommand(
|
||||
}
|
||||
|
||||
const labelFilters = parseLabelFilters(options.label);
|
||||
const fetchPayload = await client.fetchAgents(buildAgentLsFetchOptions(options));
|
||||
let agents = fetchPayload.entries.map((entry) => entry.agent);
|
||||
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);
|
||||
|
||||
// By default, exclude archived agents. `-a` includes them.
|
||||
if (!options.all) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import {
|
||||
createClientChannel,
|
||||
type EncryptedChannel,
|
||||
@@ -8,7 +9,11 @@ import type {
|
||||
DaemonTransportFactory,
|
||||
TransportLogger,
|
||||
} from "./daemon-client-transport-types.js";
|
||||
import { extractRelayMessage, normalizeTransportPayload } from "./daemon-client-transport-utils.js";
|
||||
import {
|
||||
extractRelayMessage,
|
||||
normalizeTransportPayload,
|
||||
messageTooLargeError,
|
||||
} from "./daemon-client-transport-utils.js";
|
||||
|
||||
type OpenHandler = () => void;
|
||||
type CloseHandler = (event?: unknown) => void;
|
||||
@@ -137,7 +142,13 @@ export function createEncryptedTransport(
|
||||
if (!channel) {
|
||||
throw new Error("Encrypted channel not ready");
|
||||
}
|
||||
void channel.send(normalizeTransportPayload(data)).catch((error) => {
|
||||
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) => {
|
||||
emitError(error);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -133,3 +133,10 @@ 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,3 +7527,29 @@ 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,3 +1,4 @@
|
||||
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";
|
||||
@@ -13,6 +14,7 @@ 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,
|
||||
@@ -1132,6 +1134,19 @@ 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,
|
||||
@@ -1767,6 +1782,8 @@ 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;
|
||||
signal.throwIfAborted();
|
||||
if (signal.aborted) throw new Error("Paseo API is disposed");
|
||||
return await new Promise<PaseoProviderSnapshotResult>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let requestId: string | null = null;
|
||||
|
||||
@@ -144,6 +144,34 @@ 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,10 +45,12 @@ function maskExpressions(text: string): string {
|
||||
}
|
||||
|
||||
function expressionOverlays(node: SyntaxNodeRef, input: Input): Range[] | null {
|
||||
const overlays = findExpressions(input.read(node.from, node.to)).map(({ from, to }) => ({
|
||||
from: node.from + from + 1,
|
||||
to: node.from + to,
|
||||
}));
|
||||
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,
|
||||
}));
|
||||
return overlays.length > 0 ? overlays : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** 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,9 +11,10 @@ OPENAI_TTS_BASE_URL=
|
||||
|
||||
# TTS Configuration (optional - defaults shown)
|
||||
TTS_VOICE=alloy
|
||||
# Available voices: alloy, echo, fable, onyx, nova, shimmer
|
||||
# Any voice the server knows: OpenAI ships alloy, echo, fable, onyx, nova, shimmer
|
||||
TTS_MODEL=tts-1
|
||||
# Available models: tts-1 (faster), tts-1-hd (higher quality)
|
||||
# OpenAI ships tts-1 (faster) and tts-1-hd (higher quality); self-hosted
|
||||
# servers accept whatever model id they serve. Case sensitive.
|
||||
|
||||
# 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/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: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: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",
|
||||
|
||||
@@ -3296,6 +3296,50 @@ 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({
|
||||
@@ -3770,6 +3814,11 @@ 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.
|
||||
@@ -3786,6 +3835,7 @@ 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. */
|
||||
@@ -3819,6 +3869,13 @@ 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",
|
||||
@@ -3926,6 +3983,7 @@ class HeldTurnAgentClient implements AgentClient {
|
||||
constructor(
|
||||
readonly provider: AgentProvider,
|
||||
private readonly holdTurns: boolean,
|
||||
private readonly options: HeldTurnOptions = {},
|
||||
) {}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
@@ -3933,7 +3991,7 @@ class HeldTurnAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async createSession(): Promise<AgentSession> {
|
||||
const session = new HeldTurnAgentSession(this.provider, this.holdTurns);
|
||||
const session = new HeldTurnAgentSession(this.provider, this.holdTurns, this.options);
|
||||
this.sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -1750,6 +1750,7 @@ 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;
|
||||
@@ -1867,11 +1868,38 @@ 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();
|
||||
@@ -2841,7 +2869,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
});
|
||||
const spawnError = rejectOnSpawnError(child, stderrChunks);
|
||||
child.once("exit", (code, signal) => {
|
||||
if (this.closed) {
|
||||
if (this.closed || this.historyRuntimeReleased) {
|
||||
return;
|
||||
}
|
||||
if (this.activeForegroundTurnId) {
|
||||
|
||||
+17
-10
@@ -1,5 +1,5 @@
|
||||
import { createServer } from "node:http";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { expect, test } from "vitest";
|
||||
@@ -13,12 +13,18 @@ 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",
|
||||
async ({ placement, cacheTokens }) => {
|
||||
"counts $placement input and $cacheTokens cached tokens through Claude Code ($model)",
|
||||
async ({ placement, cacheTokens, model = "claude-sonnet-4-6", resolvedModel = model }) => {
|
||||
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) {
|
||||
@@ -45,7 +51,7 @@ test.each([
|
||||
id: `msg_context_usage_${requestCount}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
model: resolvedModel,
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
@@ -86,11 +92,9 @@ test.each([
|
||||
},
|
||||
},
|
||||
});
|
||||
const session = await client.createSession(
|
||||
{ provider: "claude", cwd, model: "claude-sonnet-4-6" },
|
||||
undefined,
|
||||
{ persistSession: false },
|
||||
);
|
||||
const session = await client.createSession({ provider: "claude", cwd, model }, undefined, {
|
||||
persistSession: false,
|
||||
});
|
||||
try {
|
||||
for (let turn = 0; turn < 2; turn++) {
|
||||
const events = await collectSessionTurnEvents(session, "say hello", {
|
||||
@@ -111,7 +115,10 @@ test.each([
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "usage_updated",
|
||||
usage: expect.objectContaining({ contextWindowUsedTokens: expectedTokens }),
|
||||
usage: expect.objectContaining({
|
||||
contextWindowUsedTokens: expectedTokens,
|
||||
contextWindowMaxTokens: resolvedModel.endsWith("[1m]") ? 1_000_000 : 200_000,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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,6 +2805,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 150,
|
||||
},
|
||||
}),
|
||||
@@ -2840,7 +2841,13 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
|
||||
test("selected 1M Claude models seed active context window usage from the catalog", async () => {
|
||||
const session = await createSessionForTurns(
|
||||
[[createInitMessage(), createMessageStartEvent(), createSuccessResult()]],
|
||||
[
|
||||
[
|
||||
{ ...createInitMessage(), model: "claude-sonnet-5[1m]" },
|
||||
createMessageStartEvent(),
|
||||
createSuccessResult(),
|
||||
],
|
||||
],
|
||||
{ model: "claude-sonnet-5[1m]" },
|
||||
);
|
||||
|
||||
@@ -2880,6 +2887,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 175,
|
||||
},
|
||||
}),
|
||||
@@ -2922,6 +2930,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 20_300,
|
||||
},
|
||||
}),
|
||||
@@ -2963,6 +2972,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 1_069,
|
||||
},
|
||||
}),
|
||||
@@ -3026,6 +3036,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 55,
|
||||
},
|
||||
}),
|
||||
@@ -3035,6 +3046,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
type: "usage_updated",
|
||||
provider: "claude",
|
||||
usage: {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
contextWindowUsedTokens: 62,
|
||||
},
|
||||
}),
|
||||
@@ -3072,6 +3084,7 @@ 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 {
|
||||
findClaudeModel,
|
||||
getClaudeContextWindowMaxTokens,
|
||||
getClaudeModelsWithSettings,
|
||||
normalizeClaudeRuntimeModelId,
|
||||
resolveConfiguredClaudeModel,
|
||||
@@ -2240,7 +2240,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.resolveBinary = options.resolveBinary;
|
||||
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
|
||||
this.contextUsage = new ClaudeContextUsageState(
|
||||
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
|
||||
getClaudeContextWindowMaxTokens(this.config.model),
|
||||
);
|
||||
const handle = options.handle;
|
||||
|
||||
@@ -2572,7 +2572,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
await this.applyFastModeFeature(false, activeQuery);
|
||||
}
|
||||
this.contextUsage.setInitialContextWindowMaxTokens(
|
||||
findClaudeModel(this.config.model)?.contextWindowMaxTokens,
|
||||
getClaudeContextWindowMaxTokens(this.config.model),
|
||||
);
|
||||
this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel;
|
||||
this.lastRuntimeModel = null;
|
||||
@@ -2898,9 +2898,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.startFreshConversationSession();
|
||||
return;
|
||||
}
|
||||
const currentSessionId = this.claudeSessionId;
|
||||
await revertClaudeConversation({
|
||||
sdk: this.rewindSdk,
|
||||
sessionId: this.claudeSessionId,
|
||||
sessionId: currentSessionId,
|
||||
transcriptPath: currentSessionId ? this.resolveHistoryPath(currentSessionId) : null,
|
||||
messageId: target.messageId,
|
||||
resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId),
|
||||
setSessionId: (sessionId) => {
|
||||
@@ -4821,6 +4823,12 @@ 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,7 +15,27 @@ import {
|
||||
} from "./model-manifest.js";
|
||||
import { discoverClaudeModels } from "./model-discovery.js";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
const createdClaudeConfigDirs: string[] = [];
|
||||
|
||||
|
||||
@@ -48,6 +48,15 @@ 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,10 +1,15 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
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 type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import {
|
||||
revertClaudeConversation,
|
||||
revertClaudeConversationAndFiles,
|
||||
revertClaudeFiles,
|
||||
realClaudeRewindSdk,
|
||||
} from "./rewind.js";
|
||||
import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js";
|
||||
|
||||
@@ -16,6 +21,7 @@ describe("Claude rewind", () => {
|
||||
await revertClaudeConversation({
|
||||
sdk: claude,
|
||||
sessionId,
|
||||
transcriptPath: "/claude/projects/repo/original-session.jsonl",
|
||||
messageId: "user-message-1",
|
||||
setSessionId: (nextSessionId) => {
|
||||
sessionId = nextSessionId;
|
||||
@@ -33,6 +39,7 @@ 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) => {
|
||||
@@ -76,6 +83,7 @@ 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;
|
||||
@@ -87,3 +95,126 @@ 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,29 +1,90 @@
|
||||
import { forkSession as claudeForkSession, type Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
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";
|
||||
|
||||
export interface ClaudeRewindSdk {
|
||||
forkSession(
|
||||
sessionId: string,
|
||||
options: { upToMessageId: string },
|
||||
options: { upToMessageId: string; transcriptPath: 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: claudeForkSession,
|
||||
forkSession: (sessionId, options) =>
|
||||
claudeForkSession(sessionId, {
|
||||
upToMessageId: options.upToMessageId,
|
||||
sessionStore: projectFolderSessionStore(path.dirname(options.transcriptPath)),
|
||||
}),
|
||||
};
|
||||
|
||||
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) {
|
||||
if (!input.sessionId || !input.transcriptPath) {
|
||||
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);
|
||||
}
|
||||
@@ -44,6 +105,7 @@ 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 },
|
||||
options: { upToMessageId: string; transcriptPath: string },
|
||||
): Promise<{ sessionId: string }> {
|
||||
this.recordedForks.push({ upToMessageId: options.upToMessageId });
|
||||
return { sessionId: this.nextSessionId };
|
||||
|
||||
@@ -22,6 +22,44 @@ describe("Codex app-server transport", () => {
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
test("keeps a JSON message intact when it contains raw U+2028/U+2029", async () => {
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
|
||||
const request = client.request("thread/resume", {});
|
||||
const text = "a\u2028b\u2029c\u0085d";
|
||||
// Codex does not escape U+2028/U+2029 in JSON output, so write them raw.
|
||||
const message = `{"id":1,"result":{"text":"${text}"}}\n`;
|
||||
const bytes = Buffer.from(message, "utf8");
|
||||
// Split mid-way (inside a multi-byte character) to exercise chunk boundaries.
|
||||
child.stdout.write(bytes.subarray(0, 28));
|
||||
child.stdout.write(bytes.subarray(28));
|
||||
|
||||
await expect(request).resolves.toEqual({ text });
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
test("reads a multi-megabyte JSON message arriving in 64 KB chunks", async () => {
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
|
||||
const request = client.request("thread/resume", {});
|
||||
// A resumed long thread arrives as one large line, split into 64 KB stdout chunks.
|
||||
const text = "x".repeat(32 * 1024 * 1024);
|
||||
const bytes = Buffer.from(`{"id":1,"result":{"text":"${text}"}}\n`, "utf8");
|
||||
const chunkSize = 64 * 1024;
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
child.stdout.write(bytes.subarray(offset, offset + chunkSize));
|
||||
}
|
||||
|
||||
await expect(request).resolves.toEqual({ text });
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
test("dispose rejects pending requests instead of leaving them hanging", async () => {
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import readline from "node:readline";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -167,8 +167,41 @@ function readProviderTurnId(params: unknown): string | undefined {
|
||||
return isRecord(turn) && typeof turn.id === "string" ? turn.id : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits stdout into lines on "\n" only. `node:readline` additionally treats U+2028 and U+2029
|
||||
* as line terminators, but Codex emits those characters raw inside JSON strings, so readline
|
||||
* would cut a single JSON-RPC message into pieces that fail to parse.
|
||||
*/
|
||||
function createNewlineLineReader(
|
||||
input: NodeJS.ReadableStream,
|
||||
onLine: (line: string) => void,
|
||||
): { close(): void } {
|
||||
const decoder = new StringDecoder("utf8");
|
||||
// Text after the last "\n", kept as parts and joined once its line ends, so a large line
|
||||
// arriving in many chunks is scanned and copied once instead of once per chunk.
|
||||
let unterminated: string[] = [];
|
||||
const onData = (chunk: Buffer | string): void => {
|
||||
const text = typeof chunk === "string" ? chunk : decoder.write(chunk);
|
||||
let lineStart = 0;
|
||||
let index = text.indexOf("\n");
|
||||
while (index !== -1) {
|
||||
unterminated.push(text.slice(lineStart, index));
|
||||
const line = unterminated.join("");
|
||||
unterminated = [];
|
||||
onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
|
||||
lineStart = index + 1;
|
||||
index = text.indexOf("\n", lineStart);
|
||||
}
|
||||
if (lineStart < text.length) {
|
||||
unterminated.push(text.slice(lineStart));
|
||||
}
|
||||
};
|
||||
input.on("data", onData);
|
||||
return { close: () => void input.off("data", onData) };
|
||||
}
|
||||
|
||||
export class CodexAppServerClient {
|
||||
private readonly rl: readline.Interface;
|
||||
private readonly stdoutLineReader: { close(): void };
|
||||
private readonly pending = new Map<number, PendingRequest>();
|
||||
private readonly requestHandlers = new Map<string, RequestHandler>();
|
||||
private notificationHandler: NotificationHandler | null = null;
|
||||
@@ -182,8 +215,7 @@ export class CodexAppServerClient {
|
||||
private readonly logger: Logger,
|
||||
private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}),
|
||||
) {
|
||||
this.rl = readline.createInterface({ input: child.stdout });
|
||||
this.rl.on("line", (line) => {
|
||||
this.stdoutLineReader = createNewlineLineReader(child.stdout, (line) => {
|
||||
void this.handleLine(line).catch((error) => {
|
||||
this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line");
|
||||
});
|
||||
@@ -259,7 +291,7 @@ export class CodexAppServerClient {
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true;
|
||||
this.unexpectedTerminationHandler = null;
|
||||
this.rl.close();
|
||||
this.stdoutLineReader.close();
|
||||
this.rejectPending(new Error("Codex app-server client is closed"));
|
||||
try {
|
||||
this.child.stdin.end();
|
||||
@@ -286,7 +318,7 @@ export class CodexAppServerClient {
|
||||
return;
|
||||
}
|
||||
this.disposed = true;
|
||||
this.rl.close();
|
||||
this.stdoutLineReader.close();
|
||||
this.rejectPending(error);
|
||||
const handler = this.unexpectedTerminationHandler;
|
||||
this.unexpectedTerminationHandler = null;
|
||||
|
||||
+61
-2
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -68,8 +68,60 @@ describe("GenericACPAgentClient archived history", () => {
|
||||
).rejects.toThrow("ENOENT");
|
||||
});
|
||||
});
|
||||
|
||||
test("releases the provider process once an archived session's history is replayed", async () => {
|
||||
await withFakeACPAgent(async ({ command, removedCwd, pidPath }) => {
|
||||
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
|
||||
|
||||
const session = await client.resumeSession(
|
||||
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
|
||||
undefined,
|
||||
undefined,
|
||||
{ purpose: "history" },
|
||||
);
|
||||
try {
|
||||
const pid = Number(await readFile(pidPath, "utf8"));
|
||||
expect(isProcessAlive(pid)).toBe(false);
|
||||
expect(await collectTimelineText(session.streamHistory())).toEqual([
|
||||
"user_message:What is in the README?",
|
||||
"assistant_message:It describes the project.",
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the provider process for an interactive resume", async () => {
|
||||
await withFakeACPAgent(async ({ command, removedCwd, pidPath }) => {
|
||||
await mkdir(removedCwd, { recursive: true });
|
||||
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
|
||||
|
||||
const session = await client.resumeSession(
|
||||
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
|
||||
undefined,
|
||||
undefined,
|
||||
{ purpose: "interactive" },
|
||||
);
|
||||
try {
|
||||
const pid = Number(await readFile(pidPath, "utf8"));
|
||||
expect(isProcessAlive(pid)).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectTimelineText(events: AsyncGenerator<AgentStreamEvent>): Promise<string[]> {
|
||||
const text: string[] = [];
|
||||
for await (const event of events) {
|
||||
@@ -88,17 +140,20 @@ async function withFakeACPAgent(
|
||||
command: [string, ...string[]];
|
||||
removedCwd: string;
|
||||
loadRequestsPath: string;
|
||||
pidPath: string;
|
||||
}) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-archived-history-"));
|
||||
try {
|
||||
const scriptPath = path.join(testDir, "fake-acp-agent.cjs");
|
||||
const loadRequestsPath = path.join(testDir, "load-requests.json");
|
||||
const pidPath = path.join(testDir, "agent.pid");
|
||||
await writeFile(scriptPath, fakeACPAgentScript, "utf8");
|
||||
await run({
|
||||
command: [process.execPath, scriptPath, loadRequestsPath],
|
||||
command: [process.execPath, scriptPath, loadRequestsPath, pidPath],
|
||||
removedCwd: path.join(testDir, "removed-worktree"),
|
||||
loadRequestsPath,
|
||||
pidPath,
|
||||
});
|
||||
} finally {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
@@ -110,6 +165,10 @@ const fs = require("node:fs");
|
||||
const readline = require("node:readline");
|
||||
|
||||
const loadRequestsPath = process.argv[2];
|
||||
const pidPath = process.argv[3];
|
||||
if (pidPath) {
|
||||
fs.writeFileSync(pidPath, String(process.pid));
|
||||
}
|
||||
const loadRequests = [];
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
|
||||
|
||||
@@ -232,6 +232,7 @@ type SteeringReplayShape = "claude" | "codex";
|
||||
interface MockQuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
interface MockQuestionPromptQuestion {
|
||||
@@ -271,6 +272,16 @@ function parseSettledAssistantImageMarkdown(prompt: AgentPromptInput): string |
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
const ROLLOUT_FLAG_PREVIEW = [
|
||||
"Ship it dark behind the new-surface flag, then turn it on for internal users before everyone else.",
|
||||
"",
|
||||
'if (flags.enabled("new-surface")) {',
|
||||
" render(<NewSurface />);",
|
||||
"}",
|
||||
...Array.from({ length: 40 }, (_, i) => `// rollout step ${i + 1}`),
|
||||
"// LAST PREVIEW LINE",
|
||||
].join("\n");
|
||||
|
||||
function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRequest | null {
|
||||
const text = promptToText(prompt);
|
||||
if (!/emit\s+(?:a\s+)?synthetic\s+questions?/i.test(text)) {
|
||||
@@ -309,7 +320,10 @@ function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRe
|
||||
{
|
||||
question: "Which rollout should we use?",
|
||||
header: "rollout",
|
||||
options: [{ label: "Immediately" }, { label: "Behind feature flag" }],
|
||||
options: [
|
||||
{ label: "Immediately" },
|
||||
{ label: "Behind feature flag", preview: ROLLOUT_FLAG_PREVIEW },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -215,6 +215,55 @@ function createToolCatalog(): PaseoToolCatalog {
|
||||
}
|
||||
|
||||
describe("OMP agent client and session", () => {
|
||||
test("failed manual compact closes its progress row and shows the error", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
const events = await omp.compact({}, new Error("compaction refused"));
|
||||
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
item: { type: "compaction", status: "loading", trigger: "manual" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
item: { type: "compaction", status: "completed", trigger: "manual" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "[Error] Failed to compact context: compaction refused",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("manual compact shows its progress and publishes the compacted context usage", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
const events = await omp.compact({
|
||||
contextUsage: { tokens: 13_447, contextWindow: 272_000 },
|
||||
});
|
||||
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: { type: "compaction", status: "loading", trigger: "manual" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: { type: "compaction", status: "completed", trigger: "manual" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "usage_updated",
|
||||
usage: expect.objectContaining({
|
||||
contextWindowUsedTokens: 13_447,
|
||||
contextWindowMaxTokens: 272_000,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("owns launch configuration and registers native host tools", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start({ modeId: "ask" }, createToolCatalog());
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user