diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 73df983a50..cb51c8f7dd 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -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 diff --git a/packages/app/e2e/browser/composer-autocomplete.spec.ts b/packages/app/e2e/browser/composer-autocomplete.spec.ts index d30e69b18c..d83046d8b2 100644 --- a/packages/app/e2e/browser/composer-autocomplete.spec.ts +++ b/packages/app/e2e/browser/composer-autocomplete.spec.ts @@ -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 { 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(); } diff --git a/packages/app/e2e/browser/composer-whitespace.spec.ts b/packages/app/e2e/browser/composer-whitespace.spec.ts index 35611a32c0..0c74fc7630 100644 --- a/packages/app/e2e/browser/composer-whitespace.spec.ts +++ b/packages/app/e2e/browser/composer-whitespace.spec.ts @@ -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 { 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 { + 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 { + 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 { + 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 { 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 { + 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> { + 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 { await cdp.detach(); } -async function expectFullscreenInsideVisibleViewport(page: Page): Promise { - 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(); } diff --git a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts index a4fbd15edf..d393966ee8 100644 --- a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts +++ b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts @@ -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 { + await expect(page.getByTestId("menu-button").filter({ visible: true })).toHaveCount(0); +} + +async function expectNewWorkspaceHeaderVisible(page: Page): Promise { + 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 { + 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); + }); + }); +}); diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index 01b9a39d58..efd9266657 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -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 { + 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 { await page.getByRole("button", { name: "Send message", exact: true }).click(); } @@ -405,6 +438,22 @@ export async function configureComposerQueueSend(page: Page): Promise { /** 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 { + await page.keyboard.press( + direction === "previous" ? "Alt+Shift+BracketLeft" : "Alt+Shift+BracketRight", + ); +} +export async function expectWorkspaceHeaderHidden(page: Page): Promise { + await expect(page.getByTestId("workspace-tab-switcher-trigger")).toHaveCount(0); +} +export async function expectWorkspaceHeaderVisible(page: Page): Promise { + await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible(); +} + export async function activateRetainedComposerTab(page: Page, title: string): Promise { await page .getByTestId("workspace-tab-switcher-trigger") diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 72989bd83d..2d6525b27f 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -665,7 +665,7 @@ function MobileGestureWrapper({ return ( - + {children} diff --git a/packages/app/src/app/new.tsx b/packages/app/src/app/new.tsx index 281ec33555..67db3c2c96 100644 --- a/packages/app/src/app/new.tsx +++ b/packages/app/src/app/new.tsx @@ -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 ( - + + + + + ); } diff --git a/packages/app/src/components/ui/text-input/text-input.native.tsx b/packages/app/src/components/ui/text-input/text-input.native.tsx index 243e813f26..4ff773cefa 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.tsx @@ -104,6 +104,13 @@ export const EditingTextInput = forwardRef { + 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; diff --git a/packages/app/src/components/ui/text-input/text-input.web.tsx b/packages/app/src/components/ui/text-input/text-input.web.tsx index 30ee0c6eba..021e3b8e0c 100644 --- a/packages/app/src/components/ui/text-input/text-input.web.tsx +++ b/packages/app/src/components/ui/text-input/text-input.web.tsx @@ -77,6 +77,10 @@ export const EditingTextInput = forwardRef { + const input = inputRef.current as WebTextInputElement | null; + input?.setSelectionRange?.(selection.start, selection.end); + }, reset: () => { textRef.current = ""; const input = inputRef.current as WebTextInputElement | null; diff --git a/packages/app/src/components/ui/text-input/types.ts b/packages/app/src/components/ui/text-input/types.ts index 9f746bd656..fbc14e4726 100644 --- a/packages/app/src/components/ui/text-input/types.ts +++ b/packages/app/src/components/ui/text-input/types.ts @@ -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; diff --git a/packages/app/src/composer/dock/index.native.tsx b/packages/app/src/composer/dock/index.native.tsx index a668c00743..4b6407c872 100644 --- a/packages/app/src/composer/dock/index.native.tsx +++ b/packages/app/src/composer/dock/index.native.tsx @@ -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 | null>(null); +const ViewportSizing = createContext | 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 ( - {children} + {children} ); } /** 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 ( - - + + {content} - - {composer} + + {composer} {overlay} @@ -148,7 +186,7 @@ export function ComposerDock({ } return ( - + - - {composer} + + + {composer} + {overlay} @@ -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 }, +})); diff --git a/packages/app/src/composer/dock/index.tsx b/packages/app/src/composer/dock/index.tsx index 3f41118696..421002352e 100644 --- a/packages/app/src/composer/dock/index.tsx +++ b/packages/app/src/composer/dock/index.tsx @@ -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(); + const [surfaceSize, setSurfaceSize] = useState<{ width: number; height: number }>(); + const surfaceRef = useRef(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 ( - + {content} - {composer} + + {composer} + {overlay} ); } return ( - + {content} - {composer} + + {composer} + {overlay} ); @@ -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", diff --git a/packages/app/src/composer/dock/internal/capacity.test.ts b/packages/app/src/composer/dock/internal/capacity.test.ts index 7e98b7ef3d..44e79e6ef9 100644 --- a/packages/app/src/composer/dock/internal/capacity.test.ts +++ b/packages/app/src/composer/dock/internal/capacity.test.ts @@ -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 }); diff --git a/packages/app/src/composer/dock/internal/capacity.ts b/packages/app/src/composer/dock/internal/capacity.ts index ca4fed2991..9c460e1729 100644 --- a/packages/app/src/composer/dock/internal/capacity.ts +++ b/packages/app/src/composer/dock/internal/capacity.ts @@ -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); +} diff --git a/packages/app/src/composer/dock/internal/fullscreen.native.tsx b/packages/app/src/composer/dock/internal/fullscreen.native.tsx new file mode 100644 index 0000000000..9f27befa4c --- /dev/null +++ b/packages/app/src/composer/dock/internal/fullscreen.native.tsx @@ -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 ( + + {children} + + ); +} + +/** The Send row of a fullscreen composer rides the keyboard like the inline dock surface. */ +export function ComposerFullscreenFooter({ children, ...props }: ViewProps) { + return {children}; +} + +// Plain React Native styles: these land on Reanimated views (docs/unistyles.md). +const styles = StyleSheet.create({ + inline: { flexShrink: 1 }, + fullscreen: { flex: 1, minHeight: 0 }, +}); diff --git a/packages/app/src/composer/dock/internal/fullscreen.tsx b/packages/app/src/composer/dock/internal/fullscreen.tsx new file mode 100644 index 0000000000..36291181c1 --- /dev/null +++ b/packages/app/src/composer/dock/internal/fullscreen.tsx @@ -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 {children}; +} + +export function ComposerFullscreenFooter({ children, ...props }: ViewProps) { + return {children}; +} + +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, + surfaceSize: { width: number; height: number } | undefined, +): ComposerSlotFrame | undefined { + const [frame, setFrame] = useState(); + 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 }, +}); diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index 33041cceb6..8bc31bb176 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -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 () => { diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index c64753694a..c14db9a119 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -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); } })(); diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 4aa22a324d..b54b344a36 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -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 ( - + {dockContent} - {importPillPress ? ( + {importPillPress && !isComposerFullscreen ? ( @@ -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, }, }); diff --git a/packages/app/src/composer/editing-session/index.test.ts b/packages/app/src/composer/editing-session/index.test.ts index d593e5a64e..31c3765426 100644 --- a/packages/app/src/composer/editing-session/index.test.ts +++ b/packages/app/src/composer/editing-session/index.test.ts @@ -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(); - }, - ); }); diff --git a/packages/app/src/composer/editing-session/index.tsx b/packages/app/src/composer/editing-session/index.tsx index 97f7a79c51..6ad84d632d 100644 --- a/packages/app/src/composer/editing-session/index.tsx +++ b/packages/app/src/composer/editing-session/index.tsx @@ -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 }> - >(); +interface TabSessions { + targetKey: string; + sessions: Map; +} + +export class ComposerEditingSessions { + private workspaces = new Map>(); + 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); +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index c77b39d332..21bf7f6679 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -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 ( - + @@ -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(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} /> - + {/* Input area */} - - {queueList} + + {isFullscreen ? null : queueList} {!isFullscreen && sendErrorNode} - + ({ @@ -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%", diff --git a/packages/app/src/composer/input/fullscreen-exit.native.ts b/packages/app/src/composer/input/fullscreen-exit.native.ts new file mode 100644 index 0000000000..c2344d50b0 --- /dev/null +++ b/packages/app/src/composer/input/fullscreen-exit.native.ts @@ -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; +} diff --git a/packages/app/src/composer/input/fullscreen-exit.ts b/packages/app/src/composer/input/fullscreen-exit.ts new file mode 100644 index 0000000000..b76eeed5fc --- /dev/null +++ b/packages/app/src/composer/input/fullscreen-exit.ts @@ -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; + }, + }); +} diff --git a/packages/app/src/composer/input/fullscreen-host.native.tsx b/packages/app/src/composer/input/fullscreen-host.native.tsx deleted file mode 100644 index 22ce0052bf..0000000000 --- a/packages/app/src/composer/input/fullscreen-host.native.tsx +++ /dev/null @@ -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 ( - - - {children} - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - hidden: { display: "none" }, - background: { - position: "absolute", - top: 0, - bottom: 0, - left: 0, - right: 0, - backgroundColor: theme.colors.surface1, - }, -})); diff --git a/packages/app/src/composer/input/fullscreen-host.tsx b/packages/app/src/composer/input/fullscreen-host.tsx deleted file mode 100644 index ba634b268d..0000000000 --- a/packages/app/src/composer/input/fullscreen-host.tsx +++ /dev/null @@ -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( - - - {children} - - , - 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, - }; -} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index a0476ff7a7..a1b8c0fdbb 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -84,6 +84,7 @@ import { computeCanStartDictation, resolveComposerSurfacePresentation, resolveComposerSurfaceText, + resolveRetainedSelection, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, @@ -676,13 +677,7 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen ); } return ( - + {!isWeb && props.compact && !props.fullscreen ? ( ( const inputWrapperRef = useRef(null); const textInputRef = useRef(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(null); const appliedTextReplacementKeyRef = useRef(textReplacement.key); - const textReplacementRef = useRef(textReplacement); - textReplacementRef.current = textReplacement; const webTextareaRef = useRef(null); const updateLiveTextPresence = useCallback((text: string) => { const nextHasLiveText = text.trim().length > 0; @@ -1289,43 +1276,49 @@ export const MessageInput = forwardRef( 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( 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( 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( }); }, [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( }, [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( 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( () => [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( 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( canPressLoadingButton, onSubmitLoadingPress, handleDefaultSendAction, - isSendButtonDisabled, - hasLiveText, - attachments, - hasExternalContent, - allowEmptySubmit, + isPresentedSendDisabled, submitAccessibilityLabel, sendButtonCombinedStyle, isSubmitLoading, @@ -2008,9 +1984,7 @@ export const MessageInput = forwardRef( 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( [ readOnly, textReplacement, - editingSession, - isActiveComposer, assignTextInput, textInputStyle, readOnlyTextStyle, @@ -2162,11 +2134,10 @@ export const MessageInput = forwardRef( handleRealtimeVoiceStop, ], ); - const enterFullscreen = useCallback(() => changePresentation(true), [changePresentation]); return ( @@ -2177,8 +2148,7 @@ export const MessageInput = forwardRef( /> ({ 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, diff --git a/packages/app/src/composer/input/presentation.tsx b/packages/app/src/composer/input/presentation.tsx index d02821696a..64697999f5 100644 --- a/packages/app/src/composer/input/presentation.tsx +++ b/packages/app/src/composer/input/presentation.tsx @@ -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) => , - [iconSize], + (color: string) => , + [], ); const minimizeIcon = useCallback( - (color: string) => , - [iconSize], + (color: string) => , + [], ); - const enterStyle = useMemo( - () => [styles.fullscreenButton, { right: -6 + railAdjustment }], - [railAdjustment], - ); - const exitStyle = useMemo( - () => [styles.iconButton, { marginRight: railAdjustment }], - [railAdjustment], - ); - if (!fullscreen) - return ( - <> - - {attachmentSlot} - - {surface} - {showFullscreenButton ? ( - - ) : null} - - {toolbar} - - {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 ( - - + + {fullscreen ? ( + ) : null} + + {fullscreen ? null : toolbar} + + + {fullscreen ? ( + + {error} + {send} + + ) : null} + {overlay} + ); } 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" }, })); diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index a7f4113b28..6f2cdef74d 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index 3786613eb2..698965b5ac 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -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 }; +} diff --git a/packages/app/src/composer/types.ts b/packages/app/src/composer/types.ts index ee01144ed7..cb46412de0 100644 --- a/packages/app/src/composer/types.ts +++ b/packages/app/src/composer/types.ts @@ -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; } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index bb51832aa0..f857bbb349 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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 = ( - + {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 ( - {children} + {children} ); } @@ -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, }, }); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 39c414d9e7..6d8fd3de5a 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -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) => { 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} - {errorMessage ? {errorMessage} : null} - - + + {composer} + {errorMessage ? {errorMessage} : null} + {importSession.sheet} ); } +/** 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 ( - - {setupFields} - <> - {children} - {isCompact ? null : importSessionButton} - - + <> + {fullscreen ? null : } + + + + {setupFields} + <> + {children} + {isCompact || fullscreen ? null : importSessionButton} + + + + ); } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 62c9ee4fa6..a8089b6112 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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({ > {(topOverlayInset) => (