Merge remote-tracking branch 'origin/main' into fix/6199-live-narration-764ea71e-1

This commit is contained in:
paseo-bot[bot]
2026-10-15 10:43:27 +02:00
138 changed files with 5634 additions and 1640 deletions
@@ -16,6 +16,7 @@ import {
} from "../support/helpers/assistant-images";
import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import path from "node:path";
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
imageWorkspace: async ({ page: _page }, provide) => {
@@ -28,6 +29,42 @@ const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
},
});
test("assistant images at paths with spaces, Unicode and literal percent escapes render", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(120_000);
const image = await createSmallAssistantPng(workspace, {
alt: "Angle destination image",
fileName: "My image Мои.png",
});
const agent = await createSettledMockAgent(workspace, "Encoded image paths");
await emitSettledAssistantImage(workspace.client, agent, image, `<${image.relativePath}>`);
await openAssistantImageTimeline(page, agent);
await expectAssistantImageRendered(page, image);
const encodedImage = { ...image, alt: "Encoded destination image" };
await emitSettledAssistantImage(
workspace.client,
agent,
encodedImage,
encodeURI(path.join(workspace.repoPath, image.relativePath)),
);
await expectAssistantImageRendered(page, encodedImage);
const literalPercentImage = await createSmallAssistantPng(workspace, {
alt: "Literal percent filename image",
fileName: "literal%20name.png",
});
await emitSettledAssistantImage(
workspace.client,
agent,
literalPercentImage,
encodeURI(literalPercentImage.relativePath),
);
await expectAssistantImageRendered(page, literalPercentImage);
});
test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({
imageWorkspace: workspace,
page,
@@ -4,18 +4,23 @@ import { expect, test } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { openSubagentFromCompactTrack, seedSubagent } from "../support/helpers/subagents";
import {
closeWorkspaceSheet,
expectSubagentRowVisible,
openSubagentFromCompactTrack,
seedSubagent,
} from "../support/helpers/subagents";
import {
closeSheet,
expectOpenTabCount,
openWorkspaceSheetAsTab,
openSheetAsTab,
expectDeclinedDiscardPrompt,
expectWorkspaceSheetAtSnap,
replaceWorkspaceSheetFileText,
expectSheetAtSnap,
replaceSheetFileText,
selectTabFromSwitcher,
tapWorkspaceSheetBackdrop,
workspaceSheet,
} from "../support/helpers/workspace-sheet";
sheet,
sheetHeader,
tapSheetBackdrop,
} from "../support/helpers/sheet";
const FILE_LINK = "linked.ts:3";
@@ -55,47 +60,90 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await expect(fileLink).toBeVisible({ timeout: 15_000 });
await fileLink.click();
const sheet = workspaceSheet(page, "linked.ts");
await expect(sheet).toBeVisible();
await expect(sheet.getByText("three")).toBeVisible();
await expectWorkspaceSheetAtSnap(page, "linked.ts");
await expect(sheet.getByRole("button", { name: "Open as tab" })).toHaveCount(0);
const fileSheet = sheet(page, "linked.ts");
await expect(fileSheet).toBeVisible();
await expect(fileSheet.getByText("three")).toBeVisible();
await expectSheetAtSnap(page, "linked.ts");
await expect(
sheetHeader(page, "linked.ts").getByRole("button", { name: "Open as tab" }),
).toHaveCount(0);
await expectOpenTabCount(page, 1);
await closeWorkspaceSheet(page, "linked.ts");
await closeSheet(page, "linked.ts");
});
await test.step("a subagent opens in a sheet without its composer", async () => {
await test.step("a subagent opens in a sheet over the subagents list", async () => {
await openSubagentFromCompactTrack(page, child.id);
const sheet = workspaceSheet(page, child.title);
await expect(sheet).toBeVisible();
await expectWorkspaceSheetAtSnap(page, child.title);
await expect(sheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
const childSheet = sheet(page, child.title);
await expect(childSheet).toBeVisible();
await expectSheetAtSnap(page, child.title);
await expect(childSheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
// The composer's model, thinking, and mode stand in for it, resolved the same way. An idle
// seeded agent has no context usage yet.
await expect(childSheet.getByTestId("agent-pane-subtitle")).toHaveText(
"Ten second stream · Low · Load test",
);
await expectOpenTabCount(page, 1);
});
await test.step("Open as tab promotes the subagent to a full tab", async () => {
await openWorkspaceSheetAsTab(page, child.title);
await test.step("closing the subagent's sheet uncovers the list", async () => {
await closeSheet(page, child.title);
await expectSubagentRowVisible(page, child.id);
});
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
await page.getByTestId(`subagents-track-row-${child.id}`).click();
await expectSheetAtSnap(page, child.title);
await openSheetAsTab(page, child.title);
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
await expectOpenTabCount(page, 2);
await expectComposerVisible(page);
});
await test.step("a sheet with unsaved edits closes only through the guarded close", async () => {
await test.step("the list the subagent was opened from closed with its tab", async () => {
await selectTabFromSwitcher(page, "Sheet parent");
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
});
await test.step("once its tab closes, the subagent reopens in a sheet that keeps the list", async () => {
await page
.getByRole("button", { name: /Switch tabs/ })
.filter({ visible: true })
.click();
await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible();
const menuBase = `workspace-tab-menu-agent_${child.id}`;
await page.getByTestId(`${menuBase}-trigger`).click();
await page.getByTestId(`${menuBase}-close`).click();
await expectOpenTabCount(page, 1);
// Closing a tab keeps the switcher open, with the parent as the tab left. Its backdrop is
// tapped once the row menu's own sheet has gone.
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
await tapSheetBackdrop(page);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
await openSubagentFromCompactTrack(page, child.id);
await expectSheetAtSnap(page, child.title);
await closeSheet(page, child.title);
await expectSubagentRowVisible(page, child.id);
await tapSheetBackdrop(page);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
});
await test.step("a sheet with unsaved edits closes only through the guarded close", async () => {
await page.getByText(FILE_LINK, { exact: true }).click();
const sheet = workspaceSheet(page, "linked.ts");
await expect(sheet).toBeVisible();
const fileSheet = sheet(page, "linked.ts");
await expect(fileSheet).toBeVisible();
// Hold the autosave so the edit stays unsaved.
gate.holdNextClientRequest("fs.file.write.request");
await replaceWorkspaceSheetFileText(page, "linked.ts", "edited in the sheet");
await replaceSheetFileText(page, "linked.ts", "edited in the sheet");
await gate.waitForHeldClientRequest();
await expectDeclinedDiscardPrompt(page, () => tapWorkspaceSheetBackdrop(page));
await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet");
await expectDeclinedDiscardPrompt(page, () => tapSheetBackdrop(page));
await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet");
await expectDeclinedDiscardPrompt(page, () =>
sheet.getByRole("button", { name: "Close" }).click(),
sheetHeader(page, "linked.ts").getByRole("button", { name: "Close" }).click(),
);
await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet");
await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet");
gate.releaseHeldClientRequest();
await expect.poll(() => readFile(linkedPath, "utf8")).toBe("edited in the sheet");
@@ -9,7 +9,12 @@ 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 { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
openMobileAgentSidebar,
} from "../support/helpers/sidebar";
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
const TEST_COMMANDS = [
@@ -96,16 +101,6 @@ 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();
@@ -701,19 +696,15 @@ test.describe("Composer autocomplete", () => {
timeout: 30_000,
});
await page.getByRole("button", { name: "Open menu" }).click();
// 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 expectMobileAgentSidebarVisible(page);
await expect(popover).toHaveCount(0);
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");
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
} finally {
await agent.cleanup();
}
@@ -1,13 +1,30 @@
import { expect, test, type Page } from "../support/fixtures";
import {
composerLocator,
blurComposerInput,
enterComposerFullscreen,
exitComposerFullscreen,
expectComposerDraft,
expectComposerFocused,
expectComposerFullscreenControls,
expectComposerNotFocused,
expectComposerVisible,
expectWorkspaceHeaderVisible,
resizeComposerViewport,
submitMessage,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
closeMobileAgentSidebar,
closeMobileExplorer,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
expectMobileExplorerHidden,
expectMobileExplorerVisible,
swipeOpenMobileAgentSidebar,
swipeOpenMobileExplorer,
} from "../support/helpers/sidebar";
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
@@ -76,3 +93,76 @@ test.describe("touch screen wide enough for the desktop layout", () => {
}
});
});
const COMPACT_PANELS = [
{
name: "agent list",
open: swipeOpenMobileAgentSidebar,
expectOpen: expectMobileAgentSidebarVisible,
close: closeMobileAgentSidebar,
expectClosed: expectMobileAgentSidebarHidden,
},
{
name: "Explorer",
open: swipeOpenMobileExplorer,
expectOpen: expectMobileExplorerVisible,
close: closeMobileExplorer,
expectClosed: expectMobileExplorerHidden,
},
] as const;
for (const panel of COMPACT_PANELS) {
test(`closing the compact ${panel.name} does not restore composer focus`, async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-panel-",
title: `Composer focus under ${panel.name}`,
});
const draft = "Line one\nLine two\nLine three";
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await test.step("an untouched editor stays untouched", async () => {
await expectComposerNotFocused(page);
await panel.open(page);
await panel.expectOpen(page);
await panel.close(page);
await panel.expectClosed(page);
await expectComposerNotFocused(page);
});
await test.step("an inline editor blurred under the panel stays blurred", async () => {
await composerLocator(page).fill(draft);
await composerLocator(page).click();
await expectComposerFocused(page);
await panel.open(page);
await panel.expectOpen(page);
await blurComposerInput(page);
await panel.close(page);
await panel.expectClosed(page);
await expectComposerNotFocused(page);
await expectComposerDraft(page, draft);
});
await test.step("a fullscreen editor blurred under the panel stays blurred", async () => {
await composerLocator(page).click();
await enterComposerFullscreen(page);
await expectComposerFullscreenControls(page);
await expectComposerFocused(page);
await panel.open(page);
await panel.expectOpen(page);
await blurComposerInput(page);
await panel.close(page);
await panel.expectClosed(page);
await expectComposerNotFocused(page);
await expectComposerFullscreenControls(page);
await expectComposerDraft(page, draft);
await exitComposerFullscreen(page);
await expectWorkspaceHeaderVisible(page);
});
} finally {
await agent.cleanup();
}
});
}
@@ -20,6 +20,9 @@ import {
queueComposerWithButton,
configureComposerQueueSend,
activateRetainedComposerTab,
switchComposerTabWithKeyboard,
expectWorkspaceHeaderHidden,
expectWorkspaceHeaderVisible,
recordVisibleComposerGeometry,
expectNoCollapsedVisibleComposer,
expectAttachmentPill,
@@ -27,8 +30,15 @@ import {
startRunningMockAgent,
composerLocator,
expectComposerVisible,
expectComposerFullscreenFitsVisibleViewport,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
swipeOpenMobileAgentSidebar,
} from "../support/helpers/sidebar";
async function composerHeight(page: Page): Promise<number> {
return composerLocator(page).evaluate((element) => element.getBoundingClientRect().height);
@@ -156,6 +166,26 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
}
});
/** 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 });
// The installed clock advances between protocol calls; pause at a future time.
await page.clock.pauseAt(now + 60_000);
}
/** 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);
@@ -177,7 +207,7 @@ test("compact long drafts stop growing before the header", async ({ page }) => {
}
});
test("compact fullscreen editing preserves the live draft and selection and stays open after send", async ({
test("compact fullscreen editing preserves the live draft and selection and returns to chat after send", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
@@ -188,47 +218,109 @@ test("compact fullscreen editing preserves the live draft and selection and stay
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
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 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 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 returns to chat and clears the editor", 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 expectWorkspaceHeaderVisible(page);
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
});
await test.step("typing and sending keep working after leaving fullscreen again", async () => {
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await enterComposerFullscreen(page);
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 sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectComposerFullscreenControls(page);
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
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 expect(composerLocator(page)).toBeFocused();
await composerLocator(page).press("Y");
await expect(composerLocator(page)).toHaveValue("ShortXY draft");
} finally {
await agent.cleanup();
}
@@ -250,15 +342,86 @@ test("fullscreen submit rejection retains the editable draft and supports retry"
await writeCompactDraft(page, draft);
await enterComposerFullscreen(page);
await sendComposerWithButton(page);
await expect(page.getByTestId("composer-fullscreen").getByRole("alert")).toHaveText(
"Requested mock prompt rejection",
);
await expectWorkspaceHeaderVisible(page);
await expect(page.getByRole("alert")).toHaveText("Requested mock prompt rejection");
await enterComposerFullscreen(page);
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();
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectComposerFullscreenControls(page);
await expectWorkspaceHeaderVisible(page);
} finally {
await agent.cleanup();
}
});
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();
}
@@ -280,8 +443,7 @@ test("fullscreen Send keeps the configured queue behavior while an agent runs",
await expectComposerFullscreenControls(page);
await queueComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectComposerFullscreenControls(page);
await exitComposerFullscreen(page);
await expectWorkspaceHeaderVisible(page);
await expectQueuedMessageButton(page);
} finally {
await agent.cleanup();
@@ -333,15 +495,28 @@ 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);
await activateRetainedComposerTab(page, first.title);
// 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 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 expectFullscreenInsideVisibleViewport(page);
await expectComposerFullscreenFitsVisibleViewport(page);
await expectComposerDraftSelection(page, 4, 17);
await expect(composerLocator(page)).toBeFocused();
expect(await pasteComposerTestImage(page)).toBe(true);
@@ -352,6 +527,36 @@ 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,
}) => {
@@ -408,30 +613,6 @@ 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-",
@@ -443,11 +624,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 expectFullscreenInsideVisibleViewport(page);
await expectComposerFullscreenFitsVisibleViewport(page);
await shrinkVisibleViewport(page);
await expectFullscreenInsideVisibleViewport(page);
await expectComposerFullscreenFitsVisibleViewport(page);
await panVisibleViewport(page);
await expectFullscreenInsideVisibleViewport(page);
await expectComposerFullscreenFitsVisibleViewport(page);
} finally {
await agent.cleanup();
}
@@ -1,7 +1,14 @@
import {
composerLocator,
enterComposerFullscreen,
exitComposerFullscreen,
resizeComposerViewport,
} from "../support/helpers/composer";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
delayBrowserWorkspaceCreatedResponse,
expectNewWorkspaceDraft,
expectNewWorkspaceProjectSelected,
fillNewWorkspaceDraft,
@@ -134,3 +141,45 @@ test.describe("New workspace composer draft", () => {
}
});
});
test("fullscreen creation returns inline and keeps submission locked when reopened", async ({
page,
}) => {
const project = await seedWorkspace({ repoPrefix: "fullscreen-submit-pending-" });
const delay = await delayBrowserWorkspaceCreatedResponse(page);
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, project);
await resizeComposerViewport(page, "portrait");
await fillNewWorkspaceDraft(page, DRAFT);
await enterComposerFullscreen(page);
await page.getByRole("button", { name: "Create", exact: true }).click();
await delay.waitForCreateRequest();
await test.step("submission returns inline with the original draft and spinner", async () => {
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
await expect(composerLocator(page)).toHaveValue(DRAFT);
await expect(composerLocator(page)).not.toBeEditable();
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
await expect(page.getByRole("progressbar")).toBeVisible();
});
await test.step("fullscreen can reopen while pending without unlocking or duplicating submission", async () => {
await enterComposerFullscreen(page);
await expect(page.getByTestId("composer-fullscreen")).toBeVisible();
await expect(composerLocator(page)).toHaveValue(DRAFT);
await expect(composerLocator(page)).not.toBeEditable();
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
await expect(page.getByRole("progressbar")).toBeVisible();
await exitComposerFullscreen(page);
});
delay.release();
await page.waitForURL((url) => url.pathname.includes("/workspace/"));
await expect(composerLocator(page)).toHaveValue("");
await expect(composerLocator(page)).toBeEditable();
expect(delay.agentRequests).toHaveLength(1);
} finally {
delay.release();
await project.cleanup();
}
});
@@ -1,7 +1,18 @@
import { expect, test } from "../support/fixtures";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
import {
expectNewWorkspaceDraft,
fillNewWorkspaceDraft,
openNewWorkspaceComposer,
} from "../support/helpers/new-workspace";
import {
composerLocator,
enterComposerFullscreen,
exitComposerFullscreen,
expectComposerFullscreenControls,
resizeComposerViewport,
} from "../support/helpers/composer";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectTerminalLaunchSelected,
@@ -145,3 +156,117 @@ 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);
});
});
});
@@ -87,10 +87,11 @@ export async function emitSettledAssistantImage(
client: SeedDaemonClient,
agent: ArchiveTabAgent,
image: AssistantImageFixture,
destination: string = image.relativePath,
): Promise<void> {
await client.sendAgentMessage(
agent.id,
`Emit settled assistant image Markdown: ![${image.alt}](${image.relativePath})`,
`Emit settled assistant image Markdown: ![${image.alt}](${destination})`,
);
const result = await client.waitForFinish(agent.id, 30_000);
if (result.status !== "idle" || result.final?.lastError) {
@@ -56,6 +56,12 @@ export async function expectComposerNotFocused(page: Page): Promise<void> {
await expect(composerInput(page)).not.toBeFocused();
}
/** Blur the editor without anything else taking focus. */
export async function blurComposerInput(page: Page): Promise<void> {
await composerInput(page).blur();
await expectComposerNotFocused(page);
}
export async function submitMessage(page: Page, text: string): Promise<void> {
const input = composerInput(page);
await expect(input).toBeEditable({ timeout: 30_000 });
@@ -355,6 +361,12 @@ 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,
@@ -390,6 +402,33 @@ 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();
}
@@ -405,6 +444,22 @@ 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,31 +1,37 @@
import { expect, type Locator, type Page } from "@playwright/test";
/** The compact workspace sheet, named after the tab it hosts. */
export function workspaceSheet(page: Page, label: string): Locator {
/** A sheet tab's body, named after the tab it shows. */
export function sheet(page: Page, label: string): Locator {
return page.getByRole("dialog", { name: label });
}
/** Sheet height as a fraction of the viewport; mirrors the host's 92% snap point. */
/** A sheet tab's header, which carries its title, Close, and the Open as tab glyph. */
export function sheetHeader(page: Page, label: string): Locator {
return page.getByTestId("sheet-tab-header").filter({ hasText: label });
}
/** Sheet height as a fraction of the viewport; mirrors the sheet tab's 92% snap point. */
const SHEET_SNAP_FRACTION = 0.92;
/** The dialog starts below the sheet's drag handle. */
const SHEET_HANDLE_MAX_HEIGHT = 32;
/** The header starts below the sheet's drag indicator, inside the handle. */
const SHEET_INDICATOR_MAX_HEIGHT = 32;
/**
* The sheet rests at its snap point, docked to the bottom edge. A sheet pushed up past it
* covers the workspace header; a sheet sunk below it hides content.
*/
export async function expectWorkspaceSheetAtSnap(page: Page, label: string): Promise<void> {
export async function expectSheetAtSnap(page: Page, label: string): Promise<void> {
const viewportHeight = page.viewportSize()?.height ?? 0;
const snapTop = Math.round(viewportHeight * (1 - SHEET_SNAP_FRACTION));
const sheet = workspaceSheet(page, label);
const header = sheetHeader(page, label);
const body = sheet(page, label);
await expect
.poll(async () => {
const box = await sheet.boundingBox();
if (!box) return null;
const top = Math.round(box.y);
const [headerBox, bodyBox] = await Promise.all([header.boundingBox(), body.boundingBox()]);
if (!headerBox || !bodyBox) return null;
const top = Math.round(headerBox.y);
return {
dockedToBottom: Math.round(box.y + box.height) === viewportHeight,
topAtSnap: top >= snapTop && top <= snapTop + SHEET_HANDLE_MAX_HEIGHT,
dockedToBottom: Math.round(bodyBox.y + bodyBox.height) === viewportHeight,
topAtSnap: top >= snapTop && top <= snapTop + SHEET_INDICATOR_MAX_HEIGHT,
};
})
.toEqual({ dockedToBottom: true, topAtSnap: true });
@@ -37,26 +43,22 @@ export async function expectOpenTabCount(page: Page, count: number): Promise<voi
).toBeVisible();
}
export async function closeWorkspaceSheet(page: Page, label: string): Promise<void> {
const sheet = workspaceSheet(page, label);
await sheet.getByRole("button", { name: "Close" }).click();
await expect(sheet).toHaveCount(0);
export async function closeSheet(page: Page, label: string): Promise<void> {
await sheetHeader(page, label).getByRole("button", { name: "Close" }).click();
await expect(sheet(page, label)).toHaveCount(0);
}
export async function openWorkspaceSheetAsTab(page: Page, label: string): Promise<void> {
const sheet = workspaceSheet(page, label);
await sheet.getByRole("button", { name: "Open as tab" }).click();
await expect(sheet).toHaveCount(0);
export async function openSheetAsTab(page: Page, label: string): Promise<void> {
await sheetHeader(page, label).getByRole("button", { name: "Open as tab" }).click();
await expect(sheet(page, label)).toHaveCount(0);
}
export async function replaceWorkspaceSheetFileText(
export async function replaceSheetFileText(
page: Page,
label: string,
content: string,
): Promise<void> {
const editor = workspaceSheet(page, label)
.getByTestId("file-source-editor")
.locator(".cm-content");
const editor = sheet(page, label).getByTestId("file-source-editor").locator(".cm-content");
await editor.click();
await editor.press("ControlOrMeta+A");
await editor.pressSequentially(content);
@@ -67,15 +69,18 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise<
.getByRole("button", { name: /Switch tabs/ })
.filter({ visible: true })
.click();
await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible();
const option = page.getByText(label, { exact: true }).filter({ visible: true }).last();
await expect(option).toBeVisible();
await option.click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
}
export async function tapWorkspaceSheetBackdrop(page: Page): Promise<void> {
/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */
export async function tapSheetBackdrop(page: Page): Promise<void> {
await page
.getByRole("button", { name: "Bottom sheet backdrop" })
.last()
.click({ position: { x: 10, y: 10 } });
}
@@ -250,3 +250,41 @@ export async function expectMobileAgentSidebarVisible(page: Page): Promise<void>
export async function expectMobileAgentSidebarHidden(page: Page): Promise<void> {
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
}
/** Drag from the left edge: the only way to open the list while fullscreen hides the header. */
export 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();
}
/** Drag from the right edge to open the compact Explorer over the agent. */
export async function swipeOpenMobileExplorer(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(viewport.width - 8, y);
await page.mouse.down();
await page.mouse.move(viewport.width - 40, y, { steps: 4 });
await page.mouse.move(Math.round(viewport.width * 0.3), y, { steps: 12 });
await page.mouse.up();
}
export async function closeMobileExplorer(page: Page): Promise<void> {
const closeButton = page.getByTestId("explorer-close");
await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 });
await closeButton.click();
}
export async function expectMobileExplorerVisible(page: Page): Promise<void> {
await expect(page.getByTestId("explorer-header")).toBeInViewport({ ratio: 1, timeout: 5_000 });
}
export async function expectMobileExplorerHidden(page: Page): Promise<void> {
await expect(page.getByTestId("explorer-header")).not.toBeInViewport({ timeout: 5_000 });
}
+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}>
<View collapsable={false} style={layoutStyles.surfaceFill} testID="compact-shell">
{children}
</View>
</GestureDetector>
+29 -8
View File
@@ -1,6 +1,15 @@
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<{
@@ -23,16 +32,28 @@ 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>
<NewWorkspaceScreen
key={screenKey}
serverId={serverId}
sourceDirectory={sourceDirectory}
displayName={displayName}
projectId={projectId}
draftId={draftId}
/>
<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>
</HostRouteBootstrapBoundary>
);
}
@@ -42,6 +42,18 @@ describe("fileUriToPath", () => {
});
describe("localFileSourceToPath", () => {
it.each([
"https://example.com/image%20name.png",
"data:image/png;base64,abc%20",
"blob:https://example.com/image%20name",
])("preserves a direct Markdown image URI: %s", (source) => {
expect(localFileSourceToPath(source, { decodeHref: true })).toBe(source);
});
it("preserves malformed percent escapes rather than throwing", () => {
expect(localFileSourceToPath("/tmp/100%.png", { decodeHref: true })).toBe("/tmp/100%.png");
});
it("decodes markdown-encoded Windows drive-letter paths", () => {
expect(localFileSourceToPath("C:%5CUsers%5Cfile.txt")).toBe("C:/Users/file.txt");
});
+9 -2
View File
@@ -173,11 +173,18 @@ export function fileUriToPath(uri: string): string {
return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1"));
}
export function localFileSourceToPath(source: string): string {
export function localFileSourceToPath(
source: string,
// Markdown destinations are URI-encoded; plain filesystem paths are not.
{ decodeHref = false }: { decodeHref?: boolean } = {},
): string {
if (/^(https?:|data:|blob:)/i.test(source)) {
return source;
}
let path = source;
if (source.startsWith("file://")) {
path = fileUriToPath(source);
} else if (isMarkdownEncodedWindowsDrivePath(source)) {
} else if (decodeHref || isMarkdownEncodedWindowsDrivePath(source)) {
path = decodeFilePathSource(source);
}
return normalizeWindowsDrivePath(path);
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import type { ComponentProps, 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 } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
getOverlayRoot,
@@ -12,9 +12,9 @@ import {
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
} from "../lib/overlay-root";
import { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native";
import { ArrowLeft, Search, X, type LucideIcon } from "lucide-react-native";
import {
IsolatedBottomSheetModal,
SheetVisibleFrame,
@@ -223,7 +223,8 @@ const styles = StyleSheet.create((theme) => ({
const WEB_EXIT_DURATION_MS = 160;
function SheetBackground({ style }: BottomSheetBackgroundProps) {
/** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */
export function SheetBackground({ style }: BottomSheetBackgroundProps) {
const { theme } = useUnistyles();
const combinedStyle = useMemo(
() => [
@@ -239,6 +240,39 @@ 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.
*
@@ -253,6 +287,37 @@ function SheetContent({ style, children }: { style: StyleProp<ViewStyle>; childr
return <View style={[styles.sheetContent, style]}>{children}</View>;
}
/**
* An icon action in a sheet header's trailing rail, drawn like the Close button so a header's
* actions line up with it at one size. Pass it through `SheetHeader.actions`.
*/
export function SheetHeaderIconButton({
icon: Icon,
accessibilityLabel,
onPress,
testID,
}: {
icon: LucideIcon;
accessibilityLabel: string;
onPress: () => void;
testID?: string;
}) {
const { theme } = useUnistyles();
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
style={styles.closeButton}
onPress={onPress}
testID={testID}
>
{({ pressed }) => (
<Icon size={16} color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted} />
)}
</Pressable>
);
}
export function SheetHeaderView({
header,
onClose,
@@ -309,19 +374,11 @@ export function SheetHeaderView({
</View>
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
{showCloseButton ? (
<Pressable
accessibilityRole="button"
<SheetHeaderIconButton
icon={X}
accessibilityLabel={t("common.actions.close")}
style={styles.closeButton}
onPress={onClose}
>
{({ pressed }) => (
<X
size={16}
color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
/>
) : null}
</View>
{search ? (
@@ -449,7 +506,6 @@ export function AdaptiveModalSheet({
bodyStyle,
contextBridge = null,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const insets = useSafeAreaInsets();
@@ -481,10 +537,6 @@ 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
@@ -600,7 +652,7 @@ export function AdaptiveModalSheet({
backdropOpacity={0.45}
enablePanDownToClose
backgroundComponent={SheetBackground}
handleIndicatorStyle={handleIndicatorStyle}
handleComponent={SheetHandle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
+13 -38
View File
@@ -35,6 +35,7 @@ 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";
@@ -805,21 +806,18 @@ function DesktopSidebar({
function WorkspacesSectionHeader() {
return (
<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>
<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>
);
}
@@ -850,29 +848,6 @@ 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,
+2 -1
View File
@@ -95,6 +95,7 @@ import {
} from "@/assistant-file-links";
import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAssistantImage } from "@/assistant-image/use-assistant-image";
import { localFileSourceToPath } from "@/attachments/utils";
import {
AttachmentFrame,
AttachmentLabel,
@@ -1945,7 +1946,7 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
<AssistantMarkdownImage
key={node.key}
source={String(node.attributes?.src ?? "")}
source={localFileSourceToPath(String(node.attributes?.src ?? ""), { decodeHref: true })}
occurrenceKey={`${occurrenceKey}:${node.key}`}
alt={typeof node.attributes?.alt === "string" ? node.attributes.alt : undefined}
hasLeadingContent={hasLeadingContent}
@@ -30,7 +30,7 @@ import {
type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import { density, 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 { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import {
@@ -2114,6 +2114,7 @@ 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>>>(
@@ -2413,7 +2414,12 @@ function ProjectModeList({
<>
{pinnedChats.length > 0 ? (
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
<SidebarSectionHeader
title={t("sidebar.pinned.title")}
collapsed={pinnedCollapsed}
onToggle={togglePinnedCollapsed}
testID="sidebar-pinned-section-header"
/>
{pinnedCollapsed ? null : (
<>
<DraggableList
@@ -2550,12 +2556,12 @@ const styles = StyleSheet.create((theme) => ({
},
newWorkspaceGhostText: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
},
newWorkspaceGhostTextHovered: {
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
color: theme.colors.foreground,
@@ -2603,7 +2609,7 @@ const styles = StyleSheet.create((theme) => ({
},
projectTitle: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
@@ -1,62 +0,0 @@
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 { density, densityIconBox, densityIconProps } from "@/styles/density";
import { densityIconBox, densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import {
@@ -24,6 +24,9 @@ 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
@@ -273,10 +276,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 shares the same `rowLineHeight`.
// workspace status indicator is as tall as `LEADING_SLOT_HEIGHT`.
projectLeadingVisualSlot: {
width: densityIconBox("md"),
height: density.rowLineHeight,
height: LEADING_SLOT_HEIGHT,
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, densityFontSize, densityIconBox } from "@/styles/density";
import { density, 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: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
},
textHovered: {
color: theme.colors.foreground,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.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, densityFontSize, densityIconBox } from "@/styles/density";
import { density, 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: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
@@ -0,0 +1,115 @@
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, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density";
import { density, 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 { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-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,6 +146,7 @@ export function SidebarStatusWorkspaceList({
parentGestureRef,
dragGestureHostActive,
}: StatusWorkspaceListProps) {
const { t } = useTranslation();
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedWorkspaceGroupKeys,
);
@@ -202,7 +203,12 @@ export function SidebarStatusWorkspaceList({
<>
{pinnedWorkspaces.length > 0 ? (
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
<SidebarSectionHeader
title={t("sidebar.pinned.title")}
collapsed={pinnedCollapsed}
onToggle={togglePinnedCollapsed}
testID="sidebar-pinned-section-header"
/>
{pinnedCollapsed ? null : (
<>
<DraggableList
@@ -1099,7 +1105,7 @@ const styles = StyleSheet.create((theme) => ({
},
statusGroupTitle: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.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 { density, densityFontSize, densityIconBox } from "@/styles/density";
import { 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,6 +453,9 @@ 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",
@@ -481,7 +484,7 @@ const styles = StyleSheet.create((theme) => ({
workspaceStatusDot: {
position: "relative",
width: densityIconBox("md"),
height: density.rowLineHeight,
height: TITLE_LINE_HEIGHT,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
alignItems: "center",
@@ -513,9 +516,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: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: "400",
lineHeight: density.rowLineHeight,
lineHeight: TITLE_LINE_HEIGHT,
opacity: 0.76,
flex: 1,
minWidth: 0,
@@ -0,0 +1,91 @@
import {
BottomSheetModalInternalProvider,
INITIAL_CONTAINER_LAYOUT,
useBottomSheetModalInternal,
} from "@gorhom/bottom-sheet";
import { PortalHost } from "@gorhom/portal";
import { useCallback, useId, useMemo, useRef, type ReactNode } from "react";
import {
Dimensions,
StatusBar,
StyleSheet,
View,
type LayoutChangeEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import { useSharedValue } from "react-native-reanimated";
/**
* Hosts the sheets presented from inside it.
*
* A sheet's content normally lands at the app root, on the far side of the portal, where every
* context between the root and the call site is gone (floating-panels.md Gotcha 7). Inside a
* scope, a sheet renders into the scope's own host instead, so its content reads the scope's
* contexts — the navigator, the workspace providers — without bridging each one by hand. The
* scope only moves where the sheet draws: it still joins the app-wide sheet stack, so stacking,
* Back and the backdrop treat it like every other sheet.
*
* The host fills the scope, and the sheets presented in it size and dock to the scope, not the
* window. Sheets presented outside the scope keep drawing at the root, above it.
*/
export function SheetHostScope({
children,
style,
}: {
children: ReactNode;
style?: StyleProp<ViewStyle>;
}) {
const parent = useBottomSheetModalInternal();
const hostName = `sheet-host-${useId()}`;
const containerLayoutState = useSharedValue({ ...INITIAL_CONTAINER_LAYOUT });
const hostRef = useRef<View>(null);
// Mirrors Gorhom's own hosting container measurement, so a sheet in the scope lays out against
// the scope the way a root sheet lays out against the window.
const handleLayout = useCallback(
({ nativeEvent }: LayoutChangeEvent) => {
const { height } = nativeEvent.layout;
hostRef.current?.measure((_x, _y, _width, _height, _pageX, pageY) => {
const windowHeight = Dimensions.get("window").height;
const top = pageY ?? 0;
containerLayoutState.set({
height,
offset: {
top,
bottom: Math.max(0, windowHeight - (top + height + (StatusBar.currentHeight ?? 0))),
left: 0,
right: 0,
},
});
});
},
[containerLayoutState],
);
const value = useMemo(
() => ({ ...parent, hostName, containerLayoutState }),
[containerLayoutState, hostName, parent],
);
return (
<BottomSheetModalInternalProvider value={value}>
<View style={[styles.scope, style]}>
{children}
<View ref={hostRef} style={styles.host} pointerEvents="box-none" onLayout={handleLayout}>
<PortalHost name={hostName} />
</View>
</View>
</BottomSheetModalInternalProvider>
);
}
const styles = StyleSheet.create({
scope: {
flex: 1,
minHeight: 0,
},
host: {
...StyleSheet.absoluteFillObject,
},
});
@@ -40,6 +40,13 @@ type IsolatedBottomSheetModalProps = Omit<
BottomSheetModalProps,
"enableDismissOnClose" | "stackBehavior" | "children" | "backdropComponent"
> & {
/**
* Whether closing (drag, backdrop, Back) also dismisses, which unmounts the content. Default
* true: the owner hears `onDismiss` and drops its state. Pass `false` for a sheet whose close the
* owner may veto: it parks at index -1 with its content mounted, the owner hears `onChange(-1)`,
* and either snaps it back or unmounts it, which dismisses it from the stack.
*/
enableDismissOnClose?: boolean;
/**
* Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge
* would have to reach around it.
@@ -84,19 +91,24 @@ export const IsolatedBottomSheetModal = forwardRef<
presentation = "push",
contextBridge,
backdropOpacity,
enableDismissOnClose = true,
...bottomSheetProps
} = props;
const renderBackdrop = useCallback(
(backdropProps: BottomSheetBackdropProps) => (
<SheetBackdrop {...backdropProps} opacity={backdropOpacity} />
<SheetBackdrop
{...backdropProps}
opacity={backdropOpacity}
pressBehavior={enableDismissOnClose ? "dismiss" : "close"}
/>
),
[backdropOpacity],
[backdropOpacity, enableDismissOnClose],
);
const modal = (
<GorhomBottomSheetModal
{...bottomSheetProps}
ref={modalRef}
enableDismissOnClose
enableDismissOnClose={enableDismissOnClose}
stackBehavior={presentation}
backdropComponent={backdropOpacity === undefined ? undefined : renderBackdrop}
>
@@ -154,3 +166,5 @@ export function useIsolatedBottomSheetVisibility({
}
export { SheetVisibleFrame } from "./visible-frame";
export { systemBackPress } from "./back-press";
export { SheetHostScope } from "./host-scope";
@@ -1,5 +1,15 @@
import { BottomSheetBackdrop, type BottomSheetBackdropProps } from "@gorhom/bottom-sheet";
export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) {
/**
* What a backdrop tap does. `dismiss` takes the top modal off the stack and unmounts it; `close`
* only slides this sheet down, so a sheet that keeps its content through a close can veto it.
*/
export type SheetBackdropPress = "dismiss" | "close";
export function SheetBackdrop({
pressBehavior: _pressBehavior,
...props
}: BottomSheetBackdropProps & { opacity?: number; pressBehavior: SheetBackdropPress }) {
// Gorhom's native backdrop closes its own sheet, which here is always the top one.
return <BottomSheetBackdrop {...props} appearsOnIndex={0} disappearsOnIndex={-1} />;
}
@@ -1,16 +1,30 @@
import {
BottomSheetBackdrop,
useBottomSheet,
useBottomSheetModal,
type BottomSheetBackdropProps,
} from "@gorhom/bottom-sheet";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, StyleSheet, View } from "react-native";
import type { SheetBackdropPress } from "./sheet-backdrop";
export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) {
export type { SheetBackdropPress };
export function SheetBackdrop({
pressBehavior,
...props
}: BottomSheetBackdropProps & { opacity?: number; pressBehavior: SheetBackdropPress }) {
const { dismiss } = useBottomSheetModal();
const { close } = useBottomSheet();
const { t } = useTranslation();
const dismissTopSheet = useCallback(() => dismiss(), [dismiss]);
const handlePress = useCallback(() => {
// A sheet that may veto its close slides itself down and lets its owner decide. The other
// sheets ask the provider for the top modal, so a lower backdrop caught mid-transition still
// dismisses the sheet the user sees.
if (pressBehavior === "close") close();
else dismiss();
}, [close, dismiss, pressBehavior]);
return (
<View style={StyleSheet.absoluteFill}>
@@ -28,7 +42,7 @@ export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: numb
accessibilityLabel={t("common.bottomSheetBackdrop")}
// The provider owns presentation order, including when a lower backdrop remounts.
// Keep this target until the portal unmounts: animation must not expose the sheet below.
onPress={dismissTopSheet}
onPress={handlePress}
style={StyleSheet.absoluteFill}
/>
</View>
@@ -126,8 +126,14 @@ export interface MenuSurfaceProps {
* popover (plus one flyout per open submenu) or a bottom sheet showing one page at a time.
*/
export function MenuSurface(props: MenuSurfaceProps): ReactElement | null {
const { presentation } = useMenuContext("MenuSurface");
const { presentation, open, setOpen } = useMenuContext("MenuSurface");
const active = useRetainedPanelActive();
// A menu belongs to the panel that opened it. When that panel leaves the screen the menu closes
// for good, rather than waiting hidden to reappear with the panel: a row that opened a sheet
// and promoted it to a tab, say, would otherwise bring the list back on the next tab switch.
useEffect(() => {
if (!active && open) setOpen(false);
}, [active, open, setOpen]);
// Portals escape the hidden panel. Remove the surface in its inactive commit,
// before freezing can suspend an async menu action's closing update.
if (!active) return null;
@@ -104,6 +104,13 @@ 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,6 +77,10 @@ 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,6 +8,7 @@ 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;
+75 -19
View File
@@ -1,5 +1,6 @@
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";
@@ -8,22 +9,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,
resolveInlineComposerCapacity,
resolveComposerSlotCapacity,
updateComposerCapacity,
type ComposerCapacity,
} from "./internal/capacity";
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
const ViewportSizing = createContext<SharedValue<ComposerCapacity | undefined> | null>(null);
interface ComposerViewportProps extends ViewProps {
bottomInset?: number;
@@ -55,7 +56,6 @@ function ComposerViewport({
},
[],
);
const capacity = useDerivedValue(() => sizing.value?.capacity);
const measureViewport = useCallback(
(event: LayoutChangeEvent) => {
measuredHeight.value = event.nativeEvent.layout.height;
@@ -66,20 +66,24 @@ function ComposerViewport({
return (
<View testID="composer-viewport" {...props} onLayout={measureViewport} collapsable={false}>
<ViewportCapacity.Provider value={capacity}>{children}</ViewportCapacity.Provider>
<ViewportSizing.Provider value={sizing}>{children}</ViewportSizing.Provider>
</View>
);
}
/** Bound the complete composer (including its controls), not just the text input. */
function ComposerViewportContent({ style, ...props }: ViewProps) {
function ComposerViewportContent({
style,
fullscreen = false,
...props
}: ViewProps & { fullscreen?: boolean }) {
const compact = useIsCompactFormFactor();
const capacity = useContext(ViewportCapacity);
if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport");
const sizing = useContext(ViewportSizing);
if (!sizing) 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: resolveInlineComposerCapacity(capacity.value, compact),
maxHeight: resolveComposerSlotCapacity(sizing.value, { compact, fullscreen }),
}));
return (
@@ -108,37 +112,71 @@ 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 ? HEADER_INNER_HEIGHT + 24 : 0;
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,
},
];
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={[dockStyles.centeredViewport, { paddingBottom: bottomInset }]}
style={
fullscreen
? dockStyles.viewport
: [dockStyles.centeredViewport, { paddingBottom: bottomInset }]
}
bottomInset={bottomInset}
centered
onLayout={centeredClearance.measureViewport}
>
<KeyboardTranslateView
style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}
style={
fullscreen ? dockStyles.surface : [dockStyles.centered, { maxWidth: contentMaxWidth }]
}
enabled={!fullscreen}
bottomClearance={centeredClearance.value}
onLayout={centeredClearance.measureForm}
>
<ComposerViewportContent style={dockStyles.composer}>
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
<ComposerViewportContent
style={fullscreen ? dockStyles.surface : dockStyles.composer}
fullscreen={fullscreen}
>
<ScrollView
style={fullscreen ? dockStyles.hiddenSetup : dockStyles.setup}
keyboardShouldPersistTaps="handled"
>
{content}
</ScrollView>
<ComposerViewportContent style={dockStyles.centeredComposer}>
{composer}
<ComposerViewportContent
style={fullscreen ? dockStyles.surface : dockStyles.centeredComposer}
fullscreen={fullscreen}
>
<View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View>
</ComposerViewportContent>
</ComposerViewportContent>
{overlay}
@@ -148,7 +186,7 @@ export function ComposerDock({
}
return (
<ComposerViewport style={dockStyles.viewport}>
<KeyboardTranslateView style={dockStyles.surface}>
<KeyboardTranslateView style={dockStyles.surface} enabled={!fullscreen}>
<View
testID="composer-dock-content"
collapsable={false}
@@ -162,8 +200,19 @@ export function ComposerDock({
{content}
</View>
</View>
<ComposerViewportContent style={dockStyles.composer}>
<View style={[dockStyles.composer, { paddingBottom: insets.bottom }]}>{composer}</View>
<ComposerViewportContent
style={fullscreen ? dockStyles.fullscreenSlot : dockStyles.composer}
fullscreen={fullscreen}
>
<View
style={
fullscreen
? fullscreenSurface
: [dockStyles.composer, { paddingBottom: insets.bottom }]
}
>
{composer}
</View>
</ComposerViewportContent>
{overlay}
</KeyboardTranslateView>
@@ -178,9 +227,16 @@ 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 },
}));
+35 -10
View File
@@ -1,48 +1,69 @@
export { ComposerDockBackground } from "./internal/background";
export { ComposerFullscreenBody, ComposerFullscreenFooter } from "./internal/fullscreen";
import { ScrollView } from "@/components/ui/scroll-view";
import { useState, useCallback, useMemo, type ReactNode } from "react";
import { useState, useCallback, useMemo, useRef, 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 [availableHeight, setAvailableHeight] = useState<number>();
const [surfaceSize, setSurfaceSize] = useState<{ width: number; height: number }>();
const surfaceRef = useRef<View>(null);
const fullscreenFrame = useFullscreenSlotFrame(fullscreen, surfaceRef, surfaceSize);
const constraint = useMemo(
() => ({ maxHeight: resolveInlineComposerCapacity(availableHeight, compact) }),
[availableHeight, compact],
() => ({ maxHeight: resolveInlineComposerCapacity(surfaceSize?.height, compact) }),
[surfaceSize?.height, compact],
);
const measureViewport = useCallback((event: import("react-native").LayoutChangeEvent) => {
const { height } = event.nativeEvent.layout;
if (height > 0) setAvailableHeight(height);
const { width, height } = event.nativeEvent.layout;
if (height > 0) setSurfaceSize({ width, height });
}, []);
if (centered) {
return (
<View style={styles.centered} onLayout={measureViewport}>
<View ref={surfaceRef} style={styles.centered} onLayout={measureViewport}>
<View style={styles.form}>
<ScrollView style={styles.setup} keyboardShouldPersistTaps="handled">
{content}
</ScrollView>
<View style={[styles.centeredComposer, constraint]}>{composer}</View>
<View
style={
fullscreen
? [styles.fullscreenComposer, fullscreenFrame]
: [styles.centeredComposer, constraint]
}
>
{composer}
</View>
{overlay}
</View>
</View>
);
}
return (
<View style={styles.surface} onLayout={measureViewport}>
<View ref={surfaceRef} style={styles.surface} onLayout={measureViewport}>
<View style={styles.content}>{content}</View>
<View style={[styles.composer, constraint]}>{composer}</View>
<View
style={
fullscreen ? [styles.fullscreenComposer, fullscreenFrame] : [styles.composer, constraint]
}
>
{composer}
</View>
{overlay}
</View>
);
@@ -52,6 +73,10 @@ 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,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
resolveCenteredClearance,
resolveComposerSlotCapacity,
resolveInlineComposerCapacity,
resolveComposerCapacity,
updateComposerCapacity,
@@ -19,6 +20,20 @@ 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,6 +10,8 @@ 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 {
@@ -48,6 +50,7 @@ export function updateComposerCapacity(
return {
keyboardReserve,
capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }),
viewportHeight: input.height,
};
}
@@ -60,3 +63,19 @@ 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);
}
@@ -0,0 +1,39 @@
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 },
});
@@ -0,0 +1,89 @@
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,6 +210,8 @@ 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");
@@ -223,6 +225,7 @@ 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(
(nextText: string) => {
(kind: "hydrated" | "replace", nextText: string) => {
textReplacementRevisionRef.current += 1;
setTextReplacement({
kind: "replace",
kind,
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(nextText);
publishTextReplacement("replace", 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(hydratedText);
publishTextReplacement("hydrated", hydratedText);
setHydratedDraftKey(draftKey);
}
})();
@@ -6,6 +6,7 @@ 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";
@@ -385,6 +386,8 @@ 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 },
@@ -628,10 +631,10 @@ export function WorkspaceDraftAgentTab({
return (
<FileDropZone style={styles.container}>
<ComposerDock>
<ComposerDock fullscreen={isComposerFullscreen}>
{dockContent}
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
{importPillPress ? (
{importPillPress && !isComposerFullscreen ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
<ComposerImportPill onPress={importPillPress} />
@@ -655,6 +658,7 @@ export function WorkspaceDraftAgentTab({
onChangeAttachments={draftInput.setAttachments}
cwd={composerState.workingDir}
clearDraft={draftInput.clear}
editingSession={editingSession}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
onFocusInput={handleFocusInputCallback}
@@ -672,6 +676,8 @@ const animatedStaticStyles = RNStyleSheet.create({
inputAreaWrapper: {
width: "100%",
flexShrink: 1,
// Content-sized inline; fills the dock's fullscreen slot.
flexGrow: 1,
},
});
@@ -1,52 +1,61 @@
import { describe, expect, it } from "vitest";
import { ComposerEditingSession } from "./index";
import { ComposerEditingSession, ComposerEditingSessions } from "./index";
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", () => {
describe("composer editing session", () => {
it("publishes fullscreen changes once per transition", () => {
const session = new ComposerEditingSession();
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);
const changes: boolean[] = [];
session.subscribe(() => changes.push(session.getFullscreen()));
session.setFullscreen(true);
session.setFullscreen(true);
session.setFullscreen(false);
expect(changes).toEqual([true, false]);
});
it("keeps local edits when the last applied replacement is still current", () => {
it("hands the retained caret and focus intent to exactly one later mount", () => {
const session = new ComposerEditingSession();
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 },
});
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);
});
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,5 +1,6 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
@@ -7,62 +8,72 @@ import React, {
useSyncExternalStore,
type ReactNode,
} from "react";
import type { TextReplacement } from "@/composer/types";
import type { ComposerInputMode } from "@/composer/input-mode";
export interface ComposerEditingHandoff {
snapshot: { text: string; selection: { start: number; end: number } };
focused: boolean;
replacementKey: string;
export interface ComposerSelection {
start: number;
end: number;
}
/** Editing metadata outlives a layout's input; draft text still belongs to its draft owner. */
/** 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. */
focused: boolean;
}
/**
* 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.
*/
export class ComposerEditingSession {
private fullscreen = false;
private handoff: ComposerEditingHandoff | null = null;
private retained: ComposerRetainedEditor | 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);
};
};
changePresentation(fullscreen: boolean, handoff: ComposerEditingHandoff) {
this.captureForRemount(handoff);
setFullscreen(fullscreen: boolean) {
if (fullscreen === this.fullscreen) return;
this.fullscreen = fullscreen;
for (const listener of this.listeners) listener();
}
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;
this.onChange?.();
}
}
class ComposerEditingSessions {
private workspaces = new Map<
string,
Map<string, { targetKey: string; sessions: Map<ComposerInputMode, ComposerEditingSession> }>
>();
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);
};
};
get(workspaceKey: string, tabId: string, targetKey: string, purpose: ComposerInputMode) {
let workspace = this.workspaces.get(workspaceKey);
if (!workspace) {
@@ -76,17 +87,31 @@ class ComposerEditingSessions {
}
let session = tab.sessions.get(purpose);
if (!session) {
session = new ComposerEditingSession();
session = new ComposerEditingSession(this.notify);
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);
for (const tabId of workspace.keys()) if (!retained.has(tabId)) workspace.delete(tabId);
let changed = false;
for (const tabId of workspace.keys()) {
if (retained.has(tabId)) continue;
workspace.delete(tabId);
changed = true;
}
if (workspace.size === 0) this.workspaces.delete(workspaceKey);
if (changed) this.notify();
}
}
@@ -140,3 +165,16 @@ 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);
}
+68 -9
View File
@@ -1,4 +1,8 @@
import { useComposerEditingSession, useComposerFullscreen } from "./editing-session";
import {
useComposerEditingSession,
useComposerFullscreen,
type ComposerEditingSession,
} from "./editing-session";
import type { ComposerTextSource } from "./text-source";
import { createStore, type StoreApi } from "zustand/vanilla";
import { useStore } from "zustand";
@@ -27,6 +31,7 @@ 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 {
@@ -967,6 +972,12 @@ 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[] = [];
@@ -1182,8 +1193,19 @@ 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.
// Pane focus stays put, so the keyboard the panel dismisses is a blur in the
// user's own pane: closing the panel does not bring focus back.
const isCompact = useIsCompactFormFactor();
const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent");
const coveredByOverlayPanel = isCompact && overlayPanelRequested;
return (
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
<ComposerKeyboardScopeProvider
isPaneFocused={isPaneFocused}
isActiveComposer={isPaneFocused && !coveredByOverlayPanel}
>
<RenderProfile id="ComposerContent">
<ComposerContent {...props} />
</RenderProfile>
@@ -1234,6 +1256,7 @@ function ComposerContentImpl({
inputWrapperStyle,
isCompactLayout: isCompactLayoutOverride,
inputMode = "chat",
editingSession: providedEditingSession,
readOnly = false,
submitLabel,
placeholder,
@@ -1339,7 +1362,7 @@ function ComposerContentImpl({
const nextPendingFileId = useRef(0);
const isUploadingFile = pendingFiles.length > 0;
const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0);
const editingSession = useComposerEditingSession(inputMode);
const editingSession = useComposerEditingSession(inputMode, providedEditingSession);
const isFullscreen = useComposerFullscreen(editingSession);
const [sendError, setSendError] = useState<string | null>(null);
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
@@ -2250,9 +2273,23 @@ 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],
[isComposerLocked],
() => [
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],
);
const attachmentTray = useMemo(
@@ -2363,15 +2400,19 @@ function ComposerContentImpl({
focusMessageInputForKeyboardAction={focusMessageInputForKeyboardAction}
isMessageInputFocused={isMessageInputFocused}
/>
<View style={animatedStaticStyles.container}>
<View
style={
isFullscreen ? animatedStaticStyles.fullscreenContainer : animatedStaticStyles.container
}
>
<AttachmentLightbox source={lightboxSource} onClose={handleLightboxClose} />
{/* Input area */}
<View style={inputAreaContainerStyle}>
<View style={styles.inputAreaContent}>
{queueList}
<View style={inputAreaContentStyle}>
{isFullscreen ? null : queueList}
{!isFullscreen && sendErrorNode}
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<View ref={messageInputContainerRef} style={messageInputContainerStyle}>
<ComposerAutocompleteBinding
text={textSource}
cursor={cursor}
@@ -2475,6 +2516,12 @@ const animatedStaticStyles = RNStyleSheet.create({
flexShrink: 1,
position: "relative",
},
fullscreenContainer: {
flexDirection: "column",
flex: 1,
minHeight: 0,
position: "relative",
},
});
const styles = StyleSheet.create((theme: Theme) => ({
@@ -2496,6 +2543,18 @@ 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%",
@@ -0,0 +1,18 @@
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;
}
@@ -0,0 +1,23 @@
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;
},
});
}
@@ -1,57 +0,0 @@
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,
},
}));
@@ -1,77 +0,0 @@
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,
};
}
+109 -122
View File
@@ -84,6 +84,7 @@ import {
computeCanStartDictation,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveRetainedSelection,
runAlternateSendAction,
runDefaultSendAction,
runMessageInputKeyboardAction,
@@ -523,8 +524,8 @@ function MessageInputAutoFocus({
autoFocusKey: string | undefined;
textInputRef: React.MutableRefObject<ComposerTextInputHandle | null>;
}) {
const { isActiveComposer } = useComposerKeyboardScope();
useAutoFocusOnWebEffect(textInputRef, enabled && isActiveComposer, autoFocusKey);
const { isPaneFocused } = useComposerKeyboardScope();
useAutoFocusOnWebEffect(textInputRef, enabled && isPaneFocused, autoFocusKey);
return null;
}
@@ -676,13 +677,7 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
);
}
return (
<View
style={[
styles.textInputScrollWrapper,
props.fullscreen && styles.fullscreenTextSurface,
props.compact && !props.fullscreen && styles.compactTextSurface,
]}
>
<View style={[styles.textInputScrollWrapper, props.fullscreen && styles.fullscreenTextSurface]}>
{!isWeb && props.compact && !props.fullscreen ? (
<Text
accessible={false}
@@ -947,6 +942,7 @@ interface SendMessageContext {
onSubmit: (payload: MessagePayload) => void;
onMinimizeHeight: () => void;
preserveHeightOnSubmit: boolean;
exitFullscreen: () => void;
}
function sendMessageImpl(ctx: SendMessageContext): void {
@@ -959,6 +955,7 @@ function sendMessageImpl(ctx: SendMessageContext): void {
) {
return;
}
ctx.exitFullscreen();
ctx.onSubmit({
text: trimmed,
attachments: ctx.attachments,
@@ -979,12 +976,14 @@ interface QueueMessageContext {
onQueue: ((payload: MessagePayload) => void) | undefined;
replaceText: (text: string) => void;
onMinimizeHeight: () => void;
exitFullscreen: () => void;
}
function queueMessageImpl(ctx: QueueMessageContext): void {
if (!ctx.onQueue) return;
const trimmed = ctx.value.trim();
if (!trimmed && ctx.attachments.length === 0) return;
ctx.exitFullscreen();
ctx.onQueue({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd });
ctx.replaceText("");
ctx.onMinimizeHeight();
@@ -1040,11 +1039,8 @@ function getComposerInputSnapshot(
current: ComposerTextInputHandle | null,
fallbackText: string,
fallbackSelection: ComposerInputSnapshot["selection"],
mountedTextArea?: TextAreaHandle | null,
): ComposerInputSnapshot {
// 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 textArea = getWebTextAreaImpl(current);
const text = textArea?.value ?? current?.getText() ?? fallbackText;
const start = textArea?.selectionStart ?? fallbackSelection.start;
const end = textArea?.selectionEnd ?? fallbackSelection.end;
@@ -1244,7 +1240,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
textReplacement,
submitLabel,
} = resolveMessageInputProps(props);
const { isActiveComposer } = useComposerKeyboardScope();
const { isPaneFocused } = useComposerKeyboardScope();
const mode = resolveComposerInputMode(inputMode);
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
@@ -1269,18 +1265,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const inputWrapperRef = useRef<View | null>(null);
const textInputRef = useRef<ComposerTextInputHandle | null>(null);
const isInputFocusedRef = useRef(false);
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 focusIntentRef = useRef(false);
const valueRef = useRef(value);
const selectionRef = useRef({ start: value.length, end: value.length });
const paneFocusedRef = useRef(isPaneFocused);
paneFocusedRef.current = isPaneFocused;
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;
@@ -1289,43 +1280,53 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
setHasLiveText(nextHasLiveText);
}, []);
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],
);
// 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);
}, []);
// Focus intent outlives a blur only while the pane is not the user's pane (a retained
// tab hidden, a sheet over it); the pane coming back restores focus. A blur while the
// pane is theirs drops it, whatever the layout mode: the user dismissed the keyboard,
// or a compact overlay panel did on their behalf, and nothing later refocuses.
useLayoutEffect(() => {
if (isActiveComposer && focusIntentRef.current) textInputRef.current?.focus();
}, [isActiveComposer]);
if (isPaneFocused && focusIntentRef.current) textInputRef.current?.focus();
}, [isPaneFocused]);
// 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 (paneFocusedRef.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,
@@ -1340,29 +1341,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const updateComposerHeightForText = measuredComposerHeight?.onTextChange;
const resetComposerHeight = measuredComposerHeight?.reset;
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;
},
[editingSession],
);
const exitFullscreen = useCallback(() => changePresentation(false), [changePresentation]);
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) => onHeightChange?.(event.nativeEvent.layout.height),
[onHeightChange],
(event: LayoutChangeEvent) => {
if (!isFullscreen) onHeightChange?.(event.nativeEvent.layout.height);
},
[isFullscreen, onHeightChange],
);
const replaceText = useCallback(
@@ -1427,8 +1414,15 @@ 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);
}
@@ -1650,6 +1644,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onSubmit,
onMinimizeHeight: minimizeInputHeight,
preserveHeightOnSubmit,
exitFullscreen,
});
}, [
allowEmptySubmit,
@@ -1661,6 +1656,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
minimizeInputHeight,
preserveHeightOnSubmit,
updateLiveTextPresence,
exitFullscreen,
]);
const handleQueueMessage = useCallback(
@@ -1672,8 +1668,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onQueue,
replaceText,
onMinimizeHeight: minimizeInputHeight,
exitFullscreen,
}),
[attachments, cwd, onQueue, replaceText, minimizeInputHeight],
[attachments, cwd, onQueue, replaceText, minimizeInputHeight, exitFullscreen],
);
const handleDefaultSendAction = useCallback(() => {
@@ -1696,17 +1693,6 @@ 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,
@@ -1832,11 +1818,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}, [onFocusChange]);
const handleInputBlur = useCallback(() => {
if (activeComposerRef.current && !isFullscreen) focusIntentRef.current = false;
if (paneFocusedRef.current) focusIntentRef.current = false;
isInputFocusedRef.current = false;
setIsInputFocused(false);
onFocusChange?.(false);
}, [isFullscreen, onFocusChange]);
}, [onFocusChange]);
const handlePasteError = useCallback(
(message: string) => {
@@ -1888,8 +1874,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
styles.textInput,
mode.isMonospace && styles.textInputMonospace,
isFullscreen ? styles.fullscreenTextInput : composerHeightStyle,
isCompact && !isFullscreen && styles.compactTextInput,
],
[composerHeightStyle, mode.isMonospace, isFullscreen],
[composerHeightStyle, mode.isMonospace, isFullscreen, isCompact],
);
// Static content has no textarea to mirror, so it grows with its own text
// instead of the measured input height.
@@ -1897,13 +1884,22 @@ 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,
isSendButtonDisabled && styles.buttonDisabled,
isPresentedSendDisabled && styles.buttonDisabled,
],
[isSendButtonDisabled, submitLabel],
[isPresentedSendDisabled, submitLabel],
);
const overlayContainerStyle = useMemo(
() => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }],
@@ -1942,14 +1938,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canPressLoadingButton={canPressLoadingButton}
onSubmitLoadingPress={onSubmitLoadingPress}
onDefaultSendAction={handleDefaultSendAction}
isSendButtonDisabled={resolvePresentationSendDisabled({
fullscreen: isFullscreen,
disabled: isSendButtonDisabled,
hasText: hasLiveText,
attachments,
hasExternalContent,
allowEmpty: allowEmptySubmit,
})}
isSendButtonDisabled={isPresentedSendDisabled}
submitAccessibilityLabel={submitAccessibilityLabel}
sendButtonCombinedStyle={sendButtonCombinedStyle}
isSubmitLoading={isSubmitLoading}
@@ -1968,11 +1957,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canPressLoadingButton,
onSubmitLoadingPress,
handleDefaultSendAction,
isSendButtonDisabled,
hasLiveText,
attachments,
hasExternalContent,
allowEmptySubmit,
isPresentedSendDisabled,
submitAccessibilityLabel,
sendButtonCombinedStyle,
isSubmitLoading,
@@ -2008,9 +1993,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onBlur={handleInputBlur}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
scrollEnabled={isComposerScrollEnabled}
autoFocus={
Boolean(editingSession.peekHandoff(textReplacement)?.focused) && isActiveComposer
}
autoFocus={false}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
onPasteImages={onPasteImages}
@@ -2029,8 +2012,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[
readOnly,
textReplacement,
editingSession,
isActiveComposer,
assignTextInput,
textInputStyle,
readOnlyTextStyle,
@@ -2162,11 +2143,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
handleRealtimeVoiceStop,
],
);
const enterFullscreen = useCallback(() => changePresentation(true), [changePresentation]);
return (
<View
ref={rootRef}
style={styles.container}
style={isFullscreen ? styles.fullscreenContainer : styles.container}
testID="message-input-root"
onLayout={handleComposerLayout}
>
@@ -2177,8 +2157,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
/>
<ComposerInputPresentation
fullscreen={isFullscreen}
iconSize={buttonIconSize}
active={isActiveComposer}
sendIconSize={buttonIconSize}
surface={textSurface}
toolbar={toolbar}
send={sendButton}
@@ -2207,6 +2186,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
flexShrink: 1,
position: "relative",
},
fullscreenContainer: {
flex: 1,
position: "relative",
},
inputWrapper: {
flexShrink: 1,
flexDirection: "column",
@@ -2241,14 +2224,18 @@ const styles = StyleSheet.create((theme: Theme) => ({
inputWrapperReadOnly: {
borderStyle: "dotted",
},
compactTextSurface: { paddingRight: 36 },
// 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 },
fullscreenTextSurface: { flex: 1, minHeight: 0 },
fullscreenTextInput: { flex: 1, height: "100%", textAlignVertical: "top" },
lineMeasurement: {
position: "absolute",
top: 0,
left: 0,
right: 36,
right: 0,
opacity: 0,
maxHeight: undefined,
minHeight: undefined,
@@ -3,19 +3,31 @@ 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 { ComposerFullscreenHost } from "./fullscreen-host";
import { useComposerFullscreenExit } from "./fullscreen-exit";
import { resolveGlyphRailOffset } from "./state";
const CONTROL_SIZE = 28;
const FULLSCREEN_GLYPH_SIZE = ICON_SIZE.md;
function keepEditorFocused(event: import("react-native").PointerEvent) {
if (isWeb) event.preventDefault();
}
/** Both presentations receive the same editing surface and send capability. */
/**
* 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.
*/
export function ComposerInputPresentation({
fullscreen,
iconSize,
active,
sendIconSize,
surface,
toolbar,
send,
@@ -30,8 +42,7 @@ export function ComposerInputPresentation({
overlay,
}: {
fullscreen: boolean;
iconSize: number;
active: boolean;
sendIconSize: number;
surface: ReactNode;
toolbar: ReactNode;
send: ReactNode;
@@ -46,55 +57,33 @@ export function ComposerInputPresentation({
overlay: ReactNode;
}) {
const { t } = useTranslation();
// 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;
// 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,
});
const maximizeIcon = useCallback(
(color: string) => <Maximize2 color={color} size={iconSize} />,
[iconSize],
(color: string) => <Maximize2 color={color} size={FULLSCREEN_GLYPH_SIZE} />,
[],
);
const minimizeIcon = useCallback(
(color: string) => <Minimize2 color={color} size={iconSize} />,
[iconSize],
(color: string) => <Minimize2 color={color} size={FULLSCREEN_GLYPH_SIZE} />,
[],
);
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 ? (
<Button
variant="ghost"
size="xs"
accessibilityLabel={t("composer.input.fullscreen")}
leftIcon={maximizeIcon}
onPointerDown={keepEditorFocused}
onPress={onEnter}
style={enterStyle}
hitSlop={8}
>
{null}
</Button>
) : null}
</View>
{toolbar}
</View>
{overlay}
</>
);
// 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 (
<ComposerFullscreenHost onExit={onExit} active={active}>
<View style={styles.fullscreen} testID="composer-fullscreen">
<View
ref={exitScopeRef}
style={fullscreen ? styles.fullscreen : styles.inline}
testID={fullscreen ? "composer-fullscreen" : undefined}
>
{fullscreen ? (
<View style={styles.exitRow}>
<Button
variant="ghost"
@@ -109,28 +98,60 @@ export function ComposerInputPresentation({
{null}
</Button>
</View>
{surface}
{error}
<View style={styles.sendRow}>{send}</View>
</View>
</ComposerFullscreenHost>
) : null}
<ComposerFullscreenBody fullscreen={fullscreen}>
<View
ref={inputWrapperRef}
style={fullscreen ? styles.fullscreenInputWrapper : inputWrapperStyle}
pointerEvents={inputPointerEvents}
>
{fullscreen ? null : attachmentSlot}
<View style={fullscreen ? styles.fullscreenTextSurface : styles.inlineTextSurface}>
{surface}
{showFullscreenButton && !fullscreen ? (
<Button
variant="ghost"
size="xs"
accessibilityLabel={t("composer.input.fullscreen")}
leftIcon={maximizeIcon}
onPointerDown={keepEditorFocused}
onPress={onEnter}
style={enterStyle}
hitSlop={8}
>
{null}
</Button>
) : null}
</View>
{fullscreen ? null : toolbar}
</View>
</ComposerFullscreenBody>
{fullscreen ? (
<ComposerFullscreenFooter style={styles.footer}>
{error}
<View style={styles.sendRow}>{send}</View>
</ComposerFullscreenFooter>
) : null}
{overlay}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
inline: { flexShrink: 1, position: "relative" },
inlineTextSurface: { flexShrink: 1, position: "relative" },
iconButton: {
width: 28,
height: 28,
width: CONTROL_SIZE,
height: CONTROL_SIZE,
padding: 0,
borderWidth: 0,
borderRadius: theme.borderRadius.full,
},
fullscreenButton: {
enterButton: {
position: "absolute",
top: 0,
width: 28,
height: 28,
width: CONTROL_SIZE,
height: CONTROL_SIZE,
padding: 0,
borderWidth: 0,
borderRadius: theme.borderRadius.full,
@@ -141,6 +162,10 @@ 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 },
sendRow: { flexShrink: 0, alignItems: "flex-end" },
// 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" },
}));
+25 -3
View File
@@ -5,6 +5,8 @@ import {
resolveActiveSendBehavior,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveGlyphRailOffset,
resolveRetainedSelection,
runAlternateSendAction,
runDefaultSendAction,
runMessageInputKeyboardAction,
@@ -333,10 +335,30 @@ describe("composer surface text", () => {
);
});
it("uses the live handoff when its replacement has already been applied", () => {
it("keeps the live buffer when the initial publication is already applied", () => {
const replacement = { kind: "initial" as const, key: "draft:0", text: "Stale publication" };
expect(resolveComposerSurfaceText("Live remount handoff", replacement, "draft:0")).toBe(
"Live remount handoff",
expect(resolveComposerSurfaceText("Live edited draft", replacement, "draft:0")).toBe(
"Live edited draft",
);
});
});
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,6 +193,24 @@ 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,
@@ -200,3 +218,13 @@ 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 };
}
+18 -1
View File
@@ -7,18 +7,35 @@ import {
} from "react";
interface ComposerKeyboardScopeValue {
/**
* The pane this composer sits in is the user's pane. Focus restoration follows it:
* the editor keeps its focus intent across blurs that happen while the pane is not
* the user's (a retained tab hidden, a sheet over it) and brings focus back when
* the pane is theirs again.
*/
isPaneFocused: boolean;
/**
* The composer takes input right now: keyboard actions, shortcuts, autocomplete,
* and the fullscreen exit action. False while a compact overlay panel covers the
* pane, which does not take pane focus.
*/
isActiveComposer: boolean;
}
const ComposerKeyboardScopeContext = createContext<ComposerKeyboardScopeValue>({
isPaneFocused: false,
isActiveComposer: false,
});
export function ComposerKeyboardScopeProvider({
isPaneFocused,
isActiveComposer,
children,
}: PropsWithChildren<ComposerKeyboardScopeValue>): ReactElement {
const value = useMemo(() => ({ isActiveComposer }), [isActiveComposer]);
const value = useMemo(
() => ({ isPaneFocused, isActiveComposer }),
[isPaneFocused, isActiveComposer],
);
return (
<ComposerKeyboardScopeContext.Provider value={value}>
{children}
@@ -1,7 +1,8 @@
import React, { act, type ReactNode } from "react";
import React, { act, useCallback, useState, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { Pressable, Text } from "react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { ComposerTrackPill, ComposerTrackRow, type ComposerTrackPillSegment } from "./tracks";
const SUBAGENT_SEGMENTS: ComposerTrackPillSegment[] = [{ bucket: null, text: "3 subagents" }];
@@ -109,6 +110,43 @@ describe("composer track panel", () => {
expect(row("row")).not.toBeNull();
});
it("closes for good when the panel it belongs to leaves the screen", () => {
const onPress = vi.fn();
function Harness() {
const [active, setActive] = useState(true);
const hide = useCallback(() => setActive(false), []);
const show = useCallback(() => setActive(true), []);
return (
<>
<Pressable accessibilityRole="button" testID="hide" onPress={hide} />
<Pressable accessibilityRole="button" testID="show" onPress={show} />
<RetainedPanelActivity active={active}>
<ComposerTrackPill testID="pill" segments={SUBAGENT_SEGMENTS} panelTitle="Subagents">
<ComposerTrackRow
accessibilityLabel="Subagent one"
testID="row"
closeOnSelect={false}
onPress={onPress}
>
<Text>Subagent one</Text>
</ComposerTrackRow>
</ComposerTrackPill>
</RetainedPanelActivity>
</>
);
}
mount(<Harness />);
openPanel();
expect(isPanelOpen()).toBe(true);
click(row("hide") as Element);
click(row("show") as Element);
expect(isPanelOpen()).toBe(false);
expect(row("row")).toBeNull();
});
it("leaves the panel alone when a row's own action button is pressed", () => {
const onPress = vi.fn();
const onAction = vi.fn();
+5 -1
View File
@@ -9,8 +9,12 @@ 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" | "replace";
kind: "initial" | "hydrated" | "replace";
key: string;
text: string;
}
+1 -1
View File
@@ -631,7 +631,7 @@ export const ar: TranslationResources = {
},
switcher: {
trigger: "تبديل علامات التبويب (فتح{{count}})",
title: "علامة التبويب التبديل",
title: "علامات تبويب الـ Workspace",
searchPlaceholder: "علامات تبويب البحث",
},
menu: {
+1 -1
View File
@@ -628,7 +628,7 @@ export const en = {
},
switcher: {
trigger: "Switch tabs ({{count}} open)",
title: "Switch tab",
title: "Workspace tabs",
searchPlaceholder: "Search tabs",
},
menu: {
+1 -1
View File
@@ -637,7 +637,7 @@ export const es: TranslationResources = {
},
switcher: {
trigger: "Cambiar pestañas ({{count}}abierto)",
title: "Cambiar pestaña",
title: "Pestañas del workspace",
searchPlaceholder: "Pestañas de búsqueda",
},
menu: {
+1 -1
View File
@@ -636,7 +636,7 @@ export const fr: TranslationResources = {
},
switcher: {
trigger: "Changer d’onglet ({{count}} ouverts)",
title: "Changer d’onglet",
title: "Onglets de l’espace de travail",
searchPlaceholder: "Rechercher des onglets",
},
menu: {
+1 -1
View File
@@ -637,7 +637,7 @@ export const ja: TranslationResources = {
},
switcher: {
trigger: "タブを切り替え({{count}}件開いています)",
title: "タブを切り替え",
title: "ワークスペースのタブ",
searchPlaceholder: "タブを検索",
},
menu: {
+1 -1
View File
@@ -632,7 +632,7 @@ export const ko: TranslationResources = {
},
switcher: {
trigger: "탭 전환 ({{count}}개 열림)",
title: "탭 전환",
title: "워크스페이스 탭",
searchPlaceholder: "탭 검색",
},
menu: {
+1 -1
View File
@@ -636,7 +636,7 @@ export const ptBR: TranslationResources = {
},
switcher: {
trigger: "Alternar abas ({{count}} abertas)",
title: "Alternar aba",
title: "Abas do workspace",
searchPlaceholder: "Buscar abas",
},
menu: {
+1 -1
View File
@@ -638,7 +638,7 @@ export const ru: TranslationResources = {
},
switcher: {
trigger: "Переключить вкладки (открыто: {{count}})",
title: "Переключить вкладку",
title: "Вкладки рабочего пространства",
searchPlaceholder: "Поиск вкладок",
},
menu: {
+1 -1
View File
@@ -631,7 +631,7 @@ export const zhCN: TranslationResources = {
},
switcher: {
trigger: "切换标签(已打开 {{count}} 个)",
title: "切换标签",
title: "Workspace 标签",
searchPlaceholder: "搜索标签",
},
menu: {
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { buildAgentFactsLine, type AgentFactsInput } from "./agent-facts-line";
const models = [
{
provider: "claude",
id: "claude-fable-5-1",
label: "Fable 5.1",
thinkingOptions: [
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
],
},
];
const modes = [
{ id: "default", label: "Default" },
{ id: "bypassPermissions", label: "Bypass permissions" },
];
const base: AgentFactsInput = {
models,
runtimeModelId: "claude-fable-5-1",
configuredModelId: null,
runtimeThinkingOptionId: "high",
explicitThinkingOptionId: null,
availableModes: modes,
currentModeId: "bypassPermissions",
contextWindowUsedTokens: 56_200,
contextWindowMaxTokens: 200_000,
};
describe("buildAgentFactsLine", () => {
it("joins model, thinking, mode, and context usage as the controls show them", () => {
expect(buildAgentFactsLine(base)).toBe(
"Fable 5.1 · High · Bypass permissions · 56k / 200k tokens (28%)",
);
});
it("shows the raw model id before the catalog has loaded", () => {
expect(
buildAgentFactsLine({
...base,
models: null,
runtimeModelId: null,
configuredModelId: "gpt-6.1-sol",
runtimeThinkingOptionId: null,
explicitThinkingOptionId: "medium",
availableModes: [],
contextWindowUsedTokens: null,
contextWindowMaxTokens: null,
}),
).toBe("gpt-6.1-sol · Medium");
});
it("falls back to the provider's first mode until the agent reports one", () => {
expect(
buildAgentFactsLine({
...base,
currentModeId: null,
contextWindowUsedTokens: undefined,
contextWindowMaxTokens: undefined,
}),
).toBe("Fable 5.1 · High · Default");
});
it("leaves thinking and context out when the agent has none", () => {
expect(
buildAgentFactsLine({
...base,
models: [{ provider: "mock", id: "m", label: "Model M" }],
runtimeModelId: "m",
runtimeThinkingOptionId: null,
availableModes: [],
contextWindowUsedTokens: 10,
contextWindowMaxTokens: 0,
}),
).toBe("Model M");
});
it("is empty until the agent has a model", () => {
expect(
buildAgentFactsLine({ ...base, runtimeModelId: null, configuredModelId: " " }),
).toBeNull();
});
});
@@ -0,0 +1,59 @@
import type { AgentMode, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import { formatAgentModeLabel } from "@/agent-controls/labels";
import { formatTokenCount } from "@/components/context-window-meter.utils";
import { resolveAgentModelSelection } from "@/composer/agent-controls/utils";
export interface AgentFactsInput {
models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined;
configuredModelId: string | null | undefined;
runtimeThinkingOptionId: string | null | undefined;
explicitThinkingOptionId: string | null | undefined;
availableModes: AgentMode[];
currentModeId: string | null | undefined;
contextWindowUsedTokens: number | null | undefined;
contextWindowMaxTokens: number | null | undefined;
}
/**
* What the composer's controls and context meter show about a Paseo agent, as one line: model,
* thinking, mode, and context usage, each resolved exactly as its control resolves it. Null until
* the agent has a model at all.
*/
export function buildAgentFactsLine(input: AgentFactsInput): string | null {
const hasModel = Boolean(input.runtimeModelId?.trim() || input.configuredModelId?.trim());
if (!hasModel) return null;
const selection = resolveAgentModelSelection(input);
const parts = [selection.displayModel];
if (selection.selectedThinkingId) parts.push(selection.displayThinking);
const mode = resolveModeLabel(input.availableModes, input.currentModeId);
if (mode) parts.push(mode);
const context = formatContextUsage(input.contextWindowUsedTokens, input.contextWindowMaxTokens);
if (context) parts.push(context);
return parts.join(" · ");
}
/** The mode control shows the current mode, or the provider's first one until the agent reports. */
function resolveModeLabel(modes: AgentMode[], currentModeId: string | null | undefined) {
const mode = modes.find((candidate) => candidate.id === currentModeId) ?? modes[0];
return mode ? formatAgentModeLabel(mode) : null;
}
/** The context meter's numbers: used of max, with the share it draws as the ring. */
function formatContextUsage(
usedTokens: number | null | undefined,
maxTokens: number | null | undefined,
): string | null {
if (
typeof usedTokens !== "number" ||
typeof maxTokens !== "number" ||
!Number.isFinite(usedTokens) ||
!Number.isFinite(maxTokens) ||
usedTokens < 0 ||
maxTokens <= 0
) {
return null;
}
const percentage = Math.round(Math.min(100, (usedTokens / maxTokens) * 100));
return `${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens (${percentage}%)`;
}
+55 -2
View File
@@ -27,6 +27,11 @@ 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,
@@ -55,6 +60,9 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state";
import { TimelineSyncStatus } from "@/timeline/sync-status";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import { buildAgentFactsLine } from "@/panels/agent-facts-line";
import { PaneSubtitle } from "@/panels/pane-subtitle";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
import { RenderProfile } from "@/utils/render-profiler";
import { useHasPluginComposerPills } from "@/plugins";
@@ -1155,6 +1163,9 @@ 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),
@@ -1229,6 +1240,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
cwd={cwd}
isSubmitLoading={false}
agentInputDraft={agentInputDraft}
editingSession={composerEditingSession}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onComposerHeightChange={handleComposerHeightChange}
@@ -1238,6 +1250,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
) : null;
const streamContent = (
<View style={animatedStaticStyles.content}>
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
<RenderProfile id={`AgentStreamSection:${agentId}`}>
<AgentStreamSection
streamViewRef={streamViewRef}
@@ -1292,7 +1305,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
);
const dock = (
<ChatSurface disabled={isArchivingCurrentAgent}>
<ChatSurface disabled={isArchivingCurrentAgent} fullscreen={isComposerFullscreen}>
{dockContent}
{composerSection}
{dockOverlay}
@@ -1323,13 +1336,15 @@ 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>{children}</ComposerDock>
<ComposerDock fullscreen={fullscreen}>{children}</ComposerDock>
</FileDropZone>
);
}
@@ -1364,6 +1379,36 @@ function TimelineSyncErrorCallout({
);
}
/**
* What the composer's controls and context meter would show, as one line, for an agent shown
* without its composer.
*/
function SheetAgentFacts({ serverId, agentId }: { serverId: string; agentId: string }) {
const agent = useSessionStore(
useShallow((state) => {
const current = state.sessions[serverId]?.agents.get(agentId);
return current
? {
provider: current.provider,
cwd: current.cwd,
runtimeModelId: current.runtimeInfo?.model ?? null,
configuredModelId: current.model ?? null,
runtimeThinkingOptionId: current.runtimeInfo?.thinkingOptionId ?? null,
explicitThinkingOptionId: current.thinkingOptionId ?? null,
availableModes: current.availableModes,
currentModeId: current.currentModeId,
contextWindowUsedTokens: current.lastUsage?.contextWindowUsedTokens ?? null,
contextWindowMaxTokens: current.lastUsage?.contextWindowMaxTokens ?? null,
}
: null;
}),
);
const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null;
const text = agent ? buildAgentFactsLine({ ...agent, models }) : null;
return text ? <PaneSubtitle text={text} testID="agent-pane-subtitle" /> : null;
}
const AgentStreamSection = memo(function AgentStreamSection({
streamViewRef,
serverId,
@@ -1473,6 +1518,7 @@ const AgentComposerSection = memo(function AgentComposerSection({
cwd,
isSubmitLoading,
agentInputDraft,
editingSession,
onAttentionInputFocus,
onAttentionPromptSend,
onComposerHeightChange,
@@ -1486,6 +1532,7 @@ const AgentComposerSection = memo(function AgentComposerSection({
cwd: string;
isSubmitLoading: boolean;
agentInputDraft: AgentInputDraft;
editingSession: ComposerEditingSession;
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onComposerHeightChange: (height: number) => void;
@@ -1509,6 +1556,7 @@ const AgentComposerSection = memo(function AgentComposerSection({
cwd={cwd}
isSubmitLoading={isSubmitLoading}
agentInputDraft={agentInputDraft}
editingSession={editingSession}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onComposerHeightChange={onComposerHeightChange}
@@ -1524,6 +1572,7 @@ function ActiveAgentComposer({
cwd,
isSubmitLoading,
agentInputDraft,
editingSession,
onAttentionInputFocus,
onAttentionPromptSend,
onComposerHeightChange,
@@ -1535,6 +1584,7 @@ function ActiveAgentComposer({
cwd: string;
isSubmitLoading: boolean;
agentInputDraft: AgentInputDraft;
editingSession: ComposerEditingSession;
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onComposerHeightChange: (height: number) => void;
@@ -1631,6 +1681,7 @@ function ActiveAgentComposer({
onChangeAttachments={agentInputDraft.setAttachments}
cwd={cwd}
clearDraft={agentInputDraft.clear}
editingSession={editingSession}
autoFocus
autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)}
isSubmitLoading={isSubmitLoading}
@@ -1723,6 +1774,8 @@ const animatedStaticStyles = RNStyleSheet.create({
inputAreaWrapper: {
width: "100%",
flexShrink: 1,
// Content-sized inline; fills the dock's fullscreen slot.
flexGrow: 1,
},
});
+3
View File
@@ -12,6 +12,7 @@ import {
type ArchiveFinishedStatus,
useArchiveSubagent,
useDetachSubagent,
useSubagentOpensInSheet,
type SubagentRow,
} from "@/subagents";
import { SubagentsTrack } from "@/subagents/track";
@@ -90,6 +91,7 @@ export const AgentTracks = memo(function AgentTracks({
},
[openSubagentTarget],
);
const opensInSheet = useSubagentOpensInSheet(workspaceKey);
const handleOpenChanges = useCallback(() => {
if (!workspaceKey) {
return;
@@ -122,6 +124,7 @@ export const AgentTracks = memo(function AgentTracks({
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
onOpenProviderSubagent={handleOpenProviderSubagent}
opensInSheet={opensInSheet}
onArchiveSubagent={archiveSubagent}
onArchiveFinished={onArchiveFinished}
archiveFinishedStatus={archiveFinishedStatus}
+30
View File
@@ -0,0 +1,30 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
/**
* One muted line of facts about a pane's agent, under the pane's header and above its stream:
* model, thinking, tokens. Provider subagents get it from the daemon; a Paseo agent shown without
* its composer gets it from the same facts the composer's controls show.
*/
export function PaneSubtitle({ text, testID }: { text: string; testID?: string }) {
return (
<View style={styles.header}>
<Text style={styles.text} numberOfLines={1} testID={testID}>
{text}
</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
header: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
text: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
@@ -13,9 +13,11 @@ import { ComposerTrackBar } from "@/composer/tracks";
import { useIsCompactFormFactor } from "@/constants/layout";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { usePaneContext } from "@/panels/pane-context";
import { PaneSubtitle } from "@/panels/pane-subtitle";
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
import { useSessionStore } from "@/stores/session-store";
import { useSubagentsForParent } from "@/subagents/select";
import { type SubagentRow, useSubagentsForParent } from "@/subagents/select";
import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet";
import { SubagentsTrack } from "@/subagents/track";
import {
providerSubagentKey,
@@ -30,6 +32,7 @@ import type { StreamItem } from "@/types/stream";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
import type { TurnPresentation } from "@/timeline/turn-liveness";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
const EMPTY_PERMISSIONS = new Map<string, PendingPermission>();
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
@@ -47,10 +50,12 @@ function ProviderSubagentChildTrack({
serverId,
rows,
onOpenProviderSubagent,
opensInSheet,
}: {
serverId: string;
rows: ReturnType<typeof useSubagentsForParent>;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
opensInSheet: (row: SubagentRow) => boolean;
}) {
if (rows.length === 0) return null;
return (
@@ -60,6 +65,7 @@ function ProviderSubagentChildTrack({
rows={rows}
onOpenSubagent={NOOP_SUBAGENT}
onOpenProviderSubagent={onOpenProviderSubagent}
opensInSheet={opensInSheet}
onArchiveSubagent={NOOP_SUBAGENT}
/>
</ComposerTrackBar>
@@ -109,7 +115,7 @@ function useProviderSubagentDescriptor(
function ProviderSubagentPanel() {
const { t } = useTranslation();
const { serverId, target, openFileInWorkspace, openTab } = usePaneContext();
const { serverId, workspaceId, target, openFileInWorkspace, openTab } = usePaneContext();
invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target");
const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId);
const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`;
@@ -137,6 +143,9 @@ function ProviderSubagentPanel() {
providerParentSubagentId: target.subagentId,
});
const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact);
const opensInSheet = useSubagentOpensInSheet(
buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
);
const openProviderChild = useCallback(
(parentAgentId: string, subagentId: string) => {
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
@@ -243,17 +252,7 @@ function ProviderSubagentPanel() {
return (
<View style={styles.container} testID="provider-subagent-panel">
{subtitle ? (
<View style={styles.subtitleHeader}>
<Text
style={styles.subtitleText}
numberOfLines={1}
testID="provider-subagent-pane-subtitle"
>
{subtitle}
</Text>
</View>
) : null}
{subtitle ? <PaneSubtitle text={subtitle} testID="provider-subagent-pane-subtitle" /> : null}
<AgentStreamView
agentId={streamId}
serverId={serverId}
@@ -273,6 +272,7 @@ function ProviderSubagentPanel() {
serverId={serverId}
rows={childRows}
onOpenProviderSubagent={openProviderChild}
opensInSheet={opensInSheet}
/>
</View>
);
@@ -280,16 +280,6 @@ function ProviderSubagentPanel() {
const styles = StyleSheet.create((theme) => ({
container: { flex: 1, minHeight: 0 },
subtitleHeader: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
subtitleText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
}));
@@ -17,6 +17,7 @@ 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,
@@ -1636,6 +1637,8 @@ 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));
@@ -2121,6 +2124,7 @@ export function NewWorkspaceScreen({
return;
}
outcome = "navigated";
leaveFullscreen();
navigateToWorkspace({ serverId: targetServerId, workspaceId });
},
});
@@ -2171,6 +2175,7 @@ export function NewWorkspaceScreen({
forkDraftSetup,
isStillOnCreateScreen,
launchTarget,
leaveFullscreen,
selectedServerId,
supportsForgeSearch,
t,
@@ -2227,6 +2232,7 @@ export function NewWorkspaceScreen({
return;
}
outcome = "navigated";
leaveFullscreen();
navigateToWorkspace({ serverId: targetServerId, workspaceId, target });
},
});
@@ -2250,6 +2256,7 @@ export function NewWorkspaceScreen({
t,
terminalPromptText,
toast,
leaveFullscreen,
updateFormPreferences,
withConnectedClient,
]);
@@ -2369,10 +2376,12 @@ 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}
@@ -2399,6 +2408,7 @@ export function NewWorkspaceScreen({
) : (
<Composer
key="chat"
editingSession={composerEditing.chat}
agentId={draftKey}
serverId={selectedServerId}
isPaneFocused={true}
@@ -2429,32 +2439,53 @@ export function NewWorkspaceScreen({
);
return (
<FileDropZone style={styles.container}>
<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>
<NewWorkspaceLayout
isCompact={isCompact}
fullscreen={isComposerFullscreen}
title={t("newWorkspace.title")}
formStack={formStack}
onImportSession={importSession.open}
>
{composer}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</NewWorkspaceLayout>
{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;
@@ -2472,13 +2503,19 @@ function NewWorkspaceLayout({
</>
);
return (
<ComposerDock centered={!isCompact}>
{setupFields}
<>
{children}
{isCompact ? null : importSessionButton}
</>
</ComposerDock>
<>
{fullscreen ? null : <MenuHeader borderless />}
<View style={styles.content}>
<TitlebarDragRegion />
<ComposerDock centered={!isCompact} fullscreen={fullscreen}>
{setupFields}
<>
{children}
{isCompact || fullscreen ? null : importSessionButton}
</>
</ComposerDock>
</View>
</>
);
}
@@ -80,6 +80,7 @@ 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,
@@ -0,0 +1,311 @@
import type { BottomSheetHandleProps } from "@gorhom/bottom-sheet";
import { useIsFocused } from "@react-navigation/native";
import {
type ComponentType,
type FC,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { 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 { useStore } from "zustand";
import { createStore } from "zustand/vanilla";
import { Maximize2 } from "lucide-react-native";
import {
SheetBackground,
SheetHandle,
SheetHeaderIconButton,
SheetHeaderView,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import {
IsolatedBottomSheetModal,
type IsolatedBottomSheetModalRef,
systemBackPress,
} from "@/components/ui/isolated-bottom-sheet-modal";
import type { PanelIconProps } from "@/panels/panel-registry";
import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
WorkspacePaneContent,
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store";
const SNAP_POINTS = ["92%"];
const NO_SHEETS: readonly SheetTab[] = [];
function PanelIcon({
icon: Icon,
size,
color,
}: { icon: ComponentType<PanelIconProps> } & PanelIconProps) {
return <Icon size={size} color={color} />;
}
const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({
color: theme.colors.foregroundMuted,
}));
interface SheetTabHandleValue {
header: SheetHeader;
onClose: () => void;
}
/**
* The sheet's handle, showing its header. Gorhom takes `handleComponent` as a component type and
* renders it on the far side of the portal: a new function per render would remount the header on
* every change, and no context from here reaches it. So each sheet gets one handle component for
* its lifetime, which reads the header through a store the sheet keeps current.
*/
function useSheetTabHandle(value: SheetTabHandleValue): FC<BottomSheetHandleProps> {
const [store] = useState(() => createStore<SheetTabHandleValue>(() => value));
useEffect(() => store.setState(value, true), [store, value]);
const [Handle] = useState(
() =>
function SheetTabHandle(props: BottomSheetHandleProps) {
const { header, onClose } = useStore(store);
return (
<SheetHandle {...props}>
<SheetHeaderView header={header} onClose={onClose} testID="sheet-tab-header" />
</SheetHandle>
);
},
);
return Handle;
}
export interface SheetTabsProps {
workspaceKey: string | null;
serverId: string;
workspaceId: string;
isWorkspaceFocused: boolean;
buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
/** The workspace's ordinary tab close, including its unsaved-changes guard. */
onCloseTab: (tabId: string) => Promise<void>;
}
interface SheetTabSheetProps extends Omit<SheetTabsProps, "workspaceKey" | "onCloseTab"> {
sheet: SheetTab;
/** Only the top sheet's pane is focused and answers Android Back; the ones below wait their turn. */
isTop: boolean;
/** Runs the guarded close and resolves to whether the tab is still open. */
onClose: (tabId: string) => Promise<boolean>;
onOpenAsTab: (tabId: string) => void;
}
/**
* The workspace's sheet tabs, one sheet each, on the app-wide sheet stack. They present from inside
* the workspace's `SheetHostScope`, so each panel keeps every context an ordinary pane has, and a
* sheet opened from any other sheet (a subagent chosen from the subagents list, say) lands on top of
* it and uncovers it on close. The shared sheet parts (`SheetBackground`, `SheetHandle`,
* `SheetHeaderView`) keep them looking and dragging like every other sheet.
*/
export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps) {
const sheets = useWorkspaceLayoutStore((state) =>
workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS,
);
const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab);
const handleOpenAsTab = useCallback(
(tabId: string) => {
if (workspaceKey) openSheetAsTab(workspaceKey, tabId);
},
[openSheetAsTab, workspaceKey],
);
const handleClose = useCallback(
async (tabId: string) => {
await onCloseTab(tabId);
// A declined unsaved-changes prompt leaves the tab in its sheet.
const open = workspaceKey
? useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey]
: undefined;
return open?.some((sheet) => sheet.tab.tabId === tabId) ?? false;
},
[onCloseTab, workspaceKey],
);
return (
<>
{sheets.map((sheet, index) => (
<SheetTabSheet
// 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}
isTop={index === sheets.length - 1}
onClose={handleClose}
onOpenAsTab={handleOpenAsTab}
/>
))}
</>
);
}
function SheetTabSheet({
sheet,
isTop,
serverId,
workspaceId,
isWorkspaceFocused,
buildContent,
onClose,
onOpenAsTab,
}: SheetTabSheetProps) {
const { t } = useTranslation();
const { tab } = sheet;
const sheetRef = useRef<IsolatedBottomSheetModalRef>(null);
const isScreenFocused = useIsFocused();
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.target.kind);
if (!registration) {
throw new Error(`No panel registration for kind: ${tab.target.kind}`);
}
const descriptor = registration.useDescriptor(tab.target as never, {
serverId,
workspaceId,
tabId: tab.tabId,
});
// The sheet lives as long as its store entry: it presents on mount, and unmounting takes it off
// the stack, which uncovers the sheet below. Gorhom slides an unmounting sheet down and still
// reports that as `onChange(-1)`, which must not read as the user closing it: the store already
// moved on, and the same tab id may now be a pane tab (Open as tab).
const unmounted = useRef(false);
useEffect(() => {
sheetRef.current?.present();
return () => {
unmounted.current = true;
};
}, []);
// Every close path (drag, backdrop, Close, Back) slides the sheet down first; `onChange(-1)` then
// runs the one guarded close. The sheet keeps its content through the close, so a declined
// unsaved-changes prompt only has to bring it back up.
const close = useCallback(() => sheetRef.current?.close(), []);
const handleChange = useCallback(
(index: number) => {
if (index !== -1 || unmounted.current) return;
void onClose(tab.tabId).then((stillOpen) => {
if (stillOpen) sheetRef.current?.snapToIndex(0);
return stillOpen;
});
},
[onClose, tab.tabId],
);
// 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(() => {
close();
return true;
});
}, [close, ownsBackPress]);
const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]);
const header = useMemo<SheetHeader>(
() => ({
title: descriptor.label,
leading: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
// The sheet is for looking; the full-screen glyph takes the tab out of the sheet and into
// the switcher, where it gets its composer back.
actions: sheet.policy.openAsTab ? (
<SheetHeaderIconButton
icon={Maximize2}
accessibilityLabel={t("workspace.sheet.openAsTab")}
onPress={handleOpenAsTab}
testID="sheet-tab-open-as-tab"
/>
) : undefined,
}),
[descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t],
);
const handleValue = useMemo<SheetTabHandleValue>(
() => ({ header, onClose: close }),
[close, header],
);
// 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.
const SheetTabHandle = useSheetTabHandle(handleValue);
return (
<IsolatedBottomSheetModal
ref={sheetRef}
contextBridge={null}
snapPoints={SNAP_POINTS}
enableDynamicSizing={false}
enablePanDownToClose
enableContentPanningGesture={false}
enableDismissOnClose={false}
backgroundComponent={SheetBackground}
handleComponent={SheetTabHandle}
backdropOpacity={0.45}
onChange={handleChange}
accessible={false}
>
{/* A covered sheet stays as live as the pane under it; only focus follows the top sheet. */}
<SheetTabBody
tab={tab}
label={descriptor.label}
isPaneFocused={isTop && isWorkspaceFocused}
isWorkspaceFocused={isWorkspaceFocused}
buildContent={buildContent}
/>
</IsolatedBottomSheetModal>
);
}
function SheetTabBody({
tab,
label,
isPaneFocused,
isWorkspaceFocused,
buildContent,
}: {
tab: SheetTab["tab"];
label: string;
/** 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,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
state: tab.state,
}),
[tab],
);
const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]);
// 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],
);
return (
<View style={contentStyle} role="dialog" aria-label={label} testID="sheet-tab-body">
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
);
}
const styles = StyleSheet.create({
content: {
flex: 1,
minHeight: 0,
},
});
@@ -1,5 +1,9 @@
import { WorkspaceComposerEditingSessionsProvider } from "@/composer/editing-session";
import {
WorkspaceComposerEditingSessionsProvider,
useComposerTabFullscreen,
} from "@/composer/editing-session";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { SheetHostScope } from "@/components/ui/isolated-bottom-sheet-modal";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
import {
@@ -75,6 +79,7 @@ import {
selectExplorerSidebarPaneId,
selectPaneFocusSuspended,
type WorkspaceLayout,
type SheetTab,
type WorkspaceTabPlacement,
useWorkspaceLayoutStore,
useWorkspaceLayoutStoreHydrated,
@@ -161,7 +166,7 @@ import {
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import { WorkspaceSheetHost } from "@/screens/workspace/workspace-sheet-host";
import { SheetTabs } from "@/screens/workspace/sheet-tabs";
import type { PaneHost } from "@/panels/panel-manifest";
import { WorkspaceFocusProvider } from "@/workspace/focus";
import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache";
@@ -204,6 +209,7 @@ 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 SheetTab[] = [];
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
const EMPTY_UI_TABS: WorkspaceTab[] = [];
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
@@ -824,6 +830,16 @@ 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;
@@ -1309,8 +1325,12 @@ 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.sheetByWorkspace[persistenceKey]?.tab ?? null) : null,
persistenceKey ? (state.sheetsByWorkspace[persistenceKey]?.at(-1)?.tab ?? null) : null,
);
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
@@ -2069,7 +2089,7 @@ function WorkspaceScreenContent({
const handleCloseTabById = useCallback(
async (tabId: string) => {
if (persistenceKey && sheetTab?.tabId === tabId) {
if (persistenceKey && sheets.some((sheet) => sheet.tab.tabId === tabId)) {
if (await confirmDiscardModifiedTab(tabId)) {
closeWorkspaceTab(persistenceKey, tabId);
}
@@ -2100,7 +2120,7 @@ function WorkspaceScreenContent({
handleClosePassiveTab,
handleCloseTerminalTab,
persistenceKey,
sheetTab,
sheets,
],
);
@@ -3095,7 +3115,6 @@ function WorkspaceScreenContent({
focusedPaneTabDescriptorMap,
isRouteFocused,
focusedPaneId,
// The sheet owns input while it covers the tab.
paneFocusSuspended,
onFocusPane: handleFocusPane,
buildMobilePaneContentModel,
@@ -3524,22 +3543,28 @@ function WorkspaceScreenContent({
const rendersDesktopSplitContent = !isMobile && desktopSplitContent !== null;
const workspacePanelTabIds = useMemo(
() => [...uiTabs.map((tab) => tab.tabId), ...(sheetTab ? [sheetTab.tabId] : [])],
[uiTabs, sheetTab],
() => [...uiTabs.map((tab) => tab.tabId), ...sheets.map((sheet) => sheet.tab.tabId)],
[uiTabs, sheets],
);
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.
// Sheets opened from the compact column present inside it, so their panels read the workspace
// providers, and they cover the compact header as a modal.
const workspaceCenterColumn = isMobile ? (
<WorkspacePanelProviders
launcher={newTabLauncher}
workspaceKey={workspacePanelKey}
tabIds={workspacePanelTabIds}
>
<View style={styles.centerColumn}>
<SheetHostScope style={styles.centerColumn}>
<FloatingHeaderLayout
header={compactWorkspaceHeader}
floating={floatsHeaderOverContent(activeTabDescriptor)}
header={compactHeaderLayout.header}
floating={compactHeaderLayout.floating}
>
{(topOverlayInset) => (
<StreamTopOverlayProvider value={topOverlayInset}>
@@ -3547,17 +3572,15 @@ function WorkspaceScreenContent({
</StreamTopOverlayProvider>
)}
</FloatingHeaderLayout>
<StreamTopOverlayProvider value={0}>
<WorkspaceSheetHost
workspaceKey={persistenceKey}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isRouteFocused}
buildContent={buildSheetPaneContentModel}
onCloseTab={handleCloseTabById}
/>
</StreamTopOverlayProvider>
</View>
<SheetTabs
workspaceKey={persistenceKey}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isRouteFocused}
buildContent={buildSheetPaneContentModel}
onCloseTab={handleCloseTabById}
/>
</SheetHostScope>
</WorkspacePanelProviders>
) : (
<View style={styles.centerColumn}>
@@ -1,245 +0,0 @@
import BottomSheet, {
BottomSheetBackdrop,
type BottomSheetBackdropProps,
} from "@gorhom/bottom-sheet";
import { useIsFocused } from "@react-navigation/native";
import { type ComponentType, useCallback, useEffect, useMemo, useRef } from "react";
import { BackHandler, View } from "react-native";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { SheetHeaderView } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { isNative } from "@/constants/platform";
import type { PanelIconProps } from "@/panels/panel-registry";
import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import {
WorkspacePaneContent,
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import { useWorkspaceLayoutStore, type WorkspaceSheet } from "@/stores/workspace-layout-store";
const SNAP_POINTS = ["92%"];
const ThemedBottomSheet = withUnistyles(BottomSheet, (theme) => ({
backgroundStyle: {
backgroundColor: theme.colors.surface0,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
},
handleIndicatorStyle: {
backgroundColor: theme.colors.palette.zinc[600],
},
}));
function PanelIcon({
icon: Icon,
size,
color,
}: { icon: ComponentType<PanelIconProps> } & PanelIconProps) {
return <Icon size={size} color={color} />;
}
const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({
color: theme.colors.foregroundMuted,
}));
function renderBackdrop(props: BottomSheetBackdropProps) {
return (
<BottomSheetBackdrop
{...props}
appearsOnIndex={0}
disappearsOnIndex={-1}
opacity={0.45}
pressBehavior="close"
/>
);
}
export interface WorkspaceSheetHostProps {
workspaceKey: string | null;
serverId: string;
workspaceId: string;
isWorkspaceFocused: boolean;
buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
/** The workspace's ordinary tab close, including its unsaved-changes guard. */
onCloseTab: (tabId: string) => Promise<void>;
}
interface WorkspaceSheetPresentationProps extends Omit<
WorkspaceSheetHostProps,
"workspaceKey" | "onCloseTab"
> {
sheet: WorkspaceSheet;
onClose: () => Promise<void>;
onOpenAsTab: () => void;
}
/**
* Hosts the workspace's sheet tab over the compact tab strip. It renders in the workspace tree,
* not through a portal, so the panel keeps every context an ordinary pane has.
*/
export function WorkspaceSheetHost({ workspaceKey, onCloseTab, ...rest }: WorkspaceSheetHostProps) {
const sheet = useWorkspaceLayoutStore((state) =>
workspaceKey ? (state.sheetByWorkspace[workspaceKey] ?? null) : null,
);
const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab);
const sheetTabId = sheet?.tab.tabId ?? null;
const handleClose = useCallback(async () => {
if (sheetTabId) await onCloseTab(sheetTabId);
}, [onCloseTab, sheetTabId]);
const handleOpenAsTab = useCallback(() => {
if (workspaceKey) openSheetAsTab(workspaceKey);
}, [openSheetAsTab, workspaceKey]);
if (!sheet) return null;
return (
<WorkspaceSheetPresentation
{...rest}
sheet={sheet}
onClose={handleClose}
onOpenAsTab={handleOpenAsTab}
/>
);
}
function WorkspaceSheetPresentation({
sheet,
serverId,
workspaceId,
isWorkspaceFocused,
buildContent,
onClose,
onOpenAsTab,
}: WorkspaceSheetPresentationProps) {
const sheetRef = useRef<BottomSheet>(null);
const isScreenFocused = useIsFocused();
const ownsBackPress = isNative && isWorkspaceFocused && isScreenFocused;
const handleChange = useCallback(
(index: number) => {
if (index !== -1) return;
// A declined close (unsaved changes) leaves the tab in the sheet, so bring it back up.
void onClose().then(() => sheetRef.current?.snapToIndex(0));
},
[onClose],
);
useEffect(() => {
// Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns back.
if (!ownsBackPress) return;
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
void onClose();
return true;
});
return () => subscription.remove();
}, [onClose, ownsBackPress]);
return (
<View style={styles.host} pointerEvents="box-none">
<ThemedBottomSheet
ref={sheetRef}
index={0}
snapPoints={SNAP_POINTS}
enableDynamicSizing={false}
enablePanDownToClose
enableContentPanningGesture={false}
backdropComponent={renderBackdrop}
onChange={handleChange}
>
<WorkspaceSheetBody
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
sheet={sheet}
serverId={serverId}
workspaceId={workspaceId}
isWorkspaceFocused={isWorkspaceFocused}
buildContent={buildContent}
onClose={onClose}
onOpenAsTab={onOpenAsTab}
/>
</ThemedBottomSheet>
</View>
);
}
function WorkspaceSheetBody({
sheet,
serverId,
workspaceId,
isWorkspaceFocused,
buildContent,
onClose,
onOpenAsTab,
}: WorkspaceSheetPresentationProps) {
const { t } = useTranslation();
const { tab } = sheet;
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.target.kind);
if (!registration) {
throw new Error(`No panel registration for kind: ${tab.target.kind}`);
}
// The body is keyed by tab id and kind, so the registration and its descriptor hook stay fixed.
const descriptor = registration.useDescriptor(tab.target as never, {
serverId,
workspaceId,
tabId: tab.tabId,
});
const tabDescriptor = useMemo<WorkspaceTabDescriptor>(
() => ({
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
state: tab.state,
}),
[tab],
);
const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]);
const handleClose = useCallback(() => {
void onClose();
}, [onClose]);
// The sheet reaches the bottom of the screen, so it owns the system navigation inset.
const { bottom: bottomInset } = useSafeAreaInsets();
const contentStyle = useMemo(
() => [styles.content, { paddingBottom: bottomInset }],
[bottomInset],
);
const header = useMemo(
() => ({
title: descriptor.label,
leading: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
actions: sheet.policy.openAsTab ? (
<Button size="sm" variant="secondary" onPress={onOpenAsTab}>
{t("workspace.sheet.openAsTab")}
</Button>
) : undefined,
}),
[descriptor.icon, descriptor.label, onOpenAsTab, sheet.policy.openAsTab, t],
);
return (
<View style={styles.body} role="dialog" aria-label={descriptor.label}>
<SheetHeaderView header={header} onClose={handleClose} />
<View style={contentStyle}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isWorkspaceFocused}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
host: {
...StyleSheet.absoluteFillObject,
},
body: {
flex: 1,
minHeight: 0,
},
content: {
flex: 1,
minHeight: 0,
},
});
@@ -4449,7 +4449,7 @@ describe("compact workspace sheet", () => {
function createCompactStore() {
const store = createWorkspaceLayoutStore(workspaceLayoutIds);
store.setState({ layoutByWorkspace: {}, sheetByWorkspace: {} });
store.setState({ layoutByWorkspace: {}, sheetsByWorkspace: {} });
store.getState().setLayoutMode("compact");
store.getState().openTab({
workspaceKey,
@@ -4459,6 +4459,16 @@ 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]));
@@ -4523,7 +4533,7 @@ describe("compact workspace sheet", () => {
const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab).toEqual({
expect(topSheet(store)?.tab).toEqual({
tabId,
target: fileTarget,
createdAt: expect.any(Number),
@@ -4547,22 +4557,71 @@ describe("compact workspace sheet", () => {
const tabId = store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
expect(tabId).toBe(fileTabId);
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(findPaneContainingTab(layout.root, fileTabId ?? "")?.focusedTabId).toBe(fileTabId);
});
it("replaces the sheet content with the next sheet target", () => {
it("stacks the next sheet target over the open sheet", () => {
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" });
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual(nextTarget);
store.getState().closeTab(workspaceKey, fileTabId ?? "");
expect(sheetTargets(store)).toEqual([nextTarget]);
expect(tabKinds(store)).toEqual(["terminal"]);
});
it("uncovers a covered sheet 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" });
@@ -4572,7 +4631,7 @@ describe("compact workspace sheet", () => {
target: { kind: "file", path: "src/background.ts" },
intent: "background",
});
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.target).toEqual(fileTarget);
expect(topSheet(store)?.tab.target).toEqual(fileTarget);
expect(tabKinds(store)).toEqual(["terminal", "file"]);
store.getState().openTab({
@@ -4580,7 +4639,7 @@ describe("compact workspace sheet", () => {
target: { kind: "terminal", terminalId: "term-2" },
intent: "reveal",
});
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
});
it("moves the sheet tab into the focused pane with the same tab id and state", () => {
@@ -4591,10 +4650,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);
const tabId = store.getState().openSheetAsTab(workspaceKey, sheetTabId);
expect(tabId).toBe(sheetTabId);
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
const layout = store.getState().layoutByWorkspace[workspaceKey];
const pane = findPaneContainingTab(layout.root, sheetTabId);
expect(pane?.id).toBe(layout.focusedPaneId);
@@ -4616,7 +4675,7 @@ describe("compact workspace sheet", () => {
expect(store.getState().layoutByWorkspace[workspaceKey]).toBe(layoutBefore);
store.getState().closeTab(workspaceKey, sheetTabId);
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
expect(tabKinds(store)).toEqual(["terminal"]);
});
@@ -4639,19 +4698,21 @@ describe("compact workspace sheet", () => {
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
expect(store.getState().sheetByWorkspace).toEqual({});
expect(store.getState().sheetsByWorkspace).toEqual({});
expect(tabKinds(store)).toEqual(["terminal", "file"]);
});
it("keeps the sheet content as a tab when the layout widens", () => {
it("keeps every stacked sheet as a tab when the layout widens, focusing the top one", () => {
const store = createCompactStore();
store.getState().openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
const sheetTabId = store
.getState()
.openTab({ workspaceKey, target: fileTarget, intent: "reveal" });
.openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" });
store.getState().setLayoutMode("split-panes");
expect(store.getState().sheetByWorkspace).toEqual({});
expect(store.getState().sheetsByWorkspace).toEqual({});
expect(tabKinds(store)).toEqual(["terminal", "file", "file"]);
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(findPaneContainingTab(layout.root, sheetTabId ?? "")?.focusedTabId).toBe(sheetTabId);
});
@@ -4669,7 +4730,7 @@ describe("compact workspace sheet", () => {
target: { kind: "agent", agentId: "child" },
intent: "reveal",
});
expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({
expect(topSheet(store)).toMatchObject({
tab: { target: { kind: "agent", agentId: "child" } },
policy: { openAsTab: true },
});
@@ -4679,7 +4740,7 @@ describe("compact workspace sheet", () => {
target: { kind: "provider_subagent", parentAgentId: "root", subagentId: "task-1" },
intent: "reveal",
});
expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({
expect(topSheet(store)).toMatchObject({
tab: { target: { kind: "provider_subagent", subagentId: "task-1" } },
policy: { openAsTab: false },
});
@@ -4689,7 +4750,7 @@ describe("compact workspace sheet", () => {
.getState()
.openTab({ workspaceKey, target: { kind: "agent", agentId }, intent: "reveal" });
}
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]);
});
@@ -4715,9 +4776,9 @@ describe("compact workspace sheet", () => {
intent: "reveal",
parentTabId: childSheetTabId ?? undefined,
});
expect(store.getState().sheetByWorkspace[workspaceKey]?.parentTabId).toBe(rootTabId);
expect(topSheet(store)?.parentTabId).toBe(rootTabId);
store.getState().openSheetAsTab(workspaceKey);
store.getState().openSheetAsTab(workspaceKey, fileSheetTabId ?? "");
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(layout.parentTabIdByTabId?.[fileSheetTabId ?? ""]).toBe(rootTabId);
});
@@ -4746,7 +4807,7 @@ describe("compact workspace sheet", () => {
intent: "reveal",
parentTabId: openChildSheet(),
});
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
let layout = store.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(terminalTabId);
@@ -4756,7 +4817,7 @@ describe("compact workspace sheet", () => {
intent: "reveal",
parentTabId: openChildSheet(),
});
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
layout = store.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(secondRootTabId);
expect(layout.parentTabIdByTabId?.[secondRootTabId ?? ""]).toBe(rootTabId);
@@ -4798,22 +4859,47 @@ describe("compact workspace sheet", () => {
});
function sheetTabId(store: ReturnType<typeof createCompactStore>): string | null {
return store.getState().sheetByWorkspace[workspaceKey]?.tab.tabId ?? null;
return topSheet(store)?.tab.tabId ?? null;
}
it("opens the next sheet target as a tab under a modified sheet", () => {
it("stacks the next sheet target over a modified sheet", () => {
const store = createCompactStore();
const modifiedSheetTabId = openModifiedFileSheet(store);
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("keeps a covered sheet in place under a modified sheet instead of reordering", () => {
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 unmodifiedTopTarget = { kind: "file" as const, path: "src/third.ts" };
store.getState().openTab({ workspaceKey, target: unmodifiedTopTarget, intent: "reveal" });
const focusedBefore = focusedTabId(store);
const tabId = store
.getState()
.openTab({ workspaceKey, target: otherTarget, intent: "reveal" });
expect(tabId).toBe(otherTabId);
// Moving a sheet would remount its panel, so the stack keeps its order: the modified sheet
// stays on top of the one revealed, and only the unmodified sheet above them closes.
expect(sheetTargets(store)).toEqual([otherTarget, fileTarget]);
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);
expect(stripTab(store, tabId ?? "")).toBeNull();
expect(focusedTabId(store)).toBe(focusedBefore);
});
it("opens a tab target under a modified sheet without closing it", () => {
@@ -4853,23 +4939,6 @@ 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({
@@ -4913,10 +4982,38 @@ describe("compact workspace sheet", () => {
};
store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: false });
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeDefined();
expect(topSheet(store)).toBeDefined();
store.getState().reconcileTabs(workspaceKey, { ...snapshot, agentsHydrated: true });
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
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]);
});
it("closes the sheet when its agent stops being a subagent, leaving one tab for it", () => {
@@ -4941,7 +5038,7 @@ describe("compact workspace sheet", () => {
};
store.getState().reconcileTabs(workspaceKey, { ...snapshot, autoOpenAgentIds: [] });
expect(store.getState().sheetByWorkspace[workspaceKey]?.tab.tabId).toBe(sheetTabId);
expect(topSheet(store)?.tab.tabId).toBe(sheetTabId);
seedAgents([
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
@@ -4949,7 +5046,7 @@ describe("compact workspace sheet", () => {
]);
store.getState().reconcileTabs(workspaceKey, snapshot);
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(
collectAllTabs(layout.root).filter(
@@ -4975,7 +5072,7 @@ describe("compact workspace sheet", () => {
.replaceTab(workspaceKey, sheetTabId ?? "", { kind: "agent", agentId: "child" });
expect(replacedTabId).toBe(sheetTabId);
expect(store.getState().sheetByWorkspace[workspaceKey]).toMatchObject({
expect(topSheet(store)).toMatchObject({
tab: { tabId: sheetTabId, target: { kind: "agent", agentId: "child" } },
policy: { openAsTab: true },
});
@@ -4992,7 +5089,7 @@ describe("compact workspace sheet", () => {
terminalId: "term-2",
});
expect(store.getState().sheetByWorkspace[workspaceKey]).toBeUndefined();
expect(topSheet(store)).toBeUndefined();
expect(replacedTabId).toBe(sheetTabId);
const layout = store.getState().layoutByWorkspace[workspaceKey];
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(sheetTabId);
@@ -5008,12 +5105,12 @@ describe("compact workspace sheet", () => {
const received: string[][] = [];
const stop = observeOpenWorkspaceAgentIds("server-1", (ids) => received.push(ids), store);
store.getState().openTab({
const sheetTabId = store.getState().openTab({
workspaceKey,
target: { kind: "agent", agentId: "child" },
intent: "reveal",
});
store.getState().closeSheet(workspaceKey);
store.getState().closeTab(workspaceKey, sheetTabId ?? "");
stop();
expect(received).toEqual([[], ["child"], []]);
+191 -124
View File
@@ -111,13 +111,13 @@ export interface OpenWorkspaceTabInput {
}
/**
* How the app shell lays out a workspace: compact (one tab at a time, plus the sheet), a single
* How the app shell lays out a workspace: compact (one tab at a time, plus sheets), a single
* wide pane (touch tablets, which cannot render splits), or split panes.
*/
export type WorkspaceLayoutMode = "compact" | "single-pane" | "split-panes";
/** A tab hosted by the compact workspace sheet instead of a pane. */
export interface WorkspaceSheet {
/** A tab shown in a sheet over the compact workspace instead of in a pane. */
export interface SheetTab {
tab: WorkspaceTab;
policy: CompactSheetPolicy;
parentTabId: string | null;
@@ -126,8 +126,13 @@ export interface WorkspaceSheet {
interface WorkspaceLayoutStore {
/** Published once by the app shell; callers never pass a form factor to open a tab. */
layoutMode: WorkspaceLayoutMode;
/** At most one sheet tab per workspace. Not persisted: a reload returns to the tab strip. */
sheetByWorkspace: Record<string, WorkspaceSheet>;
/**
* Each workspace's sheet tabs in the order they opened, so the last is the sheet on top. The
* list never reorders: the stack belongs to the sheet primitive, and moving a sheet would remount
* its panel. A workspace with no sheet has no entry. Not persisted: a reload returns to the tab
* strip.
*/
sheetsByWorkspace: Record<string, readonly SheetTab[]>;
layoutByWorkspace: Record<string, WorkspaceLayout>;
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
explorerSidebarWidthByWorkspace: Record<string, number>;
@@ -140,17 +145,16 @@ interface WorkspaceLayoutStore {
pullRequestTabAutoOpenedByWorkspace: Record<string, true>;
/**
* The one entry point for showing a target. On compact layouts a target whose panel declares
* `compactSheet` opens in the workspace sheet; the returned tab id is the sheet tab's id.
* `compactSheet` opens in a sheet; the returned tab id is the sheet tab's id.
*/
openTab: (input: OpenWorkspaceTabInput) => string | null;
/** Whether `openTab` with this input would show the target in the sheet. */
/** Whether `openTab` with this input would show the target in a sheet. */
opensInSheet: (
input: Pick<OpenWorkspaceTabInput, "workspaceKey" | "target" | "intent">,
) => boolean;
setLayoutMode: (mode: WorkspaceLayoutMode) => void;
closeSheet: (workspaceKey: string) => void;
/** Moves the sheet's tab, keeping its tab id, into the focused pane. */
openSheetAsTab: (workspaceKey: string) => string | null;
/** Moves a sheet's tab, keeping its tab id, into the focused pane. */
openSheetAsTab: (workspaceKey: string, tabId: string) => string | null;
/** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */
autoOpenPullRequestTab: (
workspaceKey: string,
@@ -508,12 +512,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" | "sheetByWorkspace">,
state: Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace" | "sheetsByWorkspace">,
workspaceKey: string,
): boolean {
return (
Boolean(state.focusRestorationByWorkspace[workspaceKey]?.restorePaneId) ||
workspaceKey in state.sheetByWorkspace
workspaceKey in state.sheetsByWorkspace
);
}
@@ -682,15 +686,63 @@ function attachParentTab(input: {
});
}
function withoutSheet(
state: Pick<WorkspaceLayoutStore, "sheetByWorkspace">,
const NO_SHEETS: readonly SheetTab[] = [];
function getSheets(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
): Pick<WorkspaceLayoutStore, "sheetByWorkspace"> {
if (!(workspaceKey in state.sheetByWorkspace)) {
return { sheetByWorkspace: state.sheetByWorkspace };
): readonly SheetTab[] {
return state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS;
}
function findSheet(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
tabId: string,
): SheetTab | undefined {
return getSheets(state, workspaceKey).find((sheet) => sheet.tab.tabId === tabId);
}
function withSheets(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
sheets: readonly SheetTab[],
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
if (sheets.length > 0) {
return { sheetsByWorkspace: { ...state.sheetsByWorkspace, [workspaceKey]: sheets } };
}
const { [workspaceKey]: _removed, ...sheetByWorkspace } = state.sheetByWorkspace;
return { sheetByWorkspace };
if (!(workspaceKey in state.sheetsByWorkspace)) {
return { sheetsByWorkspace: state.sheetsByWorkspace };
}
const { [workspaceKey]: _removed, ...sheetsByWorkspace } = state.sheetsByWorkspace;
return { sheetsByWorkspace };
}
function withoutSheet(
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
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: SheetTab) => SheetTab,
): Pick<WorkspaceLayoutStore, "sheetsByWorkspace"> {
return withSheets(
state,
workspaceKey,
getSheets(state, workspaceKey).map((sheet) =>
sheet.tab.tabId === tabId ? update(sheet) : sheet,
),
);
}
function isTargetOpenAsTab(layout: WorkspaceLayout | undefined, target: WorkspaceTabTarget) {
@@ -713,41 +765,27 @@ function resolveSheetPolicy(
}
/**
* 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.
* 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.
*/
function resolveOpenParentTabId(
state: Pick<WorkspaceLayoutStore, "sheetByWorkspace">,
state: Pick<WorkspaceLayoutStore, "sheetsByWorkspace">,
workspaceKey: string,
parentTabId: string | undefined,
): string | null {
const sheet = state.sheetByWorkspace[workspaceKey];
if (sheet && parentTabId === sheet.tab.tabId) return sheet.parentTabId;
return parentTabId ?? null;
}
/** A reveal of the resource the sheet already shows keeps the sheet's tab. */
function sheetKeepsTabFor(
sheet: WorkspaceSheet | undefined,
target: WorkspaceTabTarget,
intent: WorkspaceTabOpenIntent,
): sheet is WorkspaceSheet {
return (
intent === "reveal" &&
sheet !== undefined &&
panelResourceKey(sheet.tab.target) === panelResourceKey(target)
);
if (!parentTabId) return null;
return findSheet(state, workspaceKey, parentTabId)?.parentTabId ?? parentTabId;
}
function placeTargetInSheet(input: {
/** The sheet whose tab the target keeps, if any. */
kept: WorkspaceSheet | undefined;
kept: SheetTab | undefined;
target: WorkspaceTabTarget;
intent: WorkspaceTabOpenIntent;
policy: CompactSheetPolicy;
parentTabId: string | null;
state: JsonValue | undefined;
}): WorkspaceSheet {
}): SheetTab {
if (input.kept) {
return { ...input.kept, tab: { ...input.kept.tab, target: input.target } };
}
@@ -766,12 +804,16 @@ function placeTargetInSheet(input: {
};
}
/** Moves a workspace's sheet tab into its focused pane, keeping its tab id and state. */
/**
* 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.
*/
function promoteSheetToTab(
state: WorkspaceLayoutStore,
workspaceKey: string,
tabId: string,
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
const sheet = state.sheetByWorkspace[workspaceKey];
const sheet = findSheet(state, workspaceKey, tabId);
if (!sheet) return null;
const { tab } = sheet;
const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT);
@@ -798,7 +840,7 @@ function promoteSheetToTab(
return {
tabId: result.tabId,
patch: {
...withoutSheet(state, workspaceKey),
...withoutSheet(state, workspaceKey, tab.tabId),
...withoutFocusRestoration(state, workspaceKey),
hiddenAgentIdsByWorkspace:
tab.target.kind === "agent"
@@ -817,37 +859,55 @@ 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(
state: Pick<WorkspaceLayoutStore, "sheetByWorkspace">,
workspaceKey: string,
): boolean {
const sheet = state.sheetByWorkspace[workspaceKey];
return sheet !== undefined && isSheetTabModified(workspaceKey, sheet.tab.tabId);
function sheetHoldsUnsavedWork(workspaceKey: string, sheet: SheetTab): boolean {
return isSheetTabModified(workspaceKey, sheet.tab.tabId);
}
/**
* Where an open lands in the sheet, or null when it opens as a tab. A reveal of the resource the
* sheet shows keeps its tab; any other target never displaces unsaved work and opens as a tab.
* Where an open lands among the sheet tabs, 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 uncovers it by
* closing the sheets above it, except ones holding unsaved work, which stay where they are.
*/
function resolveSheetPlacement(
state: WorkspaceLayoutStore,
workspaceKey: string,
target: WorkspaceTabTarget,
intent: WorkspaceTabOpenIntent,
): { policy: CompactSheetPolicy; kept: WorkspaceSheet | undefined } | null {
): { policy: CompactSheetPolicy; keptIndex: number | null } | null {
const policy = resolveSheetPolicy(state, workspaceKey, target, intent);
if (!policy) return null;
const sheet = state.sheetByWorkspace[workspaceKey];
if (sheetKeepsTabFor(sheet, target, intent)) return { policy, kept: sheet };
return sheetHoldsUnsavedWork(state, workspaceKey) ? null : { policy, kept: undefined };
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 };
}
/** Closes the sheet because navigation moved on underneath it, unless it holds unsaved work. */
function dismissSheet(
/** 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(
state: WorkspaceLayoutStore,
workspaceKey: string,
): Partial<WorkspaceLayoutStore> {
return sheetHoldsUnsavedWork(state, workspaceKey) ? {} : withoutSheet(state, workspaceKey);
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) },
};
}
/**
@@ -857,10 +917,10 @@ function dismissSheet(
*/
function sheetAgentNoLongerFits(
workspaceKey: string,
sheet: WorkspaceSheet | undefined,
sheet: SheetTab,
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;
}
@@ -905,7 +965,7 @@ export function createWorkspaceLayoutStore(
return tabId;
},
layoutMode: "split-panes",
sheetByWorkspace: {},
sheetsByWorkspace: {},
layoutByWorkspace: {},
splitSizesByWorkspace: {},
explorerSidebarWidthByWorkspace: {},
@@ -933,17 +993,27 @@ export function createWorkspaceLayoutStore(
input.intent,
);
if (sheetPlacement) {
const sheets = getSheets(get(), normalizedWorkspaceKey);
const { keptIndex } = sheetPlacement;
const sheet = placeTargetInSheet({
kept: sheetPlacement.kept,
kept: keptIndex === null ? undefined : sheets[keptIndex],
target: normalizedTarget,
intent: input.intent,
policy: sheetPlacement.policy,
parentTabId,
state: input.state,
});
set((state) => ({
sheetByWorkspace: { ...state.sheetByWorkspace, [normalizedWorkspaceKey]: sheet },
}));
const next =
keptIndex === null
? [...sheets, sheet]
: sheets.flatMap((existing, index) => {
if (index === keptIndex) return [sheet];
if (index < keptIndex) return [existing];
return sheetHoldsUnsavedWork(normalizedWorkspaceKey, existing)
? [existing]
: [];
});
set((state) => withSheets(state, normalizedWorkspaceKey, next));
return sheet.tab.tabId;
}
const placement = getOpenTabPlacement(
@@ -990,7 +1060,7 @@ export function createWorkspaceLayoutStore(
set((state) => ({
...withoutFocusRestoration(state, normalizedWorkspaceKey),
// A tab brought to the front dismisses the sheet over it.
...(input.intent === "background" ? {} : dismissSheet(state, normalizedWorkspaceKey)),
...(input.intent === "background" ? {} : dismissSheets(state, normalizedWorkspaceKey)),
hiddenAgentIdsByWorkspace:
normalizedTarget.kind !== "agent"
? state.hiddenAgentIdsByWorkspace
@@ -1032,24 +1102,25 @@ 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 the sheet as an ordinary tab.
// Widening keeps whatever the user had open in sheets as ordinary tabs.
const next: WorkspaceLayoutStore = { ...state, layoutMode: mode };
for (const workspaceKey of Object.keys(state.sheetByWorkspace)) {
const promoted = promoteSheetToTab(next, workspaceKey);
Object.assign(next, promoted?.patch ?? withoutSheet(next, workspaceKey));
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),
);
}
}
return next;
});
},
closeSheet: (workspaceKey) => {
openSheetAsTab: (workspaceKey, tabId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) return;
set((state) => withoutSheet(state, normalizedWorkspaceKey));
},
openSheetAsTab: (workspaceKey) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
if (!normalizedWorkspaceKey) return null;
const promoted = promoteSheetToTab(get(), normalizedWorkspaceKey);
const normalizedTabId = trimNonEmpty(tabId);
if (!normalizedWorkspaceKey || !normalizedTabId) return null;
const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, normalizedTabId);
if (!promoted) return null;
set(promoted.patch);
return promoted.tabId;
@@ -1173,8 +1244,8 @@ export function createWorkspaceLayoutStore(
}
set((state) => {
if (state.sheetByWorkspace[normalizedWorkspaceKey]?.tab.tabId === normalizedTabId) {
return withoutSheet(state, normalizedWorkspaceKey);
if (findSheet(state, normalizedWorkspaceKey, normalizedTabId)) {
return withoutSheet(state, normalizedWorkspaceKey, normalizedTabId);
}
const layout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
const explorerSidebarPaneId = resolveExplorerSidebarPaneId(
@@ -1283,8 +1354,8 @@ export function createWorkspaceLayoutStore(
return {
...withoutFocusRestoration(state, normalizedWorkspaceKey),
// Choosing a tab dismisses the sheet that covers it.
...dismissSheet(state, normalizedWorkspaceKey),
// Choosing a tab dismisses the sheets that cover it.
...dismissSheets(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
@@ -1308,8 +1379,8 @@ export function createWorkspaceLayoutStore(
});
return nextLayout
? {
// Choosing a tab dismisses the sheet that covers it.
...dismissSheet(state, normalizedWorkspaceKey),
// Choosing a tab dismisses the sheets that cover it.
...dismissSheets(state, normalizedWorkspaceKey),
layoutByWorkspace: {
...state.layoutByWorkspace,
[normalizedWorkspaceKey]: nextLayout,
@@ -1323,29 +1394,27 @@ export function createWorkspaceLayoutStore(
const normalizedTabId = trimNonEmpty(tabId);
const normalizedTarget = normalizeWorkspaceTabTarget(target);
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null;
const sheet = get().sheetByWorkspace[normalizedWorkspaceKey];
if (sheet?.tab.tabId === normalizedTabId) {
if (findSheet(get(), normalizedWorkspaceKey, normalizedTabId)) {
const policy = resolveCompactSheetPolicy(normalizedWorkspaceKey, normalizedTarget);
const replaced: WorkspaceSheet = {
...sheet,
policy: policy ?? sheet.policy,
tab: {
...sheet.tab,
target: normalizedTarget,
...(tabState !== undefined ? { state: tabState } : {}),
},
};
const next: WorkspaceLayoutStore = {
...get(),
sheetByWorkspace: { ...get().sheetByWorkspace, [normalizedWorkspaceKey]: replaced },
...withSheetTab(get(), normalizedWorkspaceKey, normalizedTabId, (sheet) => ({
...sheet,
policy: policy ?? sheet.policy,
tab: {
...sheet.tab,
target: normalizedTarget,
...(tabState !== undefined ? { state: tabState } : {}),
},
})),
};
if (policy) {
set({ sheetByWorkspace: next.sheetByWorkspace });
set({ sheetsByWorkspace: next.sheetsByWorkspace });
return normalizedTabId;
}
// A target the sheet does not host becomes the tab it would have opened as.
const promoted = promoteSheetToTab(next, normalizedWorkspaceKey);
set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey));
// 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));
return promoted?.tabId ?? null;
}
const result = replaceTabTargetInLayout({
@@ -1378,18 +1447,14 @@ export function createWorkspaceLayoutStore(
const normalizedTabId = trimNonEmpty(tabId);
if (!normalizedWorkspaceKey || !normalizedTabId) return;
set((state) => {
const sheet = state.sheetByWorkspace[normalizedWorkspaceKey];
if (sheet?.tab.tabId === normalizedTabId) {
const { state: _previous, ...tab } = sheet.tab;
return {
sheetByWorkspace: {
...state.sheetByWorkspace,
[normalizedWorkspaceKey]: {
...sheet,
tab: tabState === undefined ? tab : { ...tab, state: tabState },
},
},
};
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 layout = setTabStateInLayout({
layout: getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey),
@@ -1485,11 +1550,11 @@ export function createWorkspaceLayoutStore(
);
}
}
const sheetAgentGone = sheetAgentNoLongerFits(
normalizedWorkspaceKey,
state.sheetByWorkspace[normalizedWorkspaceKey],
snapshot,
const sheets = getSheets(state, normalizedWorkspaceKey);
const fittingSheets = sheets.filter(
(sheet) => !sheetAgentNoLongerFits(normalizedWorkspaceKey, sheet, snapshot),
);
const sheetAgentGone = fittingSheets.length !== sheets.length;
if (
state.layoutByWorkspace[normalizedWorkspaceKey] &&
nextLayout === rawLayout &&
@@ -1500,7 +1565,7 @@ export function createWorkspaceLayoutStore(
}
return {
...(sheetAgentGone ? withoutSheet(state, normalizedWorkspaceKey) : {}),
...(sheetAgentGone ? withSheets(state, normalizedWorkspaceKey, fittingSheets) : {}),
pinnedAgentIdsByWorkspace,
layoutByWorkspace: {
...state.layoutByWorkspace,
@@ -2006,7 +2071,7 @@ export function createWorkspaceLayoutStore(
normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace ||
normalizedWorkspaceKey in state.sidePaneIdByWorkspace ||
normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace ||
normalizedWorkspaceKey in state.sheetByWorkspace;
normalizedWorkspaceKey in state.sheetsByWorkspace;
if (!hasAny) {
return state;
}
@@ -2035,7 +2100,7 @@ export function createWorkspaceLayoutStore(
const { [normalizedWorkspaceKey]: _sidePane, ...sidePaneIdByWorkspace } =
state.sidePaneIdByWorkspace;
return {
...withoutSheet(state, normalizedWorkspaceKey),
...withSheets(state, normalizedWorkspaceKey, NO_SHEETS),
pullRequestTabAutoOpenedByWorkspace,
layoutByWorkspace,
splitSizesByWorkspace,
@@ -2152,14 +2217,16 @@ export function observeOpenWorkspaceAgentIds(
store = useWorkspaceLayoutStore,
): () => void {
let previous: string[] | undefined;
const publish = (state: Pick<WorkspaceLayoutStore, "layoutByWorkspace" | "sheetByWorkspace">) => {
const publish = (
state: Pick<WorkspaceLayoutStore, "layoutByWorkspace" | "sheetsByWorkspace">,
) => {
const ids = new Set<string>();
const tabsByWorkspace = [
...Object.entries(state.layoutByWorkspace).map(
([workspaceKey, layout]) => [workspaceKey, collectAllTabs(layout.root)] as const,
),
...Object.entries(state.sheetByWorkspace).map(
([workspaceKey, sheet]) => [workspaceKey, [sheet.tab]] as const,
...Object.entries(state.sheetsByWorkspace).map(
([workspaceKey, sheets]) => [workspaceKey, sheets.map((sheet) => sheet.tab)] as const,
),
];
for (const [workspaceKey, tabs] of tabsByWorkspace) {
@@ -2177,7 +2244,7 @@ export function observeOpenWorkspaceAgentIds(
const unsubscribe = store.subscribe((state, before) => {
if (
state.layoutByWorkspace !== before.layoutByWorkspace ||
state.sheetByWorkspace !== before.sheetByWorkspace
state.sheetsByWorkspace !== before.sheetsByWorkspace
) {
publish(state);
}
+2 -10
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, icons, and text grow on compact
* Density sizes for surfaces that opt into touch sizing: rows and icons 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,10 +11,8 @@ 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) {
@@ -25,9 +23,7 @@ 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(40, 28),
/** The line height of a row's `base` text; slots that align to its first line share it. */
rowLineHeight: byLayout(22, 20),
tightRowHeight: byLayout(36, 28),
/** A square icon-only button that sits in a row or toolbar. */
iconButton: byLayout(44, 28),
};
@@ -37,10 +33,6 @@ 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];
}
+1
View File
@@ -9,3 +9,4 @@ export {
type ArchiveFinishedStatus,
type UseArchiveFinishedSubagentsInput,
} from "./use-archive-finished";
export { useSubagentOpensInSheet } from "./opens-in-sheet";
@@ -0,0 +1,133 @@
/**
* @vitest-environment jsdom
*/
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { useSubagentOpensInSheet } from "./opens-in-sheet";
import { selectSubagentsForParent, type SubagentRow } from "./select";
vi.mock("@react-native-async-storage/async-storage", () => {
const storage = new Map<string, string>();
return {
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value);
}),
removeItem: vi.fn(async (key: string) => {
storage.delete(key);
}),
},
};
});
const SERVER_ID = "server-1";
const WORKSPACE_ID = "ws-main";
const AGENT_TIMESTAMP = new Date("2026-04-21T10:00:00.000Z");
const AGENT_DEFAULTS: Agent = {
serverId: SERVER_ID,
id: "agent",
provider: "codex",
status: "idle",
turn: { phase: "idle", cancellationRequestId: null },
createdAt: AGENT_TIMESTAMP,
updatedAt: AGENT_TIMESTAMP,
lastUserMessageAt: null,
lastActivityAt: AGENT_TIMESTAMP,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: undefined,
lastUsage: undefined,
lastError: null,
title: "Agent",
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
model: null,
features: undefined,
thinkingOptionId: undefined,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
parentAgentId: null,
labels: {},
projectPlacement: null,
};
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
});
if (!workspaceKey) throw new Error("workspace key");
function seedParentWithChild(): SubagentRow {
const parent: Agent = { ...AGENT_DEFAULTS, id: "parent", title: "Parent" };
const child: Agent = { ...AGENT_DEFAULTS, id: "child", title: "Child", parentAgentId: "parent" };
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore.getState().setAgents(
SERVER_ID,
new Map([
[parent.id, parent],
[child.id, child],
]),
);
const [row] = selectSubagentsForParent(
useSessionStore.getState(),
{ serverId: SERVER_ID, parentAgentId: "parent" },
new Set(),
);
if (!row) throw new Error("child row");
return row;
}
afterEach(() => {
useSessionStore.getState().clearSession(SERVER_ID);
useWorkspaceLayoutStore.setState({
layoutMode: "split-panes",
layoutByWorkspace: {},
sheetsByWorkspace: {},
hiddenAgentIdsByWorkspace: {},
pinnedAgentIdsByWorkspace: {},
});
});
describe("useSubagentOpensInSheet", () => {
it("re-renders the row's answer as the child's tab opens and closes", () => {
const row = seedParentWithChild();
const store = useWorkspaceLayoutStore.getState();
store.setLayoutMode("compact");
const target = { kind: "agent", agentId: "child" } as const;
// The row renders its answer, as the track does, so a stale render is a stale answer.
const { result } = renderHook(() => useSubagentOpensInSheet(workspaceKey)(row));
expect(result.current).toBe(true);
let tabId: string | null = null;
act(() => {
const sheetTabId = store.openTab({ workspaceKey, target, intent: "reveal" });
tabId = sheetTabId ? store.openSheetAsTab(workspaceKey, sheetTabId) : null;
});
expect(tabId).not.toBeNull();
expect(result.current).toBe(false);
act(() => {
store.closeTab(workspaceKey, tabId ?? "");
});
expect(result.current).toBe(true);
});
});
@@ -0,0 +1,29 @@
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { SubagentRow } from "./select";
/** The workspace target a subagent row opens. */
export function subagentRowTarget(row: SubagentRow): WorkspaceTabTarget {
return row.kind === "provider"
? { kind: "provider_subagent", parentAgentId: row.parentAgentId, subagentId: row.id }
: { kind: "agent", agentId: row.id };
}
/** Answers, per subagent row, whether opening it in this workspace shows a sheet rather than a tab. */
export function useSubagentOpensInSheet(
workspaceKey: string | null,
): (row: SubagentRow) => boolean {
// The rows render the answer, so this subscribes to what the answer reads: the layout mode and
// the workspace's tabs. A subagent already open as a tab reveals that tab instead of a sheet,
// and a row rendered while that tab existed must re-render once it closes, or choosing the row
// would still close the list it sits in.
useWorkspaceLayoutStore((state) => state.layoutMode);
useWorkspaceLayoutStore((state) =>
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
);
return (row) =>
workspaceKey !== null &&
useWorkspaceLayoutStore
.getState()
.opensInSheet({ workspaceKey, target: subagentRowTarget(row), intent: "reveal" });
}
+10
View File
@@ -35,6 +35,11 @@ export interface SubagentsTrackProps {
rows: SubagentRow[];
onOpenSubagent: (id: string) => void;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
/**
* Whether opening this row shows it in a sheet. A sheet stacks over the list and uncovers it on
* close, so the list stays open; a tab replaces the list's pane, so choosing it ends the list.
*/
opensInSheet: (row: SubagentRow) => boolean;
onArchiveSubagent: (id: string) => void;
onArchiveFinished?: () => void;
archiveFinishedStatus?: ArchiveFinishedStatus;
@@ -63,6 +68,7 @@ export function SubagentsTrack({
rows,
onOpenSubagent,
onOpenProviderSubagent,
opensInSheet,
onArchiveSubagent,
onArchiveFinished,
archiveFinishedStatus = IDLE_ARCHIVE_FINISHED_STATUS,
@@ -103,6 +109,7 @@ export function SubagentsTrack({
serverId={serverId}
onOpenSubagent={onOpenSubagent}
onOpenProviderSubagent={onOpenProviderSubagent}
opensInSheet={opensInSheet(row)}
onArchiveSubagent={onArchiveSubagent}
onDetachSubagent={onDetachSubagent}
/>
@@ -174,6 +181,7 @@ interface SubagentsTrackRowProps {
row: SubagentRow;
onOpenSubagent: (id: string) => void;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
opensInSheet: boolean;
onArchiveSubagent: (id: string) => void;
onDetachSubagent?: (id: string) => void;
}
@@ -183,6 +191,7 @@ function SubagentsTrackRow({
row,
onOpenSubagent,
onOpenProviderSubagent,
opensInSheet,
onArchiveSubagent,
onDetachSubagent,
}: SubagentsTrackRowProps): ReactElement {
@@ -245,6 +254,7 @@ function SubagentsTrackRow({
<ComposerTrackRow
accessibilityLabel={displayLabel}
testID={`subagents-track-row-${row.id}`}
closeOnSelect={!opensInSheet}
onPress={handlePress}
>
{renderRow}
@@ -0,0 +1,58 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { localFileSourceToPath } from "@/attachments/utils";
import { createAssistantMarkdownParser } from "./assistant-markdown-parser";
import { resolveAssistantImageSource } from "./assistant-image-source";
describe("local assistant Markdown images", () => {
let workspaceRoot: string;
const imageBytes = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==",
"base64",
);
beforeEach(async () => {
workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "assistant-markdown-image-"));
await mkdir(path.join(workspaceRoot, "My Apps", "Мои"), { recursive: true });
await writeFile(path.join(workspaceRoot, "My Apps", "Мои", "test.png"), imageBytes);
await writeFile(path.join(workspaceRoot, "literal%20name.png"), imageBytes);
});
afterEach(async () => {
await rm(workspaceRoot, { recursive: true, force: true });
});
it.each([
"absolute angle",
"absolute encoded",
"relative angle",
"relative encoded",
"file URI",
"literal percent",
])("reads the actual image for the %s destination", async (form) => {
const absolute = path.join(workspaceRoot, "My Apps", "Мои", "test.png");
const destinations: Record<string, string> = {
"absolute angle": `<${absolute}>`,
"absolute encoded": encodeURI(absolute),
"relative angle": "<My Apps/Мои/test.png>",
"relative encoded": encodeURI("My Apps/Мои/test.png"),
"file URI": `file://${encodeURI(absolute)}`,
"literal percent": `${workspaceRoot}/literal%2520name.png`,
};
const tokens = createAssistantMarkdownParser().parseInline(
`![test](${destinations[form]})`,
{},
);
const image = tokens
.flatMap((token) => token.children ?? [])
.find((token) => token.type === "image");
expect(image).toBeDefined();
const source = localFileSourceToPath(image!.attrGet("src")!, { decodeHref: true });
const target = resolveAssistantImageSource({ source, workspaceRoot });
expect(target?.kind).toBe("file_rpc");
if (target?.kind !== "file_rpc") throw new Error("Expected a local image read target");
await expect(readFile(path.resolve(target.cwd, target.path))).resolves.toEqual(imageBytes);
});
});
@@ -27,7 +27,7 @@ const WORKSPACE_KEY = "server-1:workspace-1";
beforeEach(() => {
useWorkspaceLayoutStore.setState({
layoutMode: "split-panes",
sheetByWorkspace: {},
sheetsByWorkspace: {},
layoutByWorkspace: {},
explorerSidebarPaneIdByWorkspace: {},
sidePaneIdByWorkspace: {},
@@ -56,7 +56,7 @@ describe("openPreferredWorkspacePreview", () => {
});
const state = useWorkspaceLayoutStore.getState();
expect(state.sheetByWorkspace[WORKSPACE_KEY]?.tab).toMatchObject({
expect(state.sheetsByWorkspace[WORKSPACE_KEY]?.at(-1)?.tab).toMatchObject({
tabId,
target: { kind: "file", path: "src/preview.ts" },
});
@@ -96,7 +96,7 @@ describe("previews under a sheet with unsaved work", () => {
});
}
it("reuses one preview tab instead of adding a tab per link", () => {
it("stacks link previews over the sheet instead of dropping its unsaved work", () => {
const store = useWorkspaceLayoutStore.getState();
store.openTab({
workspaceKey: WORKSPACE_KEY,
@@ -114,18 +114,17 @@ describe("previews under a sheet with unsaved work", () => {
{ modified: true },
);
const firstPreviewTabId = previewFile("src/first.ts");
const secondPreviewTabId = previewFile("src/second.ts");
previewFile("src/first.ts");
previewFile("src/second.ts");
const state = useWorkspaceLayoutStore.getState();
expect(state.sheetByWorkspace[WORKSPACE_KEY]?.tab.target).toEqual(sheetTarget);
expect(secondPreviewTabId).toBe(firstPreviewTabId);
const root = state.layoutByWorkspace[WORKSPACE_KEY].root;
const tabsById = new Map(collectAllTabs(root).map((tab) => [tab.tabId, tab.target]));
expect(findPaneById(root, "main")?.tabIds.map((tabId) => tabsById.get(tabId))).toEqual([
{ kind: "terminal", terminalId: "term-1" },
expect(state.sheetsByWorkspace[WORKSPACE_KEY]?.map((sheet) => sheet.tab.target)).toEqual([
sheetTarget,
{ kind: "file", path: "src/first.ts" },
{ kind: "file", path: "src/second.ts" },
]);
const root = state.layoutByWorkspace[WORKSPACE_KEY].root;
expect(findPaneById(root, "main")?.tabIds).toHaveLength(1);
});
});
+21 -2
View File
@@ -3,11 +3,13 @@
// to keep the module graph importable.
const Stub = () => null;
const PassThrough = ({ children }: { children?: unknown }) => children;
const sharedValue = (value: unknown) => ({ get: () => value });
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;
@@ -16,10 +18,27 @@ export function useBottomSheetModal(): never {
throw new Error("Modal stack interactions require the real Gorhom provider in browser E2E tests");
}
// A sheet host scope only redirects where sheets draw; with no sheet drawn there is nothing to host.
export function useBottomSheet(): never {
throw new Error("useBottomSheet is unavailable in tests");
}
export const BottomSheetModalInternalProvider = PassThrough;
export const INITIAL_CONTAINER_LAYOUT = {
height: 0,
offset: { top: 0, bottom: 0, left: 0, right: 0 },
};
export function useBottomSheetModalInternal() {
return {
hostName: "stub",
containerLayoutState: sharedValue(INITIAL_CONTAINER_LAYOUT),
mountSheet: () => undefined,
unmountSheet: () => undefined,
willUnmountSheet: () => undefined,
};
}
// The app imports the internal layout values to compute the visible sheet body. Browser unit tests
// never render the native sheet, but Vite still validates this module's export surface.
const sharedValue = (value: unknown) => ({ get: () => value });
export const KEYBOARD_STATUS = { SHOWN: "SHOWN" };
export function useBottomSheetInternal() {
return {
+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/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.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: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,5 +1,6 @@
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";
@@ -120,6 +121,29 @@ 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;
@@ -0,0 +1,42 @@
import pino from "pino";
import { expect, test } from "vitest";
import { ClaudeAgentClient } from "./agent.js";
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
// Explicit real-provider test: run separately from the default suite.
test("uses the active model context window after switching from a 1M model", async () => {
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
const session = await client.createSession(
{ provider: "claude", cwd: process.cwd(), model: "claude-haiku-4-5" },
undefined,
{ persistSession: false },
);
try {
for (const [model, maxTokens] of [
["claude-haiku-4-5", 200_000],
["claude-opus-5-5", 1_000_000],
["claude-haiku-4-5", 200_000],
] as const) {
await session.setModel(model);
const events = await collectSessionTurnEvents(
session,
"Reply with exactly: OK. Do not use tools.",
{ signal: AbortSignal.timeout(90_000) },
);
expect(events).toContainEqual(
expect.objectContaining({
type: "usage_updated",
usage: expect.objectContaining({ contextWindowMaxTokens: maxTokens }),
}),
);
expect(events).toContainEqual(
expect.objectContaining({
type: "turn_completed",
usage: expect.objectContaining({ contextWindowMaxTokens: maxTokens }),
}),
);
}
} finally {
await session.close();
}
}, 300_000);
@@ -6,12 +6,28 @@ import type { ClaudeQueryFactory } from "./query.js";
// A query dependency that speaks the same stream interface as Claude Code.
// Each request repeats init so a second turn exercises runtime-limit retention.
function contextQuery(runtimeModel: string, reportedLimit: number): ClaudeQueryFactory {
function contextQuery({
runtimeModel,
reportedLimit,
modelWindowsByTurn,
}: {
runtimeModel: string;
reportedLimit: number;
modelWindowsByTurn?: Array<Record<string, number>>;
}): ClaudeQueryFactory {
return ({ prompt }) => {
let model = runtimeModel;
let turn = 0;
const messages = (async function* () {
if (typeof prompt === "string") throw new Error("Expected streaming input");
for await (const _message of prompt) {
const modelWindows = modelWindowsByTurn?.[turn] ?? { [model]: reportedLimit };
turn += 1;
const usageEntries = Object.entries(modelWindows).map(([id, contextWindow]) => [
id,
{ contextWindow },
]);
const modelUsage = Object.fromEntries(usageEntries);
yield {
type: "system",
subtype: "init",
@@ -41,7 +57,7 @@ function contextQuery(runtimeModel: string, reportedLimit: number): ClaudeQueryF
stop_reason: null,
total_cost_usd: 0,
usage: { input_tokens: 249_950, output_tokens: 50, cache_read_input_tokens: 0 },
modelUsage: { [model]: { contextWindow: reportedLimit } },
modelUsage,
permission_denials: [],
uuid: "context-result",
};
@@ -71,7 +87,7 @@ test.each([
async ({ selected, runtime, expected }) => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: contextQuery(runtime, 800_000),
queryFactory: contextQuery({ runtimeModel: runtime, reportedLimit: 800_000 }),
resolveBinary: async () => process.execPath,
});
const session = await client.createSession({
@@ -110,3 +126,93 @@ test.each([
}
},
);
test("completed turns use the active model's window after switching both ways", async () => {
const haiku = "claude-haiku-4-5";
const opus = "claude-opus-5-5";
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => process.execPath,
queryFactory: contextQuery({
runtimeModel: haiku,
reportedLimit: 200_000,
modelWindowsByTurn: [
{ [haiku]: 200_000 },
{ [haiku]: 200_000, [opus]: 1_000_000 },
{ [haiku]: 200_000, [opus]: 1_000_000 },
],
}),
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: haiku,
});
try {
for (const [model, limit] of [
[haiku, 200_000],
[opus, 1_000_000],
[haiku, 200_000],
] as const) {
await session.setModel(model);
const events = await collectSessionTurnEvents(session, "OK");
expect(events).toContainEqual(
expect.objectContaining({
type: "usage_updated",
usage: expect.objectContaining({ contextWindowMaxTokens: limit }),
}),
);
expect(events).toContainEqual(
expect.objectContaining({
type: "turn_completed",
usage: expect.objectContaining({ contextWindowMaxTokens: limit }),
}),
);
}
} finally {
await session.close();
}
});
test.each([
{
name: "runtime-reported custom limit",
model: "custom-model",
windows: { "custom-model": 400_000, "prior-model": 1_000_000 },
expected: 400_000,
},
{
name: "missing active model entry",
model: "claude-haiku-4-5",
windows: { "prior-model": 1_000_000 },
expected: 200_000,
},
{
name: "invalid active model limit",
model: "claude-haiku-4-5",
windows: { "claude-haiku-4-5": -1, "prior-model": 1_000_000 },
expected: 200_000,
},
])("preserves $name without using another model's window", async ({ model, windows, expected }) => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => process.execPath,
queryFactory: contextQuery({
runtimeModel: model,
reportedLimit: expected,
modelWindowsByTurn: [windows],
}),
});
const session = await client.createSession({ provider: "claude", cwd: process.cwd(), model });
try {
const events = await collectSessionTurnEvents(session, "OK");
expect(events).toContainEqual(
expect.objectContaining({
type: "turn_completed",
usage: expect.objectContaining({ contextWindowMaxTokens: expected }),
}),
);
} finally {
await session.close();
}
});
@@ -1936,30 +1936,22 @@ async function resolveClaudeAuth(
}
}
function extractContextWindowSize(modelUsage: unknown): number | undefined {
function extractContextWindowSize(
modelUsage: unknown,
modelId: string | null | undefined,
): number | undefined {
const usageRecord = toObjectRecord(modelUsage);
if (!usageRecord) {
return undefined;
}
let maxContextWindow: number | undefined;
for (const value of Object.values(usageRecord)) {
const valueRecord = toObjectRecord(value);
if (!valueRecord) {
continue;
}
const contextWindow = valueRecord.contextWindow;
if (
typeof contextWindow !== "number" ||
!Number.isFinite(contextWindow) ||
contextWindow <= 0
) {
continue;
}
maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow);
}
return maxContextWindow;
// modelUsage accumulates every model used in the session, including earlier turns
// and subagents. Only the active model describes this turn's context capacity.
const modelUsageEntry = modelId ? usageRecord[modelId] : undefined;
const contextWindow = toObjectRecord(modelUsageEntry)?.contextWindow;
return typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0
? contextWindow
: undefined;
}
interface StreamRequestInputUsage {
@@ -2113,8 +2105,8 @@ class ClaudeContextUsageState {
this.contextWindowMaxTokens = contextWindowMaxTokens;
}
recordModelUsage(modelUsage: unknown): number | undefined {
const contextWindowMaxTokens = extractContextWindowSize(modelUsage);
recordModelUsage(modelUsage: unknown, modelId: string | null | undefined): number | undefined {
const contextWindowMaxTokens = extractContextWindowSize(modelUsage, modelId);
if (contextWindowMaxTokens !== undefined) {
this.contextWindowMaxTokens = contextWindowMaxTokens;
}
@@ -2157,7 +2149,10 @@ class ClaudeContextUsageState {
return this.createUsageUpdatedEvent(usedTokens);
}
buildResultUsage(message: SDKResultMessage, modelUsage: unknown): AgentUsage | undefined {
buildResultUsage(
message: SDKResultMessage,
modelId: string | null | undefined,
): AgentUsage | undefined {
try {
if (!message.usage) {
return undefined;
@@ -2169,7 +2164,7 @@ class ClaudeContextUsageState {
totalCostUsd: message.total_cost_usd,
};
const modelContextWindowMaxTokens = this.recordModelUsage(modelUsage ?? message.modelUsage);
const modelContextWindowMaxTokens = this.recordModelUsage(message.modelUsage, modelId);
if (this.contextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = this.contextWindowMaxTokens;
} else if (modelContextWindowMaxTokens !== undefined) {
@@ -4803,7 +4798,7 @@ class ClaudeAgentSession implements AgentSession {
message: Extract<SDKMessage, { type: "result" }>,
events: AgentStreamEvent[],
): void {
const usage = this.convertUsage(message, message.modelUsage);
const usage = this.convertUsage(message);
if (message.subtype === "success") {
events.push(...this.sidechainTracker.finishAll("completed"));
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
@@ -4985,8 +4980,8 @@ class ClaudeAgentSession implements AgentSession {
return null;
}
private convertUsage(message: SDKResultMessage, modelUsage?: unknown): AgentUsage | undefined {
return this.contextUsage.buildResultUsage(message, modelUsage);
private convertUsage(message: SDKResultMessage): AgentUsage | undefined {
return this.contextUsage.buildResultUsage(message, this.lastRuntimeModel ?? this.config.model);
}
private handlePermissionRequest: CanUseTool = async (
@@ -157,15 +157,19 @@ test("DaemonClient uploads file bytes to daemon temp storage", async () => {
chunkSize: 5,
});
const uploadId = result.file?.id ?? "";
expect(uploadId).toMatch(
/^upload_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
expect(result).toEqual({
requestId: "req-upload-e2e",
file: {
type: "uploaded_file",
id: "upload_req-upload-e2e",
id: uploadId,
fileName: "notes.txt",
mimeType: "text/plain",
size: 11,
path: path.join(daemon.paseoHome, "uploads", "upload_req-upload-e2e", "notes.txt"),
path: path.join(daemon.paseoHome, "uploads", uploadId, "notes.txt"),
},
error: null,
});
@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
@@ -85,3 +85,76 @@ test.each(["uncommitted", "base", "commit"] as const)(
},
60_000,
);
test.each(
(["uncommitted", "base"] as const).flatMap((mode) =>
(["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })),
),
)(
"$mode $shape diff includes hunks from a repository subdirectory",
async ({ mode, shape }) => {
const ctx = await createDaemonTestContext();
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-"));
try {
git(cwd, "init", "-b", "main");
git(cwd, "config", "user.name", "Test");
git(cwd, "config", "user.email", "test@example.com");
git(cwd, "config", "core.autocrlf", "false");
const subdirectory = join(cwd, "sub");
mkdirSync(subdirectory);
const unchangedLines = "unchanged\n".repeat(10);
writeFileSync(join(subdirectory, "normal.txt"), `before\n${unchangedLines}`);
if (shape === "batch") {
writeFileSync(join(cwd, "sibling.txt"), "old sibling\n");
}
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
if (mode === "base") {
git(cwd, "checkout", "-b", "feature");
}
const filename = shape === "rename" ? "renamed.txt" : "normal.txt";
if (shape === "rename") {
git(cwd, "mv", "sub/normal.txt", `sub/${filename}`);
}
writeFileSync(join(subdirectory, filename), `after\n${unchangedLines}`);
if (shape === "batch") {
writeFileSync(join(cwd, "sibling.txt"), "new sibling\n");
}
if (mode === "base") {
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
}
const compare = { mode, baseRef: "main" };
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare);
const liveDiff = await subscription.ready;
await subscription.release();
expect(rootDiff.error).toBeNull();
expect(subdirectoryDiff.error).toBeNull();
expect(liveDiff.error).toBeNull();
expect(liveDiff.files).toEqual(rootDiff.files);
expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1);
const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`);
expect(file).toMatchObject({
additions: 1,
deletions: 1,
...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}),
});
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "before" }),
expect.objectContaining({ type: "add", content: "after" }),
]),
);
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
} finally {
await ctx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}
},
60_000,
);
@@ -124,15 +124,12 @@ test("Antigravity creates an agent and answers a prompt with real agy", async ({
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
await expect
.poll(
async () => {
const snapshot = await client.getProvidersSnapshot({ cwd });
return snapshot.entries.find((entry) => entry.provider === "antigravity")?.status;
},
{ timeout: 30000 },
)
.toBe("ready");
// Snapshot reads start discovery again; await one refresh before checking readiness.
await client.refreshProvidersSnapshot({ cwd, providers: ["antigravity"] });
const snapshot = await client.getProvidersSnapshot({ cwd });
expect(snapshot.entries.find((entry) => entry.provider === "antigravity")?.status).toBe(
"ready",
);
const agent = await client.createAgent({
provider: "antigravity",
cwd,
@@ -244,3 +244,28 @@ 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([]);
},
);
@@ -5,6 +5,44 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { createTestPaseoDaemon, DaemonClient } from "./test-utils/index.js";
test("a rename-only commit returns zero added or deleted lines through the client", async () => {
const cwd = mkdtempSync(join(tmpdir(), "commit-rename-diff-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
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");
writeFileSync(join(cwd, "old.txt"), "same\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "initial");
git("mv", "old.txt", "new.txt");
git("-c", "commit.gpgsign=false", "commit", "-m", "rename");
const sha = git("rev-parse", "HEAD");
await client.connect();
const history = await client.listCheckoutCommits(cwd);
expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([
{ path: "new.txt", additions: 0, deletions: 0, status: "renamed" },
]);
expect(await client.getCommitFileDiff(cwd, sha, "new.txt")).toEqual({
file: {
path: "new.txt",
isNew: false,
isDeleted: false,
additions: 0,
deletions: 0,
hunks: [],
},
});
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
test.skipIf(process.platform === "win32")(
"checkout and commit diffs preserve filenames that Git quotes",
async () => {
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import type { ForgeService } from "../services/forge-service.js";
import { buildForkLocalBranchName } from "../utils/change-request-checkout.js";
import {
CheckoutSourceForgeMismatchError,
MissingCheckoutTargetError,
@@ -32,14 +33,8 @@ function createResolverHarness(overrides?: {
defaultCheckoutRefs: ({ changeRequestNumber }) => [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
buildPrLocalBranchName: ({ headRef, checkoutTarget }) => {
const normalized = checkoutTarget.headOwnerLogin?.trim().toLowerCase() ?? "";
const owner =
checkoutTarget.isCrossRepository && /^[a-z0-9-]+$/.test(normalized)
? normalized
: null;
return owner ? `${owner}/${headRef}` : headRef;
},
buildPrLocalBranchName: ({ headRef, checkoutTarget }) =>
buildForkLocalBranchName({ headRef, ...checkoutTarget }),
supportsCrossRepoCheckoutWithoutRefs: true,
}
: {};
@@ -182,6 +177,28 @@ describe("resolveWorktreeCreationIntent", () => {
expect(deps.headRefLookups).toEqual([]);
});
test("does not track origin for a same-repo head with no origin branch", async () => {
const deps = createResolverHarness();
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
number: 42,
baseRefName: "main",
headRefName: "ford/towel",
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
headRefKind: "pull-ref",
});
const intent = await resolveWorktreeCreationIntent(
{ action: "checkout", githubPrNumber: 42 },
repoRoot,
deps,
);
expect(intent).not.toHaveProperty("trackOriginHead");
});
test("configures the contributor remote for fork PR targets", async () => {
const deps = createResolverHarness();
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
@@ -211,6 +228,29 @@ describe("resolveWorktreeCreationIntent", () => {
expect(deps.headRefLookups).toEqual([]);
});
test("prefixes the pr number when the fork was deleted", async () => {
const deps = createResolverHarness();
deps.forgeService.getPullRequestCheckoutTarget = async () => ({
number: 42,
baseRefName: "main",
headRefName: "towel",
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: true,
});
const intent = await resolveWorktreeCreationIntent(
{ action: "checkout", githubPrNumber: 42 },
repoRoot,
deps,
);
expect(intent).toMatchObject({ headRef: "towel", localBranchName: "pr-42/towel" });
expect(intent).not.toHaveProperty("pushRemoteUrl");
expect(intent).not.toHaveProperty("trackOriginHead");
});
test("uses an explicit PR head ref without calling GitHub", async () => {
const deps = createResolverHarness();
@@ -293,6 +333,105 @@ describe("resolveWorktreeCreationIntent", () => {
expect(deps.headRefLookups).toEqual([]);
});
test("names the local branch pr-<number> for a gitea pull-ref-only head", async () => {
const deps = createResolverHarness({
forge: "gitea",
forgeService: {
defaultCheckoutRefs: ({ changeRequestNumber }) => [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
{ remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
buildPrLocalBranchName: ({ headRef, checkoutTarget }) =>
buildForkLocalBranchName({ headRef, ...checkoutTarget }),
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: `refs/pull/${number}/head`,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: false,
headRefKind: "pull-ref",
}),
},
});
const intent = await resolveWorktreeCreationIntent(
{ action: "checkout", checkoutSource: { kind: "change_request", number: 3 } },
repoRoot,
deps,
);
expect(intent).toMatchObject({ localBranchName: "pr-3" });
expect(intent).not.toHaveProperty("trackOriginHead");
});
test("names the local branch owner/pr-<number> for a gitea fork pull-ref-only head", async () => {
const deps = createResolverHarness({
forge: "gitea",
forgeService: {
defaultCheckoutRefs: ({ changeRequestNumber }) => [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
{ remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
buildPrLocalBranchName: ({ headRef, checkoutTarget }) =>
buildForkLocalBranchName({ headRef, ...checkoutTarget }),
supportsCrossRepoCheckoutWithoutRefs: true,
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: `refs/pull/${number}/head`,
headOwnerLogin: "contributor",
headRepositorySshUrl: "git@gitea.test:contributor/repo.git",
headRepositoryUrl: "https://gitea.test/contributor/repo",
isCrossRepository: true,
headRefKind: "pull-ref",
}),
},
});
const intent = await resolveWorktreeCreationIntent(
{ action: "checkout", checkoutSource: { kind: "change_request", number: 3 } },
repoRoot,
deps,
);
expect(intent).toMatchObject({ localBranchName: "contributor/pr-3" });
});
test("does not double the pr number for a gitea deleted fork's pull-ref-only head", async () => {
const deps = createResolverHarness({
forge: "gitea",
forgeService: {
defaultCheckoutRefs: ({ changeRequestNumber }) => [
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
{ remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` },
],
buildPrLocalBranchName: ({ headRef, checkoutTarget }) =>
buildForkLocalBranchName({ headRef, ...checkoutTarget }),
supportsCrossRepoCheckoutWithoutRefs: true,
getPullRequestCheckoutTarget: async ({ number }) => ({
number,
baseRefName: "main",
headRefName: `refs/pull/${number}/head`,
headOwnerLogin: null,
headRepositorySshUrl: null,
headRepositoryUrl: null,
isCrossRepository: true,
headRefKind: "pull-ref",
}),
},
});
const intent = await resolveWorktreeCreationIntent(
{ action: "checkout", checkoutSource: { kind: "change_request", number: 3 } },
repoRoot,
deps,
);
expect(intent).toMatchObject({ localBranchName: "pr-3" });
});
test("reports unsupported cross-repository checkout targets without a checkout ref", async () => {
const deps = createResolverHarness({
forge: "gitea",
@@ -191,7 +191,8 @@ async function resolvePrCheckoutIntent(
}) ?? [{ remoteName: "origin", remoteRef: `refs/heads/${headRef}` }];
const localBranchName = service.buildPrLocalBranchName?.({ headRef, checkoutTarget });
const crossRepository = resolveCrossRepositoryFields(checkoutTarget);
const trackOriginHead = !checkoutTarget.isCrossRepository;
const trackOriginHead =
!checkoutTarget.isCrossRepository && checkoutTarget.headRefKind !== "pull-ref";
return {
kind: "checkout-change-request",
@@ -39,6 +39,8 @@ export interface PullRequestCheckoutTarget {
headRepositorySshUrl: string | null;
headRepositoryUrl: string | null;
isCrossRepository: boolean;
/** "pull-ref" when headRefName is a synthetic pull ref, not a real branch name (agit PRs, deleted branches); undefined means "branch" */
headRefKind?: "branch" | "pull-ref";
}
export interface PullRequestCheckoutRef {
@@ -441,9 +443,9 @@ export interface ForgeService {
getPullRequestCheckoutTarget(options: GetPullRequestOptions): Promise<PullRequestCheckoutTarget>;
/**
* Refs to fetch for a change-request checkout when the resolved checkout
* target carries none. Adapters that expose a universal change-request head
* ref (GitHub's refs/pull/N/head) return it here; others let the shell fall
* back to the head branch.
* target carries none. Adapters with a universal change-request head ref
* (refs/pull/N/head) return it here; others let the shell fall back to the
* head branch.
*/
defaultCheckoutRefs?(params: {
changeRequestNumber: number;
@@ -451,7 +453,7 @@ export interface ForgeService {
}): PullRequestCheckoutRef[];
/**
* Local branch name for a checked-out change request when the adapter
* disambiguates cross-repository heads (GitHub prefixes the fork owner).
* disambiguates cross-repository heads (prefixes the fork owner).
* Returns undefined to keep the head ref name as-is.
*/
buildPrLocalBranchName?(params: {

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