Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] d86cb4cb2b fix(app): show the sidebar toggle on plugin screens
Plugin screens rendered only their icon and title on the left of the header,
so on wide web and Android layouts there was no way to collapse the sidebar
from them, or to reopen it after it was closed elsewhere.

Fixes #6315
2026-10-14 08:43:20 +02:00
104 changed files with 1224 additions and 4753 deletions
+6 -11
View File
@@ -7,7 +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 | Stacks tabs; each closes or becomes a tab |
| Workspace sheet | Compact look-ups | Holds one tab; closes or becomes a tab |
## Panel host contract
@@ -28,17 +28,12 @@ retention, while each shell owns its tabs, focus, dragging, resizing, and shortc
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. Each sheet holds one ordinary
`WorkspaceTab`, and sheets stack: a target opened from a sheet covers it, and closing the top sheet
(drag, Close, backdrop, or Android Back) uncovers the one below. Revealing a target that already has
a sheet brings that sheet back to the top and closes the sheets above it, except ones holding unsaved
work. Opening a sheet as a tab dismisses the sheets above it, since the
tab would sit under them. The stack is not persisted.
sheet and tab, so callers open targets the same way on every layout. The sheet holds one ordinary
`WorkspaceTab`, which is not persisted.
Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag
handle, because the panel scrolls on its own and content panning would fight it. Sheets render in
the workspace tree rather than through `IsolatedBottomSheetModal`: their panels need the navigator
and every workspace provider an ordinary pane has, and a portal drops them
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.
-34
View File
@@ -198,40 +198,6 @@ viewport keeps the last keyboard reservation until the next opening replaces
it, so a capped draft keeps one height through every keyboard transition
instead of expanding on close and shrinking again on open.
Fullscreen is a layout mode of the dock, not a second editor. `ComposerDock
fullscreen` fills the stationary viewport with the composer slot over the
still-mounted content, paints its own surface under the safe areas, and turns
the surface translation off. The composer's single tree keeps the editing
surface at the same position in both modes, so the mounted editor, its native
buffer, selection, and focus survive the toggle and Send's clear always reaches
the live input. The one remount left is a viewport crossing the compact
breakpoint: the app shell swaps its gesture wrapper and the workspace screen
mounts tabs in different trees, so every screen below remounts. The draft owner
restores the text and the editing session restores the caret and focus intent.
New workspace keys its sessions by draft in the app-level store for the same
reason. The centered form honors fullscreen by style alone: no centering, no
translation, setup fields hidden, slot filling the stationary viewport. The
global exit action (Escape, hardware back) is registered only for the active
composer: a retained tab keeps its fullscreen editor mounted while hidden, the
sheet suspends pane focus, and the composer gives up input from the moment the
user asks for the compact agent list or explorer, before the panel's motion
settles. Pane focus itself stays put under those panels: the agent panel reads
it as "the user is in this chat" for attention clearing, and opening the agent
list must not clear the attention it shows.
The band beside the inline editor where the fullscreen glyph sits is the input's
own right padding, so a tap anywhere in it focuses the editor whether or not the
glyph shows. On web the slot is a fixed box over the visible viewport, which is
also what lets a zoomed mobile page pan under it. Inside the slot, `ComposerFullscreenBody` reserves the keyboard
height as a bottom margin at move start and end (boundary-time, like the
capacity reservation) while `ComposerFullscreenFooter` rides the keyboard per
frame with `KeyboardTranslateView`, so Send lands on the keyboard without a gap
and without animating the editor's height. The screen that owns the chrome
unmounts it: the workspace screen reads `useComposerTabFullscreen` for the
active tab and New workspace reads the mounted composer's session. Hosts that
render the dock pass the same `ComposerEditingSession` to the `Composer` so the
dock, chrome, and editor agree on one fullscreen state. Escape (web overlay
registration, focus untouched) and the Android back button leave fullscreen.
Keep setup fields out of the composer's shrink chain. New workspace supplies its
setup fields as the first dock child so they move up under the header as the draft
grows. Putting the fields and composer in one bounded, shrinkable column made
+1 -1
View File
@@ -21,7 +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** — A compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher; opening another target from a sheet stacks a new one on top. UI action: "Open as tab". Code: `sheetsByWorkspace` 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).
- **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).
-6
View File
@@ -86,12 +86,6 @@ buildNpmPackage {
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
# to phone home during the build.
CI = "1";
# Metro's worker runs out of heap transforming the generated
# ws-outbound.aot.js validator (about 12 MB). Node's default heap limit is
# 2 GB on machines with less than 15 GB of memory, such as GitHub's 7 GB
# macOS runners, and 4 GB above that. 4096 MB is that 4 GB ceiling, so this
# only raises the limit on smaller builders, like desktop-release.yml does.
NODE_OPTIONS = "--max-old-space-size=4096";
};
buildPhase = ''
@@ -223,7 +223,6 @@ test.describe("Add Project command-center flow", () => {
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "directory-search");
await expect(addProjectFlowInput(page)).toBeFocused();
await page.keyboard.type(projectPickerFixture.fuzzyQuery);
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName, {
timeout: 30_000,
@@ -1,48 +0,0 @@
import { expect, test } from "../support/fixtures";
import type { Page } from "@playwright/test";
import { openSettingsSection } from "../support/helpers/settings";
async function openAppearance(page: Page) {
await page.goto("/settings");
await openSettingsSection(page, "appearance");
}
async function setFontFamily(page: Page, name: string, family: string) {
const input = page.getByRole("textbox", { name });
await input.fill(family);
await input.press("Tab");
}
for (const name of ["Code font family", "Interface font family"]) {
test(`keeps settings usable after an unfinished quote in ${name}`, async ({ page }, testInfo) => {
await openAppearance(page);
await setFontFamily(page, name, "'Fira Code', monospace");
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await setFontFamily(page, name, "'foo");
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
await page.screenshot({ path: testInfo.outputPath("rejected-font.png") });
await page.reload();
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await setFontFamily(page, name, "");
await expect(page.getByRole("textbox", { name })).toHaveValue("");
});
}
test("opens settings with defaults when a malformed font was already saved", async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ monoFontFamily: "'foo", uiFontFamily: '"foo' }),
);
});
await openAppearance(page);
await expect(page.getByRole("textbox", { name: "Code font family" })).toHaveValue("");
await expect(page.getByRole("textbox", { name: "Interface font family" })).toHaveValue("");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
});
@@ -1,70 +0,0 @@
import { expect, test } from "../support/fixtures";
import type { Page } from "@playwright/test";
import { openSettingsSection } from "../support/helpers/settings";
async function openDefaultAppearance(page: Page) {
await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ codeFontSize: 12 }));
const observations: unknown[] = [];
Object.assign(window, { fontRaceObservations: observations });
for (const type of ["input", "change", "keydown", "focusin", "focusout"]) {
document.addEventListener(
type,
(event) => {
const input = event.target as HTMLInputElement;
if (!input?.getAttribute?.("aria-label")?.startsWith("Code font")) return;
observations.push({
type,
label: input.getAttribute("aria-label"),
value: input.value,
key: (event as KeyboardEvent).key,
at: performance.now(),
});
},
true,
);
}
new MutationObserver((records) => {
for (const record of records)
for (const removed of record.removedNodes) {
if (
removed instanceof Element &&
(removed.matches('[aria-label="Code font size"]') ||
removed.querySelector('[aria-label="Code font size"]'))
)
observations.push({ type: "removed-size-input", at: performance.now() });
}
}).observe(document, { childList: true, subtree: true });
});
await page.goto("/settings");
await openSettingsSection(page, "appearance");
}
async function changeCodeFamilyThenSize(page: Page) {
await page.getByLabel("Code font family").fill("Courier New, Courier, monospace");
await page.getByLabel("Code font family").press("Enter");
await page.getByLabel("Code font size").fill("18");
await page.getByLabel("Code font size").press("Enter");
}
test("keeps the size entered immediately after changing the code font family", async ({
page,
}, testInfo) => {
await openDefaultAppearance(page);
try {
await changeCodeFamilyThenSize(page);
await expect(page.getByLabel("Code font size")).toHaveValue("18");
} finally {
await testInfo.attach("font-race", {
body: JSON.stringify(
await page.evaluate(() => ({
events: (window as Window & { fontRaceObservations: unknown[] }).fontRaceObservations,
stored: localStorage.getItem("@paseo:app-settings"),
})),
null,
2,
),
contentType: "application/json",
});
}
});
@@ -9,12 +9,7 @@ import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/arch
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
openMobileAgentSidebar,
} from "../support/helpers/sidebar";
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
const TEST_COMMANDS = [
@@ -101,6 +96,16 @@ interface PopoverFrameRecorderWindow extends Window {
__stopComposerAutocompleteFrameRecorder?: () => void;
}
async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
return page.evaluate(
([pointX, pointY]) => {
const element = document.elementFromPoint(pointX, pointY);
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
},
[x, y],
);
}
async function installListCommandsStub(page: Page): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
@@ -696,15 +701,19 @@ test.describe("Composer autocomplete", () => {
timeout: 30_000,
});
// The sidebar covers the composer, so the composer gives up its popover while the
// sidebar is open and brings it back once the sidebar closes.
await openMobileAgentSidebar(page);
await page.getByRole("button", { name: "Open menu" }).click();
await expectMobileAgentSidebarVisible(page);
await expect(popover).toHaveCount(0);
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
const popoverBox = await popover.boundingBox();
expect(popoverBox).not.toBeNull();
const topTestId = await getTopTestIdAtPoint(
page,
popoverBox!.x + popoverBox!.width / 2,
popoverBox!.y + popoverBox!.height / 2,
);
expect(topTestId).not.toBe("composer-autocomplete-popover");
} finally {
await agent.cleanup();
}
@@ -20,9 +20,6 @@ import {
queueComposerWithButton,
configureComposerQueueSend,
activateRetainedComposerTab,
switchComposerTabWithKeyboard,
expectWorkspaceHeaderHidden,
expectWorkspaceHeaderVisible,
recordVisibleComposerGeometry,
expectNoCollapsedVisibleComposer,
expectAttachmentPill,
@@ -30,14 +27,8 @@ import {
startRunningMockAgent,
composerLocator,
expectComposerVisible,
expectComposerFullscreenFitsVisibleViewport,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
} from "../support/helpers/sidebar";
async function composerHeight(page: Page): Promise<number> {
return composerLocator(page).evaluate((element) => element.getBoundingClientRect().height);
@@ -165,36 +156,6 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
}
});
async function swipeOpenMobileAgentSidebar(page: Page): Promise<void> {
const viewport = page.viewportSize();
if (!viewport) throw new Error("viewport size is required");
const y = Math.round(viewport.height / 2);
await page.mouse.move(8, y);
await page.mouse.down();
await page.mouse.move(40, y, { steps: 4 });
await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 });
await page.mouse.up();
}
/** Freeze the page's timers and animation frames until `page.clock.resume()`. */
async function pausePageClock(page: Page): Promise<void> {
const now = Date.now();
await page.clock.install({ time: now });
await page.clock.pauseAt(now);
}
/** The send error shares the footer with Send, so whatever keeps Send visible keeps it visible. */
async function expectFullscreenErrorAboveSend(page: Page): Promise<void> {
const frame = page.getByTestId("composer-fullscreen");
const alertBox = (await frame.getByRole("alert").boundingBox())!;
const sendBox = (await page
.getByRole("button", { name: "Send message", exact: true })
.boundingBox())!;
const editorBox = (await composerLocator(page).boundingBox())!;
expect(alertBox.y).toBeGreaterThanOrEqual(editorBox.y + editorBox.height);
expect(alertBox.y + alertBox.height).toBeLessThanOrEqual(sendBox.y);
}
async function writeCompactDraft(page: Page, text: string): Promise<void> {
await composerLocator(page).fill(text);
await expect(composerLocator(page)).toHaveValue(text);
@@ -227,107 +188,47 @@ test("compact fullscreen editing preserves the live draft and selection and stay
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await test.step("the affordance appears after two rendered lines, including soft wraps", async () => {
await attachComposerTestImage(page);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await writeCompactDraft(page, "First line\nSecond line");
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toHaveCount(0);
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
await writeCompactDraft(
page,
"A sentence with enough words to wrap across several rendered lines in a narrow input. ".repeat(
4,
),
);
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
});
await test.step("fullscreen covers the header and keeps the live draft and selection", async () => {
await selectComposerDraftRange(page, 4, 17);
await enterComposerFullscreen(page);
await expectComposerFullscreenControls(page);
await expectWorkspaceHeaderHidden(page);
await expectComposerDraftSelection(page, 4, 17);
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
await writeCompactDraft(page, "Edited fullscreen draft\nSecond line\nThird line");
await selectComposerDraftRange(page, 3, 12);
});
await test.step("exit restores the inline composer with the fullscreen edits", async () => {
await exitComposerFullscreen(page);
await expectWorkspaceHeaderVisible(page);
await expect(composerLocator(page)).toHaveValue(
"Edited fullscreen draft\nSecond line\nThird line",
);
await expectComposerDraftSelection(page, 3, 12);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await expect(composerLocator(page)).toBeFocused();
});
await test.step("sending inline after a fullscreen edit clears the visible editor", async () => {
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
await writeCompactDraft(page, "Typed after the fullscreen cycle");
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
});
await test.step("sending inside fullscreen clears the editor and keeps fullscreen open", async () => {
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await enterComposerFullscreen(page);
await writeCompactDraft(page, "Short draft");
await expectComposerFullscreenControls(page);
await selectComposerDraftRange(page, 0, 5);
await resizeComposerViewport(page, "landscape");
await expectComposerFullscreenControls(page);
await expectComposerDraftSelection(page, 0, 5);
await expect(composerLocator(page)).toHaveValue("Short draft");
await expect(composerLocator(page)).toBeFocused();
await resizeComposerViewport(page, "portrait");
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectComposerFullscreenControls(page);
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
});
await test.step("typing and sending keep working after leaving fullscreen again", async () => {
await writeCompactDraft(page, "Typed inside fullscreen");
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
await exitComposerFullscreen(page);
await expectWorkspaceHeaderVisible(page);
await expect(composerLocator(page)).toHaveValue("Typed inside fullscreen");
await writeCompactDraft(page, "Typed inside fullscreen, finished inline");
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await writeCompactDraft(page, "One more inline message");
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
});
} finally {
await agent.cleanup();
}
});
test("a caret inside an unpublished edit survives a breakpoint remount", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-remount-caret-",
title: "Remount caret",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await writeCompactDraft(page, "Short draft");
await selectComposerDraftRange(page, 5, 5);
// The draft owner publishes web edits after the next paint. A rotation inside that
// window remounts the editor before the edit has reached the draft store; the paused
// clock holds the page there instead of racing the frame.
await pausePageClock(page);
await composerLocator(page).press("X");
await resizeComposerViewport(page, "landscape");
await page.clock.resume();
await expect(composerLocator(page)).toHaveValue("ShortX draft");
await expectComposerDraftSelection(page, 6, 6);
await attachComposerTestImage(page);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await writeCompactDraft(page, "First line\nSecond line");
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toHaveCount(0);
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
await writeCompactDraft(
page,
"A sentence with enough words to wrap across several rendered lines in a narrow input. ".repeat(
4,
),
);
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
await selectComposerDraftRange(page, 4, 17);
await enterComposerFullscreen(page);
await expectComposerFullscreenControls(page);
await expectComposerDraftSelection(page, 4, 17);
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
await writeCompactDraft(page, "Edited fullscreen draft\nSecond line\nThird line");
await selectComposerDraftRange(page, 3, 12);
await exitComposerFullscreen(page);
await expect(composerLocator(page)).toHaveValue(
"Edited fullscreen draft\nSecond line\nThird line",
);
await expectComposerDraftSelection(page, 3, 12);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await expect(composerLocator(page)).toBeFocused();
await composerLocator(page).press("Y");
await expect(composerLocator(page)).toHaveValue("ShortXY draft");
await enterComposerFullscreen(page);
await writeCompactDraft(page, "Short draft");
await expectComposerFullscreenControls(page);
await selectComposerDraftRange(page, 0, 5);
await resizeComposerViewport(page, "landscape");
await expectComposerFullscreenControls(page);
await expectComposerDraftSelection(page, 0, 5);
await expect(composerLocator(page)).toHaveValue("Short draft");
await expect(composerLocator(page)).toBeFocused();
await resizeComposerViewport(page, "portrait");
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectComposerFullscreenControls(page);
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
} finally {
await agent.cleanup();
}
@@ -352,7 +253,6 @@ test("fullscreen submit rejection retains the editable draft and supports retry"
await expect(page.getByTestId("composer-fullscreen").getByRole("alert")).toHaveText(
"Requested mock prompt rejection",
);
await expectFullscreenErrorAboveSend(page);
await expect(composerLocator(page)).toHaveValue(draft);
await expect(composerLocator(page)).toBeEditable();
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
@@ -364,76 +264,6 @@ test("fullscreen submit rejection retains the editable draft and supports retry"
}
});
async function blurComposer(page: Page): Promise<void> {
await composerLocator(page).blur();
await expect(composerLocator(page)).not.toBeFocused();
}
/** Mid-band at the given height and near the editor's bottom edge. */
async function reservedBandPoints(
page: Page,
bandX: number,
topY: number,
): Promise<Array<{ x: number; y: number }>> {
const editor = (await composerLocator(page).boundingBox())!;
return [
{ x: bandX, y: topY },
{ x: bandX, y: editor.y + editor.height - 6 },
];
}
test("the reserved band beside the inline editor focuses it", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-band-focus-",
title: "Composer band focus",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const exitButton = page.getByRole("button", { name: "Exit fullscreen", exact: true });
const fullscreenButton = page.getByRole("button", { name: "Fullscreen", exact: true });
const longDraft = "Band first line\nBand second line\nBand third line";
await writeCompactDraft(page, longDraft);
await expect(fullscreenButton).toBeVisible();
const button = (await fullscreenButton.boundingBox())!;
const bandX = button.x + button.width / 2;
await test.step("with the fullscreen button shown, the band under it focuses the editor", async () => {
for (const point of await reservedBandPoints(page, bandX, button.y + button.height + 10)) {
await blurComposer(page);
await page.mouse.click(point.x, point.y);
await expect(composerLocator(page)).toBeFocused();
await expect(composerLocator(page)).toHaveValue(longDraft);
await expect(exitButton).toHaveCount(0);
}
});
await test.step("the button itself still toggles fullscreen", async () => {
await fullscreenButton.click();
await expectComposerFullscreenControls(page);
await exitComposerFullscreen(page);
await expect(exitButton).toHaveCount(0);
await expect(composerLocator(page)).toHaveValue(longDraft);
});
await test.step("with no fullscreen button, the whole band still focuses the editor", async () => {
await writeCompactDraft(page, "Short");
await expect(fullscreenButton).toHaveCount(0);
const editor = (await composerLocator(page).boundingBox())!;
for (const point of await reservedBandPoints(page, bandX, editor.y + 6)) {
await blurComposer(page);
await page.mouse.click(point.x, point.y);
await expect(composerLocator(page)).toBeFocused();
await expect(composerLocator(page)).toHaveValue("Short");
await expect(exitButton).toHaveCount(0);
}
});
} finally {
await agent.cleanup();
}
});
test("fullscreen Send keeps the configured queue behavior while an agent runs", async ({
page,
}) => {
@@ -503,28 +333,15 @@ test("retained agent tabs preserve fullscreen editing and paste when switched aw
const draft = "Live first line\nLive second line\nLive third line";
await writeCompactDraft(page, draft);
await enterComposerFullscreen(page);
await expectWorkspaceHeaderHidden(page);
await selectComposerDraftRange(page, 4, 17);
// Fullscreen covers the compact header, so a phone leaves the tab by exiting first;
// a desktop-width window still has the tab shortcut.
await resizeComposerViewport(page, "landscape");
await switchComposerTabWithKeyboard(page, "previous");
await activateRetainedComposerTab(page, first.title);
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
// The exit action belongs to the active pane: Escape here leaves the hidden tab alone.
await composerLocator(page).press("Escape");
await switchComposerTabWithKeyboard(page, "next");
await expectComposerFullscreenControls(page);
await switchComposerTabWithKeyboard(page, "previous");
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
await resizeComposerViewport(page, "portrait");
await expectWorkspaceHeaderVisible(page);
await writeCompactDraft(page, "Other tab draft");
await shrinkVisibleViewport(page);
await activateRetainedComposerTab(page, second.title);
await expectComposerFullscreenControls(page);
await expectWorkspaceHeaderHidden(page);
await expect(composerLocator(page)).toHaveValue(draft);
await expectComposerFullscreenFitsVisibleViewport(page);
await expectFullscreenInsideVisibleViewport(page);
await expectComposerDraftSelection(page, 4, 17);
await expect(composerLocator(page)).toBeFocused();
expect(await pasteComposerTestImage(page)).toBe(true);
@@ -535,36 +352,6 @@ test("retained agent tabs preserve fullscreen editing and paste when switched aw
}
});
test("the compact sidebar over a fullscreen editor leaves the exit action alone", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-fullscreen-sidebar-",
title: "Fullscreen under sidebar",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
const draft = "Line one\nLine two\nLine three";
await writeCompactDraft(page, draft);
await enterComposerFullscreen(page);
await expectWorkspaceHeaderHidden(page);
// Fullscreen hides the header's menu button; the edge swipe still opens the list over it.
await swipeOpenMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
// The list covers the pane, so the editor's exit action is not armed.
await page.keyboard.press("Escape");
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
await expectComposerFullscreenControls(page);
await expect(composerLocator(page)).toHaveValue(draft);
await exitComposerFullscreen(page);
await expectWorkspaceHeaderVisible(page);
} finally {
await agent.cleanup();
}
});
test("retained inline composers keep usable capacity through hidden tab geometry", async ({
page,
}) => {
@@ -621,6 +408,30 @@ async function panVisibleViewport(page: Page): Promise<void> {
await cdp.detach();
}
async function expectFullscreenInsideVisibleViewport(page: Page): Promise<void> {
await expect
.poll(() =>
page.getByTestId("composer-fullscreen").evaluate((element) => {
const viewport = window.visualViewport!;
const frame = element.getBoundingClientRect();
const send = element.querySelector('[aria-label="Send message"]')!.getBoundingClientRect();
return {
frameFits:
Math.abs(frame.top - viewport.offsetTop) < 1 &&
Math.abs(frame.left - viewport.offsetLeft) < 1 &&
Math.abs(frame.height - viewport.height) < 1 &&
Math.abs(frame.width - viewport.width) < 1,
sendFits:
send.top >= viewport.offsetTop &&
send.bottom <= viewport.offsetTop + viewport.height &&
send.left >= viewport.offsetLeft &&
send.right <= viewport.offsetLeft + viewport.width,
};
}),
)
.toEqual({ frameFits: true, sendFits: true });
}
test("fullscreen Send follows the visible viewport when it shrinks and pans", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-visible-viewport-",
@@ -632,11 +443,11 @@ test("fullscreen Send follows the visible viewport when it shrinks and pans", as
await expectComposerVisible(page);
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await enterComposerFullscreen(page);
await expectComposerFullscreenFitsVisibleViewport(page);
await expectFullscreenInsideVisibleViewport(page);
await shrinkVisibleViewport(page);
await expectComposerFullscreenFitsVisibleViewport(page);
await expectFullscreenInsideVisibleViewport(page);
await panVisibleViewport(page);
await expectComposerFullscreenFitsVisibleViewport(page);
await expectFullscreenInsideVisibleViewport(page);
} finally {
await agent.cleanup();
}
@@ -116,7 +116,6 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
);
expect(workspaces).toHaveLength(1);
gate.release();
await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
const first = (await client.fetchAgents()).entries[0]!.agent;
await client.waitForFinish(first.id, 20_000);
@@ -129,7 +128,6 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
await fillComposerDraft(page, secondPrompt);
await pressSubmitBeforeTheNextRender(page, "Send message");
await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
const second = (await client.fetchAgents()).entries.find(
({ agent }) => agent.id !== first.id,
@@ -208,40 +206,21 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
async function holdLegacyAgentCreation(page: Page, port: number) {
const readers = await loadSessionMessageReaders();
const arrived = Promise.withResolvers<void>();
let created = Promise.withResolvers<void>();
let createRequestId: string | undefined;
let release = () => {};
let holding = true;
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
const message = readers.client(frame);
if (message?.type === "create_agent_request") createRequestId = message.requestId;
if (holding && message?.type === "create_agent_request") {
if (holding && readers.client(frame)?.type === "create_agent_request") {
holding = false;
release = () => server.send(frame);
arrived.resolve();
} else server.send(frame);
});
server.onMessage((frame) => {
const message = readers.server(frame);
if (
message?.type === "status" &&
message.payload.status === "agent_created" &&
message.payload.requestId === createRequestId
) {
created.resolve();
}
browser.send(frame);
});
server.onMessage((frame) => browser.send(frame));
});
return {
waitForRequest: () => arrived.promise,
async waitForCreated() {
// Agent-list membership includes initialization, before the initial prompt starts.
await created.promise;
created = Promise.withResolvers<void>();
},
release() {
const send = release;
release = () => {};
@@ -1,29 +0,0 @@
import { test } from "../support/fixtures";
import {
addCustomModel,
expectCustomModelRetained,
openCustomModels,
removeCustomModel,
} from "../support/helpers/custom-models";
const longModelId = "custom-model-with-a-long-name-".repeat(6);
const shortModelId = "custom-short";
for (const width of [1200, 390]) {
test(`removes a long custom model at ${width}px without removing another model`, async ({
page,
}) => {
await page.setViewportSize({ width: 1200, height: 1000 });
await openCustomModels(page);
await addCustomModel(page, shortModelId);
await addCustomModel(page, longModelId);
await test.step("remove the long model at the target width", async () => {
await page.setViewportSize({ width, height: 1000 });
await removeCustomModel(page, longModelId);
await expectCustomModelRetained(page, shortModelId);
});
await removeCustomModel(page, shortModelId);
});
}
@@ -2,10 +2,10 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
import { openFileExplorer } from "../support/helpers/file-explorer";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
import { gotoWorkspace } from "../support/helpers/launcher";
import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
let workspace: SeededWorkspace;
@@ -444,25 +444,3 @@ test("hides unsupported file operations and revert actions", async ({ page }) =>
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0);
});
test("downloads a file over direct TCP with its original filename and bytes", async ({
page,
}, testInfo) => {
const fileName = "rapport données.bin";
const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]);
await writeFile(path.join(workspace.workspaceDirectory, fileName), source);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const downloaded = await downloadFileFromExplorer(page, fileName);
const url = new URL(downloaded.url());
expect(url.protocol).toBe("http:");
expect(url.port).toBe(getE2EDaemonPort());
expect(url.pathname).toBe("/api/files/download");
expect(url.searchParams.get("token")).toBeTruthy();
expect(downloaded.suggestedFilename()).toBe(fileName);
const savedFile = testInfo.outputPath(fileName);
await downloaded.saveAs(savedFile);
expect(await downloaded.failure()).toBeNull();
expect(await readFile(savedFile)).toEqual(source);
});
@@ -1,18 +1,7 @@
import { expect, test, type Page } from "../support/fixtures";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import {
expectNewWorkspaceDraft,
fillNewWorkspaceDraft,
openNewWorkspaceComposer,
} from "../support/helpers/new-workspace";
import {
composerLocator,
enterComposerFullscreen,
exitComposerFullscreen,
expectComposerFullscreenControls,
resizeComposerViewport,
} from "../support/helpers/composer";
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectTerminalLaunchSelected,
@@ -156,117 +145,3 @@ test.describe("New workspace: launching a terminal", () => {
});
});
});
async function expectNewWorkspaceHeaderHidden(page: Page): Promise<void> {
await expect(page.getByTestId("menu-button").filter({ visible: true })).toHaveCount(0);
}
async function expectNewWorkspaceHeaderVisible(page: Page): Promise<void> {
await expect(page.getByTestId("menu-button").filter({ visible: true }).first()).toBeVisible();
}
/**
* Fullscreen fills the content area top to bottom and out to the right edge (a pinned
* sidebar keeps its column), covers the form, and shows only exit and the mode's submit.
*/
async function expectNewWorkspaceFullscreen(page: Page, submitLabel: string): Promise<void> {
const frame = page.getByTestId("composer-fullscreen");
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible();
await expect(frame.getByRole("button")).toHaveCount(2);
await expect(frame.getByRole("button", { name: submitLabel })).toBeVisible();
await expectNewWorkspaceHeaderHidden(page);
const picker = (await page.getByRole("button", { name: "Workspace project" }).boundingBox())!;
await expect
.poll(() =>
frame.evaluate(
(element, point) => {
const viewport = window.visualViewport!;
const box = element.getBoundingClientRect();
const covering = document.elementFromPoint(point.x, point.y);
return {
fillsHeight:
Math.abs(box.top - viewport.offsetTop) < 1 &&
Math.abs(box.height - viewport.height) < 1,
reachesRightEdge:
Math.abs(box.right - (viewport.offsetLeft + viewport.width)) < 1 &&
box.left >= viewport.offsetLeft,
coversForm: element.contains(covering),
};
},
{ x: picker.x + picker.width / 2, y: picker.y + picker.height / 2 },
),
)
.toEqual({ fillsHeight: true, reachesRightEdge: true, coversForm: true });
}
test.describe("New workspace: fullscreen composer across the compact breakpoint", () => {
let workspace: SeededWorkspace;
test.beforeEach(async () => {
workspace = await seedWorkspace({ repoPrefix: "launch-fullscreen-" });
});
test.afterEach(async () => {
await workspace?.cleanup();
});
test("chat fullscreen survives widening and terminal fullscreen fills the phone", async ({
page,
}) => {
test.setTimeout(90_000);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
const projectPicker = page.getByRole("button", { name: "Workspace project" });
await test.step("chat: fullscreen covers the centered form and exits back into it", async () => {
await resizeComposerViewport(page, "portrait");
const draft = "Chat first line\nChat second line\nChat third line";
await fillNewWorkspaceDraft(page, draft);
await enterComposerFullscreen(page);
await expectComposerFullscreenControls(page);
await expectNewWorkspaceHeaderHidden(page);
await resizeComposerViewport(page, "landscape");
await expectComposerFullscreenControls(page);
await expectNewWorkspaceFullscreen(page, "Create");
// The desktop form's own import button is dropped from the fullscreen slot.
await expect(
page.getByTestId("new-workspace-import-session").filter({ visible: true }),
).toHaveCount(0);
await composerLocator(page).press("End");
await composerLocator(page).pressSequentially(" wide");
await exitComposerFullscreen(page);
await expectNewWorkspaceHeaderVisible(page);
await expect(projectPicker).toBeVisible();
await expect(
page.getByTestId("new-workspace-import-session").filter({ visible: true }),
).toBeVisible();
await expectNewWorkspaceDraft(page, `${draft} wide`);
});
// Terminal mode and its prompt are screen state, and the shell remounts the screen
// at the breakpoint, so the terminal prompt's fullscreen is exercised on the phone
// layout only.
await test.step("terminal: the prompt's fullscreen shows only exit and Launch", async () => {
await resizeComposerViewport(page, "portrait");
// The compact launch control opens a sheet, not the desktop menu.
await launchTrigger(page).click();
const option = page.getByRole("menuitem").filter({ hasText: PROMPT_PROFILE.name });
await option.click();
await expect(option).not.toBeVisible();
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
const prompt = "Prompt first line\nPrompt second line\nPrompt third line";
await fillTerminalPrompt(page, prompt);
await enterComposerFullscreen(page);
await expect(terminalPromptInput(page)).toBeVisible();
await expectNewWorkspaceFullscreen(page, "Launch");
await exitComposerFullscreen(page);
await expectNewWorkspaceHeaderVisible(page);
await expect(projectPicker).toBeVisible();
await expect(terminalPromptInput(page)).toHaveValue(prompt);
});
});
});
@@ -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);
@@ -1,74 +0,0 @@
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
expectSidebarWorkspaceSelected,
switchWorkspaceViaSidebar,
} from "../support/helpers/workspace-ui";
async function readVisibleWorkspaceOrder(page: Page): Promise<string[]> {
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
await expect(rows).toHaveCount(12);
return rows.evaluateAll(
(elements, prefix) =>
elements.map((element) => element.getAttribute("data-testid")!.slice(prefix.length)),
`sidebar-workspace-row-${getServerId()}:`,
);
}
async function navigateWorkspace(
page: Page,
direction: "next" | "previous",
workspaceId: string,
): Promise<void> {
await page.keyboard.press(direction === "next" ? "Alt+]" : "Alt+[");
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId });
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}$`));
}
test("previous and next traverse every visible workspace while digit shortcuts stop at nine", async ({
page,
}) => {
test.setTimeout(120_000);
const project = await seedWorkspace({
repoPrefix: "relative-navigation-",
title: "Workspace 01",
});
try {
for (let index = 2; index <= 12; index += 1) {
const created = await project.client.createWorkspace({
source: { kind: "directory", path: project.repoPath },
title: `Workspace ${String(index).padStart(2, "0")}`,
});
expect(created.error).toBeNull();
}
await page.setViewportSize({ width: 1440, height: 1100 });
await gotoAppShell(page);
const order = await readVisibleWorkspaceOrder(page);
await test.step("next crosses the ninth workspace and previous returns to it", async () => {
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[8] });
await navigateWorkspace(page, "next", order[9]);
await navigateWorkspace(page, "previous", order[8]);
});
await test.step("wrapping uses the true first and last visible workspace", async () => {
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
await navigateWorkspace(page, "previous", order[11]);
await navigateWorkspace(page, "next", order[0]);
});
await test.step("the ninth digit shortcut still selects the ninth workspace", async () => {
await page.keyboard.press("Alt+9");
await expectSidebarWorkspaceSelected({
page,
serverId: getServerId(),
workspaceId: order[8],
});
});
} finally {
await project.cleanup();
}
});
@@ -355,12 +355,6 @@ export async function resizeComposerViewport(
short: { width: 390, height: 390 },
};
await page.setViewportSize(sizes[orientation]);
// Before the app has loaded there is no layout to wait for.
if (page.url() === "about:blank") return;
// Crossing the compact breakpoint remounts the shell and the editor. Anything that
// follows (a shortcut, a locator) belongs to the layout the new size implies, so
// wait for that layout before returning.
await expect(page.getByTestId("compact-shell")).toHaveCount(orientation === "landscape" ? 0 : 1);
}
export async function selectComposerDraftRange(
page: Page,
@@ -396,33 +390,6 @@ export async function expectComposerFullscreenControls(page: Page): Promise<void
await expect(page.getByTestId("composer-fullscreen").getByRole("button")).toHaveCount(2);
await expect(composerInput(page)).toHaveCount(1);
}
/** The fullscreen frame and its submit button sit inside the browser's visible viewport. */
export async function expectComposerFullscreenFitsVisibleViewport(
page: Page,
submitLabel = "Send message",
): Promise<void> {
await expect
.poll(() =>
page.getByTestId("composer-fullscreen").evaluate((element, label) => {
const viewport = window.visualViewport!;
const frame = element.getBoundingClientRect();
const send = element.querySelector(`[aria-label="${label}"]`)!.getBoundingClientRect();
return {
frameFits:
Math.abs(frame.top - viewport.offsetTop) < 1 &&
Math.abs(frame.left - viewport.offsetLeft) < 1 &&
Math.abs(frame.height - viewport.height) < 1 &&
Math.abs(frame.width - viewport.width) < 1,
sendFits:
send.top >= viewport.offsetTop &&
send.bottom <= viewport.offsetTop + viewport.height &&
send.left >= viewport.offsetLeft &&
send.right <= viewport.offsetLeft + viewport.width,
};
}, submitLabel),
)
.toEqual({ frameFits: true, sendFits: true });
}
export async function sendComposerWithButton(page: Page): Promise<void> {
await page.getByRole("button", { name: "Send message", exact: true }).click();
}
@@ -438,22 +405,6 @@ export async function configureComposerQueueSend(page: Page): Promise<void> {
/** Drive the real tab chooser's commands even when fullscreen covers its pointer surface.
* This exercises retained activity changes, not a claim that covered chrome is clickable.
*/
/** Needs a desktop-width viewport: compact layouts have no keyboard shortcuts. */
export async function switchComposerTabWithKeyboard(
page: Page,
direction: "previous" | "next",
): Promise<void> {
await page.keyboard.press(
direction === "previous" ? "Alt+Shift+BracketLeft" : "Alt+Shift+BracketRight",
);
}
export async function expectWorkspaceHeaderHidden(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toHaveCount(0);
}
export async function expectWorkspaceHeaderVisible(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
}
export async function activateRetainedComposerTab(page: Page, title: string): Promise<void> {
await page
.getByTestId("workspace-tab-switcher-trigger")
@@ -1,37 +0,0 @@
import { expect, type Page } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { openSettingsHostSection } from "./settings";
import { getServerId } from "./server-id";
export async function openCustomModels(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, getServerId(), "providers");
await page.getByRole("button", { name: "Claude provider details", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible();
}
export async function addCustomModel(page: Page, id: string): Promise<void> {
await page.getByRole("button", { name: "Add model", exact: true }).click();
await page.getByPlaceholder("e.g. openai/gpt-5").fill(id);
await page
.getByTestId("add-custom-model-sheet")
.getByRole("button", { name: "Add", exact: true })
.click();
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
}
export async function removeCustomModel(page: Page, id: string): Promise<void> {
await page.getByRole("textbox", { name: "Search models" }).fill(id);
const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
await expect(remove).toBeInViewport({ ratio: 1 });
await remove.click();
await expect(remove).toHaveCount(0);
}
export async function expectCustomModelRetained(page: Page, id: string): Promise<void> {
await page.getByRole("textbox", { name: "Search models" }).fill(id);
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
ratio: 1,
});
}
@@ -1,4 +1,4 @@
import { expect, type Download, type Page } from "@playwright/test";
import { expect, type Page } from "@playwright/test";
import { openFilesPanel } from "./workspace-tabs";
function fileExplorerTree(page: Page) {
@@ -39,10 +39,3 @@ export async function expectFileTabOpen(page: Page, filePath: string): Promise<v
timeout: 30_000,
});
}
export async function downloadFileFromExplorer(page: Page, fileName: string): Promise<Download> {
await fileExplorerEntry(page, fileName).click({ button: "right" });
const downloaded = page.waitForEvent("download");
await page.getByRole("menuitem", { name: "Download", exact: true }).click();
return downloaded;
}
@@ -7,8 +7,8 @@ export function workspaceSheet(page: Page, label: string): Locator {
/** Sheet height as a fraction of the viewport; mirrors the host's 92% snap point. */
const SHEET_SNAP_FRACTION = 0.92;
/** The header starts below the sheet's drag indicator, inside the handle. */
const SHEET_INDICATOR_MAX_HEIGHT = 32;
/** 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
@@ -18,16 +18,14 @@ export async function expectWorkspaceSheetAtSnap(page: Page, label: string): Pro
const viewportHeight = page.viewportSize()?.height ?? 0;
const snapTop = Math.round(viewportHeight * (1 - SHEET_SNAP_FRACTION));
const sheet = workspaceSheet(page, label);
const header = sheet.getByTestId("workspace-sheet-header");
const body = sheet.getByTestId("workspace-sheet-body");
await expect
.poll(async () => {
const [headerBox, bodyBox] = await Promise.all([header.boundingBox(), body.boundingBox()]);
if (!headerBox || !bodyBox) return null;
const top = Math.round(headerBox.y);
const box = await sheet.boundingBox();
if (!box) return null;
const top = Math.round(box.y);
return {
dockedToBottom: Math.round(bodyBox.y + bodyBox.height) === viewportHeight,
topAtSnap: top >= snapTop && top <= snapTop + SHEET_INDICATOR_MAX_HEIGHT,
dockedToBottom: Math.round(box.y + box.height) === viewportHeight,
topAtSnap: top >= snapTop && top <= snapTop + SHEET_HANDLE_MAX_HEIGHT,
};
})
.toEqual({ dockedToBottom: true, topAtSnap: true });
@@ -75,11 +73,9 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise<
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
}
/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */
export async function tapWorkspaceSheetBackdrop(page: Page): Promise<void> {
await page
.getByRole("button", { name: "Bottom sheet backdrop" })
.last()
.click({ position: { x: 10, y: 10 } });
}
+1 -1
View File
@@ -665,7 +665,7 @@ function MobileGestureWrapper({
return (
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
<View collapsable={false} style={layoutStyles.surfaceFill} testID="compact-shell">
<View collapsable={false} style={layoutStyles.surfaceFill}>
{children}
</View>
</GestureDetector>
+8 -29
View File
@@ -1,15 +1,6 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import {
ComposerEditingScope,
WorkspaceComposerEditingSessionsProvider,
} from "@/composer/editing-session";
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
import { buildNewWorkspaceDraftKey } from "@/stores/draft-keys";
/** New workspace drafts share one editing-session home, keyed like a tab by draft. */
const NEW_WORKSPACE_SESSIONS_KEY = "new-workspace";
export default function NewWorkspaceRoute() {
const params = useLocalSearchParams<{
@@ -32,28 +23,16 @@ export default function NewWorkspaceRoute() {
draftId ?? null,
]);
// The shell remounts this route when the viewport crosses the compact breakpoint;
// the draft's editing session (fullscreen, retained caret) lives above the shell.
const draftKey = buildNewWorkspaceDraftKey(draftId);
const sessionTabIds = useMemo(() => [draftKey], [draftKey]);
return (
<HostRouteBootstrapBoundary>
<WorkspaceComposerEditingSessionsProvider
workspaceKey={NEW_WORKSPACE_SESSIONS_KEY}
tabIds={sessionTabIds}
>
<ComposerEditingScope tabId={draftKey} targetKey={draftKey}>
<NewWorkspaceScreen
key={screenKey}
serverId={serverId}
sourceDirectory={sourceDirectory}
displayName={displayName}
projectId={projectId}
draftId={draftId}
/>
</ComposerEditingScope>
</WorkspaceComposerEditingSessionsProvider>
<NewWorkspaceScreen
key={screenKey}
serverId={serverId}
sourceDirectory={sourceDirectory}
displayName={displayName}
projectId={projectId}
draftId={draftId}
/>
</HostRouteBootstrapBoundary>
);
}
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ComponentProps, ReactNode } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Keyboard, Pressable, Text, View } from "react-native";
import type { DimensionValue, StyleProp, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
getOverlayRoot,
@@ -12,7 +12,7 @@ import {
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
} from "../lib/overlay-root";
import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native";
import {
@@ -223,8 +223,7 @@ const styles = StyleSheet.create((theme) => ({
const WEB_EXIT_DURATION_MS = 160;
/** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */
export function SheetBackground({ style }: BottomSheetBackgroundProps) {
function SheetBackground({ style }: BottomSheetBackgroundProps) {
const { theme } = useUnistyles();
const combinedStyle = useMemo(
() => [
@@ -240,39 +239,6 @@ export function SheetBackground({ style }: BottomSheetBackgroundProps) {
return <Animated.View pointerEvents="none" style={combinedStyle} />;
}
const ThemedBottomSheetHandle = withUnistyles(BottomSheetHandle, (theme) => ({
indicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] },
}));
const HANDLE_WITH_CONTENT_STYLE = { paddingHorizontal: 0, paddingBottom: 0 };
/**
* The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. Children
* join the handle, so a header passed here drags the sheet like the indicator does. That is how a
* sheet whose content scrolls on its own keeps its header draggable without content panning.
*/
export function SheetHandle({
children,
// Gorhom passes `indicatorStyle={undefined}`, which would override the themed indicator.
indicatorStyle: _indicatorStyle,
...props
}: ComponentProps<typeof BottomSheetHandle>) {
if (!children) return <ThemedBottomSheetHandle {...props} />;
// The children carry their own semantics; the handle's slider role would swallow them on web.
return (
<ThemedBottomSheetHandle
{...props}
style={HANDLE_WITH_CONTENT_STYLE}
accessible={false}
accessibilityRole={null}
accessibilityLabel={null}
accessibilityHint={null}
>
{children}
</ThemedBottomSheetHandle>
);
}
/**
* The sheet body, indented to the sheet's content inset.
*
@@ -483,6 +449,7 @@ export function AdaptiveModalSheet({
bodyStyle,
contextBridge = null,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const insets = useSafeAreaInsets();
@@ -514,6 +481,10 @@ export function AdaptiveModalSheet({
) : null,
[footer, footerClearanceStyle, footerContainerStyle],
);
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
[theme.colors.palette.zinc],
);
useEffect(() => {
if (!isWeb && visible) {
// A newly opened sheet owns input. A keyboard belonging to the sheet below
@@ -629,7 +600,7 @@ export function AdaptiveModalSheet({
backdropOpacity={0.45}
enablePanDownToClose
backgroundComponent={SheetBackground}
handleComponent={SheetHandle}
handleIndicatorStyle={handleIndicatorStyle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
+38 -13
View File
@@ -35,7 +35,6 @@ import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { DensityIcon } from "@/components/ui/density-icon";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
@@ -806,18 +805,21 @@ function DesktopSidebar({
function WorkspacesSectionHeader() {
return (
<SidebarSectionHeader title="Workspaces">
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarDisplayPreferencesMenu />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<IconTooltipContent label="Display preferences" />
</TooltipContent>
</Tooltip>
</SidebarSectionHeader>
<View style={styles.workspacesSectionHeader}>
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
<View style={styles.workspacesSectionActions}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarDisplayPreferencesMenu />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<IconTooltipContent label="Display preferences" />
</TooltipContent>
</Tooltip>
</View>
</View>
);
}
@@ -848,6 +850,29 @@ const styles = StyleSheet.create((theme) => ({
sidebarHeaderGroupBelowChrome: {
paddingTop: 0,
},
workspacesSectionHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
// Rendered inside the scroll's listContent (paddingHorizontal spacing[2]). The title
// lands at spacing[2] left to align with project icons. Settings2's painted path stops
// inside its 14px SVG, so 4px aligns the ink rather than the SVG box to the row rail.
paddingLeft: theme.spacing[2],
paddingRight: 4,
paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[1],
},
workspacesSectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
workspacesSectionActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
sidebarContent: {
flex: 1,
minHeight: 0,
@@ -3,71 +3,6 @@ import { describe, expect, it } from "vitest";
import { normalizeHtmlishMarkdown, splitHtmlishMarkdown } from "./html-ish";
describe("splitHtmlishMarkdown", () => {
it("renders heading text beside a linked picture without literal heading tags", () => {
const source =
'<h2><a href="https://example.com/r"><picture><source srcset="https://example.com/d.svg"><img alt="Retrigger" src="https://example.com/l.svg"></picture></a>Confidence Score: 5/5</h2>';
expect(splitHtmlishMarkdown(source)).toEqual([
{
kind: "inlineImage",
alt: "Retrigger",
src: "https://example.com/l.svg",
href: "https://example.com/r",
flowsWithText: true,
},
{ kind: "markdown", text: "Confidence Score: 5/5" },
]);
});
it.each(["h1", "h2", "h3", "h4", "h5", "h6"])(
"keeps a mixed %s heading together after preceding markdown",
(heading) => {
const source = `Intro\n\n<${heading}><img alt="Icon" src="https://example.com/icon.svg">Caption</${heading}>\n\nOutro`;
expect(splitHtmlishMarkdown(source)).toEqual([
{ kind: "markdown", text: "Intro\n\n" },
{
kind: "inlineImage",
alt: "Icon",
src: "https://example.com/icon.svg",
flowsWithText: true,
},
{ kind: "markdown", text: "Caption\n\nOutro" },
]);
},
);
it("preserves formatted text before and after images in a details heading", () => {
const source =
'<details><summary>More</summary><h2><strong>Before</strong><img alt="Icon" src="https://example.com/icon.svg"><em>After</em></h2></details>';
expect(splitHtmlishMarkdown(source)).toEqual([
{
kind: "details",
summary: "More",
body: "**Before***After*",
bodyParts: [
{ kind: "markdown", text: "**Before**" },
{ kind: "inlineImage", alt: "Icon", src: "https://example.com/icon.svg" },
{ kind: "markdown", text: "*After*" },
],
},
]);
});
it("keeps mixed HTML headings literal in code examples", () => {
const heading = '<h2><img src="https://example.com/icon.svg">Caption</h2>';
const source = `Use \`${heading}\`\n\n\`\`\`html\n${heading}\n\`\`\``;
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it("preserves unknown wrappers around text-only headings", () => {
expect(splitHtmlishMarkdown("<section><h2>Heading</h2></section>")).toEqual([
{ kind: "markdown", text: "<section>Heading</section>" },
]);
});
const inlineImageBody = [
'<a href="#"><img alt="Priority" src="https://example.com/assets/priority.svg?v=9" align="top"></a> Spoofed browser User-Agent allows access control bypass',
"",
@@ -125,20 +125,9 @@ function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] {
continue;
}
if (isHeadingTag(token)) {
const closeIndex = findMixedHeadingClose(tokens, cursor);
if (closeIndex !== null) {
// Headings render as their contents. Split those contents together so an
// embedded image cannot strand the opening heading tag in a text slice.
for (const part of splitHtmlishTokens(tokens.slice(cursor + 1, closeIndex))) {
appendDisplayPart(parts, part);
}
cursor = closeIndex + 1;
continue;
}
}
const end = findNextDisplayPartIndex(tokens, cursor + 1) ?? tokens.length;
const nextDetailsIndex = findNextOpenTag(tokens, cursor + 1, "details");
const nextInlineImageIndex = findNextInlineImageIndex(tokens, cursor + 1);
const end = Math.min(nextDetailsIndex ?? tokens.length, nextInlineImageIndex ?? tokens.length);
appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end)));
cursor = end;
}
@@ -270,44 +259,15 @@ function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number)
return false;
}
function findNextDisplayPartIndex(tokens: HtmlToken[], start: number): number | null {
function findNextInlineImageIndex(tokens: HtmlToken[], start: number): number | null {
for (let index = start; index < tokens.length; index += 1) {
if (
isOpenTag(tokens[index], "details") ||
findMixedHeadingClose(tokens, index) !== null ||
parseInlineImageAt(tokens, index)
) {
if (parseInlineImageAt(tokens, index)) {
return index;
}
}
return null;
}
function findMixedHeadingClose(tokens: HtmlToken[], start: number): number | null {
const token = tokens[start];
if (!isHeadingTag(token)) {
return null;
}
const closeIndex = findMatchingClose(tokens, start, token.name);
if (closeIndex === null) {
return null;
}
for (let index = start + 1; index < closeIndex; index += 1) {
if (parseInlineImageAt(tokens, index)) {
return closeIndex;
}
}
return null;
}
function appendDisplayPart(parts: MarkdownDisplayPart[], part: MarkdownDisplayPart): void {
if (part.kind === "markdown") {
appendMarkdownPart(parts, part.text);
} else {
parts.push(part);
}
}
function appendMarkdownPart(parts: MarkdownDisplayPart[], text: string): void {
if (!text) {
return;
@@ -95,11 +95,11 @@ function CustomModelRow({
return (
<View style={sheetStyles.modelRow}>
<Text style={[sheetStyles.modelTitle, sheetStyles.customModelText]} numberOfLines={1}>
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
{model.label}
</Text>
<Text
style={[sheetStyles.monoHint, sheetStyles.customModelText]}
style={sheetStyles.monoHint}
numberOfLines={1}
selectable
dataSet={CODE_SURFACE_DATASET}
@@ -762,7 +762,6 @@ const sheetStyles = StyleSheet.create((theme) => ({
iconButton: {
width: 28,
height: 28,
flexShrink: 0,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
@@ -808,10 +807,6 @@ const sheetStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
flexShrink: 0,
},
customModelText: {
flexShrink: 1,
minWidth: 0,
},
modelRowFiller: {
flex: 1,
},
@@ -30,7 +30,7 @@ import {
type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { density, densityIconBox } from "@/styles/density";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
@@ -93,7 +93,7 @@ import {
SidebarWorkspaceMenu,
} from "@/components/sidebar/sidebar-workspace-menu";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import {
@@ -2114,7 +2114,6 @@ function ProjectModeList({
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
}) {
const { t } = useTranslation();
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
@@ -2414,12 +2413,7 @@ function ProjectModeList({
<>
{pinnedChats.length > 0 ? (
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
<SidebarSectionHeader
title={t("sidebar.pinned.title")}
collapsed={pinnedCollapsed}
onToggle={togglePinnedCollapsed}
testID="sidebar-pinned-section-header"
/>
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
{pinnedCollapsed ? null : (
<>
<DraggableList
@@ -2556,12 +2550,12 @@ const styles = StyleSheet.create((theme) => ({
},
newWorkspaceGhostText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontSize: densityFontSize(theme, "base"),
minWidth: 0,
flexShrink: 1,
},
newWorkspaceGhostTextHovered: {
fontSize: theme.fontSize.base,
fontSize: densityFontSize(theme, "base"),
minWidth: 0,
flexShrink: 1,
color: theme.colors.foreground,
@@ -2609,7 +2603,7 @@ const styles = StyleSheet.create((theme) => ({
},
projectTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontSize: densityFontSize(theme, "base"),
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
@@ -0,0 +1,62 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight } from "lucide-react-native";
import { Pressable, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DensityIcon } from "@/components/ui/density-icon";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { density, densityFontSize } from "@/styles/density";
export function PinnedSectionHeader({
collapsed,
onToggle,
}: {
collapsed: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={onToggle}
style={styles.header}
testID="sidebar-pinned-section-header"
>
{({ hovered }) => (
<>
<Text style={styles.title}>{t("sidebar.pinned.title")}</Text>
{hovered || isNative || isCompact ? (
<DensityIcon
icon={collapsed ? ChevronRight : ChevronDown}
size="xs"
color="foregroundMuted"
/>
) : null}
</>
)}
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
header: {
minHeight: density.rowHeight,
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
userSelect: "none",
},
title: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "sm"),
fontWeight: theme.fontWeight.normal,
},
}));
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
import { densityIconBox, densityIconProps } from "@/styles/density";
import { density, densityIconBox, densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import {
@@ -24,9 +24,6 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
// Every surfaced status shares one badge shell, so the badge never changes size or position
// between states. Only the thing inside it changes.
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
// workspaceBranchText) so the icon centers on the title rather than floating above it.
const LEADING_SLOT_HEIGHT = 20;
const STATUS_BADGE_SIZE = 12;
// How far the badge overhangs the icon's bottom-right corner. Well short of half the badge:
// centered on the corner it reads as hanging off the icon rather than sitting on it, and the
@@ -276,10 +273,10 @@ const styles = StyleSheet.create((theme) => {
// The slot is as tall as the title's line box, not as tall as the icon, and centers the
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a slot
// only as tall as the icon puts it above the title and the kebab — which is why the
// workspace status indicator is as tall as `LEADING_SLOT_HEIGHT`.
// workspace status indicator shares the same `rowLineHeight`.
projectLeadingVisualSlot: {
width: densityIconBox("md"),
height: LEADING_SLOT_HEIGHT,
height: density.rowLineHeight,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
@@ -6,7 +6,7 @@ import { StyleSheet } from "react-native-unistyles";
import { isWeb } from "@/constants/platform";
import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content";
import { DensityIcon } from "@/components/ui/density-icon";
import { density, densityIconBox } from "@/styles/density";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
/**
* The row that ends a truncated group. It is a workspace row that happens to say "Show more", so
@@ -100,13 +100,13 @@ const styles = StyleSheet.create((theme) => ({
},
text: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontSize: densityFontSize(theme, "base"),
minWidth: 0,
flexShrink: 1,
},
textHovered: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontSize: densityFontSize(theme, "base"),
minWidth: 0,
flexShrink: 1,
},
@@ -4,7 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import { DensityIcon } from "@/components/ui/density-icon";
import { Shortcut } from "@/components/ui/shortcut";
import { density, densityIconBox } from "@/styles/density";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import type { ShortcutKey } from "@/utils/format-shortcut";
type SidebarHeaderRowVariant = "header" | "compact" | "inline";
@@ -152,7 +152,7 @@ const styles = StyleSheet.create((theme) => ({
iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") },
label: {
flexShrink: 1,
fontSize: theme.fontSize.base,
fontSize: densityFontSize(theme, "base"),
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
@@ -1,115 +0,0 @@
import { useMemo, type ReactNode } from "react";
import { ChevronDown, ChevronRight } from "lucide-react-native";
import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DensityIcon } from "@/components/ui/density-icon";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { density } from "@/styles/density";
/** A sidebar section label such as "Pinned" or "Workspaces". A section with `onToggle`
* collapses from its title; children sit at the trailing edge as its actions. */
export function SidebarSectionHeader({
title,
collapsed,
onToggle,
testID,
children,
}: {
title: string;
collapsed?: boolean;
onToggle?: () => void;
testID?: string;
children?: ReactNode;
}) {
return (
<View style={styles.header}>
{onToggle ? (
<CollapsibleTitle
title={title}
collapsed={collapsed ?? false}
onToggle={onToggle}
testID={testID}
/>
) : (
<Text style={styles.title} testID={testID}>
{title}
</Text>
)}
{children ? <View style={styles.actions}>{children}</View> : null}
</View>
);
}
function CollapsibleTitle({
title,
collapsed,
onToggle,
testID,
}: {
title: string;
collapsed: boolean;
onToggle: () => void;
testID?: string;
}) {
const isCompact = useIsCompactFormFactor();
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={onToggle}
style={styles.toggle}
testID={testID}
>
{({ hovered }) => (
<>
<Text style={styles.title}>{title}</Text>
{hovered || isNative || isCompact ? (
<DensityIcon
icon={collapsed ? ChevronRight : ChevronDown}
size="xs"
color="foregroundMuted"
/>
) : null}
</>
)}
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
header: {
minHeight: density.rowHeight,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
// The title lands at spacing[2] to align with the rows' leading icons. Trailing actions
// are icon buttons whose painted glyph stops inside the SVG box, so 4px aligns the ink
// rather than the box to the row rail.
paddingLeft: theme.spacing[2],
paddingRight: 4,
userSelect: "none",
},
// The toggle reaches back over the header's leading padding so the whole label edge is pressable.
toggle: {
alignSelf: "stretch",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
marginLeft: -theme.spacing[2],
paddingHorizontal: theme.spacing[2],
},
title: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
actions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
}));
@@ -28,7 +28,7 @@ import type { HostBadgeModel } from "@/hosts/appearance";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { StyleSheet } from "react-native-unistyles";
import { density, densityIconBox, densityIconProps } from "@/styles/density";
import { density, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { withUnistyles } from "react-native-unistyles";
import {
@@ -73,7 +73,7 @@ import {
SidebarWorkspaceContextMenu,
SidebarWorkspaceMenu,
} from "@/components/sidebar/sidebar-workspace-menu";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin";
@@ -146,7 +146,6 @@ export function SidebarStatusWorkspaceList({
parentGestureRef,
dragGestureHostActive,
}: StatusWorkspaceListProps) {
const { t } = useTranslation();
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedWorkspaceGroupKeys,
);
@@ -203,12 +202,7 @@ export function SidebarStatusWorkspaceList({
<>
{pinnedWorkspaces.length > 0 ? (
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
<SidebarSectionHeader
title={t("sidebar.pinned.title")}
collapsed={pinnedCollapsed}
onToggle={togglePinnedCollapsed}
testID="sidebar-pinned-section-header"
/>
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
{pinnedCollapsed ? null : (
<>
<DraggableList
@@ -1105,7 +1099,7 @@ const styles = StyleSheet.create((theme) => ({
},
statusGroupTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
fontSize: densityFontSize(theme, "base"),
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
@@ -16,7 +16,7 @@ import {
type SidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
import { useAppSettings } from "@/hooks/use-settings";
import { densityIconBox } from "@/styles/density";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color";
@@ -453,9 +453,6 @@ export function SidebarWorkspaceTrailingActionOverlay({
);
}
// The status dot centres on the title's first line, so both share its line height.
const TITLE_LINE_HEIGHT = 20;
const styles = StyleSheet.create((theme) => ({
workspaceRowContent: {
position: "relative",
@@ -484,7 +481,7 @@ const styles = StyleSheet.create((theme) => ({
workspaceStatusDot: {
position: "relative",
width: densityIconBox("md"),
height: TITLE_LINE_HEIGHT,
height: density.rowLineHeight,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
alignItems: "center",
@@ -516,9 +513,9 @@ const styles = StyleSheet.create((theme) => ({
// to the meta row, so it takes the full width the trailing slot leaves behind.
workspaceBranchText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontSize: densityFontSize(theme, "base"),
fontWeight: "400",
lineHeight: TITLE_LINE_HEIGHT,
lineHeight: density.rowLineHeight,
opacity: 0.76,
flex: 1,
minWidth: 0,
@@ -154,4 +154,3 @@ export function useIsolatedBottomSheetVisibility({
}
export { SheetVisibleFrame } from "./visible-frame";
export { systemBackPress } from "./back-press";
@@ -104,13 +104,6 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
});
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
},
setSelection: (selection) => {
if (inputRef.current?.setSelection) {
inputRef.current.setSelection(selection.start, selection.end);
return;
}
inputRef.current?.setNativeProps?.({ selection });
},
reset: () => {
textRef.current = "";
const autoFocus = inputRef.current?.isFocused?.() ?? false;
@@ -77,10 +77,6 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
input.setSelectionRange(selection.start, selection.end);
}
},
setSelection: (selection) => {
const input = inputRef.current as WebTextInputElement | null;
input?.setSelectionRange?.(selection.start, selection.end);
},
reset: () => {
textRef.current = "";
const input = inputRef.current as WebTextInputElement | null;
@@ -8,7 +8,6 @@ export interface EditingTextInputHandle {
isFocused(): boolean;
getText(): string;
replaceText(text: string, selection?: { start: number; end: number }): void;
setSelection(selection: { start: number; end: number }): void;
/** Clear the editor and reset its intrinsic layout, preserving focus intent. */
reset(): void;
getNativeRef(): unknown;
+19 -75
View File
@@ -1,6 +1,5 @@
import { ComposerDockBackground } from "./internal/background";
export { ComposerDockBackground } from "./internal/background";
export { ComposerFullscreenBody, ComposerFullscreenFooter } from "./internal/fullscreen";
import { ScrollView } from "@/components/ui/scroll-view";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -9,22 +8,22 @@ import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings";
import { KeyboardTranslateView } from "@/keyboard/shift";
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native";
import { StyleSheet as ThemedStyleSheet } from "react-native-unistyles";
import Animated, {
useAnimatedStyle,
useSharedValue,
useDerivedValue,
useAnimatedReaction,
type SharedValue,
} from "react-native-reanimated";
import { useKeyboardShift } from "@/keyboard/shift";
import {
resolveCenteredClearance,
resolveComposerSlotCapacity,
resolveInlineComposerCapacity,
updateComposerCapacity,
type ComposerCapacity,
} from "./internal/capacity";
const ViewportSizing = createContext<SharedValue<ComposerCapacity | undefined> | null>(null);
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
interface ComposerViewportProps extends ViewProps {
bottomInset?: number;
@@ -56,6 +55,7 @@ function ComposerViewport({
},
[],
);
const capacity = useDerivedValue(() => sizing.value?.capacity);
const measureViewport = useCallback(
(event: LayoutChangeEvent) => {
measuredHeight.value = event.nativeEvent.layout.height;
@@ -66,24 +66,20 @@ function ComposerViewport({
return (
<View testID="composer-viewport" {...props} onLayout={measureViewport} collapsable={false}>
<ViewportSizing.Provider value={sizing}>{children}</ViewportSizing.Provider>
<ViewportCapacity.Provider value={capacity}>{children}</ViewportCapacity.Provider>
</View>
);
}
/** Bound the complete composer (including its controls), not just the text input. */
function ComposerViewportContent({
style,
fullscreen = false,
...props
}: ViewProps & { fullscreen?: boolean }) {
function ComposerViewportContent({ style, ...props }: ViewProps) {
const compact = useIsCompactFormFactor();
const sizing = useContext(ViewportSizing);
if (!sizing) throw new Error("ComposerViewportContent requires ComposerViewport");
const capacity = useContext(ViewportCapacity);
if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport");
const constraint = useAnimatedStyle(() => ({
// Only viewport changes and keyboard start/end events trigger layout. Motion
// stays in KeyboardTranslateView and never changes this constraint per frame.
maxHeight: resolveComposerSlotCapacity(sizing.value, { compact, fullscreen }),
maxHeight: resolveInlineComposerCapacity(capacity.value, compact),
}));
return (
@@ -112,71 +108,37 @@ function useCenteredClearance(safeAreaBottom: number) {
interface ComposerDockProps {
children: [ReactNode, ReactNode, ReactNode?];
centered?: boolean;
/**
* Fullscreen is a layout mode of the same mounted composer: the slot fills the
* stationary viewport over the content and lays out the keyboard itself, so the
* surface stops translating. The screen that owns the chrome hides it.
*/
fullscreen?: boolean;
}
/** The stationary viewport, translated surface, and bounded composer are one owner. */
export function ComposerDock({
children: [content, composer, overlay],
centered = false,
fullscreen = false,
}: ComposerDockProps) {
const insets = useSafeAreaInsets();
const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings);
const centeredClearance = useCenteredClearance(insets.bottom);
// Preserve the existing centered form's visual balance on tablets.
const bottomInset = centered && !fullscreen ? HEADER_INNER_HEIGHT + 24 : 0;
const fullscreenSurface = [
themedStyles.fullscreenComposer,
{
paddingTop: insets.top,
paddingBottom: insets.bottom,
paddingLeft: insets.left,
paddingRight: insets.right,
},
];
const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0;
if (centered) {
// Same tree in both modes. Fullscreen stops centering and translating, hides the
// setup fields, and lets the composer slot take the whole stationary viewport.
return (
<ComposerViewport
style={
fullscreen
? dockStyles.viewport
: [dockStyles.centeredViewport, { paddingBottom: bottomInset }]
}
style={[dockStyles.centeredViewport, { paddingBottom: bottomInset }]}
bottomInset={bottomInset}
centered
onLayout={centeredClearance.measureViewport}
>
<KeyboardTranslateView
style={
fullscreen ? dockStyles.surface : [dockStyles.centered, { maxWidth: contentMaxWidth }]
}
enabled={!fullscreen}
style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}
bottomClearance={centeredClearance.value}
onLayout={centeredClearance.measureForm}
>
<ComposerViewportContent
style={fullscreen ? dockStyles.surface : dockStyles.composer}
fullscreen={fullscreen}
>
<ScrollView
style={fullscreen ? dockStyles.hiddenSetup : dockStyles.setup}
keyboardShouldPersistTaps="handled"
>
<ComposerViewportContent style={dockStyles.composer}>
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
{content}
</ScrollView>
<ComposerViewportContent
style={fullscreen ? dockStyles.surface : dockStyles.centeredComposer}
fullscreen={fullscreen}
>
<View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View>
<ComposerViewportContent style={dockStyles.centeredComposer}>
{composer}
</ComposerViewportContent>
</ComposerViewportContent>
{overlay}
@@ -186,7 +148,7 @@ export function ComposerDock({
}
return (
<ComposerViewport style={dockStyles.viewport}>
<KeyboardTranslateView style={dockStyles.surface} enabled={!fullscreen}>
<KeyboardTranslateView style={dockStyles.surface}>
<View
testID="composer-dock-content"
collapsable={false}
@@ -200,19 +162,8 @@ export function ComposerDock({
{content}
</View>
</View>
<ComposerViewportContent
style={fullscreen ? dockStyles.fullscreenSlot : dockStyles.composer}
fullscreen={fullscreen}
>
<View
style={
fullscreen
? fullscreenSurface
: [dockStyles.composer, { paddingBottom: insets.bottom }]
}
>
{composer}
</View>
<ComposerViewportContent style={dockStyles.composer}>
<View style={[dockStyles.composer, { paddingBottom: insets.bottom }]}>{composer}</View>
</ComposerViewportContent>
{overlay}
</KeyboardTranslateView>
@@ -227,16 +178,9 @@ const dockStyles = StyleSheet.create({
surface: { flex: 1 },
content: { flex: 1, justifyContent: "flex-end" },
composer: { width: "100%", flexShrink: 1 },
fullscreenSlot: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0 },
centeredViewport: { flex: 1, alignItems: "center", justifyContent: "center" },
centered: { flexShrink: 1, width: "100%" },
// Reserve the composer's own capped height before the setup scroll view shrinks.
centeredComposer: { width: "100%", flexShrink: 0 },
setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
hiddenSetup: { display: "none" },
});
// The fullscreen composer paints its own surface, including under the safe areas.
const themedStyles = ThemedStyleSheet.create((theme) => ({
fullscreenComposer: { flex: 1, backgroundColor: theme.colors.surface1 },
}));
+10 -35
View File
@@ -1,69 +1,48 @@
export { ComposerDockBackground } from "./internal/background";
export { ComposerFullscreenBody, ComposerFullscreenFooter } from "./internal/fullscreen";
import { ScrollView } from "@/components/ui/scroll-view";
import { useState, useCallback, useMemo, useRef, type ReactNode } from "react";
import { useState, useCallback, useMemo, type ReactNode } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { resolveInlineComposerCapacity } from "./internal/capacity";
import { useFullscreenSlotFrame } from "./internal/fullscreen";
import { useIsCompactFormFactor } from "@/constants/layout";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
interface ComposerDockProps {
children: [ReactNode, ReactNode, ReactNode?];
centered?: boolean;
/** See the native dock: the same mounted composer fills the visible surface. */
fullscreen?: boolean;
}
export function ComposerDock({
children: [content, composer, overlay],
centered = false,
fullscreen = false,
}: ComposerDockProps) {
const compact = useIsCompactFormFactor();
const [surfaceSize, setSurfaceSize] = useState<{ width: number; height: number }>();
const surfaceRef = useRef<View>(null);
const fullscreenFrame = useFullscreenSlotFrame(fullscreen, surfaceRef, surfaceSize);
const [availableHeight, setAvailableHeight] = useState<number>();
const constraint = useMemo(
() => ({ maxHeight: resolveInlineComposerCapacity(surfaceSize?.height, compact) }),
[surfaceSize?.height, compact],
() => ({ maxHeight: resolveInlineComposerCapacity(availableHeight, compact) }),
[availableHeight, compact],
);
const measureViewport = useCallback((event: import("react-native").LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
if (height > 0) setSurfaceSize({ width, height });
const { height } = event.nativeEvent.layout;
if (height > 0) setAvailableHeight(height);
}, []);
if (centered) {
return (
<View ref={surfaceRef} style={styles.centered} onLayout={measureViewport}>
<View style={styles.centered} onLayout={measureViewport}>
<View style={styles.form}>
<ScrollView style={styles.setup} keyboardShouldPersistTaps="handled">
{content}
</ScrollView>
<View
style={
fullscreen
? [styles.fullscreenComposer, fullscreenFrame]
: [styles.centeredComposer, constraint]
}
>
{composer}
</View>
<View style={[styles.centeredComposer, constraint]}>{composer}</View>
{overlay}
</View>
</View>
);
}
return (
<View ref={surfaceRef} style={styles.surface} onLayout={measureViewport}>
<View style={styles.surface} onLayout={measureViewport}>
<View style={styles.content}>{content}</View>
<View
style={
fullscreen ? [styles.fullscreenComposer, fullscreenFrame] : [styles.composer, constraint]
}
>
{composer}
</View>
<View style={[styles.composer, constraint]}>{composer}</View>
{overlay}
</View>
);
@@ -73,10 +52,6 @@ const styles = StyleSheet.create((theme) => ({
surface: { flex: 1, overflow: "hidden" },
content: { flex: 1, justifyContent: "flex-end" },
composer: { width: "100%", flexShrink: 1 },
// Fixed, not absolute: a touch pan that starts inside an overflow-hidden ancestor
// never reaches the zoomed visual viewport, while a fixed box chains to the viewport
// directly. React Native Web renders `fixed`; React Native's style type omits it.
fullscreenComposer: { position: "fixed" as "absolute" },
centered: {
flex: 1,
alignItems: "center",
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
resolveCenteredClearance,
resolveComposerSlotCapacity,
resolveInlineComposerCapacity,
resolveComposerCapacity,
updateComposerCapacity,
@@ -20,20 +19,6 @@ describe("composer viewport", () => {
expect(resolveInlineComposerCapacity(open.capacity, false)).toBe(245);
expect(resolveInlineComposerCapacity(undefined, true)).toBeUndefined();
});
it("lets a fullscreen composer fill the viewport while inline keeps its cap", () => {
const open = updateComposerCapacity(undefined, {
height: 582,
bottomInset: 24,
safeAreaBottom: 0,
centered: false,
keyboardShift: 308,
});
expect(resolveComposerSlotCapacity(open, { compact: true, fullscreen: true })).toBe(582);
expect(resolveComposerSlotCapacity(open, { compact: true, fullscreen: false })).toBe(147);
expect(resolveComposerSlotCapacity(undefined, { compact: true, fullscreen: true })).toBe(
undefined,
);
});
it("preserves the editing capacity when the keyboard closes", () => {
const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false };
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
@@ -10,8 +10,6 @@ interface ComposerGeometry {
export interface ComposerCapacity {
keyboardReserve: number;
capacity: number;
/** The stationary viewport; a fullscreen composer fills it and lays out the keyboard itself. */
viewportHeight: number;
}
export function resolveComposerCapacity(input: ComposerGeometry): number {
@@ -50,7 +48,6 @@ export function updateComposerCapacity(
return {
keyboardReserve,
capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }),
viewportHeight: input.height,
};
}
@@ -63,19 +60,3 @@ export function resolveInlineComposerCapacity(
if (capacity === undefined) return undefined;
return compact ? capacity * 0.6 : capacity;
}
export interface ComposerSlotLayout {
compact: boolean;
fullscreen: boolean;
}
/** The bound on the composer slot: inline capacity, or the whole viewport in fullscreen. */
export function resolveComposerSlotCapacity(
sizing: ComposerCapacity | undefined,
layout: ComposerSlotLayout,
): number | undefined {
"worklet";
if (sizing === undefined) return undefined;
if (layout.fullscreen) return sizing.viewportHeight;
return resolveInlineComposerCapacity(sizing.capacity, layout.compact);
}
@@ -1,39 +0,0 @@
import type { ReactNode } from "react";
import { StyleSheet, type ViewProps } from "react-native";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { KeyboardTranslateView, useKeyboardShift } from "@/keyboard/shift";
/**
* The editing region of a composer. Inline it only continues the shrink chain. In
* fullscreen it fills the stationary frame and reserves the keyboard's space at
* motion boundaries, the same boundary-time layout the dock capacity uses: the
* keyboard start/end events write `layoutShift`; frames in between move the footer.
*/
export function ComposerFullscreenBody({
fullscreen,
children,
}: {
fullscreen: boolean;
children: ReactNode;
}) {
const { layoutShift } = useKeyboardShift();
const reservation = useAnimatedStyle(() => ({
marginBottom: fullscreen ? layoutShift.value : 0,
}));
return (
<Animated.View style={[fullscreen ? styles.fullscreen : styles.inline, reservation]}>
{children}
</Animated.View>
);
}
/** The Send row of a fullscreen composer rides the keyboard like the inline dock surface. */
export function ComposerFullscreenFooter({ children, ...props }: ViewProps) {
return <KeyboardTranslateView {...props}>{children}</KeyboardTranslateView>;
}
// Plain React Native styles: these land on Reanimated views (docs/unistyles.md).
const styles = StyleSheet.create({
inline: { flexShrink: 1 },
fullscreen: { flex: 1, minHeight: 0 },
});
@@ -1,89 +0,0 @@
import { useLayoutEffect, useState, type ReactNode, type RefObject } from "react";
import { StyleSheet, View, type ViewProps } from "react-native";
/** The editing region of a composer; see the native file for the keyboard contract. */
export function ComposerFullscreenBody({
fullscreen,
children,
}: {
fullscreen: boolean;
children: ReactNode;
}) {
return <View style={fullscreen ? styles.fullscreen : styles.inline}>{children}</View>;
}
export function ComposerFullscreenFooter({ children, ...props }: ViewProps) {
return <View {...props}>{children}</View>;
}
export interface ComposerSlotFrame {
top: number;
left: number;
width: number;
height: number;
}
/**
* Where a fullscreen composer sits, in layout-viewport coordinates for a fixed box: the
* part of the dock surface the browser's visible viewport shows. A mobile browser
* keyboard shrinks or pans that viewport without resizing the page, so the frame
* follows it instead of the layout.
*/
export function useFullscreenSlotFrame(
active: boolean,
surfaceRef: RefObject<View | null>,
surfaceSize: { width: number; height: number } | undefined,
): ComposerSlotFrame | undefined {
const [frame, setFrame] = useState<ComposerSlotFrame>();
useLayoutEffect(() => {
if (!active) {
setFrame(undefined);
return;
}
const update = () => {
const surface = surfaceRef.current as unknown as HTMLElement | null;
if (!surface) return;
setFrame(resolveVisibleSurfaceFrame(surface.getBoundingClientRect(), readVisibleViewport()));
};
const visualViewport = window.visualViewport;
update();
visualViewport?.addEventListener("resize", update);
visualViewport?.addEventListener("scroll", update);
window.addEventListener("resize", update);
return () => {
visualViewport?.removeEventListener("resize", update);
visualViewport?.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, [active, surfaceRef, surfaceSize]);
return frame;
}
function readVisibleViewport() {
const viewport = window.visualViewport;
return {
top: viewport?.offsetTop ?? 0,
left: viewport?.offsetLeft ?? 0,
width: viewport?.width ?? window.innerWidth,
height: viewport?.height ?? window.innerHeight,
};
}
function resolveVisibleSurfaceFrame(
surface: { top: number; left: number; right: number; bottom: number },
viewport: { top: number; left: number; width: number; height: number },
): ComposerSlotFrame {
const top = Math.max(viewport.top, surface.top);
const left = Math.max(viewport.left, surface.left);
return {
top,
left,
width: Math.max(0, Math.min(viewport.left + viewport.width, surface.right) - left),
height: Math.max(0, Math.min(viewport.top + viewport.height, surface.bottom) - top),
};
}
const styles = StyleSheet.create({
inline: { flexShrink: 1 },
fullscreen: { flex: 1, minHeight: 0 },
});
@@ -210,8 +210,6 @@ describe("useAgentInputDraft live contract", () => {
});
const hydratedTextReplacement = getLatest().textReplacement;
// The editor applies a retained caret to the hydrated text, so hydration announces itself.
expect(hydratedTextReplacement.kind).toBe("hydrated");
await act(async () => {
getLatest().editText("hello world");
@@ -225,7 +223,6 @@ describe("useAgentInputDraft live contract", () => {
});
expect(getLatest().textReplacement).not.toBe(hydratedTextReplacement);
expect(getLatest().textReplacement.kind).toBe("replace");
expect(getLatest().textReplacement.text).toBe("replacement text");
await act(async () => {
@@ -123,10 +123,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
}));
const publishTextReplacement = useCallback(
(kind: "hydrated" | "replace", nextText: string) => {
(nextText: string) => {
textReplacementRevisionRef.current += 1;
setTextReplacement({
kind,
kind: "replace",
key: `${draftKey}:${textReplacementRevisionRef.current}`,
text: nextText,
});
@@ -176,7 +176,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
(nextText: string) => {
textPublication.cancel();
useDraftStore.getState().editDraftText({ draftKey, text: nextText });
publishTextReplacement("replace", nextText);
publishTextReplacement(nextText);
},
[draftKey, publishTextReplacement, textPublication],
);
@@ -229,7 +229,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
await useDraftStore.getState().hydrateDraftInput({ draftKey });
if (!cancelled) {
const hydratedText = useDraftStore.getState().getDraftInput(draftKey)?.text ?? "";
publishTextReplacement("hydrated", hydratedText);
publishTextReplacement(hydratedText);
setHydratedDraftKey(draftKey);
}
})();
@@ -6,7 +6,6 @@ import { ComposerDock } from "@/composer/dock";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import invariant from "tiny-invariant";
import { Composer } from "@/composer";
import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { ComposerImportPill } from "@/composer/draft/import-pill";
import { COMPOSER_PILL_CLEARANCE } from "@/composer/pill-styles";
@@ -386,8 +385,6 @@ export function WorkspaceDraftAgentTab({
}, [pendingCreateAttempt]);
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
const isCompactFormFactor = useIsCompactFormFactor();
const editingSession = useComposerEditingSession("chat");
const isComposerFullscreen = useComposerFullscreen(editingSession);
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
COMPACT_FORM_FACTOR_WIDTH,
{ initialIsBelow: isCompactFormFactor },
@@ -631,10 +628,10 @@ export function WorkspaceDraftAgentTab({
return (
<FileDropZone style={styles.container}>
<ComposerDock fullscreen={isComposerFullscreen}>
<ComposerDock>
{dockContent}
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
{importPillPress && !isComposerFullscreen ? (
{importPillPress ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
<ComposerImportPill onPress={importPillPress} />
@@ -658,7 +655,6 @@ export function WorkspaceDraftAgentTab({
onChangeAttachments={draftInput.setAttachments}
cwd={composerState.workingDir}
clearDraft={draftInput.clear}
editingSession={editingSession}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
onFocusInput={handleFocusInputCallback}
@@ -676,8 +672,6 @@ const animatedStaticStyles = RNStyleSheet.create({
inputAreaWrapper: {
width: "100%",
flexShrink: 1,
// Content-sized inline; fills the dock's fullscreen slot.
flexGrow: 1,
},
});
@@ -1,61 +1,52 @@
import { describe, expect, it } from "vitest";
import { ComposerEditingSession, ComposerEditingSessions } from "./index";
import { ComposerEditingSession } from "./index";
describe("composer editing session", () => {
it("publishes fullscreen changes once per transition", () => {
function leaveLiveDraft(session: ComposerEditingSession) {
session.captureForRemount({
snapshot: { text: "Unpublished live draft", selection: { start: 4, end: 17 } },
focused: true,
replacementKey: "draft:applied",
});
}
describe("composer editing remount handoff", () => {
it("keeps live text and selection when remounting from an initial draft publication", () => {
const session = new ComposerEditingSession();
const changes: boolean[] = [];
session.subscribe(() => changes.push(session.getFullscreen()));
session.setFullscreen(true);
session.setFullscreen(true);
session.setFullscreen(false);
expect(changes).toEqual([true, false]);
leaveLiveDraft(session);
const handoff = session.takeHandoff({ kind: "initial", key: "draft:initial", text: "stale" });
expect(handoff?.snapshot).toEqual({
text: "Unpublished live draft",
selection: { start: 4, end: 17 },
});
expect(handoff?.focused).toBe(true);
});
it("hands the retained caret and focus intent to exactly one later mount", () => {
it("keeps local edits when the last applied replacement is still current", () => {
const session = new ComposerEditingSession();
expect(session.takeEditor()).toBeNull();
session.retainEditor({ selection: { start: 4, end: 17 }, focused: true });
expect(session.takeEditor()).toEqual({ selection: { start: 4, end: 17 }, focused: true });
expect(session.takeEditor()).toBeNull();
});
});
describe("composer editing sessions", () => {
const workspace = "server:workspace";
const target = JSON.stringify({ kind: "agent", agentId: "a" });
it("keeps one session per tab and purpose until the tab target changes", () => {
const sessions = new ComposerEditingSessions();
const chat = sessions.get(workspace, "tab-1", target, "chat");
expect(sessions.get(workspace, "tab-1", target, "chat")).toBe(chat);
expect(sessions.get(workspace, "tab-1", target, "terminal")).not.toBe(chat);
const retargeted = JSON.stringify({ kind: "agent", agentId: "b" });
expect(sessions.get(workspace, "tab-1", retargeted, "chat")).not.toBe(chat);
});
it("reports a tab as fullscreen while any of its composers is fullscreen", () => {
const sessions = new ComposerEditingSessions();
const notifications: boolean[] = [];
sessions.subscribe(() => notifications.push(sessions.isTabFullscreen(workspace, "tab-1")));
const chat = sessions.get(workspace, "tab-1", target, "chat");
sessions.get(workspace, "tab-2", target, "chat").setFullscreen(true);
expect(sessions.isTabFullscreen(workspace, "tab-1")).toBe(false);
expect(sessions.isTabFullscreen(workspace, null)).toBe(false);
chat.setFullscreen(true);
expect(sessions.isTabFullscreen(workspace, "tab-1")).toBe(true);
chat.setFullscreen(false);
expect(notifications).toEqual([false, true, false]);
});
it("drops sessions for closed tabs and keeps retained ones", () => {
const sessions = new ComposerEditingSessions();
const kept = sessions.get(workspace, "tab-1", target, "chat");
kept.setFullscreen(true);
sessions.get(workspace, "tab-2", target, "chat").setFullscreen(true);
sessions.retain(workspace, ["tab-1"]);
expect(sessions.get(workspace, "tab-1", target, "chat")).toBe(kept);
expect(sessions.isTabFullscreen(workspace, "tab-2")).toBe(false);
expect(sessions.get(workspace, "tab-2", target, "chat").getFullscreen()).toBe(false);
leaveLiveDraft(session);
const handoff = session.takeHandoff({
kind: "replace",
key: "draft:applied",
text: "old replacement",
});
expect(handoff?.snapshot).toEqual({
text: "Unpublished live draft",
selection: { start: 4, end: 17 },
});
});
it.each(["New explicit draft", ""])(
"honors an explicit replacement made while the editor is absent: %j",
(text) => {
const session = new ComposerEditingSession();
leaveLiveDraft(session);
const handoff = session.takeHandoff({ kind: "replace", key: "draft:new", text });
expect(handoff?.snapshot).toEqual({
text,
selection: { start: text.length, end: text.length },
});
expect(handoff?.focused).toBe(true);
expect(session.takeHandoff({ kind: "replace", key: "draft:new", text })).toBeNull();
},
);
});
@@ -1,6 +1,5 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
@@ -8,72 +7,62 @@ import React, {
useSyncExternalStore,
type ReactNode,
} from "react";
import type { TextReplacement } from "@/composer/types";
import type { ComposerInputMode } from "@/composer/input-mode";
export interface ComposerSelection {
start: number;
end: number;
}
/** What an unmounting editor leaves for the next mount of the same session. */
export interface ComposerRetainedEditor {
selection: ComposerSelection;
/** The editor was focused, or meant to regain focus once its pane became active. */
export interface ComposerEditingHandoff {
snapshot: { text: string; selection: { start: number; end: number } };
focused: boolean;
replacementKey: string;
}
/**
* Editing state that outlives the editor's mount. Draft text belongs to its draft
* owner; the editor keeps its own live buffer across layout modes. Switching between
* the compact and desktop screens remounts the editor, so the session carries the
* caret and focus intent across that remount while the draft owner carries the text.
*/
/** Editing metadata outlives a layout's input; draft text still belongs to its draft owner. */
export class ComposerEditingSession {
private fullscreen = false;
private retained: ComposerRetainedEditor | null = null;
private handoff: ComposerEditingHandoff | null = null;
private listeners = new Set<() => void>();
constructor(private readonly onChange?: () => void) {}
getFullscreen = () => this.fullscreen;
/** Called by the unmounting editor; the next mount of this session takes it. */
retainEditor(retained: ComposerRetainedEditor) {
this.retained = retained;
}
takeEditor(): ComposerRetainedEditor | null {
const retained = this.retained;
this.retained = null;
return retained;
}
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
setFullscreen(fullscreen: boolean) {
changePresentation(fullscreen: boolean, handoff: ComposerEditingHandoff) {
this.captureForRemount(handoff);
if (fullscreen === this.fullscreen) return;
this.fullscreen = fullscreen;
for (const listener of this.listeners) listener();
this.onChange?.();
}
captureForRemount(handoff: ComposerEditingHandoff) {
this.handoff = handoff;
}
peekHandoff(replacement: TextReplacement): ComposerEditingHandoff | null {
const handoff = this.handoff;
if (!handoff || replacement.kind === "initial" || replacement.key === handoff.replacementKey)
return handoff;
// A replacement command issued since capture supersedes the old editor's live buffer.
return {
snapshot: {
text: replacement.text,
selection: { start: replacement.text.length, end: replacement.text.length },
},
focused: handoff.focused,
replacementKey: replacement.key,
};
}
takeHandoff(replacement: TextReplacement) {
const handoff = this.peekHandoff(replacement);
this.handoff = null;
return handoff;
}
}
interface TabSessions {
targetKey: string;
sessions: Map<ComposerInputMode, ComposerEditingSession>;
}
export class ComposerEditingSessions {
private workspaces = new Map<string, Map<string, TabSessions>>();
private listeners = new Set<() => void>();
private notify = () => {
for (const listener of this.listeners) listener();
};
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
class ComposerEditingSessions {
private workspaces = new Map<
string,
Map<string, { targetKey: string; sessions: Map<ComposerInputMode, ComposerEditingSession> }>
>();
get(workspaceKey: string, tabId: string, targetKey: string, purpose: ComposerInputMode) {
let workspace = this.workspaces.get(workspaceKey);
if (!workspace) {
@@ -87,31 +76,17 @@ export class ComposerEditingSessions {
}
let session = tab.sessions.get(purpose);
if (!session) {
session = new ComposerEditingSession(this.notify);
session = new ComposerEditingSession();
tab.sessions.set(purpose, session);
}
return session;
}
/** A tab is fullscreen when any of its composers is; the screen hides its chrome for it. */
isTabFullscreen(workspaceKey: string, tabId: string | null): boolean {
if (tabId === null) return false;
const tab = this.workspaces.get(workspaceKey)?.get(tabId);
if (!tab) return false;
for (const session of tab.sessions.values()) if (session.getFullscreen()) return true;
return false;
}
retain(workspaceKey: string, tabIds: readonly string[]) {
const workspace = this.workspaces.get(workspaceKey);
if (!workspace) return;
const retained = new Set(tabIds);
let changed = false;
for (const tabId of workspace.keys()) {
if (retained.has(tabId)) continue;
workspace.delete(tabId);
changed = true;
}
for (const tabId of workspace.keys()) if (!retained.has(tabId)) workspace.delete(tabId);
if (workspace.size === 0) this.workspaces.delete(workspaceKey);
if (changed) this.notify();
}
}
@@ -165,16 +140,3 @@ export function useComposerEditingSession(
export function useComposerFullscreen(session: ComposerEditingSession): boolean {
return useSyncExternalStore(session.subscribe, session.getFullscreen, session.getFullscreen);
}
/** Whether the composer of a workspace tab is fullscreen, for the chrome that tab sits under. */
export function useComposerTabFullscreen(workspaceKey: string, tabId: string | null): boolean {
const sessions = useContext(SessionsContext);
const subscribe = useCallback(
(listener: () => void) => sessions?.subscribe(listener) ?? (() => {}),
[sessions],
);
const read = useCallback(
() => sessions?.isTabFullscreen(workspaceKey, tabId) ?? false,
[sessions, workspaceKey, tabId],
);
return useSyncExternalStore(subscribe, read, read);
}
+9 -63
View File
@@ -1,8 +1,4 @@
import {
useComposerEditingSession,
useComposerFullscreen,
type ComposerEditingSession,
} from "./editing-session";
import { useComposerEditingSession, useComposerFullscreen } from "./editing-session";
import type { ComposerTextSource } from "./text-source";
import { createStore, type StoreApi } from "zustand/vanilla";
import { useStore } from "zustand";
@@ -31,7 +27,6 @@ import {
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store";
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
import { useShallow } from "zustand/shallow";
import {
@@ -972,12 +967,6 @@ interface ComposerProps {
submitLabel?: string;
/** Overrides the mode's default placeholder, for text only the caller can build. */
placeholder?: string;
/**
* The host that also lays out the dock and chrome shares this session so its
* fullscreen state and the composer's agree. Defaults to the tab's session for
* `inputMode`.
*/
editingSession?: ComposerEditingSession;
}
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
@@ -1193,14 +1182,8 @@ function ComposerVoiceModeButton({
}
export function Composer({ isPaneFocused, ...props }: ComposerProps) {
// A compact overlay panel (agent list, explorer) covers the pane without taking its
// focus. The composer gives up input from the moment the user asks for the panel,
// not when its motion settles, so the exit action and autocomplete never race it.
const isCompact = useIsCompactFormFactor();
const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent");
const coveredByOverlayPanel = isCompact && overlayPanelRequested;
return (
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused && !coveredByOverlayPanel}>
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
<RenderProfile id="ComposerContent">
<ComposerContent {...props} />
</RenderProfile>
@@ -1251,7 +1234,6 @@ function ComposerContentImpl({
inputWrapperStyle,
isCompactLayout: isCompactLayoutOverride,
inputMode = "chat",
editingSession: providedEditingSession,
readOnly = false,
submitLabel,
placeholder,
@@ -1357,7 +1339,7 @@ function ComposerContentImpl({
const nextPendingFileId = useRef(0);
const isUploadingFile = pendingFiles.length > 0;
const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0);
const editingSession = useComposerEditingSession(inputMode, providedEditingSession);
const editingSession = useComposerEditingSession(inputMode);
const isFullscreen = useComposerFullscreen(editingSession);
const [sendError, setSendError] = useState<string | null>(null);
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
@@ -2268,23 +2250,9 @@ function ComposerContentImpl({
[githubSearchItems, selectedAttachments, handleToggleGithubItem],
);
// Fullscreen fills the dock slot: the chain down to the input stretches and drops
// the inline padding, which the fullscreen presentation supplies itself.
const inputAreaContainerStyle = useMemo(
() => [
styles.inputAreaContainer,
isComposerLocked && styles.inputAreaLocked,
isFullscreen && styles.inputAreaFullscreen,
],
[isComposerLocked, isFullscreen],
);
const inputAreaContentStyle = useMemo(
() => [styles.inputAreaContent, isFullscreen && styles.fullscreenFill],
[isFullscreen],
);
const messageInputContainerStyle = useMemo(
() => [styles.messageInputContainer, isFullscreen && styles.fullscreenFill],
[isFullscreen],
() => [styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked],
[isComposerLocked],
);
const attachmentTray = useMemo(
@@ -2395,19 +2363,15 @@ function ComposerContentImpl({
focusMessageInputForKeyboardAction={focusMessageInputForKeyboardAction}
isMessageInputFocused={isMessageInputFocused}
/>
<View
style={
isFullscreen ? animatedStaticStyles.fullscreenContainer : animatedStaticStyles.container
}
>
<View style={animatedStaticStyles.container}>
<AttachmentLightbox source={lightboxSource} onClose={handleLightboxClose} />
{/* Input area */}
<View style={inputAreaContainerStyle}>
<View style={inputAreaContentStyle}>
{isFullscreen ? null : queueList}
<View style={styles.inputAreaContent}>
{queueList}
{!isFullscreen && sendErrorNode}
<View ref={messageInputContainerRef} style={messageInputContainerStyle}>
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<ComposerAutocompleteBinding
text={textSource}
cursor={cursor}
@@ -2511,12 +2475,6 @@ const animatedStaticStyles = RNStyleSheet.create({
flexShrink: 1,
position: "relative",
},
fullscreenContainer: {
flexDirection: "column",
flex: 1,
minHeight: 0,
position: "relative",
},
});
const styles = StyleSheet.create((theme: Theme) => ({
@@ -2538,18 +2496,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
inputAreaLocked: {
opacity: 0.6,
},
inputAreaFullscreen: {
flex: 1,
minHeight: 0,
alignItems: "stretch",
paddingHorizontal: 0,
paddingBottom: 0,
},
fullscreenFill: {
flex: 1,
minHeight: 0,
maxWidth: "100%",
},
inputAreaContent: {
flexShrink: 1,
width: "100%",
@@ -1,18 +0,0 @@
import { useEffect } from "react";
import { BackHandler } from "react-native";
/** The hardware back button leaves fullscreen instead of the screen. */
export function useComposerFullscreenExit(
active: boolean,
onExit: () => void,
): ((node: unknown) => void) | undefined {
useEffect(() => {
if (!active) return;
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
onExit();
return true;
});
return () => subscription.remove();
}, [active, onExit]);
return undefined;
}
@@ -1,23 +0,0 @@
import { useOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root";
/**
* Escape leaves fullscreen before app shortcuts see it. The registration only claims
* keys; focus stays where the user left it because the editor never remounts.
*/
export function useComposerFullscreenExit(
active: boolean,
onExit: () => void,
): ((node: unknown) => void) | undefined {
const layer = useOverlayLayer("modal");
return useWebOverlayRegistration({
active,
layer,
manageFocus: false,
onKeyDown: (event) => {
if (event.key !== "Escape") return false;
event.preventDefault();
onExit();
return true;
},
});
}
@@ -0,0 +1,57 @@
import { useEffect, type ReactNode } from "react";
import { Portal } from "@gorhom/portal";
import { BackHandler, View, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { DEFAULT_FLOATING_PANEL_PORTAL_HOST } from "@/components/ui/floating-panel-portal";
import { useKeyboardShift } from "@/keyboard/shift";
/** Same-window host keeps the IME attached while escaping the dock's clipping. */
export function ComposerFullscreenHost({
children,
onExit,
active,
}: {
children: ReactNode;
onExit: () => void;
active: boolean;
}) {
useEffect(() => {
if (!active) return;
const handler = BackHandler.addEventListener("hardwareBackPress", () => {
onExit();
return true;
});
return () => handler.remove();
}, [active, onExit]);
const insets = useSafeAreaInsets();
const { height } = useWindowDimensions();
const { layoutShift } = useKeyboardShift();
const frame = useAnimatedStyle(() => ({
height: Math.max(0, height - layoutShift.value),
paddingTop: insets.top,
paddingBottom: insets.bottom,
paddingLeft: insets.left,
paddingRight: insets.right,
}));
return (
<Portal hostName={DEFAULT_FLOATING_PANEL_PORTAL_HOST}>
<View style={[styles.background, !active && styles.hidden]}>
<Animated.View style={[{ width: "100%" }, frame]}>{children}</Animated.View>
</View>
</Portal>
);
}
const styles = StyleSheet.create((theme) => ({
hidden: { display: "none" },
background: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
backgroundColor: theme.colors.surface1,
},
}));
@@ -0,0 +1,77 @@
import { useMemo, useLayoutEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { View } from "react-native";
import {
getOverlayRoot,
useOverlayLayer,
OverlayLayerProvider,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
export function ComposerFullscreenHost({
children,
onExit,
active,
}: {
children: ReactNode;
onExit: () => void;
active: boolean;
}) {
const layer = useOverlayLayer("modal");
const registration = useWebOverlayRegistration({
active,
layer,
onKeyDown: (event) => {
if (event.key !== "Escape") return false;
event.preventDefault();
onExit();
return true;
},
});
const [viewport, setViewport] = useState(readVisibleViewport);
useLayoutEffect(() => {
if (!active) return;
const update = () => setViewport(readVisibleViewport());
const visualViewport = window.visualViewport;
update();
visualViewport?.addEventListener("resize", update);
visualViewport?.addEventListener("scroll", update);
window.addEventListener("resize", update);
return () => {
visualViewport?.removeEventListener("resize", update);
visualViewport?.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, [active]);
const frameStyle = useMemo(
() => ({
display: active ? ("flex" as const) : ("none" as const),
position: "absolute" as const,
top: viewport.top,
left: viewport.left,
width: viewport.width,
zIndex: layer,
pointerEvents: "auto" as const,
height: viewport.height,
}),
[active, layer, viewport],
);
return createPortal(
<OverlayLayerProvider layer={layer}>
<View ref={registration} style={frameStyle}>
{children}
</View>
</OverlayLayerProvider>,
getOverlayRoot(),
);
}
function readVisibleViewport() {
const viewport = window.visualViewport;
return {
top: viewport?.offsetTop ?? 0,
left: viewport?.offsetLeft ?? 0,
width: viewport?.width ?? window.innerWidth,
height: viewport?.height ?? window.innerHeight,
};
}
+113 -91
View File
@@ -84,7 +84,6 @@ import {
computeCanStartDictation,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveRetainedSelection,
runAlternateSendAction,
runDefaultSendAction,
runMessageInputKeyboardAction,
@@ -677,7 +676,13 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
);
}
return (
<View style={[styles.textInputScrollWrapper, props.fullscreen && styles.fullscreenTextSurface]}>
<View
style={[
styles.textInputScrollWrapper,
props.fullscreen && styles.fullscreenTextSurface,
props.compact && !props.fullscreen && styles.compactTextSurface,
]}
>
{!isWeb && props.compact && !props.fullscreen ? (
<Text
accessible={false}
@@ -1035,8 +1040,11 @@ function getComposerInputSnapshot(
current: ComposerTextInputHandle | null,
fallbackText: string,
fallbackSelection: ComposerInputSnapshot["selection"],
mountedTextArea?: TextAreaHandle | null,
): ComposerInputSnapshot {
const textArea = getWebTextAreaImpl(current);
// The inner DOM ref can detach before the outer editing handle. Keep the mounted
// element available until this owner's ref callback captures the remount handoff.
const textArea = mountedTextArea ?? getWebTextAreaImpl(current);
const text = textArea?.value ?? current?.getText() ?? fallbackText;
const start = textArea?.selectionStart ?? fallbackSelection.start;
const end = textArea?.selectionEnd ?? fallbackSelection.end;
@@ -1261,13 +1269,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const inputWrapperRef = useRef<View | null>(null);
const textInputRef = useRef<ComposerTextInputHandle | null>(null);
const isInputFocusedRef = useRef(false);
const focusIntentRef = useRef(false);
const valueRef = useRef(value);
const selectionRef = useRef({ start: value.length, end: value.length });
const handoff = editingSession.peekHandoff(textReplacement);
const focusIntentRef = useRef(Boolean(handoff?.focused));
const valueRef = useRef(handoff?.snapshot.text ?? value);
const selectionRef = useRef(
handoff?.snapshot.selection ?? { start: value.length, end: value.length },
);
const activeComposerRef = useRef(isActiveComposer);
activeComposerRef.current = isActiveComposer;
const [webTextArea, setWebTextArea] = useState<HTMLElement | null>(null);
const appliedTextReplacementKeyRef = useRef(textReplacement.key);
const textReplacementRef = useRef(textReplacement);
textReplacementRef.current = textReplacement;
const webTextareaRef = useRef<HTMLElement | null>(null);
const updateLiveTextPresence = useCallback((text: string) => {
const nextHasLiveText = text.trim().length > 0;
@@ -1276,49 +1289,43 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
setHasLiveText(nextHasLiveText);
}, []);
// One editor stays mounted through every layout mode, so this ref only follows
// the editor's own mount and unmount.
const assignTextInput = useCallback((input: ComposerTextInputHandle | null) => {
textInputRef.current = input;
if (!isWeb) return;
const element = getWebTextAreaImpl(input) as HTMLElement | null;
webTextareaRef.current = element;
setWebTextArea(element);
}, []);
const assignTextInput = useCallback(
(input: ComposerTextInputHandle | null) => {
const previous = textInputRef.current;
if (!input && previous) {
editingSession.captureForRemount({
snapshot: getComposerInputSnapshot(
previous,
valueRef.current,
selectionRef.current,
webTextareaRef.current,
),
focused: previous.isFocused() || isInputFocusedRef.current,
replacementKey: appliedTextReplacementKeyRef.current,
});
}
textInputRef.current = input;
if (isWeb) {
const element = getWebTextAreaImpl(input) as HTMLElement | null;
webTextareaRef.current = element;
setWebTextArea(element);
}
if (!input) return;
const transfer = editingSession.takeHandoff(textReplacementRef.current);
if (transfer) {
appliedTextReplacementKeyRef.current = textReplacementRef.current.key;
valueRef.current = transfer.snapshot.text;
updateLiveTextPresence(transfer.snapshot.text);
selectionRef.current = transfer.snapshot.selection;
input.replaceText(transfer.snapshot.text, transfer.snapshot.selection);
if (transfer.focused && activeComposerRef.current) input.focus();
}
},
[editingSession, updateLiveTextPresence],
);
useLayoutEffect(() => {
if (isActiveComposer && focusIntentRef.current) textInputRef.current?.focus();
}, [isActiveComposer]);
// The compact and desktop screens mount the editor in different trees, so a
// viewport that crosses the breakpoint remounts it. The draft owner brings the
// text back; the session brings the caret and focus intent back. On web the
// owner may still hold an unpublished edit while the new editor mounts, so the
// caret is placed now against the text on screen and again when the owner's
// hydrated text arrives.
const retainedSelectionRef = useRef<{ start: number; end: number } | null>(null);
useLayoutEffect(() => {
const retained = editingSession.takeEditor();
if (retained) {
const text = textInputRef.current?.getText() ?? valueRef.current;
const selection = resolveRetainedSelection(retained.selection, text.length);
retainedSelectionRef.current = retained.selection;
selectionRef.current = selection;
textInputRef.current?.setSelection(selection);
if (retained.focused) {
focusIntentRef.current = true;
if (activeComposerRef.current) textInputRef.current?.focus();
}
}
return () => {
editingSession.retainEditor({
selection: getComposerInputSnapshot(
textInputRef.current,
valueRef.current,
selectionRef.current,
).selection,
focused: isInputFocusedRef.current || focusIntentRef.current,
});
};
}, [editingSession]);
const getLiveText = useCallback(() => valueRef.current, []);
const composerHeight = useComposerHeight({
getText: getLiveText,
@@ -1333,15 +1340,29 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const updateComposerHeightForText = measuredComposerHeight?.onTextChange;
const resetComposerHeight = measuredComposerHeight?.reset;
const exitFullscreen = useCallback(() => editingSession.setFullscreen(false), [editingSession]);
const enterFullscreen = useCallback(() => editingSession.setFullscreen(true), [editingSession]);
// The conversation sizes itself against the inline composer; fullscreen covers it.
const handleComposerLayout = useCallback(
(event: LayoutChangeEvent) => {
if (!isFullscreen) onHeightChange?.(event.nativeEvent.layout.height);
const changePresentation = useCallback(
(fullscreen: boolean) => {
if (editingSession.getFullscreen() === fullscreen) return;
const snapshot = getComposerInputSnapshot(
textInputRef.current,
valueRef.current,
selectionRef.current,
);
editingSession.changePresentation(fullscreen, {
snapshot,
focused: textInputRef.current?.isFocused() ?? isInputFocusedRef.current,
replacementKey: appliedTextReplacementKeyRef.current,
});
valueRef.current = snapshot.text;
selectionRef.current = snapshot.selection;
},
[isFullscreen, onHeightChange],
[editingSession],
);
const exitFullscreen = useCallback(() => changePresentation(false), [changePresentation]);
const handleComposerLayout = useCallback(
(event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height),
[onHeightChange],
);
const replaceText = useCallback(
@@ -1406,15 +1427,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
updateComposerHeightForText?.(valueRef.current, textReplacement.text);
valueRef.current = textReplacement.text;
updateLiveTextPresence(textReplacement.text);
const retainedSelection =
textReplacement.kind === "hydrated" ? retainedSelectionRef.current : null;
retainedSelectionRef.current = null;
if (textReplacement.text === "") {
textInputRef.current?.reset();
} else if (retainedSelection) {
const selection = resolveRetainedSelection(retainedSelection, textReplacement.text.length);
selectionRef.current = selection;
textInputRef.current?.replaceText(textReplacement.text, selection);
} else {
textInputRef.current?.replaceText(textReplacement.text);
}
@@ -1682,6 +1696,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
});
}, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]);
const getWebTextArea = useCallback(
(): TextAreaHandle | null => getWebTextAreaImpl(textInputRef.current),
[],
);
useLayoutEffect(() => {
if (isWeb) {
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
}
}, [getWebTextArea, isFullscreen]);
usePasteImagesEffect({
textarea: webTextArea,
isConnected,
@@ -1807,8 +1832,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}, [onFocusChange]);
const handleInputBlur = useCallback(() => {
// Fullscreen is nothing but the editor, so a blur there (a retained tab being
// hidden, the keyboard dismissed) keeps the intent: showing it again refocuses.
if (activeComposerRef.current && !isFullscreen) focusIntentRef.current = false;
isInputFocusedRef.current = false;
setIsInputFocused(false);
@@ -1865,9 +1888,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
styles.textInput,
mode.isMonospace && styles.textInputMonospace,
isFullscreen ? styles.fullscreenTextInput : composerHeightStyle,
isCompact && !isFullscreen && styles.compactTextInput,
],
[composerHeightStyle, mode.isMonospace, isFullscreen, isCompact],
[composerHeightStyle, mode.isMonospace, isFullscreen],
);
// Static content has no textarea to mirror, so it grows with its own text
// instead of the measured input height.
@@ -1875,22 +1897,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
() => [styles.textInput, mode.isMonospace && styles.textInputMonospace, styles.readOnlyText],
[mode.isMonospace],
);
// Fullscreen always shows Send; without content it is disabled, and looks it.
const isPresentedSendDisabled = resolvePresentationSendDisabled({
fullscreen: isFullscreen,
disabled: isSendButtonDisabled,
hasText: hasLiveText,
attachments,
hasExternalContent,
allowEmpty: allowEmptySubmit,
});
const sendButtonCombinedStyle = useMemo(
() => [
styles.sendButton,
submitLabel ? styles.sendButtonLabeled : undefined,
isPresentedSendDisabled && styles.buttonDisabled,
isSendButtonDisabled && styles.buttonDisabled,
],
[isPresentedSendDisabled, submitLabel],
[isSendButtonDisabled, submitLabel],
);
const overlayContainerStyle = useMemo(
() => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }],
@@ -1929,7 +1942,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canPressLoadingButton={canPressLoadingButton}
onSubmitLoadingPress={onSubmitLoadingPress}
onDefaultSendAction={handleDefaultSendAction}
isSendButtonDisabled={isPresentedSendDisabled}
isSendButtonDisabled={resolvePresentationSendDisabled({
fullscreen: isFullscreen,
disabled: isSendButtonDisabled,
hasText: hasLiveText,
attachments,
hasExternalContent,
allowEmpty: allowEmptySubmit,
})}
submitAccessibilityLabel={submitAccessibilityLabel}
sendButtonCombinedStyle={sendButtonCombinedStyle}
isSubmitLoading={isSubmitLoading}
@@ -1948,7 +1968,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canPressLoadingButton,
onSubmitLoadingPress,
handleDefaultSendAction,
isPresentedSendDisabled,
isSendButtonDisabled,
hasLiveText,
attachments,
hasExternalContent,
allowEmptySubmit,
submitAccessibilityLabel,
sendButtonCombinedStyle,
isSubmitLoading,
@@ -1984,7 +2008,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onBlur={handleInputBlur}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
scrollEnabled={isComposerScrollEnabled}
autoFocus={false}
autoFocus={
Boolean(editingSession.peekHandoff(textReplacement)?.focused) && isActiveComposer
}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
onPasteImages={onPasteImages}
@@ -2003,6 +2029,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[
readOnly,
textReplacement,
editingSession,
isActiveComposer,
assignTextInput,
textInputStyle,
readOnlyTextStyle,
@@ -2134,10 +2162,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
handleRealtimeVoiceStop,
],
);
const enterFullscreen = useCallback(() => changePresentation(true), [changePresentation]);
return (
<View
ref={rootRef}
style={isFullscreen ? styles.fullscreenContainer : styles.container}
style={styles.container}
testID="message-input-root"
onLayout={handleComposerLayout}
>
@@ -2148,7 +2177,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
/>
<ComposerInputPresentation
fullscreen={isFullscreen}
sendIconSize={buttonIconSize}
iconSize={buttonIconSize}
active={isActiveComposer}
surface={textSurface}
toolbar={toolbar}
send={sendButton}
@@ -2177,10 +2207,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
flexShrink: 1,
position: "relative",
},
fullscreenContainer: {
flex: 1,
position: "relative",
},
inputWrapper: {
flexShrink: 1,
flexDirection: "column",
@@ -2215,18 +2241,14 @@ const styles = StyleSheet.create((theme: Theme) => ({
inputWrapperReadOnly: {
borderStyle: "dotted",
},
// The band the fullscreen glyph sits in is the input's own padding, so a tap
// anywhere in it (under the glyph, or where the glyph would be) is a tap on the
// editor: it focuses, shows the keyboard, and places the caret. Reserved whether
// or not the glyph shows, so text never reflows when it appears.
compactTextInput: { paddingRight: 36 },
compactTextSurface: { paddingRight: 36 },
fullscreenTextSurface: { flex: 1, minHeight: 0 },
fullscreenTextInput: { flex: 1, height: "100%", textAlignVertical: "top" },
lineMeasurement: {
position: "absolute",
top: 0,
left: 0,
right: 0,
right: 36,
opacity: 0,
maxHeight: undefined,
minHeight: undefined,
@@ -3,31 +3,19 @@ import { View, type ViewProps } from "react-native";
import { Maximize2, Minimize2 } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ComposerFullscreenBody, ComposerFullscreenFooter } from "@/composer/dock";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { isWeb } from "@/constants/platform";
import { ICON_SIZE } from "@/styles/theme";
import { StyleSheet } from "react-native-unistyles";
import { useComposerFullscreenExit } from "./fullscreen-exit";
import { resolveGlyphRailOffset } from "./state";
const CONTROL_SIZE = 28;
const FULLSCREEN_GLYPH_SIZE = ICON_SIZE.md;
import { ComposerFullscreenHost } from "./fullscreen-host";
function keepEditorFocused(event: import("react-native").PointerEvent) {
if (isWeb) event.preventDefault();
}
/**
* One tree for both layout modes. The editing surface keeps its position so the
* mounted editor, its native buffer, selection, and focus survive every toggle;
* only the chrome around it changes. Fullscreen shows exit top right and Send
* bottom right, both on the rail the inline Send button uses; a send error sits
* in the same footer as Send.
*/
/** Both presentations receive the same editing surface and send capability. */
export function ComposerInputPresentation({
fullscreen,
sendIconSize,
iconSize,
active,
surface,
toolbar,
send,
@@ -42,7 +30,8 @@ export function ComposerInputPresentation({
overlay,
}: {
fullscreen: boolean;
sendIconSize: number;
iconSize: number;
active: boolean;
surface: ReactNode;
toolbar: ReactNode;
send: ReactNode;
@@ -57,58 +46,33 @@ export function ComposerInputPresentation({
overlay: ReactNode;
}) {
const { t } = useTranslation();
// A retained tab keeps its fullscreen editor mounted while hidden; the global exit
// action (Escape, hardware back) belongs only to the pane the user is in.
const { isActiveComposer } = useComposerKeyboardScope();
const exitScopeRef = useComposerFullscreenExit(fullscreen && isActiveComposer, onExit);
const railOffset = resolveGlyphRailOffset({
controlSize: CONTROL_SIZE,
sendIconSize,
glyphIconSize: FULLSCREEN_GLYPH_SIZE,
});
// ArrowUp ends at x19; the fullscreen glyphs end at x21 in Lucide's 24px viewBox.
// Compensate for that difference to align visible ink with Send, not just boxes.
const railAdjustment = iconSize / 12;
const maximizeIcon = useCallback(
(color: string) => <Maximize2 color={color} size={FULLSCREEN_GLYPH_SIZE} />,
[],
(color: string) => <Maximize2 color={color} size={iconSize} />,
[iconSize],
);
const minimizeIcon = useCallback(
(color: string) => <Minimize2 color={color} size={FULLSCREEN_GLYPH_SIZE} />,
[],
(color: string) => <Minimize2 color={color} size={iconSize} />,
[iconSize],
);
// The toolbar row bleeds 6px past the input padding; the inline glyph sits on that edge.
const enterStyle = useMemo(() => [styles.enterButton, { right: -6 + railOffset }], [railOffset]);
const exitStyle = useMemo(() => [styles.iconButton, { marginRight: railOffset }], [railOffset]);
return (
<View
ref={exitScopeRef}
style={fullscreen ? styles.fullscreen : styles.inline}
testID={fullscreen ? "composer-fullscreen" : undefined}
>
{fullscreen ? (
<View style={styles.exitRow}>
<Button
variant="ghost"
size="xs"
accessibilityLabel={t("composer.input.exitFullscreen")}
onPointerDown={keepEditorFocused}
onPress={onExit}
style={exitStyle}
leftIcon={minimizeIcon}
hitSlop={8}
>
{null}
</Button>
</View>
) : null}
<ComposerFullscreenBody fullscreen={fullscreen}>
<View
ref={inputWrapperRef}
style={fullscreen ? styles.fullscreenInputWrapper : inputWrapperStyle}
pointerEvents={inputPointerEvents}
>
{fullscreen ? null : attachmentSlot}
<View style={fullscreen ? styles.fullscreenTextSurface : styles.inlineTextSurface}>
const enterStyle = useMemo(
() => [styles.fullscreenButton, { right: -6 + railAdjustment }],
[railAdjustment],
);
const exitStyle = useMemo(
() => [styles.iconButton, { marginRight: railAdjustment }],
[railAdjustment],
);
if (!fullscreen)
return (
<>
<View ref={inputWrapperRef} style={inputWrapperStyle} pointerEvents={inputPointerEvents}>
{attachmentSlot}
<View style={styles.inlineTextSurface}>
{surface}
{showFullscreenButton && !fullscreen ? (
{showFullscreenButton ? (
<Button
variant="ghost"
size="xs"
@@ -123,35 +87,50 @@ export function ComposerInputPresentation({
</Button>
) : null}
</View>
{fullscreen ? null : toolbar}
{toolbar}
</View>
</ComposerFullscreenBody>
{fullscreen ? (
<ComposerFullscreenFooter style={styles.footer}>
{error}
<View style={styles.sendRow}>{send}</View>
</ComposerFullscreenFooter>
) : null}
{overlay}
</View>
{overlay}
</>
);
return (
<ComposerFullscreenHost onExit={onExit} active={active}>
<View style={styles.fullscreen} testID="composer-fullscreen">
<View style={styles.exitRow}>
<Button
variant="ghost"
size="xs"
accessibilityLabel={t("composer.input.exitFullscreen")}
onPointerDown={keepEditorFocused}
onPress={onExit}
style={exitStyle}
leftIcon={minimizeIcon}
hitSlop={8}
>
{null}
</Button>
</View>
{surface}
{error}
<View style={styles.sendRow}>{send}</View>
</View>
</ComposerFullscreenHost>
);
}
const styles = StyleSheet.create((theme) => ({
inline: { flexShrink: 1, position: "relative" },
inlineTextSurface: { flexShrink: 1, position: "relative" },
iconButton: {
width: CONTROL_SIZE,
height: CONTROL_SIZE,
width: 28,
height: 28,
padding: 0,
borderWidth: 0,
borderRadius: theme.borderRadius.full,
},
enterButton: {
fullscreenButton: {
position: "absolute",
top: 0,
width: CONTROL_SIZE,
height: CONTROL_SIZE,
width: 28,
height: 28,
padding: 0,
borderWidth: 0,
borderRadius: theme.borderRadius.full,
@@ -162,10 +141,6 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[4],
gap: theme.spacing[3],
},
fullscreenInputWrapper: { flex: 1, minHeight: 0 },
fullscreenTextSurface: { flex: 1, minHeight: 0 },
exitRow: { alignItems: "flex-end", flexShrink: 0 },
// The send error rides the keyboard with Send; below the body it would sit behind it.
footer: { flexShrink: 0, gap: theme.spacing[3] },
sendRow: { alignItems: "flex-end" },
sendRow: { flexShrink: 0, alignItems: "flex-end" },
}));
+3 -25
View File
@@ -5,8 +5,6 @@ import {
resolveActiveSendBehavior,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveGlyphRailOffset,
resolveRetainedSelection,
runAlternateSendAction,
runDefaultSendAction,
runMessageInputKeyboardAction,
@@ -335,30 +333,10 @@ describe("composer surface text", () => {
);
});
it("keeps the live buffer when the initial publication is already applied", () => {
it("uses the live handoff when its replacement has already been applied", () => {
const replacement = { kind: "initial" as const, key: "draft:0", text: "Stale publication" };
expect(resolveComposerSurfaceText("Live edited draft", replacement, "draft:0")).toBe(
"Live edited draft",
expect(resolveComposerSurfaceText("Live remount handoff", replacement, "draft:0")).toBe(
"Live remount handoff",
);
});
});
describe("retained selection", () => {
it("keeps a caret that fits and clamps one that no longer does", () => {
expect(resolveRetainedSelection({ start: 4, end: 17 }, 40)).toEqual({ start: 4, end: 17 });
expect(resolveRetainedSelection({ start: 4, end: 17 }, 10)).toEqual({ start: 4, end: 10 });
expect(resolveRetainedSelection({ start: 12, end: 17 }, 10)).toEqual({ start: 10, end: 10 });
expect(resolveRetainedSelection({ start: -1, end: 3 }, 10)).toEqual({ start: 0, end: 3 });
});
});
describe("fullscreen glyph rail", () => {
it("nudges 16px fullscreen glyphs onto the Send arrow's ink edge", () => {
expect(
resolveGlyphRailOffset({ controlSize: 28, sendIconSize: 16, glyphIconSize: 16 }),
).toBeCloseTo(4 / 3);
expect(
resolveGlyphRailOffset({ controlSize: 28, sendIconSize: 20, glyphIconSize: 16 }),
).toBeCloseTo(1 / 6);
});
});
-28
View File
@@ -193,24 +193,6 @@ export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise<
await ctx.voice.stopVoice();
}
/**
* Offset that aligns a glyph's visible ink with the Send arrow's ink when both sit in
* equal controls on the same right rail. In Lucide's 24px viewBox ArrowUp ends at
* x19 while Maximize2/Minimize2 end at x21, so equal boxes leave the fullscreen
* glyphs visibly past the arrow.
*/
export function resolveGlyphRailOffset(input: {
controlSize: number;
sendIconSize: number;
glyphIconSize: number;
}): number {
const arrowInkInset =
(input.controlSize - input.sendIconSize) / 2 + (5 * input.sendIconSize) / 24;
const glyphInkInset =
(input.controlSize - input.glyphIconSize) / 2 + (3 * input.glyphIconSize) / 24;
return arrowInkInset - glyphInkInset;
}
export function resolveComposerSurfaceText(
liveText: string,
replacement: TextReplacement,
@@ -218,13 +200,3 @@ export function resolveComposerSurfaceText(
): string {
return replacement.key === appliedReplacementKey ? liveText : replacement.text;
}
/** A retained caret must fit the text the remounted editor shows. */
export function resolveRetainedSelection(
retained: { start: number; end: number },
textLength: number,
): { start: number; end: number } {
const end = Math.min(Math.max(retained.end, 0), textLength);
const start = Math.min(Math.max(retained.start, 0), end);
return { start, end };
}
+1 -5
View File
@@ -9,12 +9,8 @@ export interface MessagePayload {
forceSend?: boolean;
}
/**
* Text the draft owner hands the editor. `hydrated` is the owner's answer to a mount:
* the draft's text as persisted or still pending from the editor that unmounted.
*/
export interface TextReplacement {
kind: "initial" | "hydrated" | "replace";
kind: "initial" | "replace";
key: string;
text: string;
}
+5 -15
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo } from "react";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { useHosts } from "@/runtime/host-runtime";
import { useDownloadStore } from "@/stores/download-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
@@ -12,9 +12,8 @@ interface UseFileDownloadParams {
/**
* Returns a stable callback that downloads a single workspace file by its
* workspace-relative path. Shared by the file explorer tree and the git diff
* pane so both surfaces download through the same download-store pipeline instead
* of duplicating the plumbing: the HTTP token download over a direct TCP connection,
* the session socket over anything else.
* pane so both surfaces download through the same host token + download-store
* pipeline instead of duplicating the plumbing.
*/
export function useFileDownload({
serverId,
@@ -31,7 +30,7 @@ export function useFileDownload({
() => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId],
);
const { requestFileDownloadToken, requestFileBytes } = useFileExplorerActions({
const { requestFileDownloadToken } = useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
@@ -49,18 +48,9 @@ export function useFileDownload({
fileName,
path,
daemonProfile,
activeConnectionId: getHostRuntimeStore().getSnapshot(serverId)?.activeConnectionId ?? null,
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
readFile: requestFileBytes,
});
},
[
daemonProfile,
requestFileBytes,
requestFileDownloadToken,
serverId,
startDownload,
workspaceScopeId,
],
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
);
}
@@ -253,21 +253,6 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
[client, normalizedWorkspaceRoot, t],
);
const requestFileBytes = useCallback(
async (path: string, maxBytes: number) => {
if (!normalizedWorkspaceRoot) {
throw new Error(t("workspace.fileExplorer.states.unavailable"));
}
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
// No request timeout: a large file over a slow relay legitimately takes longer than the
// 60 s default, and a dead connection still rejects on disconnect.
return client.readFile(normalizedWorkspaceRoot, path, undefined, maxBytes, 0);
},
[client, normalizedWorkspaceRoot, t],
);
const createEntry = useCallback(
async (input: { parentPath: string; name: string; kind: "file" | "directory" }) => {
if (!client || !normalizedWorkspaceRoot) {
@@ -357,7 +342,6 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
requestFileBytes,
createEntry,
renameEntry,
duplicateEntry,
@@ -15,7 +15,6 @@ import {
parseClampedFontSize,
parseTerminalScrollbackLines,
resolveContentMaxWidth,
sanitizeFontFamily,
saveAppSettings,
type SettingsDeps,
} from "./storage";
@@ -1095,34 +1094,3 @@ describe("content max width", () => {
expect((await load("wide")).contentMaxWidth).toBeNull();
});
});
describe("font family CSS safety", () => {
it.each(["'foo", '"foo', "Menlo, 'foo", "foo\\", "foo/*", "foo(", "foo["])(
"rejects unfinished CSS syntax: %s",
(value) => expect(sanitizeFontFamily(value)).toBeNull(),
);
it.each([
"Menlo",
"'Fira Code', monospace",
'"Fira Code", monospace',
"'Font\\'s name'",
"Font\\ Name",
"'Font (Mono)'",
"'Font [Mono]'",
])("preserves complete font families: %s", (value) =>
expect(sanitizeFontFamily(value)).toBe(value),
);
it.each(["uiFontFamily", "monoFontFamily"])(
"recovers a persisted unmatched quote in %s",
async (field) => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ [field]: "'foo" }),
}),
});
expect(await loadAppSettingsFromStorage(deps)).toMatchObject({ [field]: "" });
},
);
});
+1 -18
View File
@@ -522,24 +522,7 @@ export function sanitizeFontFamily(value: unknown): string | null {
if ([...trimmed].some((char) => char.charCodeAt(0) <= 0x1f)) {
return null; // control chars would corrupt the font-family string
}
// Unistyles serializes theme values into one shared stylesheet. An unfinished
// string, escape, comment, or block consumes the declarations that follow.
let quote: string | null = null;
let escaped = false;
for (const char of trimmed) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (quote !== null) {
if (char === quote) quote = null;
} else if (char === "'" || char === '"') {
quote = char;
} else if (/[()[\]/*]/.test(char)) {
return null;
}
}
return quote === null && !escaped ? trimmed : null;
return trimmed; // quotes/commas are legit in stacks
}
async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): Promise<{
+2 -21
View File
@@ -27,11 +27,6 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { RetainedChatContent } from "./retained-chat-content";
import { Composer } from "@/composer";
import {
useComposerEditingSession,
useComposerFullscreen,
type ComposerEditingSession,
} from "@/composer/editing-session";
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
import {
resolveComposerTrackControlClearance,
@@ -1160,9 +1155,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const { t } = useTranslation();
// The workspace sheet is for looking; Open as tab brings the agent back with its composer.
const showsComposer = usePaneContext().host !== "sheet";
// The dock lays out fullscreen for the same session the composer edits in.
const composerEditingSession = useComposerEditingSession("chat");
const isComposerFullscreen = useComposerFullscreen(composerEditingSession);
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
state.sessions[serverId]?.agentTasks.get(agentId),
@@ -1237,7 +1229,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
cwd={cwd}
isSubmitLoading={false}
agentInputDraft={agentInputDraft}
editingSession={composerEditingSession}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onComposerHeightChange={handleComposerHeightChange}
@@ -1301,7 +1292,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
);
const dock = (
<ChatSurface disabled={isArchivingCurrentAgent} fullscreen={isComposerFullscreen}>
<ChatSurface disabled={isArchivingCurrentAgent}>
{dockContent}
{composerSection}
{dockOverlay}
@@ -1332,15 +1323,13 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
function ChatSurface({
children,
disabled,
fullscreen,
}: {
children: [ReactNode, ReactNode, ReactNode];
disabled: boolean;
fullscreen: boolean;
}) {
return (
<FileDropZone style={styles.container} disabled={disabled}>
<ComposerDock fullscreen={fullscreen}>{children}</ComposerDock>
<ComposerDock>{children}</ComposerDock>
</FileDropZone>
);
}
@@ -1484,7 +1473,6 @@ const AgentComposerSection = memo(function AgentComposerSection({
cwd,
isSubmitLoading,
agentInputDraft,
editingSession,
onAttentionInputFocus,
onAttentionPromptSend,
onComposerHeightChange,
@@ -1498,7 +1486,6 @@ const AgentComposerSection = memo(function AgentComposerSection({
cwd: string;
isSubmitLoading: boolean;
agentInputDraft: AgentInputDraft;
editingSession: ComposerEditingSession;
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onComposerHeightChange: (height: number) => void;
@@ -1522,7 +1509,6 @@ const AgentComposerSection = memo(function AgentComposerSection({
cwd={cwd}
isSubmitLoading={isSubmitLoading}
agentInputDraft={agentInputDraft}
editingSession={editingSession}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onComposerHeightChange={onComposerHeightChange}
@@ -1538,7 +1524,6 @@ function ActiveAgentComposer({
cwd,
isSubmitLoading,
agentInputDraft,
editingSession,
onAttentionInputFocus,
onAttentionPromptSend,
onComposerHeightChange,
@@ -1550,7 +1535,6 @@ function ActiveAgentComposer({
cwd: string;
isSubmitLoading: boolean;
agentInputDraft: AgentInputDraft;
editingSession: ComposerEditingSession;
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onComposerHeightChange: (height: number) => void;
@@ -1647,7 +1631,6 @@ function ActiveAgentComposer({
onChangeAttachments={agentInputDraft.setAttachments}
cwd={cwd}
clearDraft={agentInputDraft.clear}
editingSession={editingSession}
autoFocus
autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)}
isSubmitLoading={isSubmitLoading}
@@ -1740,8 +1723,6 @@ const animatedStaticStyles = RNStyleSheet.create({
inputAreaWrapper: {
width: "100%",
flexShrink: 1,
// Content-sized inline; fills the dock's fullscreen slot.
flexGrow: 1,
},
});
@@ -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} />
@@ -17,7 +17,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native";
import { Composer } from "@/composer";
import { ComposerDock } from "@/composer/dock";
import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import {
resolveComposerAttachmentSubmitFormat,
@@ -1637,8 +1636,6 @@ export function NewWorkspaceScreen({
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const toast = useToast();
const composerEditing = useNewWorkspaceComposerEditing();
const { leaveFullscreen } = composerEditing;
const mergeWorkspaces = useCallback(
(targetServerId: string, workspaces: Iterable<WorkspaceDescriptor>) => {
getHostRuntimeStore().acceptWorkspaceSnapshots(targetServerId, Array.from(workspaces));
@@ -2124,7 +2121,6 @@ export function NewWorkspaceScreen({
return;
}
outcome = "navigated";
leaveFullscreen();
navigateToWorkspace({ serverId: targetServerId, workspaceId });
},
});
@@ -2175,7 +2171,6 @@ export function NewWorkspaceScreen({
forkDraftSetup,
isStillOnCreateScreen,
launchTarget,
leaveFullscreen,
selectedServerId,
supportsForgeSearch,
t,
@@ -2232,7 +2227,6 @@ export function NewWorkspaceScreen({
return;
}
outcome = "navigated";
leaveFullscreen();
navigateToWorkspace({ serverId: targetServerId, workspaceId, target });
},
});
@@ -2256,7 +2250,6 @@ export function NewWorkspaceScreen({
t,
terminalPromptText,
toast,
leaveFullscreen,
updateFormPreferences,
withConnectedClient,
]);
@@ -2376,12 +2369,10 @@ export function NewWorkspaceScreen({
const importSession = useImportSession({ serverId: selectedServerId });
const isComposerFullscreen = composerEditing.isFullscreen(isTerminalLaunch);
const composer = isTerminalLaunch ? (
<Composer
key="terminal"
inputMode="terminal"
editingSession={composerEditing.terminal}
readOnly={!terminalTakesPrompt}
placeholder={terminalPlaceholder}
submitLabel={terminalSubmitLabel}
@@ -2408,7 +2399,6 @@ export function NewWorkspaceScreen({
) : (
<Composer
key="chat"
editingSession={composerEditing.chat}
agentId={draftKey}
serverId={selectedServerId}
isPaneFocused={true}
@@ -2439,53 +2429,32 @@ export function NewWorkspaceScreen({
);
return (
<FileDropZone style={styles.container}>
<NewWorkspaceLayout
isCompact={isCompact}
fullscreen={isComposerFullscreen}
title={t("newWorkspace.title")}
formStack={formStack}
onImportSession={importSession.open}
>
{composer}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</NewWorkspaceLayout>
<MenuHeader borderless />
<View style={styles.content}>
<TitlebarDragRegion />
<NewWorkspaceLayout
isCompact={isCompact}
title={t("newWorkspace.title")}
formStack={formStack}
onImportSession={importSession.open}
>
{composer}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</NewWorkspaceLayout>
</View>
{importSession.sheet}
</FileDropZone>
);
}
/** Terminal and chat launches edit in separate sessions; the screen follows the mounted one. */
function useNewWorkspaceComposerEditing() {
const terminal = useComposerEditingSession("terminal");
const chat = useComposerEditingSession("chat");
const isTerminalFullscreen = useComposerFullscreen(terminal);
const isChatFullscreen = useComposerFullscreen(chat);
// The sessions outlive the screen (its draft keeps them), so a launch that leaves
// the screen also leaves fullscreen; the next draft starts inline.
const leaveFullscreen = useCallback(() => {
terminal.setFullscreen(false);
chat.setFullscreen(false);
}, [chat, terminal]);
return {
terminal,
chat,
leaveFullscreen,
isFullscreen: (isTerminalLaunch: boolean) =>
isTerminalLaunch ? isTerminalFullscreen : isChatFullscreen,
};
}
function NewWorkspaceLayout({
isCompact,
fullscreen,
title,
formStack,
onImportSession,
children,
}: {
isCompact: boolean;
/** The composer covers the screen; the header leaves and the dock fills the content. */
fullscreen: boolean;
title: string;
formStack: ReactNode;
onImportSession: () => void;
@@ -2503,19 +2472,13 @@ function NewWorkspaceLayout({
</>
);
return (
<>
{fullscreen ? null : <MenuHeader borderless />}
<View style={styles.content}>
<TitlebarDragRegion />
<ComposerDock centered={!isCompact} fullscreen={fullscreen}>
{setupFields}
<>
{children}
{isCompact || fullscreen ? null : importSessionButton}
</>
</ComposerDock>
</View>
</>
<ComposerDock centered={!isCompact}>
{setupFields}
<>
{children}
{isCompact ? null : importSessionButton}
</>
</ComposerDock>
);
}
@@ -241,7 +241,7 @@ interface FontFamilyRowProps {
draft: string;
withBorder: boolean;
onChangeDraft: (value: string) => void;
onCommit: (value: string) => string;
onCommit: (value: string) => void;
}
function FontFamilyRow({
@@ -255,16 +255,13 @@ function FontFamilyRow({
onChangeDraft,
onCommit,
}: FontFamilyRowProps) {
const inputRef = useRef<EditingTextInputHandle>(null);
const handleCommit = useCallback(() => {
const committed = onCommit(draft);
inputRef.current?.replaceText(committed);
onCommit(draft);
}, [draft, onCommit]);
// Resync from the committed value when it changes elsewhere.
useEffect(() => {
onChangeDraft(value);
inputRef.current?.replaceText(value);
// Only resync on external value changes, not on local keystrokes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
@@ -276,7 +273,6 @@ function FontFamilyRow({
<Text style={settingsStyles.rowHint}>{hint}</Text>
</View>
<TextInput
ref={inputRef}
initialValue={draft}
onChangeText={onChangeDraft}
onBlur={handleCommit}
@@ -537,13 +533,12 @@ export function AppearanceSection() {
const sanitized = sanitizeFontFamily(value);
if (sanitized === null) {
setUiFontDraft(settings.uiFontFamily);
return settings.uiFontFamily;
return;
}
setUiFontDraft(sanitized);
if (sanitized !== settings.uiFontFamily) {
void updateSettings({ uiFontFamily: sanitized });
}
return sanitized;
},
[settings.uiFontFamily, updateSettings],
);
@@ -553,13 +548,12 @@ export function AppearanceSection() {
const sanitized = sanitizeFontFamily(value);
if (sanitized === null) {
setMonoFontDraft(settings.monoFontFamily);
return settings.monoFontFamily;
return;
}
setMonoFontDraft(sanitized);
if (sanitized !== settings.monoFontFamily) {
void updateSettings({ monoFontFamily: sanitized });
}
return sanitized;
},
[settings.monoFontFamily, updateSettings],
);
@@ -80,7 +80,6 @@ vi.mock("@gorhom/bottom-sheet", async () => {
await vi.importActual<typeof import("react-native")>("react-native");
return {
BottomSheetBackdrop: () => null,
BottomSheetHandle: () => null,
BottomSheetModal: ReactModule.forwardRef(() => null),
BottomSheetScrollView: ScrollView,
BottomSheetTextInput: TextInput,
@@ -1,7 +1,4 @@
import {
WorkspaceComposerEditingSessionsProvider,
useComposerTabFullscreen,
} from "@/composer/editing-session";
import { WorkspaceComposerEditingSessionsProvider } from "@/composer/editing-session";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
@@ -78,7 +75,6 @@ import {
selectExplorerSidebarPaneId,
selectPaneFocusSuspended,
type WorkspaceLayout,
type WorkspaceSheet,
type WorkspaceTabPlacement,
useWorkspaceLayoutStore,
useWorkspaceLayoutStoreHydrated,
@@ -208,7 +204,6 @@ import { RenderProfile } from "@/utils/render-profiler";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
import { useHasPullRequest, usePullRequestAutoAdd } from "@/panels/pull-request";
const NO_SHEETS: readonly WorkspaceSheet[] = [];
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
const EMPTY_UI_TABS: WorkspaceTab[] = [];
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
@@ -829,16 +824,6 @@ function floatsHeaderOverContent(activeTab: WorkspaceTabDescriptor | null): bool
return supportsStreamTopOverlay && activeTab?.kind === "agent";
}
/** A fullscreen composer covers the compact header; the column lays out without it. */
function resolveCompactHeaderLayout(input: {
header: ReactNode;
activeTab: WorkspaceTabDescriptor | null;
composerFullscreen: boolean;
}): { header: ReactNode; floating: boolean } {
if (input.composerFullscreen) return { header: null, floating: false };
return { header: input.header, floating: floatsHeaderOverContent(input.activeTab) };
}
function shouldShowWorkspaceScreenHeader(input: {
isFocusModeEnabled: boolean;
isMobile: boolean;
@@ -1324,12 +1309,8 @@ function WorkspaceScreenContent({
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
const selectWorkspaceTabInPane = useWorkspaceLayoutStore((state) => state.selectTabInPane);
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const sheets = useWorkspaceLayoutStore((state) =>
persistenceKey ? (state.sheetsByWorkspace[persistenceKey] ?? NO_SHEETS) : NO_SHEETS,
);
// Only the top sheet is on screen.
const sheetTab = useWorkspaceLayoutStore((state) =>
persistenceKey ? (state.sheetsByWorkspace[persistenceKey]?.at(-1)?.tab ?? null) : null,
persistenceKey ? (state.sheetByWorkspace[persistenceKey]?.tab ?? null) : null,
);
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
@@ -2088,7 +2069,7 @@ function WorkspaceScreenContent({
const handleCloseTabById = useCallback(
async (tabId: string) => {
if (persistenceKey && sheets.some((sheet) => sheet.tab.tabId === tabId)) {
if (persistenceKey && sheetTab?.tabId === tabId) {
if (await confirmDiscardModifiedTab(tabId)) {
closeWorkspaceTab(persistenceKey, tabId);
}
@@ -2119,7 +2100,7 @@ function WorkspaceScreenContent({
handleClosePassiveTab,
handleCloseTerminalTab,
persistenceKey,
sheets,
sheetTab,
],
);
@@ -3114,6 +3095,7 @@ function WorkspaceScreenContent({
focusedPaneTabDescriptorMap,
isRouteFocused,
focusedPaneId,
// The sheet owns input while it covers the tab.
paneFocusSuspended,
onFocusPane: handleFocusPane,
buildMobilePaneContentModel,
@@ -3542,15 +3524,10 @@ function WorkspaceScreenContent({
const rendersDesktopSplitContent = !isMobile && desktopSplitContent !== null;
const workspacePanelTabIds = useMemo(
() => [...uiTabs.map((tab) => tab.tabId), ...sheets.map((sheet) => sheet.tab.tabId)],
[uiTabs, sheets],
() => [...uiTabs.map((tab) => tab.tabId), ...(sheetTab ? [sheetTab.tabId] : [])],
[uiTabs, sheetTab],
);
const workspacePanelKey = `${normalizedServerId}:${normalizedWorkspaceId}`;
const compactHeaderLayout = resolveCompactHeaderLayout({
header: compactWorkspaceHeader,
activeTab: activeTabDescriptor,
composerFullscreen: useComposerTabFullscreen(workspacePanelKey, activeTabId),
});
// The sheet is the column's last child, so it covers the compact header as a modal.
const workspaceCenterColumn = isMobile ? (
@@ -3561,8 +3538,8 @@ function WorkspaceScreenContent({
>
<View style={styles.centerColumn}>
<FloatingHeaderLayout
header={compactHeaderLayout.header}
floating={compactHeaderLayout.floating}
header={compactWorkspaceHeader}
floating={floatsHeaderOverContent(activeTabDescriptor)}
>
{(topOverlayInset) => (
<StreamTopOverlayProvider value={topOverlayInset}>
@@ -1,31 +1,16 @@
import BottomSheet, {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
type BottomSheetHandleProps,
} from "@gorhom/bottom-sheet";
import { useIsFocused } from "@react-navigation/native";
import {
type ComponentType,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from "react";
import { View } from "react-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 {
SheetBackground,
SheetHandle,
SheetHeaderView,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { systemBackPress } from "@/components/ui/isolated-bottom-sheet-modal";
import { RetainedPanelActivity } from "@/components/retained-panel";
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";
@@ -37,7 +22,17 @@ import {
import { useWorkspaceLayoutStore, type WorkspaceSheet } from "@/stores/workspace-layout-store";
const SNAP_POINTS = ["92%"];
const NO_SHEETS: readonly WorkspaceSheet[] = [];
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,
@@ -51,8 +46,6 @@ const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({
color: theme.colors.foregroundMuted,
}));
// The workspace sheet is a Gorhom sheet rendered in place rather than a portaled modal, so the
// backdrop closes the sheet itself instead of dismissing the top of the modal stack.
function renderBackdrop(props: BottomSheetBackdropProps) {
return (
<BottomSheetBackdrop
@@ -65,45 +58,6 @@ function renderBackdrop(props: BottomSheetBackdropProps) {
);
}
// Stacked sheets share the bottom sheet's dim rather than darkening it again, but still catch the
// tap so it closes the top sheet. The backdrop is clear rather than transparent: iOS skips views
// under 0.01 opacity when hit testing, which would hand the tap to the sheet below.
function renderStackedBackdrop(props: BottomSheetBackdropProps) {
return (
<BottomSheetBackdrop
{...props}
style={styles.clearBackdrop}
appearsOnIndex={0}
disappearsOnIndex={-1}
opacity={1}
pressBehavior="close"
/>
);
}
interface WorkspaceSheetHandleValue {
header: SheetHeader;
onClose: () => void;
}
const WorkspaceSheetHandleContext = createContext<WorkspaceSheetHandleValue | null>(null);
// Gorhom renders `handleComponent` as a component type, so it stays one module-level component and
// reads the header from context; a per-render function would remount the header on every change.
function WorkspaceSheetHandle(props: BottomSheetHandleProps) {
const value = useContext(WorkspaceSheetHandleContext);
if (!value) throw new Error("WorkspaceSheetHandle must render inside a workspace sheet");
return (
<SheetHandle {...props}>
<SheetHeaderView
header={value.header}
onClose={value.onClose}
testID="workspace-sheet-header"
/>
</SheetHandle>
);
}
export interface WorkspaceSheetHostProps {
workspaceKey: string | null;
serverId: string;
@@ -119,54 +73,96 @@ interface WorkspaceSheetPresentationProps extends Omit<
"workspaceKey" | "onCloseTab"
> {
sheet: WorkspaceSheet;
/** Only the bottom sheet dims the workspace; the ones above share its dim. */
isBottom: boolean;
/** Only the top sheet answers Android Back; the ones below wait their turn. */
isTop: boolean;
onClose: (tabId: string) => Promise<void>;
onOpenAsTab: (tabId: string) => void;
onClose: () => Promise<void>;
onOpenAsTab: () => void;
}
/**
* Hosts the workspace's sheets over the compact tab strip, one Gorhom sheet per entry, top last.
* They render in the workspace tree, not through a portal, so each panel keeps every context an
* ordinary pane has. The shared sheet parts (`SheetBackground`, `SheetHandle`, `SheetHeaderView`)
* keep them looking and dragging like every other sheet.
* 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 sheets = useWorkspaceLayoutStore((state) =>
workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS,
const sheet = useWorkspaceLayoutStore((state) =>
workspaceKey ? (state.sheetByWorkspace[workspaceKey] ?? null) : null,
);
const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab);
const handleOpenAsTab = useCallback(
(tabId: string) => {
if (workspaceKey) openSheetAsTab(workspaceKey, tabId);
},
[openSheetAsTab, workspaceKey],
);
if (sheets.length === 0) return null;
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 (
<View style={styles.host} pointerEvents="box-none">
{sheets.map((sheet, index) => (
<WorkspaceSheetPresentation
// Keyed by tab id and kind, so the registration and its descriptor hook stay fixed.
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
{...rest}
sheet={sheet}
isBottom={index === 0}
isTop={index === sheets.length - 1}
onClose={onCloseTab}
onOpenAsTab={handleOpenAsTab}
/>
))}
</View>
<WorkspaceSheetPresentation
{...rest}
sheet={sheet}
onClose={handleClose}
onOpenAsTab={handleOpenAsTab}
/>
);
}
function WorkspaceSheetPresentation({
sheet,
isBottom,
isTop,
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,
@@ -176,110 +172,17 @@ function WorkspaceSheetPresentation({
}: WorkspaceSheetPresentationProps) {
const { t } = useTranslation();
const { tab } = sheet;
const sheetRef = useRef<BottomSheet>(null);
const isScreenFocused = useIsFocused();
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 handleClose = useCallback(() => onClose(tab.tabId), [onClose, tab.tabId]);
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 handleClose().then(() => sheetRef.current?.snapToIndex(0));
},
[handleClose],
);
// Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns back.
const ownsBackPress = isTop && isWorkspaceFocused && isScreenFocused;
useEffect(() => {
if (!ownsBackPress) return;
return systemBackPress.subscribe(() => {
void handleClose();
return true;
});
}, [handleClose, ownsBackPress]);
const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]);
const header = useMemo<SheetHeader>(
() => ({
title: descriptor.label,
leading: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
actions: sheet.policy.openAsTab ? (
<Button size="sm" variant="secondary" onPress={handleOpenAsTab}>
{t("workspace.sheet.openAsTab")}
</Button>
) : undefined,
}),
[descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t],
);
const handleHeaderClose = useCallback(() => {
void handleClose();
}, [handleClose]);
const handleValue = useMemo<WorkspaceSheetHandleValue>(
() => ({ header, onClose: handleHeaderClose }),
[handleHeaderClose, header],
);
return (
// Gorhom renders the handle beside the content, so the dialog wraps the whole sheet.
<View
style={styles.sheetLayer}
pointerEvents="box-none"
role="dialog"
aria-label={descriptor.label}
>
{/* The header is the sheet's handle: the panel scrolls on its own, so dragging the content
would fight it, and the header is where people reach to pull the sheet down. */}
<WorkspaceSheetHandleContext.Provider value={handleValue}>
<BottomSheet
ref={sheetRef}
index={0}
snapPoints={SNAP_POINTS}
enableDynamicSizing={false}
enablePanDownToClose
enableContentPanningGesture={false}
backgroundComponent={SheetBackground}
handleComponent={WorkspaceSheetHandle}
backdropComponent={isBottom ? renderBackdrop : renderStackedBackdrop}
onChange={handleChange}
accessible={false}
>
{/* A covered sheet keeps its state but stops its live work until it is on top again. */}
<RetainedPanelActivity active={isTop}>
<WorkspaceSheetBody
tab={tab}
isPaneFocused={isTop && isWorkspaceFocused}
isWorkspaceFocused={isWorkspaceFocused}
buildContent={buildContent}
/>
</RetainedPanelActivity>
</BottomSheet>
</WorkspaceSheetHandleContext.Provider>
</View>
);
}
function WorkspaceSheetBody({
tab,
isPaneFocused,
isWorkspaceFocused,
buildContent,
}: {
tab: WorkspaceSheet["tab"];
/** Only the top sheet's pane is focused; the ones below are covered. */
isPaneFocused: boolean;
isWorkspaceFocused: boolean;
buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
}) {
const tabDescriptor = useMemo<WorkspaceTabDescriptor>(
() => ({
key: tab.tabId,
@@ -291,20 +194,38 @@ function WorkspaceSheetBody({
[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={contentStyle} testID="workspace-sheet-body">
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
<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>
);
}
@@ -313,11 +234,9 @@ const styles = StyleSheet.create({
host: {
...StyleSheet.absoluteFillObject,
},
clearBackdrop: {
backgroundColor: "transparent",
},
sheetLayer: {
...StyleSheet.absoluteFillObject,
body: {
flex: 1,
minHeight: 0,
},
content: {
flex: 1,
@@ -1,265 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest";
import { defaultHostAppearance } from "@/hosts/appearance";
import { i18n } from "@/i18n/i18next";
import {
MAX_SESSION_DOWNLOAD_BYTES,
useDownloadStore,
type Download,
type DownloadedBytes,
} from "@/stores/download-store";
import type {
DirectTcpHostConnection,
HostProfile,
RelayHostConnection,
} from "@/types/host-connection";
const FILE_BYTES = new TextEncoder().encode("relay download payload");
const FILE_PATH = "reports/données été.bin";
const FILE_NAME = "données été.bin";
const MIME_TYPE = "application/octet-stream";
const relayConnection: RelayHostConnection = {
id: "relay:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
daemonPublicKeyB64: "cHVibGljLWtleQ==",
};
// The #3753 shape: the relay hostname stored as a directTcp entry that nothing can reach.
const staleTcpConnection: DirectTcpHostConnection = {
id: "direct:relay.paseo.sh:443",
type: "directTcp",
endpoint: "relay.paseo.sh:443",
};
const localTcpConnection: DirectTcpHostConnection = {
id: "direct:localhost:6767",
type: "directTcp",
endpoint: "localhost:6767",
};
function profileWith(connections: HostProfile["connections"]): HostProfile {
return {
serverId: "srv_remote",
label: "Remote",
appearance: defaultHostAppearance(),
lifecycle: {},
connections,
preferredConnectionId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}
function createTokenRequest(size: number | null = FILE_BYTES.byteLength) {
const requested: string[] = [];
return {
requested,
request: async (path: string) => {
requested.push(path);
return { token: "tok", fileName: FILE_NAME, mimeType: MIME_TYPE, size, error: null };
},
};
}
function createFileReader(read: () => Promise<{ bytes: Uint8Array; size: number }>) {
const requested: string[] = [];
return {
requested,
read: async (path: string) => {
requested.push(path);
return read();
},
};
}
function createFileSaver() {
const savedFiles: DownloadedBytes[] = [];
return {
savedFiles,
save: async (file: DownloadedBytes) => {
savedFiles.push(file);
return null;
},
};
}
interface StartInput {
profile: HostProfile;
activeConnectionId: string | null;
token?: ReturnType<typeof createTokenRequest>;
reader?: ReturnType<typeof createFileReader>;
saver?: ReturnType<typeof createFileSaver>;
}
async function startDownload(input: StartInput): Promise<Download | undefined> {
const token = input.token ?? createTokenRequest();
const reader =
input.reader ??
createFileReader(async () => ({ bytes: FILE_BYTES, size: FILE_BYTES.byteLength }));
const saver = input.saver ?? createFileSaver();
await useDownloadStore.getState().startDownload({
serverId: "srv_remote",
scopeId: "workspace-1",
fileName: FILE_NAME,
path: FILE_PATH,
daemonProfile: input.profile,
activeConnectionId: input.activeConnectionId,
requestFileDownloadToken: token.request,
readFile: reader.read,
saveFile: saver.save,
});
const { activeDownloadId, downloads } = useDownloadStore.getState();
return activeDownloadId ? downloads.get(activeDownloadId) : undefined;
}
describe("download store transport selection", () => {
beforeEach(() => {
useDownloadStore.setState({ downloads: new Map(), activeDownloadId: null });
});
it("downloads over the session when the active connection is the relay", async () => {
const token = createTokenRequest();
const reader = createFileReader(async () => ({
bytes: FILE_BYTES,
size: FILE_BYTES.byteLength,
}));
const saver = createFileSaver();
const download = await startDownload({
profile: profileWith([relayConnection]),
activeConnectionId: relayConnection.id,
token,
reader,
saver,
});
expect(download).toMatchObject({ fileName: FILE_NAME, status: "complete" });
expect(token.requested).toEqual([FILE_PATH]);
expect(reader.requested).toEqual([FILE_PATH]);
expect(saver.savedFiles).toEqual([
{ bytes: FILE_BYTES, fileName: FILE_NAME, mimeType: MIME_TYPE },
]);
});
it("bounds the session read even when the token reports a smaller file", async () => {
const readRequests: Array<{ path: string; maxBytes: number }> = [];
const saver = createFileSaver();
await useDownloadStore.getState().startDownload({
serverId: "srv_remote",
scopeId: "workspace-1",
fileName: FILE_NAME,
path: FILE_PATH,
daemonProfile: profileWith([relayConnection]),
activeConnectionId: relayConnection.id,
requestFileDownloadToken: createTokenRequest(1).request,
readFile: async (path: string, maxBytes: number) => {
readRequests.push({ path, maxBytes });
return { bytes: FILE_BYTES, size: FILE_BYTES.byteLength };
},
saveFile: saver.save,
});
expect(readRequests).toEqual([{ path: FILE_PATH, maxBytes: MAX_SESSION_DOWNLOAD_BYTES }]);
expect(saver.savedFiles).toEqual([
{ bytes: FILE_BYTES, fileName: FILE_NAME, mimeType: MIME_TYPE },
]);
});
it("ignores a directTcp entry that is not the active connection", async () => {
const reader = createFileReader(async () => ({
bytes: FILE_BYTES,
size: FILE_BYTES.byteLength,
}));
const saver = createFileSaver();
const download = await startDownload({
profile: profileWith([staleTcpConnection, relayConnection]),
activeConnectionId: relayConnection.id,
reader,
saver,
});
expect(download).toMatchObject({ status: "complete" });
expect(reader.requested).toEqual([FILE_PATH]);
expect(saver.savedFiles).toHaveLength(1);
});
it("keeps the HTTP token download when the active connection is directTcp", async () => {
const reader = createFileReader(async () => ({
bytes: FILE_BYTES,
size: FILE_BYTES.byteLength,
}));
const saver = createFileSaver();
const download = await startDownload({
profile: profileWith([localTcpConnection, relayConnection]),
activeConnectionId: localTcpConnection.id,
reader,
saver,
});
expect(download).toMatchObject({ status: "complete" });
expect(reader.requested).toEqual([]);
expect(saver.savedFiles).toEqual([]);
});
it("fails the download and saves nothing when the session read fails", async () => {
const reader = createFileReader(async () => {
throw new Error("Connection lost");
});
const saver = createFileSaver();
const download = await startDownload({
profile: profileWith([relayConnection]),
activeConnectionId: relayConnection.id,
reader,
saver,
});
expect(download).toMatchObject({ status: "error", message: "Connection lost" });
expect(saver.savedFiles).toEqual([]);
});
it("refuses a file over the session size limit before reading it", async () => {
const token = createTokenRequest(MAX_SESSION_DOWNLOAD_BYTES + 1);
const reader = createFileReader(async () => ({
bytes: FILE_BYTES,
size: FILE_BYTES.byteLength,
}));
const saver = createFileSaver();
const download = await startDownload({
profile: profileWith([relayConnection]),
activeConnectionId: relayConnection.id,
token,
reader,
saver,
});
expect(download).toMatchObject({
status: "error",
message: i18n.t("composer.errors.fileTooLarge", { fileName: FILE_NAME, size: "64 MB" }),
});
expect(download?.message).toContain("64 MB");
expect(reader.requested).toEqual([]);
expect(saver.savedFiles).toEqual([]);
});
it("fails instead of saving an empty file when the daemon returns no content", async () => {
const reader = createFileReader(async () => ({ bytes: new Uint8Array(), size: 6 }));
const saver = createFileSaver();
const download = await startDownload({
profile: profileWith([relayConnection]),
activeConnectionId: relayConnection.id,
reader,
saver,
});
expect(download).toMatchObject({
status: "error",
message: "File transfer incomplete: expected 6 bytes, received 0.",
});
expect(saver.savedFiles).toEqual([]);
});
});
+14 -105
View File
@@ -16,16 +16,6 @@ interface DownloadProgress {
eta: number;
}
// The session path holds the file in memory two to three times over (client chunks, the
// concatenated result, then the Blob or native write). Lift once the client streams chunks to disk.
export const MAX_SESSION_DOWNLOAD_BYTES = 64 * 1024 * 1024;
export interface DownloadedBytes {
bytes: Uint8Array;
fileName: string;
mimeType: string | null;
}
export interface Download {
id: string;
serverId: string;
@@ -47,18 +37,12 @@ interface DownloadState {
fileName: string;
path: string;
daemonProfile: HostProfile | undefined;
/** Connection the host runtime is using right now; `null` while disconnected. */
activeConnectionId: string | null;
requestFileDownloadToken: (path: string) => Promise<{
token: string | null;
fileName: string | null;
mimeType: string | null;
size: number | null;
error: string | null;
}>;
/** Reads the whole file over the session socket; used when no direct HTTP endpoint is active. */
readFile: (path: string, maxBytes: number) => Promise<{ bytes: Uint8Array; size: number }>;
saveFile?: typeof saveDownloadedBytes;
}) => Promise<void>;
updateProgress: (id: string, progress: DownloadProgress) => void;
@@ -82,10 +66,7 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
fileName,
path,
daemonProfile,
activeConnectionId,
requestFileDownloadToken,
readFile,
saveFile = saveDownloadedBytes,
}) => {
const id = generateDownloadId();
const download: Download = {
@@ -108,42 +89,12 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
throw new Error(tokenResponse.error ?? i18n.t("downloads.requestTokenFailed"));
}
const resolvedFileName = tokenResponse.fileName ?? fileName;
const downloadTarget = resolveDaemonDownloadTarget(daemonProfile, activeConnectionId);
if (!downloadTarget) {
// Relay, SSH, socket and pipe hosts have no HTTP endpoint: read the file over the
// session socket that is already connected (#543).
if (
typeof tokenResponse.size === "number" &&
tokenResponse.size > MAX_SESSION_DOWNLOAD_BYTES
) {
throw new Error(
i18n.t("composer.errors.fileTooLarge", { fileName: resolvedFileName, size: "64 MB" }),
);
}
const { bytes, size } = await readFile(path, MAX_SESSION_DOWNLOAD_BYTES);
// A daemon without a binary channel answers `encoding: "none"` for a binary file: no
// content, real size. Never save that as a complete download.
if (bytes.byteLength !== size) {
throw new Error(
`File transfer incomplete: expected ${size} bytes, received ${bytes.byteLength}.`,
);
}
const savedUri = await saveFile({
bytes,
fileName: resolvedFileName,
mimeType: tokenResponse.mimeType,
});
get().completeDownload(id);
if (savedUri !== null) {
await shareSavedFile(savedUri, tokenResponse.mimeType, resolvedFileName);
}
return;
}
const downloadTarget = resolveDaemonDownloadTarget(daemonProfile);
if (!downloadTarget.baseUrl) {
throw new Error(i18n.t("downloads.hostUnavailable"));
}
const resolvedFileName = tokenResponse.fileName ?? fileName;
const downloadUrl = buildDownloadUrl(
downloadTarget.baseUrl,
tokenResponse.token,
@@ -195,7 +146,14 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
get().completeDownload(id);
await shareSavedFile(result.uri, tokenResponse.mimeType, resolvedFileName);
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(result.uri, {
mimeType: tokenResponse.mimeType ?? undefined,
dialogTitle: resolvedFileName
? i18n.t("downloads.shareFileNamed", { fileName: resolvedFileName })
: i18n.t("downloads.shareFile"),
});
}
} catch (error) {
const message = error instanceof Error ? error.message : i18n.t("downloads.failed");
if (isWeb) {
@@ -286,18 +244,10 @@ interface DownloadTarget {
authCredentials: { username: string; password: string } | null;
}
/**
* The HTTP download route is only reachable over a direct TCP connection. Returns `null` when
* the connection in use is anything else (relay, SSH, socket, pipe, or none yet), so the caller
* reads the file over the session socket instead.
*/
function resolveDaemonDownloadTarget(
daemon: HostProfile | undefined,
activeConnectionId: string | null,
): DownloadTarget | null {
const connection = daemon?.connections.find((conn) => conn.id === activeConnectionId);
if (connection?.type !== "directTcp") {
return null;
function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget {
const connection = daemon?.connections.find((conn) => conn.type === "directTcp") ?? null;
if (!connection) {
return { baseUrl: null, authHeader: null, authCredentials: null };
}
let parsed: URL;
@@ -349,47 +299,6 @@ function buildDownloadUrl(
return url.toString();
}
/**
* Saves bytes read over the session. Web and Electron hand them to the browser's download
* manager and return `null`; native writes the file and returns its URI for the share sheet.
*/
async function saveDownloadedBytes({
bytes,
fileName,
mimeType,
}: DownloadedBytes): Promise<string | null> {
if (isWeb) {
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
const url = URL.createObjectURL(
new Blob([buffer], { type: mimeType ?? "application/octet-stream" }),
);
triggerBrowserDownload(url, fileName);
// The browser may still be reading the blob while its save prompt is open.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
return null;
}
// Resolve and write in one tick so two downloads of the same name cannot pick the same path.
const targetFile = resolveDownloadTargetFile(fileName);
targetFile.write(bytes);
return targetFile.uri;
}
async function shareSavedFile(
uri: string,
mimeType: string | null,
fileName: string,
): Promise<void> {
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(uri, {
mimeType: mimeType ?? undefined,
dialogTitle: fileName
? i18n.t("downloads.shareFileNamed", { fileName })
: i18n.t("downloads.shareFile"),
});
}
}
function triggerBrowserDownload(url: string, fileName: string) {
if (typeof document === "undefined") {
if (typeof window !== "undefined") {
@@ -4449,7 +4449,7 @@ describe("compact workspace sheet", () => {
function createCompactStore() {
const store = createWorkspaceLayoutStore(workspaceLayoutIds);
store.setState({ layoutByWorkspace: {}, sheetsByWorkspace: {} });
store.setState({ layoutByWorkspace: {}, sheetByWorkspace: {} });
store.getState().setLayoutMode("compact");
store.getState().openTab({
workspaceKey,
@@ -4459,16 +4459,6 @@ describe("compact workspace sheet", () => {
return store;
}
function topSheet(store: ReturnType<typeof createCompactStore>) {
return store.getState().sheetsByWorkspace[workspaceKey]?.at(-1);
}
function sheetTargets(store: ReturnType<typeof createCompactStore>) {
return (store.getState().sheetsByWorkspace[workspaceKey] ?? []).map(
(sheet) => sheet.tab.target,
);
}
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]));
@@ -4533,7 +4523,7 @@ describe("compact workspace sheet", () => {
const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
expect(topSheet(store)?.tab).toEqual({
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab).toEqual({
tabId,
target: fileTarget,
createdAt: expect.any(Number),
@@ -4557,71 +4547,22 @@ describe("compact workspace sheet", () => {
const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
expect(tabId).toBe(fileTabId);
expect(topSheet(store)).toBeUndefined();
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(findPaneContainingTab(layout.root, fileTabId ?? "")?.focusedTabId).toBe(fileTabId);
});
it("stacks the next sheet target over the open sheet", () => {
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" };
const nextTabId = store
.getState()
.openTab({ workspaceKey, target: nextTarget, intent: "reveal" });
expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]);
expect(tabKinds(store)).toEqual(["terminal"]);
store.getState().closeTab(workspaceKey, nextTabId ?? "");
expect(sheetTargets(store)).toEqual([fileTarget]);
});
it("closes a lower sheet and keeps the sheets above it", () => {
const store = createCompactStore();
const fileTabId = 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" });
store.getState().closeTab(workspaceKey, fileTabId ?? "");
expect(sheetTargets(store)).toEqual([nextTarget]);
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual(nextTarget);
expect(tabKinds(store)).toEqual(["terminal"]);
});
it("brings a stacked target back to the top by dropping the sheets above it", () => {
const store = createCompactStore();
const fileTabId = store
.getState()
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
store
.getState()
.openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" });
const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
expect(tabId).toBe(fileTabId);
expect(sheetTargets(store)).toEqual([fileTarget]);
});
it("opens the top sheet as a tab and dismisses the sheets that would cover it", () => {
const store = createCompactStore();
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
const nextTabId = store
.getState()
.openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" });
store.getState().openSheetAsTab(workspaceKey, nextTabId ?? "");
expect(sheetTargets(store)).toEqual([]);
expect(tabKinds(store)).toEqual(["terminal", "file"]);
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(nextTabId);
});
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" });
@@ -4631,7 +4572,7 @@ describe("compact workspace sheet", () => {
target: { kind: "file", path: "src/background.ts" },
intent: "background",
});
expect(topSheet(store)?.tab.target).toEqual(fileTarget);
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual(fileTarget);
expect(tabKinds(store)).toEqual(["terminal", "file"]);
store.getState().openTab({
@@ -4639,7 +4580,7 @@ describe("compact workspace sheet", () => {
target: { kind: "terminal", terminalId: "term-2" },
intent: "reveal",
});
expect(topSheet(store)).toBeUndefined();
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
});
it("moves the sheet tab into the focused pane with the same tab id and state", () => {
@@ -4650,10 +4591,10 @@ describe("compact workspace sheet", () => {
if (!sheetTabId) throw new Error("Expected a sheet tab");
store.getState().setTabState(workspaceKey, sheetTabId, { scrollTop: 120 });
const tabId = store.getState().openSheetAsTab(workspaceKey, sheetTabId);
const tabId = store.getState().openSheetAsTab(workspaceKey);
expect(tabId).toBe(sheetTabId);
expect(topSheet(store)).toBeUndefined();
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
const layout = store.getState().layoutByWorkspace[workspaceKey];
const pane = findPaneContainingTab(layout.root, sheetTabId);
expect(pane?.id).toBe(layout.focusedPaneId);
@@ -4675,7 +4616,7 @@ describe("compact workspace sheet", () => {
expect(store.getState().layoutByWorkspace[workspaceKey]).toBe(layoutBefore);
store.getState().closeTab(workspaceKey, sheetTabId);
expect(topSheet(store)).toBeUndefined();
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(tabKinds(store)).toEqual(["terminal"]);
});
@@ -4698,21 +4639,19 @@ describe("compact workspace sheet", () => {
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
expect(store.getState().sheetsByWorkspace).toEqual({});
expect(store.getState().sheetByWorkspace).toEqual({});
expect(tabKinds(store)).toEqual(["terminal", "file"]);
});
it("keeps every stacked sheet as a tab when the layout widens, focusing the top one", () => {
it("keeps the sheet content as a tab when the layout widens", () => {
const store = createCompactStore();
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
const sheetTabId = store
.getState()
.openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" });
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
store.getState().setLayoutMode("split-panes");
expect(store.getState().sheetsByWorkspace).toEqual({});
expect(tabKinds(store)).toEqual(["terminal", "file", "file"]);
expect(store.getState().sheetByWorkspace).toEqual({});
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(findPaneContainingTab(layout.root, sheetTabId ?? "")?.focusedTabId).toBe(sheetTabId);
});
@@ -4730,7 +4669,7 @@ describe("compact workspace sheet", () => {
target: { kind: "agent", agentId: "child" },
intent: "reveal",
});
expect(topSheet(store)).toMatchObject({
expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({
tab: { target: { kind: "agent", agentId: "child" } },
policy: { openAsTab: true },
});
@@ -4740,7 +4679,7 @@ describe("compact workspace sheet", () => {
target: { kind: "provider_subagent", parentAgentId: "root", subagentId: "task-1" },
intent: "reveal",
});
expect(topSheet(store)).toMatchObject({
expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({
tab: { target: { kind: "provider_subagent", subagentId: "task-1" } },
policy: { openAsTab: false },
});
@@ -4750,7 +4689,7 @@ describe("compact workspace sheet", () => {
.getState()
.openTab({ workspaceKey, target: { kind: "agent", agentId }, intent: "reveal" });
}
expect(topSheet(store)).toBeUndefined();
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]);
});
@@ -4776,9 +4715,9 @@ describe("compact workspace sheet", () => {
intent: "reveal",
parentTabId: childSheetTabId ?? undefined,
});
expect(topSheet(store)?.parentTabId).toBe(rootTabId);
expect(store.getState().sheetByWorkspace[workspaceKey]?.parentTabId).toBe(rootTabId);
store.getState().openSheetAsTab(workspaceKey, fileSheetTabId ?? "");
store.getState().openSheetAsTab(workspaceKey);
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(layout.parentTabIdByTabId?.[fileSheetTabId ?? ""]).toBe(rootTabId);
});
@@ -4807,7 +4746,7 @@ describe("compact workspace sheet", () => {
intent: "reveal",
parentTabId: openChildSheet(),
});
expect(topSheet(store)).toBeUndefined();
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
let layout = store.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(terminalTabId);
@@ -4817,7 +4756,7 @@ describe("compact workspace sheet", () => {
intent: "reveal",
parentTabId: openChildSheet(),
});
expect(topSheet(store)).toBeUndefined();
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);
@@ -4859,45 +4798,22 @@ describe("compact workspace sheet", () => {
});
function sheetTabId(store: ReturnType<typeof createCompactStore>): string | null {
return topSheet(store)?.tab.tabId ?? null;
return store.getState().sheetByWorkspace[workspaceKey]?.tab.tabId ?? null;
}
it("stacks the next sheet target over a modified sheet", () => {
it("opens the next sheet target as a tab under a modified sheet", () => {
const store = createCompactStore();
openModifiedFileSheet(store);
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(nextTabId);
expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]);
expect(store.getState().sheetsByWorkspace[workspaceKey]?.[0]?.tab.state).toEqual({
scrollTop: 40,
});
});
it("brings a covered sheet back to the top and keeps a modified sheet above it beneath", () => {
const store = createCompactStore();
const otherTarget = { kind: "file" as const, path: "src/other.ts" };
const otherTabId = store
.getState()
.openTab({ workspaceKey, target: otherTarget, intent: "reveal" });
const modifiedSheetTabId = openModifiedFileSheet(store);
const focusedBefore = focusedTabId(store);
const tabId = store
.getState()
.openTab({ workspaceKey, target: otherTarget, intent: "reveal" });
expect(tabId).toBe(otherTabId);
expect(sheetTargets(store)).toEqual([fileTarget, otherTarget]);
expect(store.getState().sheetsByWorkspace[workspaceKey]?.[0]?.tab.tabId).toBe(
modifiedSheetTabId,
);
expect(stripTab(store, tabId ?? "")).toBeNull();
expect(focusedTabId(store)).toBe(focusedBefore);
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", () => {
@@ -4937,6 +4853,23 @@ describe("compact workspace sheet", () => {
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({
@@ -4980,38 +4913,10 @@ describe("compact workspace sheet", () => {
};
store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: false });
expect(topSheet(store)).toBeDefined();
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeDefined();
store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: true });
expect(topSheet(store)).toBeUndefined();
});
it("drops a departed agent's sheet from the middle of the stack", () => {
seedAgents([
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
]);
const store = createCompactStore();
const nextTarget = { kind: "file" as const, path: "src/other.ts" };
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
store.getState().openTab({
workspaceKey,
target: { kind: "agent", agentId: "child" },
intent: "reveal",
});
store.getState().openTab({ workspaceKey, target: nextTarget, intent: "reveal" });
store.getState().reconcileTabs(workspaceKey, {
agentsHydrated: true,
terminalsHydrated: false,
activeAgentIds: ["root"],
autoOpenAgentIds: [],
knownTerminalIds: ["term-1"],
standaloneTerminalIds: ["term-1"],
hasActivePendingTerminalCreate: false,
});
expect(sheetTargets(store)).toEqual([fileTarget, nextTarget]);
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
});
it("closes the sheet when its agent stops being a subagent, leaving one tab for it", () => {
@@ -5036,7 +4941,7 @@ describe("compact workspace sheet", () => {
};
store.getState().reconcileTabs(workspaceKey, { ...snapshot, autoOpenAgentIds: [] });
expect(topSheet(store)?.tab.tabId).toBe(sheetTabId);
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.tabId).toBe(sheetTabId);
seedAgents([
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
@@ -5044,7 +4949,7 @@ describe("compact workspace sheet", () => {
]);
store.getState().reconcileTabs(workspaceKey, snapshot);
expect(topSheet(store)).toBeUndefined();
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(
collectAllTabs(layout.root).filter(
@@ -5070,7 +4975,7 @@ describe("compact workspace sheet", () => {
.replaceTab(workspaceKey, sheetTabId ?? "", { kind: "agent", agentId: "child" });
expect(replacedTabId).toBe(sheetTabId);
expect(topSheet(store)).toMatchObject({
expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({
tab: { tabId: sheetTabId, target: { kind: "agent", agentId: "child" } },
policy: { openAsTab: true },
});
@@ -5087,7 +4992,7 @@ describe("compact workspace sheet", () => {
terminalId: "term-2",
});
expect(topSheet(store)).toBeUndefined();
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(replacedTabId).toBe(sheetTabId);
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(sheetTabId);
@@ -5103,12 +5008,12 @@ describe("compact workspace sheet", () => {
const received: string[][] = [];
const stop = observeOpenWorkspaceAgentIds("server-1", (ids) => received.push(ids), store);
const sheetTabId = store.getState().openTab({
store.getState().openTab({
workspaceKey,
target: { kind: "agent", agentId: "child" },
intent: "reveal",
});
store.getState().closeTab(workspaceKey, sheetTabId ?? "");
store.getState().closeSheet(workspaceKey);
stop();
expect(received).toEqual([[], ["child"], []]);
+117 -181
View File
@@ -126,11 +126,8 @@ export interface WorkspaceSheet {
interface WorkspaceLayoutStore {
/** Published once by the app shell; callers never pass a form factor to open a tab. */
layoutMode: WorkspaceLayoutMode;
/**
* Each workspace's open sheets, bottom to top; only the top one is on screen. A workspace with
* no sheet has no entry. Not persisted: a reload returns to the tab strip.
*/
sheetsByWorkspace: Record<string, readonly WorkspaceSheet[]>;
/** 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>;
@@ -151,8 +148,9 @@ interface WorkspaceLayoutStore {
input: Pick<OpenWorkspaceTabInput, "workspaceKey" | "target" | "intent">,
) => boolean;
setLayoutMode: (mode: WorkspaceLayoutMode) => void;
/** Moves a sheet's tab, keeping its tab id, into the focused pane. */
openSheetAsTab: (workspaceKey: string, tabId: string) => string | null;
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,
@@ -510,12 +508,12 @@ export function selectExplorerSidebarPaneId(
/** A workspace's panes give up keyboard focus while focus is parked or the sheet covers them. */
export function selectPaneFocusSuspended(
state: Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace" | "sheetsByWorkspace">,
state: Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace" | "sheetByWorkspace">,
workspaceKey: string,
): boolean {
return (
Boolean(state.focusRestorationByWorkspace[workspaceKey]?.restorePaneId) ||
workspaceKey in state.sheetsByWorkspace
workspaceKey in state.sheetByWorkspace
);
}
@@ -684,63 +682,15 @@ function attachParentTab(input: {
});
}
const NO_SHEETS: readonly WorkspaceSheet[] = [];
function getSheets(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
): readonly WorkspaceSheet[] {
return state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS;
}
function findSheet(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
tabId: string,
): WorkspaceSheet | undefined {
return getSheets(state, workspaceKey).find((sheet) => sheet.tab.tabId === tabId);
}
function withSheets(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
sheets: readonly WorkspaceSheet[],
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
if (sheets.length > 0) {
return { sheetsByWorkspace: { ...state.sheetsByWorkspace, [workspaceKey]: sheets } };
}
if (!(workspaceKey in state.sheetsByWorkspace)) {
return { sheetsByWorkspace: state.sheetsByWorkspace };
}
const { [workspaceKey]: _removed, ...sheetsByWorkspace } = state.sheetsByWorkspace;
return { sheetsByWorkspace };
}
function withoutSheet(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
state: Pick<WorkspaceLayoutStore, "sheetByWorkspace">,
workspaceKey: string,
tabId: string,
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
return withSheets(
state,
workspaceKey,
getSheets(state, workspaceKey).filter((sheet) => sheet.tab.tabId !== tabId),
);
}
function withSheetTab(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
tabId: string,
update: (sheet: WorkspaceSheet) => WorkspaceSheet,
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
return withSheets(
state,
workspaceKey,
getSheets(state, workspaceKey).map((sheet) =>
sheet.tab.tabId === tabId ? update(sheet) : sheet,
),
);
): 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) {
@@ -763,16 +713,30 @@ function resolveSheetPolicy(
}
/**
* The parent a newly opened tab links to. A sheet tab is not in the layout, so a target opened
* from inside a sheet inherits that sheet's own parent instead.
* 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, "sheetsByWorkspace">,
state: Pick<WorkspaceLayoutStore, "sheetByWorkspace">,
workspaceKey: string,
parentTabId: string | undefined,
): string | null {
if (!parentTabId) return null;
return findSheet(state, workspaceKey, parentTabId)?.parentTabId ?? parentTabId;
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: {
@@ -802,16 +766,12 @@ function placeTargetInSheet(input: {
};
}
/**
* Moves one sheet's tab into the workspace's focused pane, keeping its tab id and state. The
* other sheets stay; callers that bring the tab to the front dismiss them.
*/
/** Moves a workspace's sheet tab into its focused pane, keeping its tab id and state. */
function promoteSheetToTab(
state: WorkspaceLayoutStore,
workspaceKey: string,
tabId: string,
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
const sheet = findSheet(state, workspaceKey, tabId);
const sheet = state.sheetByWorkspace[workspaceKey];
if (!sheet) return null;
const { tab } = sheet;
const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT);
@@ -838,7 +798,7 @@ function promoteSheetToTab(
return {
tabId: result.tabId,
patch: {
...withoutSheet(state, workspaceKey, tab.tabId),
...withoutSheet(state, workspaceKey),
...withoutFocusRestoration(state, workspaceKey),
hiddenAgentIdsByWorkspace:
tab.target.kind === "agent"
@@ -857,55 +817,37 @@ function promoteSheetToTab(
* 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(workspaceKey: string, sheet: WorkspaceSheet): boolean {
return isSheetTabModified(workspaceKey, sheet.tab.tabId);
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 stack, or null when it opens as a tab. A new target goes on
* top. A reveal of a resource a sheet already shows keeps that sheet's tab and brings it back to
* the top; the sheets above it close, except ones holding unsaved work, which stay beneath it.
* 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; keptIndex: number | null } | null {
): { policy: CompactSheetPolicy; kept: WorkspaceSheet | undefined } | null {
const policy = resolveSheetPolicy(state, workspaceKey, target, intent);
if (!policy) return null;
if (intent !== "reveal") return { policy, keptIndex: null };
const sheets = getSheets(state, workspaceKey);
const resourceKey = panelResourceKey(target);
const index = sheets.findIndex((sheet) => panelResourceKey(sheet.tab.target) === resourceKey);
return { policy, keptIndex: index === -1 ? null : index };
const sheet = state.sheetByWorkspace[workspaceKey];
if (sheetKeepsTabFor(sheet, target, intent)) return { policy, kept: sheet };
return sheetHoldsUnsavedWork(state, workspaceKey) ? null : { policy, kept: undefined };
}
/** Closes the sheets because navigation moved on underneath them, except ones with unsaved work. */
function dismissSheets(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
return withSheets(
state,
workspaceKey,
getSheets(state, workspaceKey).filter((sheet) => sheetHoldsUnsavedWork(workspaceKey, sheet)),
);
}
/** Moves a sheet's tab to the front of the tab strip, dismissing the sheets that would cover it. */
function openSheetTabInFront(
/** Closes the sheet because navigation moved on underneath it, unless it holds unsaved work. */
function dismissSheet(
state: WorkspaceLayoutStore,
workspaceKey: string,
tabId: string,
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
const promoted = promoteSheetToTab(state, workspaceKey, tabId);
if (!promoted) return null;
const next = { ...state, ...promoted.patch };
return {
tabId: promoted.tabId,
patch: { ...promoted.patch, ...dismissSheets(next, workspaceKey) },
};
): Partial<WorkspaceLayoutStore> {
return sheetHoldsUnsavedWork(state, workspaceKey) ? {} : withoutSheet(state, workspaceKey);
}
/**
@@ -915,10 +857,10 @@ function openSheetTabInFront(
*/
function sheetAgentNoLongerFits(
workspaceKey: string,
sheet: WorkspaceSheet,
sheet: WorkspaceSheet | undefined,
snapshot: WorkspaceTabSnapshot,
): boolean {
if (sheet.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false;
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;
}
@@ -963,7 +905,7 @@ export function createWorkspaceLayoutStore(
return tabId;
},
layoutMode: "split-panes",
sheetsByWorkspace: {},
sheetByWorkspace: {},
layoutByWorkspace: {},
splitSizesByWorkspace: {},
explorerSidebarWidthByWorkspace: {},
@@ -991,26 +933,17 @@ export function createWorkspaceLayoutStore(
input.intent,
);
if (sheetPlacement) {
const sheets = getSheets(get(), normalizedWorkspaceKey);
const { keptIndex } = sheetPlacement;
const sheet = placeTargetInSheet({
kept: keptIndex === null ? undefined : sheets[keptIndex],
kept: sheetPlacement.kept,
target: normalizedTarget,
intent: input.intent,
policy: sheetPlacement.policy,
parentTabId,
state: input.state,
});
const below =
keptIndex === null
? sheets
: [
...sheets.slice(0, keptIndex),
...sheets
.slice(keptIndex + 1)
.filter((above) => sheetHoldsUnsavedWork(normalizedWorkspaceKey, above)),
];
set((state) => withSheets(state, normalizedWorkspaceKey, [...below, sheet]));
set((state) => ({
sheetByWorkspace: { ...state.sheetByWorkspace, [normalizedWorkspaceKey]: sheet },
}));
return sheet.tab.tabId;
}
const placement = getOpenTabPlacement(
@@ -1057,7 +990,7 @@ export function createWorkspaceLayoutStore(
set((state) => ({
...withoutFocusRestoration(state, normalizedWorkspaceKey),
// A tab brought to the front dismisses the sheet over it.
...(input.intent === "background" ? {} : dismissSheets(state, normalizedWorkspaceKey)),
...(input.intent === "background" ? {} : dismissSheet(state, normalizedWorkspaceKey)),
hiddenAgentIdsByWorkspace:
normalizedTarget.kind !== "agent"
? state.hiddenAgentIdsByWorkspace
@@ -1099,25 +1032,24 @@ export function createWorkspaceLayoutStore(
set((state) => {
if (state.layoutMode === mode) return state;
if (mode === "compact") return { layoutMode: mode };
// Widening keeps whatever the user had open in sheets as ordinary tabs.
// Widening keeps whatever the user had open in the sheet as an ordinary tab.
const next: WorkspaceLayoutStore = { ...state, layoutMode: mode };
for (const [workspaceKey, sheets] of Object.entries(state.sheetsByWorkspace)) {
for (const sheet of sheets) {
const promoted = promoteSheetToTab(next, workspaceKey, sheet.tab.tabId);
Object.assign(
next,
promoted?.patch ?? withoutSheet(next, workspaceKey, sheet.tab.tabId),
);
}
for (const workspaceKey of Object.keys(state.sheetByWorkspace)) {
const promoted = promoteSheetToTab(next, workspaceKey);
Object.assign(next, promoted?.patch ?? withoutSheet(next, workspaceKey));
}
return next;
});
},
openSheetAsTab: (workspaceKey, tabId) => {
closeSheet: (workspaceKey) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTabId = trimNonEmpty(tabId);
if (!normalizedWorkspaceKey || !normalizedTabId) return null;
const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, normalizedTabId);
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;
@@ -1241,8 +1173,8 @@ export function createWorkspaceLayoutStore(
}
set((state) => {
if (findSheet(state, normalizedWorkspaceKey, normalizedTabId)) {
return withoutSheet(state, normalizedWorkspaceKey, normalizedTabId);
if (state.sheetByWorkspace[normalizedWorkspaceKey]?.tab.tabId === normalizedTabId) {
return withoutSheet(state, normalizedWorkspaceKey);
}
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
@@ -1351,8 +1283,8 @@ export function createWorkspaceLayoutStore(
return {
...withoutFocusRestoration(state, normalizedWorkspaceKey),
// Choosing a tab dismisses the sheets that cover it.
...dismissSheets(state, normalizedWorkspaceKey),
// Choosing a tab dismisses the sheet that covers it.
...dismissSheet(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
@@ -1376,8 +1308,8 @@ export function createWorkspaceLayoutStore(
});
return nextLayout
? {
// Choosing a tab dismisses the sheets that cover it.
...dismissSheets(state, normalizedWorkspaceKey),
// Choosing a tab dismisses the sheet that covers it.
...dismissSheet(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
@@ -1391,27 +1323,29 @@ export function createWorkspaceLayoutStore(
const normalizedTabId = trimNonEmpty(tabId);
const normalizedTarget = normalizeWorkspaceTabTarget(target);
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null;
if (findSheet(get(), normalizedWorkspaceKey, normalizedTabId)) {
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(),
...withSheetTab(get(), normalizedWorkspaceKey, normalizedTabId, (sheet) => ({
...sheet,
policy: policy ?? sheet.policy,
tab: {
...sheet.tab,
target: normalizedTarget,
...(tabState !== undefined ? { state: tabState } : {}),
},
})),
sheetByWorkspace: { ...get().sheetByWorkspace, [normalizedWorkspaceKey]: replaced },
};
if (policy) {
set({ sheetsByWorkspace: next.sheetsByWorkspace });
set({ sheetByWorkspace: next.sheetByWorkspace });
return normalizedTabId;
}
// A target sheets do not host becomes the tab it would have opened as.
const promoted = openSheetTabInFront(next, normalizedWorkspaceKey, normalizedTabId);
set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey, 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({
@@ -1444,14 +1378,18 @@ export function createWorkspaceLayoutStore(
const normalizedTabId = trimNonEmpty(tabId);
if (!normalizedWorkspaceKey || !normalizedTabId) return;
set((state) => {
if (findSheet(state, normalizedWorkspaceKey, normalizedTabId)) {
return withSheetTab(state, normalizedWorkspaceKey, normalizedTabId, (sheet) => {
const { state: _previous, ...tab } = sheet.tab;
return {
...sheet,
tab: tabState === undefined ? tab : { ...tab, state: tabState },
};
});
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),
@@ -1547,11 +1485,11 @@ export function createWorkspaceLayoutStore(
);
}
}
const sheets = getSheets(state, normalizedWorkspaceKey);
const fittingSheets = sheets.filter(
(sheet) => !sheetAgentNoLongerFits(normalizedWorkspaceKey, sheet, snapshot),
const sheetAgentGone = sheetAgentNoLongerFits(
normalizedWorkspaceKey,
state.sheetByWorkspace[normalizedWorkspaceKey],
snapshot,
);
const sheetAgentGone = fittingSheets.length !== sheets.length;
if (
state.layoutByWorkspace[normalizedWorkspaceKey] &&
nextLayout === rawLayout &&
@@ -1562,7 +1500,7 @@ export function createWorkspaceLayoutStore(
}
return {
...(sheetAgentGone ? withSheets(state, normalizedWorkspaceKey, fittingSheets) : {}),
...(sheetAgentGone ? withoutSheet(state, normalizedWorkspaceKey) : {}),
pinnedAgentIdsByWorkspace,
layoutByWorkspace: {
...state.layoutByWorkspace,
@@ -2068,7 +2006,7 @@ export function createWorkspaceLayoutStore(
normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace ||
normalizedWorkspaceKey in state.sidePaneIdByWorkspace ||
normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace ||
normalizedWorkspaceKey in state.sheetsByWorkspace;
normalizedWorkspaceKey in state.sheetByWorkspace;
if (!hasAny) {
return state;
}
@@ -2097,7 +2035,7 @@ export function createWorkspaceLayoutStore(
const { [normalizedWorkspaceKey]: _sidePane, ...sidePaneIdByWorkspace } =
state.sidePaneIdByWorkspace;
return {
...withSheets(state, normalizedWorkspaceKey, NO_SHEETS),
...withoutSheet(state, normalizedWorkspaceKey),
pullRequestTabAutoOpenedByWorkspace,
layoutByWorkspace,
splitSizesByWorkspace,
@@ -2214,16 +2152,14 @@ export function observeOpenWorkspaceAgentIds(
store = useWorkspaceLayoutStore,
): () => void {
let previous: string[] | undefined;
const publish = (
state: Pick<WorkspaceLayoutStore, "layoutByWorkspace" | "sheetsByWorkspace">,
) => {
const publish = (state: Pick<WorkspaceLayoutStore, "layoutByWorkspace" | "sheetByWorkspace">) => {
const ids = new Set<string>();
const tabsByWorkspace = [
...Object.entries(state.layoutByWorkspace).map(
([workspaceKey, layout]) => [workspaceKey, collectAllTabs(layout.root)] as const,
),
...Object.entries(state.sheetsByWorkspace).map(
([workspaceKey, sheets]) => [workspaceKey, sheets.map((sheet) => sheet.tab)] as const,
...Object.entries(state.sheetByWorkspace).map(
([workspaceKey, sheet]) => [workspaceKey, [sheet.tab]] as const,
),
];
for (const [workspaceKey, tabs] of tabsByWorkspace) {
@@ -2241,7 +2177,7 @@ export function observeOpenWorkspaceAgentIds(
const unsubscribe = store.subscribe((state, before) => {
if (
state.layoutByWorkspace !== before.layoutByWorkspace ||
state.sheetsByWorkspace !== before.sheetsByWorkspace
state.sheetByWorkspace !== before.sheetByWorkspace
) {
publish(state);
}
+10 -2
View File
@@ -2,7 +2,7 @@ import { isCompactBreakpoint } from "@/constants/layout";
import { ICON_SIZE, type Theme } from "@/styles/theme";
/**
* Density sizes for surfaces that opt into touch sizing: rows and icons grow on compact
* Density sizes for surfaces that opt into touch sizing: rows, icons, and text grow on compact
* layouts and keep their desktop sizes from the `md` breakpoint up. Plain theme tokens stay
* desktop-sized everywhere, so a surface migrates by swapping its sizes for these.
*
@@ -11,8 +11,10 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
*/
export type DensityIconSize = keyof typeof ICON_SIZE;
type TextSize = "sm" | "base";
const COMPACT_ICON_SIZE: Record<DensityIconSize, number> = { xs: 14, sm: 18, md: 20, lg: 24 };
const COMPACT_TEXT_SIZE: Record<TextSize, keyof Theme["fontSize"]> = { sm: "base", base: "lg" };
// Compact layouts end where `md` begins; see `isCompactBreakpoint`.
function byLayout<T>(compact: T, desktop: T) {
@@ -23,7 +25,9 @@ export const density = {
/** A list row such as a sidebar entry. */
rowHeight: byLayout(44, 36),
/** A row in a tight group, such as the sidebar's navigation rows. */
tightRowHeight: byLayout(36, 28),
tightRowHeight: byLayout(40, 28),
/** The line height of a row's `base` text; slots that align to its first line share it. */
rowLineHeight: byLayout(22, 20),
/** A square icon-only button that sits in a row or toolbar. */
iconButton: byLayout(44, 28),
};
@@ -33,6 +37,10 @@ export function densityIconBox(size: DensityIconSize) {
return byLayout(COMPACT_ICON_SIZE[size], ICON_SIZE[size]);
}
export function densityFontSize(theme: Theme, size: TextSize) {
return byLayout(theme.fontSize[COMPACT_TEXT_SIZE[size]], theme.fontSize[size]);
}
export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number {
return isCompactBreakpoint(rt.breakpoint) ? COMPACT_ICON_SIZE[size] : ICON_SIZE[size];
}
@@ -5,7 +5,6 @@ import type {
SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
import { routeKeyboardShortcut } from "@/keyboard/route-shortcut";
import {
buildSidebarShortcutModel,
@@ -66,46 +65,6 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
}
describe("buildSidebarShortcutModel", () => {
it.each([
[9, 1, 10],
[10, -1, 9],
[12, 1, 1],
[1, -1, 12],
] as const)(
"navigates from workspace %i by %i to workspace %i across the full visible sidebar",
(current, delta, next) => {
const workspaces = Array.from({ length: 12 }, (_, index) =>
workspace({
serverId: "s",
workspaceId: `ws-${index + 1}`,
workspaceDirectory: `/repo/w${index + 1}`,
name: `w${index + 1}`,
}),
);
const model = buildSidebarShortcutModel({
projects: [project("p", workspaces)],
collapsedProjectKeys: new Set<string>(),
});
expect(
routeKeyboardShortcut(
{ action: "workspace.navigate.relative", payload: { delta } },
{
pathname: `/h/s/workspace/ws-${current}`,
isMobile: false,
sidebarShortcutTargets: model.shortcutTargets,
navigationActiveWorkspace: null,
commandCenterOpen: false,
shortcutsDialogOpen: false,
},
),
).toEqual({ kind: "navigate-workspace", serverId: "s", workspaceId: `ws-${next}` });
expect(model.shortcutIndexByWorkspaceKey.size).toBe(9);
expect(model.shortcutIndexByWorkspaceKey.get("s:ws-9")).toBe(9);
expect(model.shortcutIndexByWorkspaceKey.has("s:ws-10")).toBe(false);
},
);
it("builds shortcut targets in visual order and excludes collapsed projects", () => {
const projects = [
project("p1", [
@@ -152,7 +111,7 @@ describe("buildSidebarShortcutModel", () => {
expect(model.shortcutIndexByWorkspaceKey.get("s1:ws-repo2-main")).toBeUndefined();
});
it("keeps all traversal targets while numbering only the first 9", () => {
it("limits shortcuts to 9", () => {
const workspaces = Array.from({ length: 20 }, (_, index) =>
workspace({
serverId: "s",
@@ -168,11 +127,9 @@ describe("buildSidebarShortcutModel", () => {
collapsedProjectKeys: new Set<string>(),
});
expect(model.shortcutTargets).toHaveLength(20);
expect(model.shortcutTargets).toHaveLength(9);
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "ws-1" });
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "ws-9" });
expect(model.shortcutTargets[9]).toEqual({ serverId: "s", workspaceId: "ws-10" });
expect(model.shortcutIndexByWorkspaceKey.get("s:ws-9")).toBe(9);
expect(model.shortcutIndexByWorkspaceKey.get("s:ws-10")).toBeUndefined();
});
it("excludes a collapsed project's workspaces regardless of project kind", () => {
+6 -3
View File
@@ -70,10 +70,13 @@ export function buildSidebarShortcutSections(input: {
}
for (const workspace of section.workspaces) {
shortcutTargets.push(createShortcutTarget(workspace));
if (shortcutTargets.length <= maxShortcuts) {
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutTargets.length);
if (shortcutTargets.length >= maxShortcuts) {
break;
}
const shortcutNumber = shortcutTargets.length + 1;
shortcutTargets.push(createShortcutTarget(workspace));
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber);
}
}
@@ -27,7 +27,7 @@ const WORKSPACE_KEY = "server-1:workspace-1";
beforeEach(() => {
useWorkspaceLayoutStore.setState({
layoutMode: "split-panes",
sheetsByWorkspace: {},
sheetByWorkspace: {},
layoutByWorkspace: {},
explorerSidebarPaneIdByWorkspace: {},
sidePaneIdByWorkspace: {},
@@ -56,7 +56,7 @@ describe("openPreferredWorkspacePreview", () => {
});
const state = useWorkspaceLayoutStore.getState();
expect(state.sheetsByWorkspace[WORKSPACE_KEY]?.at(-1)?.tab).toMatchObject({
expect(state.sheetByWorkspace[WORKSPACE_KEY]?.tab).toMatchObject({
tabId,
target: { kind: "file", path: "src/preview.ts" },
});
@@ -96,7 +96,7 @@ describe("previews under a sheet with unsaved work", () => {
});
}
it("stacks link previews over the sheet instead of dropping its unsaved work", () => {
it("reuses one preview tab instead of adding a tab per link", () => {
const store = useWorkspaceLayoutStore.getState();
store.openTab({
workspaceKey: WORKSPACE_KEY,
@@ -114,17 +114,18 @@ describe("previews under a sheet with unsaved work", () => {
{ modified: true },
);
previewFile("src/first.ts");
previewFile("src/second.ts");
const firstPreviewTabId = previewFile("src/first.ts");
const secondPreviewTabId = previewFile("src/second.ts");
const state = useWorkspaceLayoutStore.getState();
expect(state.sheetsByWorkspace[WORKSPACE_KEY]?.map((sheet) => sheet.tab.target)).toEqual([
sheetTarget,
{ kind: "file", path: "src/first.ts" },
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" },
]);
const root = state.layoutByWorkspace[WORKSPACE_KEY].root;
expect(findPaneById(root, "main")?.tabIds).toHaveLength(1);
});
});
@@ -8,7 +8,6 @@ export default Stub;
export const BottomSheetModal = Stub;
export const BottomSheetModalProvider = PassThrough;
export const BottomSheetBackdrop = Stub;
export const BottomSheetHandle = Stub;
export const BottomSheetScrollView = PassThrough;
export const BottomSheetView = PassThrough;
export const BottomSheetTextInput = Stub;
-96
View File
@@ -2801,102 +2801,6 @@ test("readFile drops an old daemon's over-budget binary chunks and reports the r
await expect(responsePromise).rejects.toThrow("File is too large to display");
});
async function connectFileClient(clientId: string) {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId,
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
return { mock, client };
}
function sendBinaryFile(
mock: ReturnType<typeof createMockTransport>,
requestId: string,
announcedSize: number,
chunks: string[],
) {
mock.triggerMessage(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId,
metadata: {
mime: "application/octet-stream",
size: announcedSize,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
},
}),
);
for (const chunk of chunks) {
mock.triggerMessage(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId,
payload: new TextEncoder().encode(chunk),
}),
);
}
}
test("readFile with timeout 0 outlives the default request timeout", async () => {
useHeartbeatClock();
const { mock, client } = await connectFileClient("clsk_file_no_timeout");
const responsePromise = client.readFile("/tmp/project", "slow.bin", "req-slow", undefined, 0);
await vi.advanceTimersByTimeAsync(60_001);
sendBinaryFile(mock, "req-slow", 5, ["hello"]);
mock.triggerMessage(
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-slow" }),
);
const result = await responsePromise;
expect(result.size).toBe(5);
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
});
test("readFile rejects a binary transfer that ends before the announced size", async () => {
const { mock, client } = await connectFileClient("clsk_file_short");
const responsePromise = client.readFile("/tmp/project", "short.bin", "req-short");
sendBinaryFile(mock, "req-short", 10, ["hello"]);
mock.triggerMessage(
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-short" }),
);
await expect(responsePromise).rejects.toThrow(
"File transfer incomplete: expected 10 bytes, received 5.",
);
});
test("readFile rejects a binary transfer that exceeds the announced size", async () => {
const { mock, client } = await connectFileClient("clsk_file_long");
const responsePromise = client.readFile("/tmp/project", "long.bin", "req-long");
const settled = responsePromise.then(
() => null,
(error: unknown) => error,
);
sendBinaryFile(mock, "req-long", 4, ["hello!"]);
expect(() =>
mock.triggerMessage(
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-long" }),
),
).not.toThrow();
expect(await settled).toMatchObject({
message: "File transfer incomplete: expected 4 bytes, received 6.",
});
});
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
-21
View File
@@ -4758,7 +4758,6 @@ export class DaemonClient {
requestId?: string,
acceptBinary = false,
maxBytes?: number,
timeout?: number,
): Promise<FileExplorerPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
@@ -4771,7 +4770,6 @@ export class DaemonClient {
...(maxBytes ? { maxBytes } : {}),
},
responseType: "file_explorer_response",
timeout,
});
}
@@ -4790,17 +4788,11 @@ export class DaemonClient {
return payload.directory;
}
/**
* `timeout` bounds the whole transfer, not each frame. `0` disables the timer (as
* `workspace.create` does) for files that legitimately take longer than the default over a
* slow link; a dead connection still rejects on disconnect.
*/
async readFile(
cwd: string,
path: string,
requestId?: string,
maxBytes?: number,
timeout?: number,
): Promise<FileReadResult> {
const resolvedRequestId = this.createRequestId(requestId);
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path, maxBytes });
@@ -4812,7 +4804,6 @@ export class DaemonClient {
resolvedRequestId,
true,
maxBytes,
timeout,
);
if (payload.error) {
throw new Error(payload.error);
@@ -6549,18 +6540,6 @@ export class DaemonClient {
return;
}
const received = transfer.chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
if (received !== transfer.size) {
this.activeBinaryFileTransfers.delete(frame.requestId);
this.rejectWaitersForRequestId(
frame.requestId,
new Error(
`File transfer incomplete: expected ${transfer.size} bytes, received ${received}.`,
),
);
return;
}
const bytes = concatByteChunks(transfer.chunks, transfer.size);
this.activeBinaryFileTransfers.delete(frame.requestId);
this.completedBinaryFileReads.set(frame.requestId, {
@@ -1,88 +0,0 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const modulePath = fileURLToPath(new URL("./console-streams.ts", import.meta.url));
type Output = "stdout" | "stderr";
async function writeToConsole(input: { closed?: Output; invalidStdout?: boolean } = {}) {
const child = spawn(
process.execPath,
[
"--import",
"tsx",
"-e",
`
const { protectConsoleStreams } = require(${JSON.stringify(modulePath)});
protectConsoleStreams();
process.send("ready");
process.once("message", () => {
if (${JSON.stringify(input.invalidStdout)}) require("node:fs").closeSync(1);
console.info("desktop info");
console.warn("desktop warning");
setImmediate(() => { process.send("alive"); process.disconnect(); });
});
`,
],
{
cwd: fileURLToPath(new URL(".", import.meta.url)),
stdio: ["ignore", "pipe", "pipe", "ipc"],
env: Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== "FORCE_COLOR")),
},
);
let stdout = "";
let stderr = "";
let alive = false;
child.stdout.setEncoding("utf8").on("data", (text: string) => {
stdout += text;
});
child.stderr.setEncoding("utf8").on("data", (text: string) => {
stderr += text;
});
child.on("message", (message) => {
if (message === "ready") {
if (input.closed) child[input.closed].destroy();
child.send("write");
}
if (message === "alive") alive = true;
});
const code = await new Promise<number | null>((resolve, reject) => {
child.once("close", resolve);
child.once("error", reject);
});
return { code, stdout, stderr, alive };
}
describe("desktop console streams", () => {
for (const stream of ["stdout", "stderr"] as const) {
it(`keeps running when the launcher closes ${stream}`, async () => {
const result = await writeToConsole({ closed: stream });
expect(result.code, result.stderr).toBe(0);
expect(result.alive).toBe(true);
expect(result[stream === "stdout" ? "stderr" : "stdout"]).toBe(
stream === "stdout" ? "desktop warning\n" : "desktop info\n",
);
});
}
// Windows stdout uses a native handle rather than the POSIX fd closed here.
it.skipIf(process.platform === "win32")(
"does not suppress a console failure other than a closed pipe",
async () => {
const result = await writeToConsole({ invalidStdout: true });
expect(result.code).toBe(1);
expect(result.alive).toBe(false);
expect(result.stderr).toContain("EBADF");
},
);
it("preserves logging while the console has readers", async () => {
expect(await writeToConsole()).toEqual({
code: 0,
stdout: "desktop info\n",
stderr: "desktop warning\n",
alive: true,
});
});
});
@@ -1,11 +0,0 @@
function ignoreClosedConsole(error: NodeJS.ErrnoException): void {
if (error.code !== "EPIPE") {
throw error;
}
}
/** Keep desktop logging independent of the program that launched its console. */
export function protectConsoleStreams(): void {
process.stdout.on("error", ignoreClosedConsole);
process.stderr.on("error", ignoreClosedConsole);
}
-3
View File
@@ -1,6 +1,3 @@
import { protectConsoleStreams } from "./diagnostics/console-streams.js";
protectConsoleStreams();
process.emitWarning = (() => {}) as typeof process.emitWarning;
import log from "electron-log/main";
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/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",
@@ -1,6 +1,5 @@
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import { delimiter, resolve } from "node:path";
import type { ProcessEnvRecord } from "../server/paseo-env.js";
import { execCommand } from "../utils/spawn.js";
import { windowsExecutableResolution } from "./windows.js";
@@ -121,29 +120,6 @@ export function executableExists(
return exists(executablePath) ? executablePath : null;
}
// PATH lookup that never runs the candidate, unlike findExecutable which probes
// each one with `--version`. A terminal spawn path cannot afford that probe: it
// would execute the profile command once before the terminal even opens.
// `path` overrides the PATH to search, so a caller can resolve against the
// environment its child process will actually get rather than the daemon's own.
export async function findExecutableWithoutProbing(
name: string,
options: { path?: string; cwd: string },
): Promise<string | null> {
if (!name) {
return null;
}
const path =
options.path === undefined
? undefined
: options.path
.split(delimiter)
.map((entry) => resolve(options.cwd, entry))
.join(delimiter);
const candidates = await enumerateCandidatesViaLibrary(name, { PATH: path });
return candidates[0] ?? null;
}
export interface ExecutableResolutionOptions {
probeTimeoutMs?: number;
env?: ProcessEnvRecord;
@@ -31,81 +31,6 @@ afterEach(async () => {
await ctx.cleanup();
}, 60000);
describe("checkout discard", () => {
test.each([".", "apps/web"])(
"discards selected Changes from the %s workspace without changing siblings",
async (workspacePath) => {
const cwd = tmpCwd();
function git(args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" });
}
try {
git(["init", "-b", "main"]);
git(["config", "user.name", "Paseo Test"]);
git(["config", "user.email", "test@getpaseo.local"]);
git(["config", "core.autocrlf", "false"]);
const nested = path.join(cwd, "apps", "web");
mkdirSync(nested, { recursive: true });
writeFileSync(path.join(nested, "target[ab].txt"), "target base\n");
writeFileSync(path.join(nested, "targeta.txt"), "sibling base\n");
writeFileSync(path.join(nested, "deleted.txt"), "deleted base\n");
git(["add", "."]);
git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"]);
const creation = await ctx.client.createWorkspace({
source: { kind: "directory", path: path.join(cwd, workspacePath) },
title: "Discard regression",
});
const workspaceCwd = creation.workspace?.workspaceDirectory;
if (!workspaceCwd) {
throw new Error(creation.error ?? "Workspace creation failed");
}
writeFileSync(path.join(nested, "target[ab].txt"), "target edited\n");
writeFileSync(path.join(nested, "targeta.txt"), "sibling edited\n");
writeFileSync(path.join(nested, "staged.txt"), "staged new file\n");
writeFileSync(path.join(nested, "scratch.txt"), "untracked file\n");
git(["--literal-pathspecs", "add", "--", "apps/web/target[ab].txt", "apps/web/staged.txt"]);
git(["rm", "apps/web/deleted.txt"]);
const subscription = ctx.client.observeCheckoutDiff(workspaceCwd, { mode: "uncommitted" });
try {
const changes = await subscription.ready;
expect(changes.error).toBeNull();
expect(changes.files.map((file) => file.path).sort()).toEqual([
"apps/web/deleted.txt",
"apps/web/scratch.txt",
"apps/web/staged.txt",
"apps/web/target[ab].txt",
"apps/web/targeta.txt",
]);
const selectedPaths = changes.files
.filter((file) => file.path !== "apps/web/targeta.txt")
.map((file) => file.path);
const discarded = await ctx.client.checkoutDiscardChanges(workspaceCwd, {
paths: selectedPaths,
});
expect(discarded).toMatchObject({ success: true, error: null });
const target = await ctx.client.readFile(cwd, "apps/web/target[ab].txt");
expect(new TextDecoder().decode(target.bytes)).toBe("target base\n");
const restored = await ctx.client.readFile(cwd, "apps/web/deleted.txt");
expect(new TextDecoder().decode(restored.bytes)).toBe("deleted base\n");
const sibling = await ctx.client.readFile(cwd, "apps/web/targeta.txt");
expect(new TextDecoder().decode(sibling.bytes)).toBe("sibling edited\n");
await expect(ctx.client.readFile(cwd, "apps/web/staged.txt")).rejects.toThrow(/ENOENT/);
await expect(ctx.client.readFile(cwd, "apps/web/scratch.txt")).rejects.toThrow(/ENOENT/);
expect(git(["status", "--porcelain=v1", "-z"])).toBe(" M apps/web/targeta.txt\0");
} finally {
await subscription.release();
}
} finally {
// afterEach shuts down the daemon and its repository observers first.
onTestFinished(() => rmSync(cwd, { recursive: true, force: true }));
}
},
);
});
describe("file explorer", () => {
test.each(["..", "..."])(
"workspace suggestions resolve exact paths starting with %s",
@@ -8,13 +8,9 @@ import net from "node:net";
import path from "node:path";
import { spawn, type ChildProcess } from "node:child_process";
import { Buffer } from "node:buffer";
import { createHash, randomBytes } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import { generateLocalPairingOffer } from "../pairing-offer.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { FILE_EXPLORER_STREAM_CHUNK_BYTES } from "../file-explorer/service.js";
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
import {
deriveSharedKey,
@@ -79,10 +75,6 @@ function decodeOfferFromFragmentUrl(url: string): {
return { serverId: offer.serverId, daemonPublicKeyB64: offer.daemonPublicKeyB64 };
}
function sha256(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
function encodeCiphertext(ciphertext: ArrayBuffer): string {
return Buffer.from(new Uint8Array(ciphertext)).toString("base64");
}
@@ -868,67 +860,4 @@ async function waitForCapturedLog(
await stopRelay();
}
}, 90000);
test("reads a workspace file over the E2EE relay session byte-for-byte", async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
const { logger, lines } = createCapturingLogger();
let daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>> | null = null;
let workspaceDir: string | null = null;
const fileName = "rapport données.bin";
const source = randomBytes(4 * FILE_EXPLORER_STREAM_CHUNK_BYTES + 123);
let client: DaemonClient | null = null;
try {
await startRelay({ useLocalRelay: true });
daemon = await createTestPaseoDaemon({
listen: "127.0.0.1",
logger,
relayEnabled: true,
relayEndpoint: `127.0.0.1:${relayPort}`,
});
workspaceDir = await mkdtemp(path.join(os.tmpdir(), "paseo-relay-download-"));
await writeFile(path.join(workspaceDir, fileName), source);
const offerUrl = await getPairingOfferUrl({
paseoHome: daemon.paseoHome,
relayEnabled: daemon.config.relayEnabled,
relayEndpoint: daemon.config.relayEndpoint,
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
appBaseUrl: daemon.config.appBaseUrl,
});
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
client = new DaemonClient({
url: buildRelayWebSocketUrl({
endpoint: `127.0.0.1:${relayPort}`,
useTls: false,
serverId,
role: "client",
}),
clientType: "cli",
connectTimeoutMs: 30_000,
e2ee: { enabled: true, daemonPublicKeyB64 },
reconnect: { enabled: false },
});
await client.connect();
const result = await client.readFile(workspaceDir, fileName, undefined, undefined, 0);
expect(result.size).toBe(source.byteLength);
expect(result.bytes.byteLength).toBe(source.byteLength);
expect(sha256(result.bytes)).toBe(sha256(source));
} catch (err) {
const tail = lines.slice(-50).join("");
// eslint-disable-next-line no-console
console.error("daemon logs (tail):\n", tail);
throw err;
} finally {
await client?.close().catch(() => undefined);
await daemon?.close();
await stopRelay();
if (workspaceDir) {
await rm(workspaceDir, { recursive: true, force: true });
}
}
}, 90000);
});
@@ -244,28 +244,3 @@ async function observeScriptLifecycles(
await subscription.ready;
return lifecycles;
}
test.runIf(process.platform !== "win32")(
"a missing command fails creation without leaving a terminal listed",
async () => {
const workspaceId = await createWorkspace("Missing command");
await expect(
sdk.terminals.create({
workspaceId,
name: "Missing command",
command: "paseo-test-absent-terminal-command",
}),
).rejects.toThrow("paseo-test-absent-terminal-command: command not found");
expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]);
const control = await sdk.terminals.create({
workspaceId,
command: process.execPath,
args: ["-e", "process.stdin.resume()"],
});
expect((await sdk.terminals.list({ workspaceId })).entries.map((entry) => entry.id)).toEqual([
control.id,
]);
await control.kill();
expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]);
},
);
@@ -1,62 +0,0 @@
import { test, expect } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createTestPaseoDaemon, DaemonClient } from "./test-utils/index.js";
test.skipIf(process.platform === "win32")(
"checkout and commit diffs preserve filenames that Git quotes",
async () => {
const cwd = mkdtempSync(join(tmpdir(), "git-filename-diffs-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const names = [
'report "draft".txt',
"back\\slash.txt",
"tab\tname.txt",
"normal.txt",
"with spaces.txt",
];
const editLines = expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "before" }),
expect.objectContaining({ type: "add", content: "after" }),
]);
const daemon = await createTestPaseoDaemon({ agentClients: {} });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
git("config", "core.quotepath", "false");
for (const name of names) writeFileSync(join(cwd, name), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "initial");
for (const name of names) writeFileSync(join(cwd, name), "after\n");
await client.connect();
const checkout = await client.getCheckoutDiff(cwd, { mode: "uncommitted" });
expect(checkout.error).toBeNull();
expect(checkout.files.map((file) => file.path).sort()).toEqual([...names].sort());
for (const file of checkout.files) {
expect(file).toMatchObject({ status: "ok", additions: 1, deletions: 1 });
expect(file.hunks.flatMap((hunk) => hunk.lines)).toEqual(editLines);
}
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "edit");
const sha = git("rev-parse", "HEAD");
for (const name of names) {
const commit = await client.getCommitFileDiff(cwd, sha, name);
expect(commit.file).toMatchObject({ path: name, additions: 1, deletions: 1 });
expect(commit.file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(editLines);
const file = await client.readFile(cwd, name);
expect(file.kind).toBe("text");
expect(new TextDecoder().decode(file.bytes)).toBe("after\n");
}
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
);
@@ -773,12 +773,8 @@ describe("Hub relationship", () => {
const second = await relationship.ownedCreateResult("second-create");
expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId);
expect(first).toMatchObject({
payload: { success: true, executionId: "first-execution", error: null },
});
expect(second).toMatchObject({
payload: { success: true, executionId: "second-execution", error: null },
});
expect(first).toMatchObject({ payload: { success: true, executionId: "first-execution" } });
expect(second).toMatchObject({ payload: { success: true, executionId: "second-execution" } });
expect(await relationship.durableOwnedAgentIds()).toHaveLength(2);
});
@@ -218,85 +218,6 @@ index 1234567..abcdefg 100644
},
);
it("decodes quoted Git patch paths before matching files", () => {
const files = parseDiff(
[
'diff --git "a/report \\"draft\\".txt" "b/report \\"draft\\".txt"',
"index 1234567..abcdefg 100644",
'--- "a/report \\"draft\\".txt"',
'+++ "b/report \\"draft\\".txt"',
"@@ -1 +1 @@",
"-before",
"+after",
"",
].join("\n"),
);
expect(files).toEqual([
{
path: 'report "draft".txt',
isNew: false,
isDeleted: false,
additions: 1,
deletions: 1,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after" },
],
},
],
},
]);
});
it.each([
["tab\\tname.txt", "tab\tname.txt"],
["line\\nname.txt", "line\nname.txt"],
["back\\\\slash.txt", "back\\slash.txt"],
["bell\\aname.txt", "bell\u0007name.txt"],
["vertical\\vname.txt", "vertical\u000bname.txt"],
["\\346\\265\\213\\350\\257\\225.txt", "测试.txt"],
['测试 \\"draft\\".txt', '测试 "draft".txt'],
])("decodes C-quoted path %s without changing its diff lines", (encoded, path) => {
const files = parseDiff(
[
'diff --git "a/' + encoded + '" "b/' + encoded + '"',
"index 1234567..abcdefg 100644",
'--- "a/' + encoded + '"',
'+++ "b/' + encoded + '"',
"@@ -1 +1 @@",
"-before",
"+after",
"",
].join("\n"),
);
expect(files).toHaveLength(1);
expect(files[0]).toMatchObject({ path, additions: 1, deletions: 1 });
expect(files[0].hunks[0].lines).toEqual([
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after" },
]);
});
it.each([
['"a/old \\"draft\\".txt" b/new with spaces.txt', "new with spaces.txt"],
['a/old with spaces.txt "b/new \\"draft\\".txt"', 'new "draft".txt'],
['"report \\"draft\\".txt" "report \\"draft\\".txt"', 'report "draft".txt'],
])("reads quoted paths from header-only patches: %s", (header, path) => {
expect(parseDiff("diff --git " + header + "\nsimilarity index 100%\n")).toEqual([
{ path, isNew: false, isDeleted: false, additions: 0, deletions: 0, hunks: [] },
]);
});
it("parses a simple diff with one hunk", () => {
const files = parseDiff(SIMPLE_DIFF);
@@ -1,7 +1,6 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { highlightCode, isLanguageSupported, type HighlightToken } from "@getpaseo/highlight";
import { unquoteGitPath } from "../../utils/git-quoted-path.js";
const MAX_DIFF_HIGHLIGHT_LINE_CHARS = 10_000;
@@ -54,7 +53,7 @@ function extractPathFromMetadata(lines: string[], prefix: "--- " | "+++ "): stri
return null;
}
const path = unquoteGitPath(line.slice(prefix.length).replace(/\t.*$/, ""));
const path = line.slice(prefix.length).replace(/\t.*$/, "").trimEnd();
return path === "/dev/null" ? null : path;
}
@@ -65,17 +64,6 @@ function extractPathFromDiffHeader(lines: string[]): string {
return prefixedPathMatch[2];
}
const quotedPathMatch =
firstLine.match(/^("(?:\\.|[^"\\])*") (.+)$/) ?? firstLine.match(/^(.+) ("(?:\\.|[^"\\])*")$/);
if (quotedPathMatch) {
const oldPath = unquoteGitPath(quotedPathMatch[1]);
const newPath = unquoteGitPath(quotedPathMatch[2]);
if (usesDiffPathPrefixes(oldPath, newPath)) {
return newPath.slice(2);
}
return newPath;
}
const metadataPath =
extractPathFromMetadata(lines, "+++ ") ?? extractPathFromMetadata(lines, "--- ");
if (metadataPath) {
@@ -33,7 +33,7 @@ describe("ensureWorkspaceServicePortPlan", () => {
expect(allocatedServices).toEqual(["api", "web", "worker"]);
});
it("extends the existing plan with newly declared services without reallocating peers", async () => {
it("returns the existing plan without calling the allocator on later calls", async () => {
let allocationCount = 0;
const firstPlan = await ensureWorkspaceServicePortPlan({
@@ -54,11 +54,9 @@ describe("ensureWorkspaceServicePortPlan", () => {
},
});
expect(Array.from(secondPlan.entries())).toEqual([
...firstPlan.entries(),
["new-service", 4300],
]);
expect(allocationCount).toBe(3);
expect(Array.from(secondPlan.entries())).toEqual(Array.from(firstPlan.entries()));
expect(secondPlan.has("new-service")).toBe(false);
expect(allocationCount).toBe(2);
});
it("shares one first-plan build across concurrent callers", async () => {
@@ -103,103 +101,6 @@ describe("ensureWorkspaceServicePortPlan", () => {
expect(allocationCount).toBe(2);
});
it("extends a pending plan for a concurrent caller with another service", async () => {
const allocation = createDeferredPort();
const workspaceId = "registry-concurrent-extension-workspace";
const firstPlan = ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }],
allocatePort: async () => allocation.promise,
});
let allocations = 0;
const secondPlan = ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }, { scriptName: "web" }],
allocatePort: async () => {
allocations += 1;
return 6101;
},
});
allocation.resolve(6100);
expect(Array.from((await firstPlan).entries())).toEqual([["backend-dev", 6100]]);
expect(Array.from((await secondPlan).entries())).toEqual([
["backend-dev", 6100],
["web", 6101],
]);
expect(allocations).toBe(1);
releaseWorkspaceServicePortPlan(workspaceId);
});
it("keeps the previous plan and reservations when an extension fails", async () => {
const workspaceId = "registry-failed-extension-workspace";
await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }],
allocatePort: async () => 6200,
});
let allocations = 0;
await expect(
ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }, { scriptName: "web" }, { scriptName: "worker" }],
allocatePort: async () => {
allocations += 1;
if (allocations === 1) return 6201;
throw new Error("Allocation failed");
},
}),
).rejects.toThrow("Allocation failed");
const priorPlan = await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }],
allocatePort: async () => {
throw new Error("Existing port must survive");
},
});
expect(Array.from(priorPlan.entries())).toEqual([["backend-dev", 6200]]);
const otherWorkspace = "registry-failed-extension-other-workspace";
const reusedPlan = await ensureWorkspaceServicePortPlan({
workspaceId: otherWorkspace,
services: [{ scriptName: "web" }],
allocatePort: async ({ reservedPorts }) => {
expect(reservedPorts.has(6200)).toBe(true);
expect(reservedPorts.has(6201)).toBe(false);
return 6201;
},
});
expect(reusedPlan.get("web")).toBe(6201);
releaseWorkspaceServicePortPlan(workspaceId);
releaseWorkspaceServicePortPlan(otherWorkspace);
});
it("rejects a new explicit port that collides with an existing service", async () => {
const workspaceId = "registry-extension-explicit-collision-workspace";
await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }],
allocatePort: async () => 6300,
});
await expect(
ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6300 }],
allocatePort: async () => 6301,
}),
).rejects.toThrow("Service 'web' has a duplicate port 6300");
const plan = await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6301 }],
allocatePort: async () => {
throw new Error("No allocation needed");
},
});
expect(Array.from(plan.entries())).toEqual([
["backend-dev", 6300],
["web", 6301],
]);
releaseWorkspaceServicePortPlan(workspaceId);
});
it("uses explicit configured ports without calling the allocator", async () => {
let allocationCount = 0;
@@ -411,123 +312,6 @@ describe("refreshWorkspaceServicePort", () => {
]);
});
it("preserves a concurrent service addition while refreshing a stopped service", async () => {
const workspaceId = "registry-refresh-and-extend-workspace";
await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }],
allocatePort: async () => 6400,
});
const allocation = createDeferredPort();
const refreshing = refreshWorkspaceServicePort({
workspaceId,
service: { scriptName: "backend-dev" },
allocatePort: async () => allocation.promise,
});
const extending = ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6402 }],
allocatePort: async () => {
throw new Error("Explicit addition needs no allocation");
},
});
allocation.resolve(6401);
expect(await refreshing).toBe(6401);
await extending;
const plan = await ensureWorkspaceServicePortPlan({
workspaceId,
services: [],
allocatePort: async () => {
throw new Error("No allocation needed");
},
});
expect(Array.from(plan.entries())).toEqual([
["backend-dev", 6401],
["web", 6402],
]);
releaseWorkspaceServicePortPlan(workspaceId);
});
it.each(["reused port", "allocation failure"])(
"cancels a pending refresh and its waiter after workspace release (%s)",
async (outcome) => {
const workspaceId = "registry-cancelled-refresh-workspace";
await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "api" }],
allocatePort: async () => 6500,
});
const allocation = createDeferredPort();
const refreshing = refreshWorkspaceServicePort({
workspaceId,
service: { scriptName: "api" },
allocatePort: async () => {
const port = await allocation.promise;
if (outcome === "allocation failure") throw new Error("Allocation failed");
return port;
},
});
const waiting = refreshWorkspaceServicePort({
workspaceId,
service: { scriptName: "web", port: 6501 },
allocatePort: async () => 6501,
});
const assertions = Promise.all([
expect(refreshing).rejects.toThrow("Workspace service port plan was released"),
expect(waiting).rejects.toThrow("Workspace service port plan was released"),
]);
releaseWorkspaceServicePortPlan(workspaceId);
allocation.resolve(6500);
await assertions;
const reusedPlan = await ensureWorkspaceServicePortPlan({
workspaceId: "registry-after-cancelled-refresh-workspace",
services: [{ scriptName: "api" }],
allocatePort: async () => 6500,
});
expect(reusedPlan.get("api")).toBe(6500);
releaseWorkspaceServicePortPlan("registry-after-cancelled-refresh-workspace");
},
);
it("lets a waiting service restart allocate after another service's restart fails", async () => {
const workspaceId = "registry-independent-refresh-failure-workspace";
await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "api" }, { scriptName: "web" }],
allocatePort: createSequentialPortAllocator(6600),
});
const allocation = createDeferredPort();
const failedRefresh = refreshWorkspaceServicePort({
workspaceId,
service: { scriptName: "api" },
allocatePort: async () => {
await allocation.promise;
throw new Error("API port script failed");
},
});
const failedAssertion = expect(failedRefresh).rejects.toThrow("API port script failed");
const waitingRefresh = refreshWorkspaceServicePort({
workspaceId,
service: { scriptName: "web" },
allocatePort: async () => 6602,
});
allocation.resolve(0);
await failedAssertion;
expect(await waitingRefresh).toBe(6602);
const plan = await ensureWorkspaceServicePortPlan({
workspaceId,
services: [],
allocatePort: async () => {
throw new Error("No allocation needed");
},
});
expect(Array.from(plan.entries())).toEqual([
["api", 6600],
["web", 6602],
]);
releaseWorkspaceServicePortPlan(workspaceId);
});
it("uses an explicit configured port without calling the allocator", async () => {
let allocationCount = 0;
@@ -24,24 +24,6 @@ interface PendingWorkspaceServicePortPlanToken {
isReleased: boolean;
}
class WorkspaceServicePortPlanReleasedError extends Error {
constructor(workspaceId: string) {
super(`Workspace service port plan was released while being created for '${workspaceId}'`);
}
}
async function waitForWorkspaceServicePortPlanUpdate(
pendingPlan: Promise<Map<string, number>>,
): Promise<void> {
try {
await pendingPlan;
} catch (error) {
// Allocation failure belongs to the original request. A waiting request
// retries against the rolled-back plan; workspace archival cancels both.
if (error instanceof WorkspaceServicePortPlanReleasedError) throw error;
}
}
const workspaceServicePortPlans = new Map<string, Map<string, number>>();
const pendingWorkspaceServicePortPlans = new Map<string, Promise<Map<string, number>>>();
const pendingWorkspaceServicePortPlanTokens = new Map<
@@ -55,19 +37,25 @@ const MAX_DYNAMIC_PORT_ALLOCATION_ATTEMPTS = 10;
export async function ensureWorkspaceServicePortPlan(
options: EnsureWorkspaceServicePortPlanOptions,
): Promise<ReadonlyMap<string, number>> {
const pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
if (pendingPlan) {
await waitForWorkspaceServicePortPlanUpdate(pendingPlan);
return ensureWorkspaceServicePortPlan(options);
}
const existingPlan =
workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>();
if (options.services.every((service) => existingPlan.has(service.scriptName))) {
const existingPlan = workspaceServicePortPlans.get(options.workspaceId);
if (existingPlan) {
return new Map(existingPlan);
}
return new Map(await startWorkspaceServicePortPlanUpdate({ ...options, existingPlan }));
let pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
if (!pendingPlan) {
const token: PendingWorkspaceServicePortPlanToken = { isReleased: false };
pendingPlan = createPendingWorkspaceServicePortPlan({
workspaceId: options.workspaceId,
services: options.services,
allocatePort: options.allocatePort,
token,
});
pendingWorkspaceServicePortPlans.set(options.workspaceId, pendingPlan);
pendingWorkspaceServicePortPlanTokens.set(options.workspaceId, token);
}
return new Map(await pendingPlan);
}
export function requirePlannedWorkspaceServicePort(
@@ -94,62 +82,27 @@ export function releaseWorkspaceServicePortPlan(workspaceId: string): void {
dynamicPortsByWorkspace.delete(workspaceId);
}
interface WorkspaceServicePortPlanUpdateOptions extends EnsureWorkspaceServicePortPlanOptions {
existingPlan: ReadonlyMap<string, number>;
refreshScriptName?: string;
}
function startWorkspaceServicePortPlanUpdate(
options: WorkspaceServicePortPlanUpdateOptions,
): Promise<Map<string, number>> {
const token: PendingWorkspaceServicePortPlanToken = { isReleased: false };
const plan = createPendingWorkspaceServicePortPlan({ ...options, token });
pendingWorkspaceServicePortPlans.set(options.workspaceId, plan);
pendingWorkspaceServicePortPlanTokens.set(options.workspaceId, token);
return plan;
}
async function createPendingWorkspaceServicePortPlan(
options: WorkspaceServicePortPlanUpdateOptions & { token: PendingWorkspaceServicePortPlanToken },
): Promise<Map<string, number>> {
const previousDynamicPorts = new Map(dynamicPortsByWorkspace.get(options.workspaceId));
const retainedPlan = new Map(options.existingPlan);
if (options.refreshScriptName !== undefined) retainedPlan.delete(options.refreshScriptName);
async function createPendingWorkspaceServicePortPlan(options: {
workspaceId: string;
services: readonly WorkspaceServicePortDeclaration[];
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
token: PendingWorkspaceServicePortPlanToken;
}): Promise<Map<string, number>> {
try {
const updatedPlan = await buildWorkspaceServicePortPlan({
const plan = await buildWorkspaceServicePortPlan({
workspaceId: options.workspaceId,
services: options.services,
allocatePort: options.allocatePort,
existingPlan: retainedPlan,
});
const plan = new Map(options.existingPlan);
for (const [scriptName, port] of updatedPlan) plan.set(scriptName, port);
if (options.token.isReleased) {
throw new WorkspaceServicePortPlanReleasedError(options.workspaceId);
}
for (const [scriptName, port] of previousDynamicPorts) {
if (plan.get(scriptName) !== port) {
releaseDynamicPort({ workspaceId: options.workspaceId, scriptName, port });
}
throw new Error(
`Workspace service port plan was released while being created for '${options.workspaceId}'`,
);
}
workspaceServicePortPlans.set(options.workspaceId, plan);
return plan;
} catch (error) {
const dynamicPorts = dynamicPortsByWorkspace.get(options.workspaceId);
if (dynamicPorts) {
for (const [scriptName, port] of dynamicPorts) {
if (options.token.isReleased || previousDynamicPorts.get(scriptName) !== port) {
releaseDynamicPort({ workspaceId: options.workspaceId, scriptName, port });
}
}
}
if (!options.token.isReleased) {
for (const [scriptName, port] of previousDynamicPorts) {
reserveDynamicPort({ workspaceId: options.workspaceId, scriptName, port });
}
}
if (options.token.isReleased)
throw new WorkspaceServicePortPlanReleasedError(options.workspaceId);
releaseWorkspaceServicePortPlan(options.workspaceId);
throw error;
} finally {
pendingWorkspaceServicePortPlans.delete(options.workspaceId);
@@ -158,16 +111,12 @@ async function createPendingWorkspaceServicePortPlan(
}
async function buildWorkspaceServicePortPlan(options: {
existingPlan: ReadonlyMap<string, number>;
workspaceId: string;
services: readonly WorkspaceServicePortDeclaration[];
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
}): Promise<Map<string, number>> {
const explicitPortOwners = new Map<number, string>(
Array.from(options.existingPlan, ([scriptName, port]) => [port, scriptName]),
);
const explicitPortOwners = new Map<number, string>();
for (const service of options.services) {
if (options.existingPlan.has(service.scriptName)) continue;
if (service.port === undefined) continue;
if (explicitPortOwners.has(service.port)) {
throw new Error(`Service '${service.scriptName}' has a duplicate port ${service.port}`);
@@ -175,9 +124,8 @@ async function buildWorkspaceServicePortPlan(options: {
explicitPortOwners.set(service.port, service.scriptName);
}
const plan = new Map(options.existingPlan);
const plan = new Map<string, number>();
for (const service of options.services) {
if (plan.has(service.scriptName)) continue;
if (service.port !== undefined) {
plan.set(service.scriptName, service.port);
continue;
@@ -200,20 +148,32 @@ async function buildWorkspaceServicePortPlan(options: {
export async function refreshWorkspaceServicePort(
options: RefreshWorkspaceServicePortOptions,
): Promise<number> {
const pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
if (pendingPlan) {
await waitForWorkspaceServicePortPlanUpdate(pendingPlan);
return refreshWorkspaceServicePort(options);
}
const plan = workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>();
const plan = await startWorkspaceServicePortPlanUpdate({
const reservedPorts = new Set(plan.values());
const previousPort = plan.get(options.service.scriptName);
if (previousPort !== undefined) {
reservedPorts.delete(previousPort);
}
const oldDynamicPort = dynamicPortsByWorkspace
.get(options.workspaceId)
?.get(options.service.scriptName);
const port = await resolveServicePort({
service: options.service,
workspaceId: options.workspaceId,
services: [options.service],
allocatePort: options.allocatePort,
existingPlan: workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>(),
refreshScriptName: options.service.scriptName,
reservedPorts,
});
return requirePlannedWorkspaceServicePort(plan, options.service.scriptName);
if (oldDynamicPort !== undefined && oldDynamicPort !== port) {
releaseDynamicPort({
workspaceId: options.workspaceId,
scriptName: options.service.scriptName,
port: oldDynamicPort,
});
}
plan.set(options.service.scriptName, port);
workspaceServicePortPlans.set(options.workspaceId, plan);
return port;
}
async function resolveServicePort(options: {
@@ -837,54 +837,6 @@ describe("runAsyncWorktreeBootstrap", () => {
});
});
it("starts a service added to paseo.json without restarting the daemon", async () => {
const backend = {
type: "service" as const,
command: "uv run uvicorn app.main:app --reload --port $PASEO_PORT",
};
commitPaseoScripts({ "backend-dev": backend });
const serviceProxy = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: CreateTerminalCall[] = [];
const terminalRecords: StubTerminalRecord[] = [];
const options = {
repoRoot: repoDir,
workspaceId: repoDir,
projectSlug: "repo",
branchName: "feature-added-service",
daemonPort: 6767,
serviceProxy,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
};
const backendResult = await spawnWorkspaceScript({ ...options, scriptName: "backend-dev" });
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
scripts: {
"backend-dev": backend,
web: { type: "service", command: "pnpm run dev -- --port $PASEO_PORT" },
},
}),
);
const webResult = await spawnWorkspaceScript({ ...options, scriptName: "web" });
expect(webResult.port).toEqual(expect.any(Number));
expect(webResult.port).not.toBe(backendResult.port);
expect(terminalRecords[1]?.sentInputs).toEqual(["pnpm run dev -- --port $PASEO_PORT\r"]);
expect(createTerminalCalls[1]?.env?.PASEO_PORT).toBe(String(webResult.port));
expect(createTerminalCalls[1]?.env?.PASEO_SERVICE_BACKEND_DEV_PORT).toBe(
String(backendResult.port),
);
expect(serviceProxy.listRoutes()).toHaveLength(2);
expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "backend-dev" })).toMatchObject({
lifecycle: "running",
terminalId: backendResult.terminalId,
});
});
it("spawns services with public aliases and public service URLs", async () => {
commitPaseoScripts(
{
+1 -187
View File
@@ -2,7 +2,6 @@ import { describe, it, expect, afterEach, vi } from "vitest";
import { isPlatform } from "../test-utils/platform.js";
import {
buildTerminalEnvironment,
assertTerminalCommandCanStart,
createTerminal,
ensureNodePtySpawnHelperExecutableForCurrentPlatform,
resolveDefaultTerminalShell,
@@ -27,7 +26,7 @@ import { spawnSync } from "node:child_process";
import * as pty from "node-pty";
import xterm from "@xterm/headless";
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
import { delimiter, join } from "node:path";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { setImmediate as waitForImmediate, setTimeout as delay } from "node:timers/promises";
import { stripVTControlCharacters } from "node:util";
@@ -638,151 +637,6 @@ describe("createTerminal", () => {
expect(exitInfo.lastOutputLines.length).toBeGreaterThan(0);
expect(exitInfo.lastOutputLines[exitInfo.lastOutputLines.length - 1]).toBe("line-2999");
});
it.runIf(isPlatform("linux", "darwin"))(
"rejects a profile command that is not installed instead of opening a terminal",
async () => {
await expect(
createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
name: "Codex",
command: "paseo-qa-absent-codex",
args: [],
}),
).rejects.toThrow("paseo-qa-absent-codex: command not found");
},
);
it.runIf(isPlatform("linux", "darwin"))(
"opens a command found only on the PATH carried by the env option",
async () => {
// execve resolves the command against the child's PATH, which carries the
// per-cwd registered environment. A version-manager shim lives there and
// not necessarily on the daemon's own PATH.
const binDir = mkdtempSync(join(tmpdir(), "terminal-envpath-bin-"));
temporaryDirs.push(binDir);
const toolPath = join(binDir, "paseo-qa-envpath-tool");
writeFileSync(toolPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n");
chmodSync(toolPath, 0o755);
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
name: "Shim",
command: "paseo-qa-envpath-tool",
args: [],
env: { PATH: `${binDir}${delimiter}${process.env.PATH ?? ""}` },
}),
);
await waitForLines(session, ["paseo-launch-ok"]);
expect(getLines(session.getState())[0]).toBe("paseo-launch-ok");
},
);
it.runIf(isPlatform("linux", "darwin"))(
"reports command not found when the command is on neither PATH",
async () => {
await expect(
createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
name: "Codex",
command: "paseo-qa-absent-codex",
args: [],
env: { PATH: `/paseo-qa-empty${delimiter}${process.env.PATH ?? ""}` },
}),
).rejects.toThrow("paseo-qa-absent-codex: command not found");
},
);
it.runIf(isPlatform("linux", "darwin"))(
"opens a relative command path resolved against the terminal cwd",
async () => {
// execve resolves a path-ish command against the child's cwd, never the
// daemon's own working directory.
const projectDir = mkdtempSync(join(tmpdir(), "terminal-relative-cmd-"));
temporaryDirs.push(projectDir);
mkdirSync(join(projectDir, "scripts"));
const scriptPath = join(projectDir, "scripts", "dev.sh");
writeFileSync(scriptPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n");
chmodSync(scriptPath, 0o755);
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(projectDir),
name: "Dev script",
command: "./scripts/dev.sh",
args: [],
}),
);
await waitForLines(session, ["paseo-launch-ok"]);
expect(getLines(session.getState())[0]).toBe("paseo-launch-ok");
},
);
it.runIf(isPlatform("linux", "darwin"))("opens an absolute command path", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "terminal-absolute-cmd-"));
temporaryDirs.push(projectDir);
const scriptPath = join(projectDir, "run.sh");
writeFileSync(scriptPath, "#!/usr/bin/env bash\nprintf 'paseo-launch-ok\\n'\nsleep 30\n");
chmodSync(scriptPath, 0o755);
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
name: "Absolute",
command: scriptPath,
args: [],
}),
);
await waitForLines(session, ["paseo-launch-ok"]);
expect(getLines(session.getState())[0]).toBe("paseo-launch-ok");
});
it("leaves the command check to the terminal itself on Windows", async () => {
// Windows resolves inside resolveTerminalSpawnCommand, which has its own
// shim handling and its own fallback for an unresolved command.
await expect(
assertTerminalCommandCanStart({
command: "paseo-qa-absent-codex",
cwd: realpathSync(tmpdir()),
env: { PATH: "/paseo-qa-empty" },
platform: "win32",
}),
).resolves.toBeUndefined();
});
it.runIf(isPlatform("linux", "darwin"))(
"still opens a terminal for an installed command that exits non-zero",
async () => {
// A profile running a failing test suite must keep opening a tab with its
// output. Only a command that cannot start at all is reported as an error,
// and "exited non-zero" does not distinguish the two: a missing binary and
// `false` both surface as exit code 1 from node-pty.
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
name: "Failing suite",
command: "false",
args: [],
}),
);
const exitInfo = await new Promise<{ exitCode: number | null }>((resolve) => {
session.onExit((info) => resolve({ exitCode: info.exitCode }));
});
expect(exitInfo).toEqual({ exitCode: 1 });
},
);
});
describe.skipIf(isPlatform("win32"))("send input", () => {
@@ -1534,43 +1388,3 @@ describe.runIf(isPlatform("win32"))(".cmd shim argv round-trip on Windows", () =
expect(await roundTrip("first\nsecond")).toEqual(["first second"]);
});
});
it.runIf(isPlatform("linux", "darwin"))(
"opens a profile command from a PATH entry relative to the terminal cwd",
async () => {
const projectDir = mkdtempSync(join(tmpdir(), "terminal-relative-path-"));
mkdirSync(join(projectDir, "bin"));
const script = join(projectDir, "bin", "paseo-relative-path-tool");
writeFileSync(script, "#!/bin/sh\nprintf 'paseo-launch-ok\\n'\nexec /bin/sleep 30\n");
chmodSync(script, 0o755);
let session: TerminalSession | undefined;
try {
session = await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(projectDir),
command: "paseo-relative-path-tool",
env: { PATH: "bin" },
});
await waitForLines(session, ["paseo-launch-ok"]);
expect(getLines(session.getState())[0]).toBe("paseo-launch-ok");
expect(session.cwd).toBe(realpathSync(projectDir));
expect(session.getExitInfo()).toBeNull();
} finally {
await session?.killAndWait({ gracefulTimeoutMs: 1000, forceTimeoutMs: 500 });
rmSync(projectDir, { recursive: true, force: true });
}
},
);
it.runIf(isPlatform("linux", "darwin"))(
"checks the exact command rather than accepting a trimmed executable name",
async () => {
await expect(
createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
command: " node ",
}),
).rejects.toThrow(" node : command not found");
},
);
+10 -95
View File
@@ -1,25 +1,14 @@
import * as pty from "node-pty";
import xterm, { type Terminal as TerminalType } from "@xterm/headless";
import { randomUUID } from "crypto";
import {
accessSync,
chmodSync,
constants,
existsSync,
mkdirSync,
readFileSync,
statSync,
} from "node:fs";
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
import { tmpdir, userInfo } from "node:os";
import { basename, delimiter, dirname, extname, join, resolve as resolvePath } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { createExternalProcessEnv } from "../server/paseo-env.js";
import { writePrivateFileAtomicSync } from "../server/private-files.js";
import {
findExecutable,
findExecutableWithoutProbing,
} from "../executable-resolution/executable-resolution.js";
import { findExecutable } from "../executable-resolution/executable-resolution.js";
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
import { TerminalActivityTracker } from "./activity/terminal-activity-tracker.js";
@@ -44,20 +33,6 @@ export interface TerminalExitInfo {
lastOutputLines: string[];
}
// The field is assigned explicitly rather than declared as a constructor
// parameter property: the terminal worker loads this module under
// --experimental-strip-types, which erases types but cannot emit the
// assignment a parameter property implies.
export class TerminalCommandNotFoundError extends Error {
readonly command: string;
constructor(command: string) {
super(`${command}: command not found`);
this.name = "TerminalCommandNotFoundError";
this.command = command;
}
}
export interface TerminalCommandFinishedInfo {
exitCode: number | null;
}
@@ -911,62 +886,6 @@ function extractLastOutputLinesFromText(text: string, limit: number): string[] {
return lines.slice(-limit);
}
function isExecutableFile(path: string): boolean {
try {
if (!statSync(path).isFile()) {
return false;
}
accessSync(path, constants.X_OK);
return true;
} catch {
return false;
}
}
export interface AssertTerminalCommandCanStartInput {
command: string | undefined;
// The terminal's own cwd and environment, not the daemon's: execve in the
// child resolves against those, so anything else produces false negatives.
cwd: string;
env: Record<string, string>;
platform?: NodeJS.Platform;
}
// node-pty spawns directly through execve on POSIX, with no shell, so a command
// that does not exist fails inside the spawn helper and surfaces as exit code 1
// with no output -- indistinguishable from `false` or any program that simply
// failed. Resolving it up front is the only signal that separates "cannot start"
// from "ran and exited non-zero", and it lets the create report the failure
// instead of handing back a terminal that dies immediately. Windows keeps
// resolving inside resolveTerminalSpawnCommand, which has its own shim handling
// and its own fallback for an unresolved command.
export async function assertTerminalCommandCanStart(
input: AssertTerminalCommandCanStartInput,
): Promise<void> {
const { command, cwd, env } = input;
const platform = input.platform ?? process.platform;
if (command === undefined || platform === "win32") {
return;
}
// A path-ish command is resolved by execve against the child's cwd, never
// through PATH and never against the daemon's working directory.
if (command.includes("/")) {
if (isExecutableFile(resolvePath(cwd, command))) {
return;
}
throw new TerminalCommandNotFoundError(command);
}
const resolved = await findExecutableWithoutProbing(command, {
path: env[getPathEnvKey(env)],
cwd,
});
if (!resolved) {
throw new TerminalCommandNotFoundError(command);
}
}
export async function createTerminal(options: CreateTerminalOptions): Promise<TerminalSession> {
const {
cwd,
@@ -1026,23 +945,19 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
const { command: spawnCommand, args: spawnArgs } = command
? await resolveTerminalSpawnCommand(command, args)
: { command: resolvedShell, args: [] as string[] };
const terminalEnv = buildTerminalEnvironment({
shell: spawnCommand,
env: {
...env,
...activityEnv,
PASEO_WORKSPACE_ID: workspaceId,
},
});
await assertTerminalCommandCanStart({ command, cwd, env: terminalEnv });
const ptyProcess = pty.spawn(spawnCommand, spawnArgs, {
name: "xterm-256color",
cols,
rows,
cwd,
env: terminalEnv,
env: buildTerminalEnvironment({
shell: spawnCommand,
env: {
...env,
...activityEnv,
PASEO_WORKSPACE_ID: workspaceId,
},
}),
});
function emitTitleChange(nextTitle: string | undefined): void {
@@ -968,139 +968,3 @@ it("removes a killed worker terminal from terminalExit without duplicate snapsho
},
]);
});
it("does not resurrect a terminal that exited before its create response was handled", async () => {
const worker = new FakeTerminalWorker();
manager = createWorkerTerminalManager({
requestTimeoutMs: 50,
forkWorker: () => worker,
});
const snapshots: Array<{ cwd: string; terminalIds: string[] }> = [];
manager.subscribeTerminalsChanged((event) => {
snapshots.push({ cwd: event.cwd, terminalIds: event.terminals.map((terminal) => terminal.id) });
});
const created = manager.createTerminal({
id: "terminal-a",
cwd: "/workspace",
workspaceId: "ws-test",
});
const request = worker.sentMessages.find((message) => message.type === "createTerminal");
if (!request) {
throw new Error("createTerminal request not sent");
}
// The worker delivers all three messages in one tick, so the awaiting
// createTerminal continuation resumes only after the exit was processed.
const terminal = {
id: "terminal-a",
name: "Shell",
cwd: "/workspace",
workspaceId: "ws-test",
activity: null,
};
const state = createTerminalState();
const exitInfo = { exitCode: 0, signal: null, lastOutputLines: [] };
worker.emitWorkerMessage({ type: "terminalCreated", terminal, state });
worker.emitWorkerMessage({
type: "response",
requestId: request.requestId,
ok: true,
result: { terminal, state },
});
worker.emitWorkerMessage({ type: "terminalExit", terminalId: "terminal-a", info: exitInfo });
const session = await created;
expect(manager.getTerminal("terminal-a")).toBeUndefined();
expect(await manager.getTerminals("/workspace")).toEqual([]);
expect(session.getExitInfo()).toEqual(exitInfo);
expect(snapshots).toEqual([
{ cwd: "/workspace", terminalIds: ["terminal-a"] },
{ cwd: "/workspace", terminalIds: [] },
]);
});
it("replays the exit to a listener attached after a create that lost the race", async () => {
const worker = new FakeTerminalWorker();
manager = createWorkerTerminalManager({
requestTimeoutMs: 50,
forkWorker: () => worker,
});
const created = manager.createTerminal({
id: "terminal-a",
cwd: "/workspace",
workspaceId: "ws-test",
});
const request = worker.sentMessages.find((message) => message.type === "createTerminal");
if (!request) {
throw new Error("createTerminal request not sent");
}
const terminal = {
id: "terminal-a",
name: "Shell",
cwd: "/workspace",
workspaceId: "ws-test",
activity: null,
};
const state = createTerminalState();
const exitInfo = { exitCode: 0, signal: null, lastOutputLines: [] };
worker.emitWorkerMessage({ type: "terminalCreated", terminal, state });
worker.emitWorkerMessage({
type: "response",
requestId: request.requestId,
ok: true,
result: { terminal, state },
});
worker.emitWorkerMessage({ type: "terminalExit", terminalId: "terminal-a", info: exitInfo });
const session = await created;
// The session controller subscribes only after createTerminal resolves; that
// subscription is what turns into a terminal_exit for the client.
const observedExits: Array<{ exitCode: number | null }> = [];
session.onExit((info) => {
observedExits.push({ exitCode: info.exitCode });
});
await new Promise((resolve) => setImmediate(resolve));
expect(observedExits).toEqual([{ exitCode: 0 }]);
});
it("rejects createTerminal and registers nothing when the worker cannot start the command", async () => {
const worker = new FakeTerminalWorker();
manager = createWorkerTerminalManager({
requestTimeoutMs: 50,
forkWorker: () => worker,
});
const snapshots: Array<{ cwd: string; terminalIds: string[] }> = [];
manager.subscribeTerminalsChanged((event) => {
snapshots.push({ cwd: event.cwd, terminalIds: event.terminals.map((terminal) => terminal.id) });
});
const created = manager.createTerminal({
id: "terminal-a",
cwd: "/workspace",
workspaceId: "ws-test",
command: "paseo-qa-absent-codex",
});
const request = worker.sentMessages.find((message) => message.type === "createTerminal");
if (!request) {
throw new Error("createTerminal request not sent");
}
worker.emitWorkerMessage({
type: "response",
requestId: request.requestId,
ok: false,
error: "paseo-qa-absent-codex: command not found",
});
await expect(created).rejects.toThrow("paseo-qa-absent-codex: command not found");
expect(manager.getTerminal("terminal-a")).toBeUndefined();
expect(await manager.getTerminals("/workspace")).toEqual([]);
expect(snapshots).toEqual([]);
});
@@ -156,9 +156,6 @@ export function createWorkerTerminalManager(
const recordsById = new Map<string, WorkerTerminalRecord>();
const terminalIdsByCwd = new Map<string, Set<string>>();
const terminalActivityTokenById = new Map<string, string>();
// Creation owns a reference to the session independently of the live-terminal
// index. Only terminalCreated registers it; terminalExit removes it permanently.
const pendingCreationsById = new Map<string, { session: TerminalSession | null }>();
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
const terminalActivityListeners = new Set<TerminalActivityListener>();
const terminalWorkspaceContributionChangedListeners =
@@ -545,14 +542,10 @@ export function createWorkerTerminalManager(
function handleWorkerEvent(message: TerminalWorkerToParentMessage): void {
switch (message.type) {
case "terminalCreated": {
const session = registerRecord({
registerRecord({
info: asRequiredWorkerTerminalInfo(message.terminal),
state: message.state,
});
const pendingCreation = pendingCreationsById.get(message.terminal.id);
if (pendingCreation) {
pendingCreation.session = session;
}
emitTerminalsChanged({
cwd: message.terminal.cwd,
terminals: listTerminalItemsForCwd(message.terminal.cwd),
@@ -693,10 +686,12 @@ export function createWorkerTerminalManager(
const activityToken = createActivityToken();
const terminalActivityUrl = managerOptions.getTerminalActivityUrl?.() ?? null;
terminalActivityTokenById.set(terminalId, activityToken);
const creation = { session: null as TerminalSession | null };
pendingCreationsById.set(terminalId, creation);
let result: {
terminal: RequiredWorkerTerminalInfo;
state: TerminalState;
};
try {
await sendRequest({
result = (await sendRequest({
type: "createTerminal",
options: {
...options,
@@ -704,17 +699,16 @@ export function createWorkerTerminalManager(
activityToken,
activityUrl: terminalActivityUrl,
},
});
if (!creation.session) {
throw new Error("Terminal worker completed creation without a terminalCreated event");
}
return creation.session;
})) as {
terminal: RequiredWorkerTerminalInfo;
state: TerminalState;
};
} catch (error) {
terminalActivityTokenById.delete(terminalId);
throw error;
} finally {
pendingCreationsById.delete(terminalId);
}
const session = registerRecord({ info: result.terminal, state: result.state });
return session;
},
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void {

Some files were not shown because too many files have changed in this diff Show More