mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
fix(app): restore fullscreen composer keyboard motion and clearing (#6902)
* fix(app): keep fullscreen composer in the keyboard dock * fix(app): preserve composer caret and scope keyboard actions
This commit is contained in:
@@ -198,6 +198,40 @@ viewport keeps the last keyboard reservation until the next opening replaces
|
||||
it, so a capped draft keeps one height through every keyboard transition
|
||||
instead of expanding on close and shrinking again on open.
|
||||
|
||||
Fullscreen is a layout mode of the dock, not a second editor. `ComposerDock
|
||||
fullscreen` fills the stationary viewport with the composer slot over the
|
||||
still-mounted content, paints its own surface under the safe areas, and turns
|
||||
the surface translation off. The composer's single tree keeps the editing
|
||||
surface at the same position in both modes, so the mounted editor, its native
|
||||
buffer, selection, and focus survive the toggle and Send's clear always reaches
|
||||
the live input. The one remount left is a viewport crossing the compact
|
||||
breakpoint: the app shell swaps its gesture wrapper and the workspace screen
|
||||
mounts tabs in different trees, so every screen below remounts. The draft owner
|
||||
restores the text and the editing session restores the caret and focus intent.
|
||||
New workspace keys its sessions by draft in the app-level store for the same
|
||||
reason. The centered form honors fullscreen by style alone: no centering, no
|
||||
translation, setup fields hidden, slot filling the stationary viewport. The
|
||||
global exit action (Escape, hardware back) is registered only for the active
|
||||
composer: a retained tab keeps its fullscreen editor mounted while hidden, the
|
||||
sheet suspends pane focus, and the composer gives up input from the moment the
|
||||
user asks for the compact agent list or explorer, before the panel's motion
|
||||
settles. Pane focus itself stays put under those panels: the agent panel reads
|
||||
it as "the user is in this chat" for attention clearing, and opening the agent
|
||||
list must not clear the attention it shows.
|
||||
The band beside the inline editor where the fullscreen glyph sits is the input's
|
||||
own right padding, so a tap anywhere in it focuses the editor whether or not the
|
||||
glyph shows. On web the slot is a fixed box over the visible viewport, which is
|
||||
also what lets a zoomed mobile page pan under it. Inside the slot, `ComposerFullscreenBody` reserves the keyboard
|
||||
height as a bottom margin at move start and end (boundary-time, like the
|
||||
capacity reservation) while `ComposerFullscreenFooter` rides the keyboard per
|
||||
frame with `KeyboardTranslateView`, so Send lands on the keyboard without a gap
|
||||
and without animating the editor's height. The screen that owns the chrome
|
||||
unmounts it: the workspace screen reads `useComposerTabFullscreen` for the
|
||||
active tab and New workspace reads the mounted composer's session. Hosts that
|
||||
render the dock pass the same `ComposerEditingSession` to the `Composer` so the
|
||||
dock, chrome, and editor agree on one fullscreen state. Escape (web overlay
|
||||
registration, focus untouched) and the Android back button leave fullscreen.
|
||||
|
||||
Keep setup fields out of the composer's shrink chain. New workspace supplies its
|
||||
setup fields as the first dock child so they move up under the header as the draft
|
||||
grows. Putting the fields and composer in one bounded, shrinkable column made
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
queueComposerWithButton,
|
||||
configureComposerQueueSend,
|
||||
activateRetainedComposerTab,
|
||||
switchComposerTabWithKeyboard,
|
||||
expectWorkspaceHeaderHidden,
|
||||
expectWorkspaceHeaderVisible,
|
||||
recordVisibleComposerGeometry,
|
||||
expectNoCollapsedVisibleComposer,
|
||||
expectAttachmentPill,
|
||||
@@ -27,8 +30,14 @@ import {
|
||||
startRunningMockAgent,
|
||||
composerLocator,
|
||||
expectComposerVisible,
|
||||
expectComposerFullscreenFitsVisibleViewport,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
closeMobileAgentSidebar,
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
async function composerHeight(page: Page): Promise<number> {
|
||||
return composerLocator(page).evaluate((element) => element.getBoundingClientRect().height);
|
||||
@@ -156,6 +165,36 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
|
||||
}
|
||||
});
|
||||
|
||||
async function swipeOpenMobileAgentSidebar(page: Page): Promise<void> {
|
||||
const viewport = page.viewportSize();
|
||||
if (!viewport) throw new Error("viewport size is required");
|
||||
const y = Math.round(viewport.height / 2);
|
||||
await page.mouse.move(8, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(40, y, { steps: 4 });
|
||||
await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/** Freeze the page's timers and animation frames until `page.clock.resume()`. */
|
||||
async function pausePageClock(page: Page): Promise<void> {
|
||||
const now = Date.now();
|
||||
await page.clock.install({ time: now });
|
||||
await page.clock.pauseAt(now);
|
||||
}
|
||||
|
||||
/** The send error shares the footer with Send, so whatever keeps Send visible keeps it visible. */
|
||||
async function expectFullscreenErrorAboveSend(page: Page): Promise<void> {
|
||||
const frame = page.getByTestId("composer-fullscreen");
|
||||
const alertBox = (await frame.getByRole("alert").boundingBox())!;
|
||||
const sendBox = (await page
|
||||
.getByRole("button", { name: "Send message", exact: true })
|
||||
.boundingBox())!;
|
||||
const editorBox = (await composerLocator(page).boundingBox())!;
|
||||
expect(alertBox.y).toBeGreaterThanOrEqual(editorBox.y + editorBox.height);
|
||||
expect(alertBox.y + alertBox.height).toBeLessThanOrEqual(sendBox.y);
|
||||
}
|
||||
|
||||
async function writeCompactDraft(page: Page, text: string): Promise<void> {
|
||||
await composerLocator(page).fill(text);
|
||||
await expect(composerLocator(page)).toHaveValue(text);
|
||||
@@ -188,47 +227,107 @@ 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 clears the editor and keeps fullscreen open", async () => {
|
||||
await writeCompactDraft(page, "First line\nSecond line\nThird line");
|
||||
await enterComposerFullscreen(page);
|
||||
await writeCompactDraft(page, "Short draft");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await selectComposerDraftRange(page, 0, 5);
|
||||
await resizeComposerViewport(page, "landscape");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expectComposerDraftSelection(page, 0, 5);
|
||||
await expect(composerLocator(page)).toHaveValue("Short draft");
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await expectComposerFullscreenControls(page);
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
|
||||
});
|
||||
await test.step("typing and sending keep working after leaving fullscreen again", async () => {
|
||||
await writeCompactDraft(page, "Typed inside fullscreen");
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
||||
await exitComposerFullscreen(page);
|
||||
await expectWorkspaceHeaderVisible(page);
|
||||
await expect(composerLocator(page)).toHaveValue("Typed inside fullscreen");
|
||||
await writeCompactDraft(page, "Typed inside fullscreen, finished inline");
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
await writeCompactDraft(page, "One more inline message");
|
||||
await sendComposerWithButton(page);
|
||||
await expect(composerLocator(page)).toHaveValue("");
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a caret inside an unpublished edit survives a breakpoint remount", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-remount-caret-",
|
||||
title: "Remount caret",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await 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();
|
||||
}
|
||||
@@ -253,6 +352,7 @@ test("fullscreen submit rejection retains the editable draft and supports retry"
|
||||
await expect(page.getByTestId("composer-fullscreen").getByRole("alert")).toHaveText(
|
||||
"Requested mock prompt rejection",
|
||||
);
|
||||
await expectFullscreenErrorAboveSend(page);
|
||||
await expect(composerLocator(page)).toHaveValue(draft);
|
||||
await expect(composerLocator(page)).toBeEditable();
|
||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
||||
@@ -264,6 +364,76 @@ test("fullscreen submit rejection retains the editable draft and supports retry"
|
||||
}
|
||||
});
|
||||
|
||||
async function blurComposer(page: Page): Promise<void> {
|
||||
await composerLocator(page).blur();
|
||||
await expect(composerLocator(page)).not.toBeFocused();
|
||||
}
|
||||
|
||||
/** Mid-band at the given height and near the editor's bottom edge. */
|
||||
async function reservedBandPoints(
|
||||
page: Page,
|
||||
bandX: number,
|
||||
topY: number,
|
||||
): Promise<Array<{ x: number; y: number }>> {
|
||||
const editor = (await composerLocator(page).boundingBox())!;
|
||||
return [
|
||||
{ x: bandX, y: topY },
|
||||
{ x: bandX, y: editor.y + editor.height - 6 },
|
||||
];
|
||||
}
|
||||
|
||||
test("the reserved band beside the inline editor focuses it", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-band-focus-",
|
||||
title: "Composer band focus",
|
||||
});
|
||||
try {
|
||||
await resizeComposerViewport(page, "portrait");
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
const exitButton = page.getByRole("button", { name: "Exit fullscreen", exact: true });
|
||||
const fullscreenButton = page.getByRole("button", { name: "Fullscreen", exact: true });
|
||||
const longDraft = "Band first line\nBand second line\nBand third line";
|
||||
await writeCompactDraft(page, longDraft);
|
||||
await expect(fullscreenButton).toBeVisible();
|
||||
const button = (await fullscreenButton.boundingBox())!;
|
||||
const bandX = button.x + button.width / 2;
|
||||
|
||||
await test.step("with the fullscreen button shown, the band under it focuses the editor", async () => {
|
||||
for (const point of await reservedBandPoints(page, bandX, button.y + button.height + 10)) {
|
||||
await blurComposer(page);
|
||||
await page.mouse.click(point.x, point.y);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await expect(composerLocator(page)).toHaveValue(longDraft);
|
||||
await expect(exitButton).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
await test.step("the button itself still toggles fullscreen", async () => {
|
||||
await fullscreenButton.click();
|
||||
await expectComposerFullscreenControls(page);
|
||||
await exitComposerFullscreen(page);
|
||||
await expect(exitButton).toHaveCount(0);
|
||||
await expect(composerLocator(page)).toHaveValue(longDraft);
|
||||
});
|
||||
|
||||
await test.step("with no fullscreen button, the whole band still focuses the editor", async () => {
|
||||
await writeCompactDraft(page, "Short");
|
||||
await expect(fullscreenButton).toHaveCount(0);
|
||||
const editor = (await composerLocator(page).boundingBox())!;
|
||||
for (const point of await reservedBandPoints(page, bandX, editor.y + 6)) {
|
||||
await blurComposer(page);
|
||||
await page.mouse.click(point.x, point.y);
|
||||
await expect(composerLocator(page)).toBeFocused();
|
||||
await expect(composerLocator(page)).toHaveValue("Short");
|
||||
await expect(exitButton).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("fullscreen Send keeps the configured queue behavior while an agent runs", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -333,15 +503,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 +535,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 +621,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 +632,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,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -355,6 +355,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 +396,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 +438,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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,14 @@ function ComposerVoiceModeButton({
|
||||
}
|
||||
|
||||
export function Composer({ isPaneFocused, ...props }: ComposerProps) {
|
||||
// A compact overlay panel (agent list, explorer) covers the pane without taking its
|
||||
// focus. The composer gives up input from the moment the user asks for the panel,
|
||||
// not when its motion settles, so the exit action and autocomplete never race it.
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent");
|
||||
const coveredByOverlayPanel = isCompact && overlayPanelRequested;
|
||||
return (
|
||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
|
||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused && !coveredByOverlayPanel}>
|
||||
<RenderProfile id="ComposerContent">
|
||||
<ComposerContent {...props} />
|
||||
</RenderProfile>
|
||||
@@ -1234,6 +1251,7 @@ function ComposerContentImpl({
|
||||
inputWrapperStyle,
|
||||
isCompactLayout: isCompactLayoutOverride,
|
||||
inputMode = "chat",
|
||||
editingSession: providedEditingSession,
|
||||
readOnly = false,
|
||||
submitLabel,
|
||||
placeholder,
|
||||
@@ -1339,7 +1357,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 +2268,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 +2395,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 +2511,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 +2538,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,
|
||||
};
|
||||
}
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
computeCanStartDictation,
|
||||
resolveComposerSurfacePresentation,
|
||||
resolveComposerSurfaceText,
|
||||
resolveRetainedSelection,
|
||||
runAlternateSendAction,
|
||||
runDefaultSendAction,
|
||||
runMessageInputKeyboardAction,
|
||||
@@ -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}
|
||||
@@ -1040,11 +1035,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;
|
||||
@@ -1269,18 +1261,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 focusIntentRef = useRef(false);
|
||||
const valueRef = useRef(value);
|
||||
const selectionRef = useRef({ start: value.length, end: value.length });
|
||||
const activeComposerRef = useRef(isActiveComposer);
|
||||
activeComposerRef.current = isActiveComposer;
|
||||
const [webTextArea, setWebTextArea] = useState<HTMLElement | null>(null);
|
||||
const appliedTextReplacementKeyRef = useRef(textReplacement.key);
|
||||
const textReplacementRef = useRef(textReplacement);
|
||||
textReplacementRef.current = textReplacement;
|
||||
const webTextareaRef = useRef<HTMLElement | null>(null);
|
||||
const updateLiveTextPresence = useCallback((text: string) => {
|
||||
const nextHasLiveText = text.trim().length > 0;
|
||||
@@ -1289,43 +1276,49 @@ 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);
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
if (isActiveComposer && focusIntentRef.current) textInputRef.current?.focus();
|
||||
}, [isActiveComposer]);
|
||||
// The compact and desktop screens mount the editor in different trees, so a
|
||||
// viewport that crosses the breakpoint remounts it. The draft owner brings the
|
||||
// text back; the session brings the caret and focus intent back. On web the
|
||||
// owner may still hold an unpublished edit while the new editor mounts, so the
|
||||
// caret is placed now against the text on screen and again when the owner's
|
||||
// hydrated text arrives.
|
||||
const retainedSelectionRef = useRef<{ start: number; end: number } | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const retained = editingSession.takeEditor();
|
||||
if (retained) {
|
||||
const text = textInputRef.current?.getText() ?? valueRef.current;
|
||||
const selection = resolveRetainedSelection(retained.selection, text.length);
|
||||
retainedSelectionRef.current = retained.selection;
|
||||
selectionRef.current = selection;
|
||||
textInputRef.current?.setSelection(selection);
|
||||
if (retained.focused) {
|
||||
focusIntentRef.current = true;
|
||||
if (activeComposerRef.current) textInputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
editingSession.retainEditor({
|
||||
selection: getComposerInputSnapshot(
|
||||
textInputRef.current,
|
||||
valueRef.current,
|
||||
selectionRef.current,
|
||||
).selection,
|
||||
focused: isInputFocusedRef.current || focusIntentRef.current,
|
||||
});
|
||||
};
|
||||
}, [editingSession]);
|
||||
const getLiveText = useCallback(() => valueRef.current, []);
|
||||
const composerHeight = useComposerHeight({
|
||||
getText: getLiveText,
|
||||
@@ -1340,29 +1333,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 +1406,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);
|
||||
}
|
||||
@@ -1696,17 +1682,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,6 +1807,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
}, [onFocusChange]);
|
||||
|
||||
const handleInputBlur = useCallback(() => {
|
||||
// Fullscreen is nothing but the editor, so a blur there (a retained tab being
|
||||
// hidden, the keyboard dismissed) keeps the intent: showing it again refocuses.
|
||||
if (activeComposerRef.current && !isFullscreen) focusIntentRef.current = false;
|
||||
isInputFocusedRef.current = false;
|
||||
setIsInputFocused(false);
|
||||
@@ -1888,8 +1865,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 +1875,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 +1929,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 +1948,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
canPressLoadingButton,
|
||||
onSubmitLoadingPress,
|
||||
handleDefaultSendAction,
|
||||
isSendButtonDisabled,
|
||||
hasLiveText,
|
||||
attachments,
|
||||
hasExternalContent,
|
||||
allowEmptySubmit,
|
||||
isPresentedSendDisabled,
|
||||
submitAccessibilityLabel,
|
||||
sendButtonCombinedStyle,
|
||||
isSubmitLoading,
|
||||
@@ -2008,9 +1984,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 +2003,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
[
|
||||
readOnly,
|
||||
textReplacement,
|
||||
editingSession,
|
||||
isActiveComposer,
|
||||
assignTextInput,
|
||||
textInputStyle,
|
||||
readOnlyTextStyle,
|
||||
@@ -2162,11 +2134,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 +2148,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
/>
|
||||
<ComposerInputPresentation
|
||||
fullscreen={isFullscreen}
|
||||
iconSize={buttonIconSize}
|
||||
active={isActiveComposer}
|
||||
sendIconSize={buttonIconSize}
|
||||
surface={textSurface}
|
||||
toolbar={toolbar}
|
||||
send={sendButton}
|
||||
@@ -2207,6 +2177,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
flexShrink: 1,
|
||||
position: "relative",
|
||||
},
|
||||
fullscreenContainer: {
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
},
|
||||
inputWrapper: {
|
||||
flexShrink: 1,
|
||||
flexDirection: "column",
|
||||
@@ -2241,14 +2215,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" },
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -1155,6 +1160,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 +1237,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
cwd={cwd}
|
||||
isSubmitLoading={false}
|
||||
agentInputDraft={agentInputDraft}
|
||||
editingSession={composerEditingSession}
|
||||
onAttentionInputFocus={onAttentionInputFocus}
|
||||
onAttentionPromptSend={onAttentionPromptSend}
|
||||
onComposerHeightChange={handleComposerHeightChange}
|
||||
@@ -1292,7 +1301,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
);
|
||||
|
||||
const dock = (
|
||||
<ChatSurface disabled={isArchivingCurrentAgent}>
|
||||
<ChatSurface disabled={isArchivingCurrentAgent} fullscreen={isComposerFullscreen}>
|
||||
{dockContent}
|
||||
{composerSection}
|
||||
{dockOverlay}
|
||||
@@ -1323,13 +1332,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>
|
||||
);
|
||||
}
|
||||
@@ -1473,6 +1484,7 @@ const AgentComposerSection = memo(function AgentComposerSection({
|
||||
cwd,
|
||||
isSubmitLoading,
|
||||
agentInputDraft,
|
||||
editingSession,
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
onComposerHeightChange,
|
||||
@@ -1486,6 +1498,7 @@ const AgentComposerSection = memo(function AgentComposerSection({
|
||||
cwd: string;
|
||||
isSubmitLoading: boolean;
|
||||
agentInputDraft: AgentInputDraft;
|
||||
editingSession: ComposerEditingSession;
|
||||
onAttentionInputFocus: () => void;
|
||||
onAttentionPromptSend: () => void;
|
||||
onComposerHeightChange: (height: number) => void;
|
||||
@@ -1509,6 +1522,7 @@ const AgentComposerSection = memo(function AgentComposerSection({
|
||||
cwd={cwd}
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
agentInputDraft={agentInputDraft}
|
||||
editingSession={editingSession}
|
||||
onAttentionInputFocus={onAttentionInputFocus}
|
||||
onAttentionPromptSend={onAttentionPromptSend}
|
||||
onComposerHeightChange={onComposerHeightChange}
|
||||
@@ -1524,6 +1538,7 @@ function ActiveAgentComposer({
|
||||
cwd,
|
||||
isSubmitLoading,
|
||||
agentInputDraft,
|
||||
editingSession,
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
onComposerHeightChange,
|
||||
@@ -1535,6 +1550,7 @@ function ActiveAgentComposer({
|
||||
cwd: string;
|
||||
isSubmitLoading: boolean;
|
||||
agentInputDraft: AgentInputDraft;
|
||||
editingSession: ComposerEditingSession;
|
||||
onAttentionInputFocus: () => void;
|
||||
onAttentionPromptSend: () => void;
|
||||
onComposerHeightChange: (height: number) => void;
|
||||
@@ -1631,6 +1647,7 @@ function ActiveAgentComposer({
|
||||
onChangeAttachments={agentInputDraft.setAttachments}
|
||||
cwd={cwd}
|
||||
clearDraft={agentInputDraft.clear}
|
||||
editingSession={editingSession}
|
||||
autoFocus
|
||||
autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)}
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
@@ -1723,6 +1740,8 @@ const animatedStaticStyles = RNStyleSheet.create({
|
||||
inputAreaWrapper: {
|
||||
width: "100%",
|
||||
flexShrink: 1,
|
||||
// Content-sized inline; fills the dock's fullscreen slot.
|
||||
flexGrow: 1,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { WorkspaceComposerEditingSessionsProvider } from "@/composer/editing-session";
|
||||
import {
|
||||
WorkspaceComposerEditingSessionsProvider,
|
||||
useComposerTabFullscreen,
|
||||
} from "@/composer/editing-session";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
|
||||
@@ -824,6 +827,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;
|
||||
@@ -3095,7 +3108,6 @@ function WorkspaceScreenContent({
|
||||
focusedPaneTabDescriptorMap,
|
||||
isRouteFocused,
|
||||
focusedPaneId,
|
||||
// The sheet owns input while it covers the tab.
|
||||
paneFocusSuspended,
|
||||
onFocusPane: handleFocusPane,
|
||||
buildMobilePaneContentModel,
|
||||
@@ -3528,6 +3540,11 @@ function WorkspaceScreenContent({
|
||||
[uiTabs, sheetTab],
|
||||
);
|
||||
const workspacePanelKey = `${normalizedServerId}:${normalizedWorkspaceId}`;
|
||||
const compactHeaderLayout = resolveCompactHeaderLayout({
|
||||
header: compactWorkspaceHeader,
|
||||
activeTab: activeTabDescriptor,
|
||||
composerFullscreen: useComposerTabFullscreen(workspacePanelKey, activeTabId),
|
||||
});
|
||||
|
||||
// The sheet is the column's last child, so it covers the compact header as a modal.
|
||||
const workspaceCenterColumn = isMobile ? (
|
||||
@@ -3538,8 +3555,8 @@ function WorkspaceScreenContent({
|
||||
>
|
||||
<View style={styles.centerColumn}>
|
||||
<FloatingHeaderLayout
|
||||
header={compactWorkspaceHeader}
|
||||
floating={floatsHeaderOverContent(activeTabDescriptor)}
|
||||
header={compactHeaderLayout.header}
|
||||
floating={compactHeaderLayout.floating}
|
||||
>
|
||||
{(topOverlayInset) => (
|
||||
<StreamTopOverlayProvider value={topOverlayInset}>
|
||||
|
||||
Reference in New Issue
Block a user