diff --git a/packages/app/e2e/browser/composer-email-mentions.spec.ts b/packages/app/e2e/browser/composer-email-mentions.spec.ts index 29b0686c20..283fca26ca 100644 --- a/packages/app/e2e/browser/composer-email-mentions.spec.ts +++ b/packages/app/e2e/browser/composer-email-mentions.spec.ts @@ -4,6 +4,10 @@ import { expectComposerDraft, expectComposerVisible, fillComposerDraft, + enterComposerFullscreen, + exitComposerFullscreen, + resizeComposerViewport, + selectComposerDraftRange, } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; @@ -53,3 +57,48 @@ for (const email of [ } }); } + +test("fullscreen mention keys edit the draft while inline keys still complete files", async ({ + page, +}) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-fullscreen-mention-", + title: "Fullscreen mention keys", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + const draft = "First line\nSecond line\n@README"; + await fillComposerDraft(page, draft); + await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible(); + await enterComposerFullscreen(page); + await expect(fileSuggestions(page)).not.toBeVisible(); + await selectComposerDraftRange(page, draft.length, draft.length); + await composerLocator(page).press("ArrowUp"); + await expect + .poll(() => + composerLocator(page).evaluate((element) => { + const input = element as HTMLTextAreaElement; + return input.selectionStart === input.selectionEnd && input.selectionStart < 23; + }), + ) + .toBe(true); + await selectComposerDraftRange(page, draft.length, draft.length); + await composerLocator(page).press("Enter"); + await expectComposerDraft(page, `${draft}\n`); + await exitComposerFullscreen(page); + await selectReadmeMention(page); + const commandDraft = "/cl\nSecond line\nThird line"; + await fillComposerDraft(page, commandDraft); + await selectComposerDraftRange(page, 2, 2); + await composerLocator(page).press("ArrowRight"); + await expect(fileSuggestions(page).getByText("/clear", { exact: true }).first()).toBeVisible(); + await enterComposerFullscreen(page); + await expect(fileSuggestions(page)).not.toBeVisible(); + await composerLocator(page).press("Enter"); + await expectComposerDraft(page, "/cl\n\nSecond line\nThird line"); + } 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 024446967f..35611a32c0 100644 --- a/packages/app/e2e/browser/composer-whitespace.spec.ts +++ b/packages/app/e2e/browser/composer-whitespace.spec.ts @@ -1,3 +1,5 @@ +import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab"; +import { seedWorkspace } from "../support/helpers/seed-client"; import { expect, test, type Page } from "../support/fixtures"; import { expectNearBottom, @@ -5,7 +7,27 @@ import { waitForScrollableChat, } from "../support/helpers/agent-bottom-anchor"; import { awaitAssistantMessage } from "../support/helpers/agent-stream"; -import { composerLocator, expectComposerVisible } from "../support/helpers/composer"; +import { + attachComposerTestImage, + pasteComposerTestImage, + enterComposerFullscreen, + exitComposerFullscreen, + resizeComposerViewport, + selectComposerDraftRange, + expectComposerDraftSelection, + expectComposerFullscreenControls, + sendComposerWithButton, + queueComposerWithButton, + configureComposerQueueSend, + activateRetainedComposerTab, + recordVisibleComposerGeometry, + expectNoCollapsedVisibleComposer, + expectAttachmentPill, + expectQueuedMessageButton, + startRunningMockAgent, + composerLocator, + expectComposerVisible, +} from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; async function composerHeight(page: Page): Promise { @@ -133,3 +155,300 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page } await agent.cleanup(); } }); + +async function writeCompactDraft(page: Page, text: string): Promise { + await composerLocator(page).fill(text); + await expect(composerLocator(page)).toHaveValue(text); +} + +test("compact long drafts stop growing before the header", async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-cap-", + title: "Composer cap", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await writeCompactDraft(page, "A long draft line\n".repeat(40)); + await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160); + } finally { + await agent.cleanup(); + } +}); + +test("compact fullscreen editing preserves the live draft and selection and stays open after send", async ({ + page, +}) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-fullscreen-", + title: "Fullscreen composer", + }); + try { + 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 resizeComposerViewport(page, "portrait"); + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + await expectComposerFullscreenControls(page); + await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled(); + } finally { + await agent.cleanup(); + } +}); + +test("fullscreen submit rejection retains the editable draft and supports retry", async ({ + page, +}) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-fullscreen-retry-", + title: "Fullscreen retry", + featureValues: { mockPromptRejections: 1 }, + }); + const draft = "First line\nSecond line\nThird line"; + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await writeCompactDraft(page, draft); + await enterComposerFullscreen(page); + await sendComposerWithButton(page); + await expect(page.getByTestId("composer-fullscreen").getByRole("alert")).toHaveText( + "Requested mock prompt rejection", + ); + await expect(composerLocator(page)).toHaveValue(draft); + await expect(composerLocator(page)).toBeEditable(); + await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled(); + await sendComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + await expectComposerFullscreenControls(page); + } finally { + await agent.cleanup(); + } +}); + +test("fullscreen Send keeps the configured queue behavior while an agent runs", async ({ + page, +}) => { + await configureComposerQueueSend(page); + await resizeComposerViewport(page, "portrait"); + const agent = await startRunningMockAgent(page, { + prefix: "composer-fullscreen-queue-", + model: "one-minute-stream", + prompt: "Stay running for fullscreen queue test.", + }); + try { + await writeCompactDraft(page, "Queued first line\nSecond line\nThird line"); + await enterComposerFullscreen(page); + await expectComposerFullscreenControls(page); + await queueComposerWithButton(page); + await expect(composerLocator(page)).toHaveValue(""); + await expectComposerFullscreenControls(page); + await exitComposerFullscreen(page); + await expectQueuedMessageButton(page); + } finally { + await agent.cleanup(); + } +}); + +test("image paste stays attached to the active editor across fullscreen entry and exit", async ({ + page, +}) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-paste-remount-", + title: "Composer paste", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await writeCompactDraft(page, "First line\nSecond line\nThird line"); + expect(await pasteComposerTestImage(page)).toBe(true); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(1); + await enterComposerFullscreen(page); + expect(await pasteComposerTestImage(page)).toBe(true); + await exitComposerFullscreen(page); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(2); + expect(await pasteComposerTestImage(page)).toBe(true); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(3); + } finally { + await agent.cleanup(); + } +}); + +test("retained agent tabs preserve fullscreen editing and paste when switched away and back", async ({ + page, +}) => { + const workspace = await seedWorkspace({ repoPrefix: "composer-retained-fullscreen-" }); + try { + const first = await createMockIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "First retained chat", + }); + const second = await createMockIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Second retained chat", + }); + await openWorkspaceWithAgents(page, [first, second]); + await resizeComposerViewport(page, "portrait"); + const draft = "Live first line\nLive second line\nLive third line"; + await writeCompactDraft(page, draft); + await enterComposerFullscreen(page); + await selectComposerDraftRange(page, 4, 17); + await activateRetainedComposerTab(page, first.title); + await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0); + await writeCompactDraft(page, "Other tab draft"); + await shrinkVisibleViewport(page); + await activateRetainedComposerTab(page, second.title); + await expectComposerFullscreenControls(page); + await expect(composerLocator(page)).toHaveValue(draft); + await expectFullscreenInsideVisibleViewport(page); + await expectComposerDraftSelection(page, 4, 17); + await expect(composerLocator(page)).toBeFocused(); + expect(await pasteComposerTestImage(page)).toBe(true); + await exitComposerFullscreen(page); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + } finally { + await workspace.cleanup(); + } +}); + +test("retained inline composers keep usable capacity through hidden tab geometry", async ({ + page, +}) => { + const workspace = await seedWorkspace({ repoPrefix: "composer-retained-capacity-" }); + try { + const first = await createMockIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "First capacity chat", + }); + const second = await createMockIdleAgent(workspace.client, { + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Second capacity chat", + }); + await openWorkspaceWithAgents(page, [first, second]); + await resizeComposerViewport(page, "portrait"); + const draft = "Retained long line\n".repeat(40); + await writeCompactDraft(page, draft); + await recordVisibleComposerGeometry(page); + await activateRetainedComposerTab(page, first.title); + await writeCompactDraft(page, "Other visible draft"); + await activateRetainedComposerTab(page, second.title); + await expect(composerLocator(page)).toHaveValue(draft); + await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160); + await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeVisible(); + await expectNoCollapsedVisibleComposer(page); + } finally { + await workspace.cleanup(); + } +}); + +async function shrinkVisibleViewport(page: Page): Promise { + const cdp = await page.context().newCDPSession(page); + await cdp.send("Emulation.setPageScaleFactor", { pageScaleFactor: 2 }); + await expect.poll(() => page.evaluate(() => window.visualViewport!.height)).toBe(422); + await cdp.detach(); +} + +async function panVisibleViewport(page: Page): Promise { + const cdp = await page.context().newCDPSession(page); + await cdp.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: 4, y: 300 }], + }); + for (let y = 290; y >= 100; y -= 10) { + await cdp.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ x: 4, y }], + }); + } + await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); + await expect.poll(() => page.evaluate(() => window.visualViewport!.offsetTop)).toBeGreaterThan(0); + 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-", + title: "Visible viewport composer", + }); + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await writeCompactDraft(page, "First line\nSecond line\nThird line"); + await enterComposerFullscreen(page); + await expectFullscreenInsideVisibleViewport(page); + await shrinkVisibleViewport(page); + await expectFullscreenInsideVisibleViewport(page); + await panVisibleViewport(page); + await expectFullscreenInsideVisibleViewport(page); + } finally { + await agent.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts b/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts index d4444d6f22..22b2b8a6c5 100644 --- a/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts +++ b/packages/app/e2e/browser/new-workspace-dictation-submit.spec.ts @@ -6,6 +6,15 @@ import { selectWorkspaceIsolation, } from "../support/helpers/new-workspace"; import { seedWorkspace } from "../support/helpers/seed-client"; +import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { + composerLocator, + expectComposerVisible, + fillComposerDraft, + enterComposerFullscreen, + resizeComposerViewport, + expectComposerFullscreenControls, +} from "../support/helpers/composer"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; type WebSocketMessage = string | Buffer; @@ -196,3 +205,52 @@ test.describe("New Workspace dictation submit", () => { } }); }); + +async function startDictationWithShortcut(page: Page): Promise { + await composerLocator(page).press("Control+d"); +} + +test("dictation shortcut leaves retained fullscreen and exposes recording controls", async ({ + page, +}) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "fullscreen-dictation-", + title: "Fullscreen dictation shortcut", + }); + await installSyntheticMicrophone(page); + const harness = await installDictationFailureHarness(page); + const draft = "First line\nSecond line\nThird line"; + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await fillComposerDraft(page, draft); + await enterComposerFullscreen(page); + await resizeComposerViewport(page, "landscape"); + await expectComposerFullscreenControls(page); + await startDictationWithShortcut(page); + await harness.waitForAudio(); + await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Cancel dictation", exact: true })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Insert transcription", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Insert transcription and send", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Cancel dictation", exact: true }).click(); + await expect(composerLocator(page)).toBeEditable(); + await expect(composerLocator(page)).toHaveValue(draft); + await resizeComposerViewport(page, "portrait"); + await enterComposerFullscreen(page); + await resizeComposerViewport(page, "landscape"); + await composerLocator(page).press("Control+Shift+d"); + await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0); + await expect( + page.getByText("Realtime voice is disabled in this test.", { exact: true }), + ).toBeVisible(); + await expect(composerLocator(page)).toHaveValue(draft); + } 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 16b277b537..a4fbd15edf 100644 --- a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts +++ b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts @@ -9,13 +9,12 @@ import { expectTerminalPreviewCommand, expectWorkspaceOpensWithTerminalTab, fillTerminalPrompt, - seedTerminalProfiles, + launchTrigger, selectLaunchOption, submitTerminalLaunch, terminalLaunchSubmit, terminalPromptInput, type TerminalProfile, - type TerminalProfileSeed, } from "../support/helpers/new-workspace-launch"; // Someone who wants a terminal agent instead of a chat: they pick a profile @@ -42,17 +41,18 @@ const BARE_PROFILE: TerminalProfile = { args: ["-c", "echo bare-launch-static-line; exec cat"], }; +test.use({ + e2eDaemonConfig: { version: 1, daemon: { terminalProfiles: [PROMPT_PROFILE, BARE_PROFILE] } }, +}); + test.describe("New workspace: launching a terminal", () => { let workspace: SeededWorkspace; - let profileSeed: TerminalProfileSeed; test.beforeEach(async () => { workspace = await seedWorkspace({ repoPrefix: "launch-terminal-" }); - profileSeed = await seedTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]); }); test.afterEach(async () => { - await profileSeed.restore(); await workspace?.cleanup(); }); @@ -97,4 +97,51 @@ test.describe("New workspace: launching a terminal", () => { await expectTerminalOutputContains(page, "bare-launch-static-line"); }); }); + + test("switching terminal profiles restores the prompt and its fullscreen affordance", async ({ + page, + }) => { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, { + projectKey: workspace.projectKey, + projectDisplayName: workspace.projectDisplayName, + }); + await page.setViewportSize({ width: 390, height: 844 }); + const prompt = "First replacement line\nSecond replacement line\nThird replacement line"; + const selectProfile = async (profile: TerminalProfile) => { + await launchTrigger(page).click(); + const option = page.getByRole("menuitem").filter({ hasText: profile.name }); + await option.click(); + await expect(option).not.toBeVisible(); + }; + const fullscreen = page.getByRole("button", { name: "Fullscreen", exact: true }); + + await test.step("restore a multiline prompt after a read-only profile", async () => { + await selectProfile(PROMPT_PROFILE); + await fillTerminalPrompt(page, prompt); + await expect(fullscreen).toBeVisible(); + await selectProfile(BARE_PROFILE); + await expect(terminalPromptInput(page)).toHaveCount(0); + await selectProfile(PROMPT_PROFILE); + await expect(terminalPromptInput(page)).toHaveValue(prompt); + await expect(fullscreen).toBeVisible(); + }); + + await test.step("restore the locally edited prompt and measure subsequent typing", async () => { + await fillTerminalPrompt(page, "Short draft"); + await expect(fullscreen).toHaveCount(0); + await selectProfile(BARE_PROFILE); + await selectProfile(PROMPT_PROFILE); + await expect(terminalPromptInput(page)).toHaveValue("Short draft"); + await expect(fullscreen).toHaveCount(0); + await terminalPromptInput(page).press("End"); + await terminalPromptInput(page).press("Shift+Enter"); + await terminalPromptInput(page).pressSequentially("Second"); + await terminalPromptInput(page).press("Shift+Enter"); + await terminalPromptInput(page).pressSequentially("Third"); + await expect(terminalPromptInput(page)).toHaveValue("Short draft\nSecond\nThird"); + await expect(fullscreen).toBeVisible(); + }); + }); }); diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index de8ddf0c9b..01b9a39d58 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -316,3 +316,133 @@ export async function openGithubWorkspace( }, }; } + +const COMPOSER_TEST_IMAGE_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + +export async function attachComposerTestImage(page: Page): Promise { + await attachImageFromMenu(page, { + name: "fullscreen.png", + mimeType: "image/png", + buffer: Buffer.from(COMPOSER_TEST_IMAGE_BASE64, "base64"), + }); +} + +export async function pasteComposerTestImage(page: Page): Promise { + return composerInput(page).evaluate((input, base64) => { + const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); + const clipboardData = new DataTransfer(); + clipboardData.items.add(new File([bytes], "pasted.png", { type: "image/png" })); + const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }); + input.dispatchEvent(event); + return event.defaultPrevented; + }, COMPOSER_TEST_IMAGE_BASE64); +} + +export async function enterComposerFullscreen(page: Page): Promise { + await page.getByRole("button", { name: "Fullscreen", exact: true }).click(); +} +export async function exitComposerFullscreen(page: Page): Promise { + await page.getByRole("button", { name: "Exit fullscreen", exact: true }).click(); +} +export async function resizeComposerViewport( + page: Page, + orientation: "portrait" | "landscape" | "short", +): Promise { + const sizes = { + portrait: { width: 390, height: 844 }, + landscape: { width: 844, height: 390 }, + short: { width: 390, height: 390 }, + }; + await page.setViewportSize(sizes[orientation]); +} +export async function selectComposerDraftRange( + page: Page, + start: number, + end: number, +): Promise { + await composerInput(page).focus(); + await composerInput(page).evaluate( + (element, selection) => { + const input = element as HTMLTextAreaElement; + input.setSelectionRange(selection.start, selection.end); + input.dispatchEvent(new Event("select", { bubbles: true })); + }, + { start, end }, + ); +} +export async function expectComposerDraftSelection( + page: Page, + start: number, + end: number, +): Promise { + await expect + .poll(() => + composerInput(page).evaluate((element) => { + const input = element as HTMLTextAreaElement; + return [input.selectionStart, input.selectionEnd]; + }), + ) + .toEqual([start, end]); +} +export async function expectComposerFullscreenControls(page: Page): Promise { + await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible(); + await expect(page.getByTestId("composer-fullscreen").getByRole("button")).toHaveCount(2); + await expect(composerInput(page)).toHaveCount(1); +} +export async function sendComposerWithButton(page: Page): Promise { + await page.getByRole("button", { name: "Send message", exact: true }).click(); +} +export async function queueComposerWithButton(page: Page): Promise { + await page.getByRole("button", { name: "Queue message", exact: true }).click(); +} +export async function configureComposerQueueSend(page: Page): Promise { + await page.addInitScript(() => + localStorage.setItem("@paseo:app-settings", JSON.stringify({ sendBehavior: "queue" })), + ); +} + +/** 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. + */ +export async function activateRetainedComposerTab(page: Page, title: string): Promise { + await page + .getByTestId("workspace-tab-switcher-trigger") + .evaluate((element) => (element as HTMLElement).click()); + const option = page.getByText(title, { exact: true }).filter({ visible: true }).last(); + await expect(option).toBeVisible(); + await option.evaluate((element) => (element as HTMLElement).click()); +} + +export async function recordVisibleComposerGeometry(page: Page): Promise { + await page.evaluate(() => { + const frames: { input: number; root: number }[] = []; + Reflect.set(window, "__composerGeometryFrames", frames); + let running = true; + const tick = () => { + if (!running) return; + for (const root of document.querySelectorAll('[data-testid="message-input-root"]')) { + if (root.getClientRects().length === 0) continue; + const input = root.querySelector("textarea"); + if (input) + frames.push({ + input: input.getBoundingClientRect().height, + root: root.getBoundingClientRect().height, + }); + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + Reflect.set(window, "__stopComposerGeometry", () => { + running = false; + }); + }); +} +export async function expectNoCollapsedVisibleComposer(page: Page): Promise { + const frames = await page.evaluate(() => { + Reflect.get(window, "__stopComposerGeometry")?.(); + return Reflect.get(window, "__composerGeometryFrames") as { input: number; root: number }[]; + }); + expect(frames.length).toBeGreaterThan(0); + expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]); +} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index aa740a1d52..9c028aa2ce 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -1,3 +1,4 @@ +import { ComposerEditingSessionsProvider } from "@/composer/editing-session"; import "@/styles/unistyles"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { PortalProvider } from "@gorhom/portal"; @@ -625,7 +626,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon surface ); - return {content}; + return ( + + {content} + + ); } function SidebarChrome({ diff --git a/packages/app/src/components/ui/text-input/text-input.native.test.tsx b/packages/app/src/components/ui/text-input/text-input.native.test.tsx index 4fa604d657..b52d7b9923 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.test.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.test.tsx @@ -56,6 +56,16 @@ afterEach(() => { function noop() {} +function createHandleRecorder() { + const handleRef = createRef(); + const received: (EditingTextInputHandle | null)[] = []; + function assignHandle(handle: EditingTextInputHandle | null) { + received.push(handle); + handleRef.current = handle; + } + return { handleRef, received, assignHandle }; +} + describe("EditingTextInputNative", () => { it("uses the bottom-sheet input only inside a bottom sheet", () => { act(() => { @@ -76,6 +86,32 @@ describe("EditingTextInputNative", () => { expect(bottomSheetTextInputRender.mock.calls[0]?.[0]).toMatchObject({ testID: "inside" }); }); + it("keeps its editing handle and selection when native editing rerenders the leaf", () => { + const { handleRef, received, assignHandle } = createHandleRecorder(); + act(() => + root?.render(), + ); + const input = container?.querySelector("textarea"); + if (!input) throw new Error("Expected native editing surface"); + const handle = handleRef.current; + act(() => { + handle?.focus(); + const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + if (!setValue) throw new Error("Expected input value setter"); + setValue.call(input, "native edit"); + input.setSelectionRange(2, 5); + input.dispatchEvent(new InputEvent("input", { bubbles: true, data: "native edit" })); + }); + expect(handleRef.current).toBe(handle); + expect(received).toEqual([handle]); + expect(handle?.getText()).toBe("native edit"); + expect([input.selectionStart, input.selectionEnd]).toEqual([2, 5]); + expect(document.activeElement).toBe(input); + act(() => root?.unmount()); + root = null; + expect(received).toEqual([handle, null]); + }); + it("clears text via clear() when replaceText receives an empty string", () => { const handleRef = createRef(); @@ -105,12 +141,14 @@ describe("EditingTextInputNative", () => { ); }); const grownInput = container?.querySelector("input"); + const handle = handleRef.current; act(() => { handleRef.current?.reset(); }); expect(container?.querySelector("input")).not.toBe(grownInput); + expect(handleRef.current).toBe(handle); }); it("restores focus after replacing a reset native input", () => { 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 72a15a9b8e..243e813f26 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 @@ -69,52 +69,56 @@ export const EditingTextInput = forwardRef ({ ...current, autoFocus })); }, []); - useImperativeHandle(ref, () => ({ - focus: () => { - if (isAwaitingReplacementRef.current) { - setReplacementFocus(true); - return; - } - inputRef.current?.focus(); - }, - blur: () => { - if (isAwaitingReplacementRef.current) { - setReplacementFocus(false); - return; - } - inputRef.current?.blur(); - }, - isFocused: () => inputRef.current?.isFocused?.() ?? false, - getText: () => textRef.current, - replaceText: (nextText, selection) => { - textRef.current = nextText; - if (inputRef.current?.replaceText) { - inputRef.current.replaceText(nextText, selection); - return; - } - if (nextText === "") { - inputRef.current?.clear?.(); - return; - } - inputRef.current?.setNativeProps?.({ - text: nextText, - ...(selection ? { selection } : {}), - }); - if (selection) inputRef.current?.setSelection?.(selection.start, selection.end); - }, - reset: () => { - textRef.current = ""; - const autoFocus = inputRef.current?.isFocused?.() ?? false; - if (inputRef.current?.replaceText) { - inputRef.current.replaceText(""); - } else { - inputRef.current?.clear?.(); - } - isAwaitingReplacementRef.current = true; - setReplacement((current) => ({ revision: current.revision + 1, autoFocus })); - }, - getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current, - })); + useImperativeHandle( + ref, + () => ({ + focus: () => { + if (isAwaitingReplacementRef.current) { + setReplacementFocus(true); + return; + } + inputRef.current?.focus(); + }, + blur: () => { + if (isAwaitingReplacementRef.current) { + setReplacementFocus(false); + return; + } + inputRef.current?.blur(); + }, + isFocused: () => inputRef.current?.isFocused?.() ?? false, + getText: () => textRef.current, + replaceText: (nextText, selection) => { + textRef.current = nextText; + if (inputRef.current?.replaceText) { + inputRef.current.replaceText(nextText, selection); + return; + } + if (nextText === "") { + inputRef.current?.clear?.(); + return; + } + inputRef.current?.setNativeProps?.({ + text: nextText, + ...(selection ? { selection } : {}), + }); + if (selection) inputRef.current?.setSelection?.(selection.start, selection.end); + }, + reset: () => { + textRef.current = ""; + const autoFocus = inputRef.current?.isFocused?.() ?? false; + if (inputRef.current?.replaceText) { + inputRef.current.replaceText(""); + } else { + inputRef.current?.clear?.(); + } + isAwaitingReplacementRef.current = true; + setReplacement((current) => ({ revision: current.revision + 1, autoFocus })); + }, + getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current, + }), + [setReplacementFocus], + ); const handleChangeText = useCallback( (nextText: string) => { 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 100ae185df..30ee0c6eba 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 @@ -57,31 +57,35 @@ export const EditingTextInput = forwardRef ({ - focus: () => inputRef.current?.focus(), - blur: () => inputRef.current?.blur(), - isFocused: () => document.activeElement === inputRef.current, - getText: () => { - const input = inputRef.current as WebTextInputElement | null; - const nextText = input?.value ?? textRef.current; - textRef.current = nextText; - return nextText; - }, - replaceText: (nextText, selection) => { - textRef.current = nextText; - const input = inputRef.current as WebTextInputElement | null; - if (input && "value" in input) input.value = nextText; - if (selection && typeof input?.setSelectionRange === "function") { - input.setSelectionRange(selection.start, selection.end); - } - }, - reset: () => { - textRef.current = ""; - const input = inputRef.current as WebTextInputElement | null; - if (input && "value" in input) input.value = ""; - }, - getNativeRef: () => inputRef.current, - })); + useImperativeHandle( + ref, + () => ({ + focus: () => inputRef.current?.focus(), + blur: () => inputRef.current?.blur(), + isFocused: () => document.activeElement === inputRef.current, + getText: () => { + const input = inputRef.current as WebTextInputElement | null; + const nextText = input?.value ?? textRef.current; + textRef.current = nextText; + return nextText; + }, + replaceText: (nextText, selection) => { + textRef.current = nextText; + const input = inputRef.current as WebTextInputElement | null; + if (input && "value" in input) input.value = nextText; + if (selection && typeof input?.setSelectionRange === "function") { + input.setSelectionRange(selection.start, selection.end); + } + }, + reset: () => { + textRef.current = ""; + const input = inputRef.current as WebTextInputElement | null; + if (input && "value" in input) input.value = ""; + }, + getNativeRef: () => inputRef.current, + }), + [], + ); return ( ({ // Only viewport changes and keyboard start/end events trigger layout. Motion // stays in KeyboardTranslateView and never changes this constraint per frame. - maxHeight: capacity.value, + maxHeight: resolveInlineComposerCapacity(capacity.value, compact), })); return ( diff --git a/packages/app/src/composer/dock/index.tsx b/packages/app/src/composer/dock/index.tsx index d39439f325..3f41118696 100644 --- a/packages/app/src/composer/dock/index.tsx +++ b/packages/app/src/composer/dock/index.tsx @@ -1,8 +1,10 @@ export { ComposerDockBackground } from "./internal/background"; import { ScrollView } from "@/components/ui/scroll-view"; -import type { ReactNode } from "react"; +import { useState, useCallback, useMemo, type ReactNode } from "react"; import { View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { resolveInlineComposerCapacity } from "./internal/capacity"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { HEADER_INNER_HEIGHT } from "@/constants/layout"; interface ComposerDockProps { @@ -14,23 +16,33 @@ export function ComposerDock({ children: [content, composer, overlay], centered = false, }: ComposerDockProps) { + const compact = useIsCompactFormFactor(); + const [availableHeight, setAvailableHeight] = useState(); + const constraint = useMemo( + () => ({ maxHeight: resolveInlineComposerCapacity(availableHeight, compact) }), + [availableHeight, compact], + ); + const measureViewport = useCallback((event: import("react-native").LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout; + if (height > 0) setAvailableHeight(height); + }, []); if (centered) { return ( - + {content} - {composer} + {composer} {overlay} ); } return ( - + {content} - {composer} + {composer} {overlay} ); diff --git a/packages/app/src/composer/dock/internal/capacity.test.ts b/packages/app/src/composer/dock/internal/capacity.test.ts index 87796f23d4..7e98b7ef3d 100644 --- a/packages/app/src/composer/dock/internal/capacity.test.ts +++ b/packages/app/src/composer/dock/internal/capacity.test.ts @@ -1,11 +1,24 @@ import { describe, expect, it } from "vitest"; import { resolveCenteredClearance, + resolveInlineComposerCapacity, resolveComposerCapacity, updateComposerCapacity, } from "./capacity"; describe("composer viewport", () => { + it("caps the complete compact composer at sixty percent of keyboard-reserved space", () => { + const open = updateComposerCapacity(undefined, { + height: 582, + bottomInset: 24, + safeAreaBottom: 0, + centered: false, + keyboardShift: 308, + }); + expect(resolveInlineComposerCapacity(open.capacity, true)).toBe(147); + expect(resolveInlineComposerCapacity(open.capacity, false)).toBe(245); + expect(resolveInlineComposerCapacity(undefined, true)).toBeUndefined(); + }); 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 eb5e4a66bd..ca4fed2991 100644 --- a/packages/app/src/composer/dock/internal/capacity.ts +++ b/packages/app/src/composer/dock/internal/capacity.ts @@ -50,3 +50,13 @@ export function updateComposerCapacity( capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }), }; } + +/** Compact editing leaves conversation space above the complete composer. */ +export function resolveInlineComposerCapacity( + capacity: number | undefined, + compact: boolean, +): number | undefined { + "worklet"; + if (capacity === undefined) return undefined; + return compact ? capacity * 0.6 : capacity; +} diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 0812fb280d..7e6805b0b3 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -115,6 +115,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr const isHydrated = hydratedDraftKey === draftKey; const textReplacementRevisionRef = useRef(0); const [textReplacement, setTextReplacement] = useState(() => ({ + kind: "initial", key: `${draftKey}:0`, text: textSource.getSnapshot(), })); @@ -123,6 +124,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr (nextText: string) => { textReplacementRevisionRef.current += 1; setTextReplacement({ + kind: "replace", key: `${draftKey}:${textReplacementRevisionRef.current}`, text: nextText, }); diff --git a/packages/app/src/composer/editing-session/index.test.ts b/packages/app/src/composer/editing-session/index.test.ts new file mode 100644 index 0000000000..d593e5a64e --- /dev/null +++ b/packages/app/src/composer/editing-session/index.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { ComposerEditingSession } 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", () => { + 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); + }); + + it("keeps local edits when the last applied replacement is still current", () => { + 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 }, + }); + }); + + 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 new file mode 100644 index 0000000000..97f7a79c51 --- /dev/null +++ b/packages/app/src/composer/editing-session/index.tsx @@ -0,0 +1,142 @@ +import React, { + createContext, + useContext, + useEffect, + useMemo, + useState, + 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; +} + +/** Editing metadata outlives a layout's input; draft text still belongs to its draft owner. */ +export class ComposerEditingSession { + private fullscreen = false; + private handoff: ComposerEditingHandoff | null = null; + private listeners = new Set<() => void>(); + getFullscreen = () => this.fullscreen; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + changePresentation(fullscreen: boolean, handoff: ComposerEditingHandoff) { + this.captureForRemount(handoff); + 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; + } +} + +class ComposerEditingSessions { + private workspaces = new Map< + string, + Map }> + >(); + get(workspaceKey: string, tabId: string, targetKey: string, purpose: ComposerInputMode) { + let workspace = this.workspaces.get(workspaceKey); + if (!workspace) { + workspace = new Map(); + this.workspaces.set(workspaceKey, workspace); + } + let tab = workspace.get(tabId); + if (!tab || tab.targetKey !== targetKey) { + tab = { targetKey, sessions: new Map() }; + workspace.set(tabId, tab); + } + let session = tab.sessions.get(purpose); + if (!session) { + session = new ComposerEditingSession(); + tab.sessions.set(purpose, session); + } + return session; + } + 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); + if (workspace.size === 0) this.workspaces.delete(workspaceKey); + } +} + +const SessionsContext = createContext(null); +const WorkspaceContext = createContext(null); +const ScopeContext = createContext<{ tabId: string; targetKey: string } | null>(null); + +/** App-container ownership survives replacement of both the gesture shell and tab layout. */ +export function ComposerEditingSessionsProvider({ children }: { children: ReactNode }) { + const [sessions] = useState(() => new ComposerEditingSessions()); + return {children}; +} +export function WorkspaceComposerEditingSessionsProvider({ + workspaceKey, + tabIds, + children, +}: { + workspaceKey: string; + tabIds: readonly string[]; + children: ReactNode; +}) { + const sessions = useContext(SessionsContext); + useEffect(() => sessions?.retain(workspaceKey, tabIds), [sessions, workspaceKey, tabIds]); + return {children}; +} +export function ComposerEditingScope({ + tabId, + targetKey, + children, +}: { + tabId: string; + targetKey: string; + children: ReactNode; +}) { + const scope = useMemo(() => ({ tabId, targetKey }), [tabId, targetKey]); + return {children}; +} +export function useComposerEditingSession( + purpose: ComposerInputMode, + provided?: ComposerEditingSession, +): ComposerEditingSession { + const sessions = useContext(SessionsContext); + const scope = useContext(ScopeContext); + const workspaceKey = useContext(WorkspaceContext); + const [local] = useState(() => provided ?? new ComposerEditingSession()); + if (provided) return provided; + return sessions && scope && workspaceKey + ? sessions.get(workspaceKey, scope.tabId, scope.targetKey, purpose) + : local; +} +export function useComposerFullscreen(session: ComposerEditingSession): boolean { + return useSyncExternalStore(session.subscribe, session.getFullscreen, session.getFullscreen); +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index c36d7f9e41..c77b39d332 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1,3 +1,4 @@ +import { useComposerEditingSession, useComposerFullscreen } from "./editing-session"; import type { ComposerTextSource } from "./text-source"; import { createStore, type StoreApi } from "zustand/vanilla"; import { useStore } from "zustand"; @@ -1003,8 +1004,10 @@ function ComposerAutocompleteBinding({ cursorIndex: Math.min(cursorIndex, userInput.length), onAutocompleteApplied: () => inputRef.current?.focus(), }); - useImperativeHandle(ref, () => ({ onKeyPress: autocomplete.onKeyPress }), [ - autocomplete.onKeyPress, + const handleKeyPress = autocomplete.onKeyPress; + useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [ + show, + handleKeyPress, ]); const selectOption = autocomplete.onSelectOption; const onSelect = useCallback( @@ -1336,6 +1339,8 @@ function ComposerContentImpl({ const nextPendingFileId = useRef(0); const isUploadingFile = pendingFiles.length > 0; const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0); + const editingSession = useComposerEditingSession(inputMode); + const isFullscreen = useComposerFullscreen(editingSession); const [sendError, setSendError] = useState(null); const [isMessageInputFocused, setIsMessageInputFocused] = useState(false); const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false); @@ -2364,7 +2369,7 @@ function ComposerContentImpl({ {queueList} - {sendErrorNode} + {!isFullscreen && sendErrorNode} @@ -2387,6 +2392,8 @@ function ComposerContentImpl({ 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 new file mode 100644 index 0000000000..ba634b268d --- /dev/null +++ b/packages/app/src/composer/input/fullscreen-host.tsx @@ -0,0 +1,77 @@ +import { useMemo, useLayoutEffect, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { View } from "react-native"; +import { + getOverlayRoot, + useOverlayLayer, + OverlayLayerProvider, + useWebOverlayRegistration, +} from "@/lib/overlay-root"; + +export function ComposerFullscreenHost({ + children, + onExit, + active, +}: { + children: ReactNode; + onExit: () => void; + active: boolean; +}) { + const layer = useOverlayLayer("modal"); + const registration = useWebOverlayRegistration({ + active, + layer, + onKeyDown: (event) => { + if (event.key !== "Escape") return false; + event.preventDefault(); + onExit(); + return true; + }, + }); + const [viewport, setViewport] = useState(readVisibleViewport); + useLayoutEffect(() => { + if (!active) return; + const update = () => setViewport(readVisibleViewport()); + const visualViewport = window.visualViewport; + update(); + visualViewport?.addEventListener("resize", update); + visualViewport?.addEventListener("scroll", update); + window.addEventListener("resize", update); + return () => { + visualViewport?.removeEventListener("resize", update); + visualViewport?.removeEventListener("scroll", update); + window.removeEventListener("resize", update); + }; + }, [active]); + const frameStyle = useMemo( + () => ({ + display: active ? ("flex" as const) : ("none" as const), + position: "absolute" as const, + top: viewport.top, + left: viewport.left, + width: viewport.width, + zIndex: layer, + pointerEvents: "auto" as const, + height: viewport.height, + }), + [active, layer, viewport], + ); + return createPortal( + + + {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/height.native.ts b/packages/app/src/composer/input/height.native.ts index 21178db4e1..a36839e2e7 100644 --- a/packages/app/src/composer/input/height.native.ts +++ b/packages/app/src/composer/input/height.native.ts @@ -7,6 +7,8 @@ interface ComposerHeightArgs { textareaRef: RefObject; minHeight: number; maxHeight: number; + onRenderedLinesChange?: (lines: number) => void; + presentationKey?: boolean; } export function useComposerHeight({ diff --git a/packages/app/src/composer/input/height.web.ts b/packages/app/src/composer/input/height.web.ts index c732bf3599..6b8c1ec561 100644 --- a/packages/app/src/composer/input/height.web.ts +++ b/packages/app/src/composer/input/height.web.ts @@ -7,6 +7,8 @@ interface ComposerHeightArgs { textareaRef: RefObject; minHeight: number; maxHeight: number; + onRenderedLinesChange?: (lines: number) => void; + presentationKey?: boolean; } const COPIED_STYLES = [ @@ -36,11 +38,13 @@ export function useComposerHeight({ textareaRef, minHeight, maxHeight, + onRenderedLinesChange, + presentationKey, }: ComposerHeightArgs): ComposerHeightResult { const [height, setHeight] = useState(minHeight); const heightRef = useRef(minHeight); - const paramsRef = useRef({ getText, minHeight, maxHeight }); - paramsRef.current = { getText, minHeight, maxHeight }; + const paramsRef = useRef({ getText, minHeight, maxHeight, onRenderedLinesChange }); + paramsRef.current = { getText, minHeight, maxHeight, onRenderedLinesChange }; const mirrorRef = useRef(null); const setBoundedHeight = useCallback((nextHeight: number) => { @@ -65,6 +69,13 @@ export function useComposerHeight({ } mirror.style.width = `${sourceWidth}px`; mirror.value = text.endsWith("\n") ? `${text} ` : text; + const padding = + parseFloat(computedStyle.paddingTop) + parseFloat(computedStyle.paddingBottom); + const lineHeight = parseFloat(computedStyle.lineHeight); + if (lineHeight > 0) + paramsRef.current.onRenderedLinesChange?.( + Math.round((mirror.scrollHeight - padding) / lineHeight), + ); setBoundedHeight(mirror.scrollHeight); }, [setBoundedHeight, textareaRef], @@ -101,7 +112,7 @@ export function useComposerHeight({ useLayoutEffect(() => { measure(getText()); - }, [maxHeight, minHeight, getText, measure]); + }, [maxHeight, minHeight, getText, measure, presentationKey]); useEffect(() => { const source = textareaRef.current; @@ -115,7 +126,7 @@ export function useComposerHeight({ }); observer.observe(source); return () => observer.disconnect(); - }, [measure, textareaRef]); + }, [measure, textareaRef, presentationKey]); const onTextChange = useCallback( (_previousText: string, nextText: string) => measure(nextText), diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index e15a00018e..a0476ff7a7 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1,3 +1,8 @@ +import { + useComposerEditingSession, + useComposerFullscreen, + type ComposerEditingSession, +} from "@/composer/editing-session"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { View, @@ -56,6 +61,7 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { RenderProfile } from "@/utils/render-profiler"; import { useComposerHeight } from "./height"; +import { ComposerInputPresentation } from "./presentation"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; import type { NativePastedFile } from "@/composer/native-pasted-image"; import { @@ -77,6 +83,7 @@ import { applyDictationTranscript, computeCanStartDictation, resolveComposerSurfacePresentation, + resolveComposerSurfaceText, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, @@ -106,6 +113,8 @@ export interface ComposerKeyPressEvent { } export interface MessageInputProps { + editingSession?: ComposerEditingSession; + submissionError?: React.ReactNode; value: string; onChangeText: (text: string) => void; onSubmit: (payload: MessagePayload) => void; @@ -206,6 +215,7 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent< >; interface TextAreaHandle { + value?: string; scrollHeight?: number; clientHeight?: number; offsetHeight?: number; @@ -215,7 +225,7 @@ interface TextAreaHandle { style?: { height?: string; overflowY?: string; - } & Record; + }; } function AttachButtonIcon({ @@ -433,7 +443,7 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM } interface PasteImagesEffectArgs { - getWebTextArea: () => TextAreaHandle | null; + textarea: HTMLElement | null; isConnected: boolean; disabled: boolean; isDictating: boolean; @@ -443,7 +453,7 @@ interface PasteImagesEffectArgs { function usePasteImagesEffect(args: PasteImagesEffectArgs): void { const { - getWebTextArea, + textarea, isConnected, disabled, isDictating, @@ -454,19 +464,7 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void { useEffect(() => { if (!isWeb || !onAddImages) return; - const textarea = getWebTextArea() as - | (TextAreaHandle & { - addEventListener?: (type: string, listener: (e: ClipboardEvent) => void) => void; - removeEventListener?: (type: string, listener: (e: ClipboardEvent) => void) => void; - }) - | null; - if ( - !textarea || - typeof textarea.addEventListener !== "function" || - typeof textarea.removeEventListener !== "function" - ) { - return; - } + if (!textarea) return; let disposed = false; const handlePaste = (event: ClipboardEvent) => { @@ -493,14 +491,7 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void { disposed = true; textarea.removeEventListener?.("paste", handlePaste); }; - }, [ - disabled, - getWebTextArea, - isConnected, - isDictating, - isRealtimeVoiceForCurrentAgent, - onAddImages, - ]); + }, [disabled, textarea, isConnected, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages]); } function useAutoFocusOnWebEffect( @@ -628,6 +619,7 @@ function FocusHint({ interface ComposerTextSurfaceProps { readOnly: boolean; value: string; + textReplacementKey: string; textInputRef: React.Ref; textInputStyle: EditingTextInputProps["style"]; readOnlyTextStyle: React.ComponentProps["style"]; @@ -643,6 +635,9 @@ interface ComposerTextSurfaceProps { onSelectionChange: (event: NativeSyntheticEvent) => void; onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined; onPasteError: (message: string) => void; + fullscreen: boolean; + compact: boolean; + onRenderedLinesChange: (lines: number) => void; focusHintVisible: boolean; focusInputKeys: ShortcutChord | null | undefined; focusHintLabel: string; @@ -654,6 +649,23 @@ interface ComposerTextSurfaceProps { * state of this composer rather than a second one. */ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElement { + const { onChangeText, onRenderedLinesChange } = props; + const [measurementText, setMeasurementText] = useState(props.value); + useEffect(() => { + if (!isWeb) setMeasurementText(props.value); + }, [props.value, props.textReplacementKey]); + const handleTextChange = useCallback( + (text: string) => { + if (!isWeb) setMeasurementText(text); + onChangeText(text); + }, + [onChangeText, setMeasurementText], + ); + const measureLines = useCallback( + (event: import("react-native").TextLayoutEvent) => + onRenderedLinesChange(event.nativeEvent.lines.length), + [onRenderedLinesChange], + ); if (props.readOnly) { return ( @@ -664,12 +676,28 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen ); } return ( - + + {!isWeb && props.compact && !props.fullscreen ? ( + + {`${measurementText}\u200b`} + + ) : null} Promise | void; toast: { error: (msg: string) => void }; interruptBeforeVoiceMessage: string; + exitFullscreen: () => void; } function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void { @@ -858,6 +887,7 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void { ctx.toast.error(ctx.interruptBeforeVoiceMessage); return; } + ctx.exitFullscreen(); void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => { console.error("[MessageInput] Failed to start realtime voice", error); const message = extractErrorMessage(error); @@ -872,6 +902,7 @@ interface StartDictationContext { canStartDictation: () => boolean; toast: { error: (msg: string) => void }; startDictation: () => Promise; + exitFullscreen: () => void; } async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise { @@ -882,6 +913,7 @@ async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promis if (!ctx.canStartDictation()) { return; } + ctx.exitFullscreen(); await ctx.startDictation(); } @@ -983,7 +1015,8 @@ function computeIsDictationStartEnabled( return (isReadyForDictation ?? isConnected) && !disabled; } -function resolveMaxInputHeight(windowHeight: number): number { +function resolveMaxInputHeight(windowHeight: number, compact: boolean): number { + if (compact) return DEFAULT_MAX_INPUT_HEIGHT; if (!Number.isFinite(windowHeight) || windowHeight <= 0) return DEFAULT_MAX_INPUT_HEIGHT; return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO)); } @@ -1007,9 +1040,12 @@ function getComposerInputSnapshot( current: ComposerTextInputHandle | null, fallbackText: string, fallbackSelection: ComposerInputSnapshot["selection"], + mountedTextArea?: TextAreaHandle | null, ): ComposerInputSnapshot { - const text = current?.getText() ?? fallbackText; - const textArea = getWebTextAreaImpl(current); + // The inner DOM ref can detach before the outer editing handle. Keep the mounted + // element available until this owner's ref callback captures the remount handoff. + const textArea = mountedTextArea ?? getWebTextAreaImpl(current); + const text = textArea?.value ?? current?.getText() ?? fallbackText; const start = textArea?.selectionStart ?? fallbackSelection.start; const end = textArea?.selectionEnd ?? fallbackSelection.end; return { text, selection: { start, end } }; @@ -1139,6 +1175,28 @@ function extractErrorMessage(error: unknown): string | null { return null; } +function resolvePresentationSendDisabled(input: { + fullscreen: boolean; + disabled: boolean; + hasText: boolean; + attachments: ComposerAttachment[]; + hasExternalContent: boolean; + allowEmpty: boolean; +}): boolean { + return ( + input.disabled || (input.fullscreen && !input.allowEmpty && !hasSendableComposerContent(input)) + ); +} + +function shouldShowFullscreenControl( + compact: boolean, + readOnly: boolean, + overlayVisible: boolean, + renderedLines: number, +) { + return compact && !readOnly && !overlayVisible && renderedLines > 2; +} + export const MessageInput = forwardRef( function MessageInput(props, ref) { const { @@ -1186,11 +1244,15 @@ export const MessageInput = forwardRef( textReplacement, submitLabel, } = resolveMessageInputProps(props); + const { isActiveComposer } = useComposerKeyboardScope(); const mode = resolveComposerInputMode(inputMode); const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); const { height: windowHeight } = useWindowDimensions(); - const maxInputHeight = resolveMaxInputHeight(windowHeight); + const editingSession = useComposerEditingSession(inputMode, props.editingSession); + const isFullscreen = useComposerFullscreen(editingSession); + const [renderedLines, setRenderedLines] = useState(1); + const maxInputHeight = resolveMaxInputHeight(windowHeight, isCompact); const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg; const toast = useToast(); const voice = useVoiceOptional(); @@ -1207,27 +1269,19 @@ export const MessageInput = forwardRef( const inputWrapperRef = useRef(null); const textInputRef = useRef(null); const isInputFocusedRef = useRef(false); - const valueRef = useRef(value); - const selectionRef = useRef({ start: value.length, end: value.length }); - const appliedTextReplacementKeyRef = useRef(textReplacement.key); - const webTextareaRef = useRef(null); - const getLiveText = useCallback(() => valueRef.current, []); - const composerHeight = useComposerHeight({ - getText: getLiveText, - textareaRef: webTextareaRef, - minHeight: MIN_INPUT_HEIGHT, - maxHeight: maxInputHeight, - }); - const { style: composerHeightStyle, scrollEnabled: isComposerScrollEnabled } = composerHeight; - const measuredComposerHeight = composerHeight.mode === "measured" ? composerHeight : undefined; - const updateComposerHeightForText = measuredComposerHeight?.onTextChange; - const resetComposerHeight = measuredComposerHeight?.reset; - - const handleComposerLayout = useCallback( - (event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height), - [onHeightChange], + const handoff = editingSession.peekHandoff(textReplacement); + const focusIntentRef = useRef(Boolean(handoff?.focused)); + const valueRef = useRef(handoff?.snapshot.text ?? value); + const selectionRef = useRef( + handoff?.snapshot.selection ?? { start: value.length, end: value.length }, ); - + const activeComposerRef = useRef(isActiveComposer); + activeComposerRef.current = isActiveComposer; + const [webTextArea, setWebTextArea] = useState(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; if (hasLiveTextRef.current === nextHasLiveText) return; @@ -1235,6 +1289,82 @@ 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], + ); + useLayoutEffect(() => { + if (isActiveComposer && focusIntentRef.current) textInputRef.current?.focus(); + }, [isActiveComposer]); + const getLiveText = useCallback(() => valueRef.current, []); + const composerHeight = useComposerHeight({ + getText: getLiveText, + textareaRef: webTextareaRef, + minHeight: MIN_INPUT_HEIGHT, + maxHeight: maxInputHeight, + onRenderedLinesChange: setRenderedLines, + presentationKey: isFullscreen, + }); + const { style: composerHeightStyle, scrollEnabled: isComposerScrollEnabled } = composerHeight; + const measuredComposerHeight = composerHeight.mode === "measured" ? composerHeight : undefined; + 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 handleComposerLayout = useCallback( + (event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height), + [onHeightChange], + ); + const replaceText = useCallback( (nextText: string, selection?: { start: number; end: number }) => { updateComposerHeightForText?.(valueRef.current, nextText); @@ -1412,8 +1542,9 @@ export const MessageInput = forwardRef( canStartDictation, toast, startDictation, + exitFullscreen, }), - [canStartDictation, dictationUnavailableMessage, startDictation, toast], + [canStartDictation, dictationUnavailableMessage, startDictation, toast, exitFullscreen], ); const handleVoicePress = useCallback( @@ -1485,10 +1616,12 @@ export const MessageInput = forwardRef( handleStopRealtimeVoice, toast, interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"), + exitFullscreen, }); }, [ disabled, handleStopRealtimeVoice, + exitFullscreen, isAgentRunning, isConnected, t, @@ -1572,10 +1705,10 @@ export const MessageInput = forwardRef( if (isWeb) { webTextareaRef.current = getWebTextArea() as HTMLElement | null; } - }, [getWebTextArea]); + }, [getWebTextArea, isFullscreen]); usePasteImagesEffect({ - getWebTextArea, + textarea: webTextArea, isConnected, disabled, isDictating, @@ -1596,16 +1729,30 @@ export const MessageInput = forwardRef( const shouldHandleWebKeyPress = isWeb; const shouldSubmitOnEnter = isWeb && !isCompact; - function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { - if (!shouldHandleWebKeyPress) return; - handleDesktopKeyPressImpl(event, { + const handleDesktopKeyPress = useCallback( + (event: WebTextInputKeyPressEvent) => { + if (!shouldHandleWebKeyPress) return; + handleDesktopKeyPressImpl(event, { + onKeyPressCallback, + input: getComposerInputSnapshot( + textInputRef.current, + valueRef.current, + selectionRef.current, + ), + submitOnEnter: shouldSubmitOnEnter, + isAgentRunning, + onQueue, + isSubmitDisabled, + isSubmitLoading, + disabled, + handleAlternateSendAction, + handleDefaultSendAction, + }); + }, + [ + shouldHandleWebKeyPress, onKeyPressCallback, - input: getComposerInputSnapshot( - textInputRef.current, - valueRef.current, - selectionRef.current, - ), - submitOnEnter: shouldSubmitOnEnter, + shouldSubmitOnEnter, isAgentRunning, onQueue, isSubmitDisabled, @@ -1613,8 +1760,8 @@ export const MessageInput = forwardRef( disabled, handleAlternateSendAction, handleDefaultSendAction, - }); - } + ], + ); const primaryActionKind = resolvePrimaryActionKind({ hasSendableContent: hasSendableComposerContent({ @@ -1678,16 +1825,18 @@ export const MessageInput = forwardRef( ); const handleInputFocus = useCallback(() => { + focusIntentRef.current = true; isInputFocusedRef.current = true; setIsInputFocused(true); onFocusChange?.(true); }, [onFocusChange]); const handleInputBlur = useCallback(() => { + if (activeComposerRef.current && !isFullscreen) focusIntentRef.current = false; isInputFocusedRef.current = false; setIsInputFocused(false); onFocusChange?.(false); - }, [onFocusChange]); + }, [isFullscreen, onFocusChange]); const handlePasteError = useCallback( (message: string) => { @@ -1735,8 +1884,12 @@ export const MessageInput = forwardRef( // `fontFamily` here is silently dropped while every other property lands. // An inline style outranks both classes. See docs/unistyles.md. const textInputStyle = useMemo( - () => [styles.textInput, mode.isMonospace && styles.textInputMonospace, composerHeightStyle], - [composerHeightStyle, mode.isMonospace], + () => [ + styles.textInput, + mode.isMonospace && styles.textInputMonospace, + isFullscreen ? styles.fullscreenTextInput : composerHeightStyle, + ], + [composerHeightStyle, mode.isMonospace, isFullscreen], ); // Static content has no textarea to mirror, so it grows with its own text // instead of the measured input height. @@ -1780,107 +1933,192 @@ export const MessageInput = forwardRef( [isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize], ); - return ( - - ( + - {/* Regular input */} - - {attachmentSlot} - {/* Text input */} - - ( + + + + ), + [ + readOnly, + textReplacement, + editingSession, + isActiveComposer, + assignTextInput, + textInputStyle, + readOnlyTextStyle, + placeholder, + t, + mode.accessibilityLabelKey, + handleInputChange, + handleInputFocus, + handleInputBlur, + isDictating, + isRealtimeVoiceForCurrentAgent, + disabled, + isComposerScrollEnabled, + shouldHandleWebKeyPress, + handleDesktopKeyPress, + handleSelectionChange, + onPasteImages, + handlePasteError, + isFullscreen, + isCompact, + isInputFocused, + value, + focusInputKeys, + ], + ); + const toolbar = useMemo( + () => ( + + {/* Toolbar left: attachment button + agent controls */} + + - + {leftContent} + - {/* Button row */} - - {/* Toolbar left: attachment button + agent controls */} - - - {leftContent} - - - {/* Right: voice button, contextual button (realtime/send/cancel) */} - - {beforeVoiceContent} - - {rightContent} - - + {/* Right: voice button, contextual button (realtime/send/cancel) */} + + {beforeVoiceContent} + + {rightContent} + {sendButton} - + ), + [ + mode.showAttachments, + isConnected, + disabled, + attachButtonStyle, + renderAttachButtonIcon, + attachmentMenuItems, + t, + leftContent, + beforeVoiceContent, + mode.showVoice, + handleVoicePress, + isDictationStartEnabled, + voiceButtonAccessibilityLabel, + voiceButtonStyle, + renderVoiceButtonIcon, + voiceTooltipText, + isRealtimeVoiceForCurrentAgent, + voiceMuteToggleKeys, + dictationToggleKeys, + rightContent, + sendButton, + ], + ); + const voiceOverlay = useMemo( + () => ( ( onRealtimeVoiceStop={handleRealtimeVoiceStop} /> + ), + [ + overlayContainerStyle, + surfacePresentation.overlay.pointerEvents, + showDictationOverlay, + showRealtimeOverlay, + voice, + dictationVolume, + dictationDuration, + isDictating, + isDictationProcessing, + dictationStatus, + dictationError, + handleCancelRecording, + handleAcceptRecording, + handleAcceptAndSendRecording, + handleRetryFailedRecording, + handleDiscardFailedRecording, + handleRealtimeVoiceStop, + ], + ); + const enterFullscreen = useCallback(() => changePresentation(true), [changePresentation]); + return ( + + + ); }, @@ -1947,6 +2241,19 @@ const styles = StyleSheet.create((theme: Theme) => ({ inputWrapperReadOnly: { borderStyle: "dotted", }, + compactTextSurface: { paddingRight: 36 }, + fullscreenTextSurface: { flex: 1, minHeight: 0 }, + fullscreenTextInput: { flex: 1, height: "100%", textAlignVertical: "top" }, + lineMeasurement: { + position: "absolute", + top: 0, + left: 0, + right: 36, + opacity: 0, + maxHeight: undefined, + minHeight: undefined, + height: undefined, + }, textInputScrollWrapper: { flexShrink: 1, position: "relative", diff --git a/packages/app/src/composer/input/presentation.tsx b/packages/app/src/composer/input/presentation.tsx new file mode 100644 index 0000000000..d02821696a --- /dev/null +++ b/packages/app/src/composer/input/presentation.tsx @@ -0,0 +1,146 @@ +import { useCallback, useMemo, type ReactNode, type Ref } from "react"; +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 { isWeb } from "@/constants/platform"; +import { StyleSheet } from "react-native-unistyles"; +import { ComposerFullscreenHost } from "./fullscreen-host"; + +function keepEditorFocused(event: import("react-native").PointerEvent) { + if (isWeb) event.preventDefault(); +} + +/** Both presentations receive the same editing surface and send capability. */ +export function ComposerInputPresentation({ + fullscreen, + iconSize, + active, + surface, + toolbar, + send, + error, + onExit, + inputWrapperRef, + inputWrapperStyle, + inputPointerEvents, + attachmentSlot, + showFullscreenButton, + onEnter, + overlay, +}: { + fullscreen: boolean; + iconSize: number; + active: boolean; + surface: ReactNode; + toolbar: ReactNode; + send: ReactNode; + error: ReactNode; + onExit: () => void; + inputWrapperRef: Ref; + inputWrapperStyle: ViewProps["style"]; + inputPointerEvents: ViewProps["pointerEvents"]; + attachmentSlot: ReactNode; + showFullscreenButton: boolean; + onEnter: () => void; + 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; + const maximizeIcon = useCallback( + (color: string) => , + [iconSize], + ); + const minimizeIcon = useCallback( + (color: string) => , + [iconSize], + ); + 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} + + ); + return ( + + + + + + {surface} + {error} + {send} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + inlineTextSurface: { flexShrink: 1, position: "relative" }, + iconButton: { + width: 28, + height: 28, + padding: 0, + borderWidth: 0, + borderRadius: theme.borderRadius.full, + }, + fullscreenButton: { + position: "absolute", + top: 0, + width: 28, + height: 28, + padding: 0, + borderWidth: 0, + borderRadius: theme.borderRadius.full, + }, + fullscreen: { + flex: 1, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[4], + gap: theme.spacing[3], + }, + exitRow: { alignItems: "flex-end", flexShrink: 0 }, + sendRow: { flexShrink: 0, alignItems: "flex-end" }, +})); diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index 5503ea1512..a7f4113b28 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -4,6 +4,7 @@ import { computeCanStartDictation, resolveActiveSendBehavior, resolveComposerSurfacePresentation, + resolveComposerSurfaceText, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, @@ -316,3 +317,26 @@ describe("stopRealtimeVoice", () => { expect(calls).toEqual(["cancel agent", "stop voice"]); }); }); + +describe("composer surface text", () => { + it("measures a new nonempty replacement before it reaches the editing handle", () => { + const replacement = { kind: "replace" as const, key: "draft:2", text: "First\nSecond\nThird" }; + expect(resolveComposerSurfaceText("Short draft", replacement, "draft:1")).toBe( + replacement.text, + ); + }); + + it("keeps live typing once the replacement is applied", () => { + const replacement = { kind: "replace" as const, key: "draft:2", text: "First\nSecond\nThird" }; + expect(resolveComposerSurfaceText("Edited live draft", replacement, "draft:2")).toBe( + "Edited live draft", + ); + }); + + it("uses the live handoff when its replacement has already been applied", () => { + const replacement = { kind: "initial" as const, key: "draft:0", text: "Stale publication" }; + expect(resolveComposerSurfaceText("Live remount handoff", replacement, "draft:0")).toBe( + "Live remount handoff", + ); + }); +}); diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index a63aeb02dc..3786613eb2 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -1,6 +1,6 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; -import type { MessagePayload } from "@/composer/types"; +import type { MessagePayload, TextReplacement } from "@/composer/types"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; export type SendBehavior = ActiveTurnBehavior | "queue"; @@ -192,3 +192,11 @@ export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise< await ctx.voice.stopVoice(); } + +export function resolveComposerSurfaceText( + liveText: string, + replacement: TextReplacement, + appliedReplacementKey: string, +): string { + return replacement.key === appliedReplacementKey ? liveText : replacement.text; +} diff --git a/packages/app/src/composer/input/text-input.web.browser.test.tsx b/packages/app/src/composer/input/text-input.web.browser.test.tsx index 6e70fa3edf..29df20012c 100644 --- a/packages/app/src/composer/input/text-input.web.browser.test.tsx +++ b/packages/app/src/composer/input/text-input.web.browser.test.tsx @@ -96,6 +96,35 @@ describe("ComposerTextInput web IME composition", () => { expect(document.activeElement).toBe(mounted.textarea); }); + it("keeps its editing handle and selection across a rerender during composition", () => { + const recorder = createTextRecorder(); + const mounted = mountInput(recorder.onChangeText); + const handle = mounted.inputRef.current; + act(() => { + handle?.focus(); + dispatchComposition(mounted.textarea, "compositionstart"); + typeFromIme(mounted.textarea, "你好"); + mounted.textarea.setSelectionRange(1, 2); + mounted.root.render( + , + ); + }); + expect(mounted.inputRef.current).toBe(handle); + expect(handle?.getNativeRef()).toBe(mounted.textarea); + expect([mounted.textarea.selectionStart, mounted.textarea.selectionEnd]).toEqual([1, 2]); + expect(document.activeElement).toBe(mounted.textarea); + expect(recorder.changes).toEqual([]); + act(() => dispatchComposition(mounted.textarea, "compositionend")); + expect(recorder.changes).toEqual(["你好"]); + expect(handle?.getText()).toBe("你好"); + }); + it("keeps locally typed text when its parent rerenders with a stale value", () => { const recorder = createTextRecorder(); const mounted = mountInput(recorder.onChangeText); diff --git a/packages/app/src/composer/types.ts b/packages/app/src/composer/types.ts index 2f03b7e89a..ee01144ed7 100644 --- a/packages/app/src/composer/types.ts +++ b/packages/app/src/composer/types.ts @@ -10,6 +10,7 @@ export interface MessagePayload { } export interface TextReplacement { + kind: "initial" | "replace"; key: string; text: string; } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 79355f7110..ca402264f2 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -124,6 +124,8 @@ export const ar: TranslationResources = { input: { accessibilityLabel: "وكيل الرسائل...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "ملء الشاشة", + exitFullscreen: "الخروج من ملء الشاشة", focusHint: "{{shortcut}}للتركيز", addAttachment: "إضافة مرفق", interruptAgent: "عامل المقاطعة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 35606c853e..92dbb3f78c 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -120,6 +120,8 @@ export const en = { input: { accessibilityLabel: "Message agent...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Fullscreen", + exitFullscreen: "Exit fullscreen", focusHint: "{{shortcut}} to focus", addAttachment: "Add attachment", interruptAgent: "Interrupt agent", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 721b56cbec..bc53c8b2aa 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -125,6 +125,8 @@ export const es: TranslationResources = { input: { accessibilityLabel: "Agente de mensajes...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Pantalla completa", + exitFullscreen: "Salir de pantalla completa", focusHint: "{{shortcut}}para enfocar", addAttachment: "Agregar archivo adjunto", interruptAgent: "agente de interrupción", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 0e8937d4d8..78a9b2e82b 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -126,6 +126,8 @@ export const fr: TranslationResources = { input: { accessibilityLabel: "Écrire à l’agent…", terminalAccessibilityLabel: "Prompt du terminal", + fullscreen: "Plein écran", + exitFullscreen: "Quitter le plein écran", focusHint: "{{shortcut}} pour saisir", addAttachment: "Ajouter une pièce jointe", interruptAgent: "Interrompre l’agent", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 076ac32536..9899d0ff2b 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -125,6 +125,8 @@ export const ja: TranslationResources = { input: { accessibilityLabel: "エージェントにメッセージ...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "全画面表示", + exitFullscreen: "全画面表示を終了", focusHint: "{{shortcut}}でフォーカス", addAttachment: "添付ファイルを追加", interruptAgent: "エージェントを中断", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 8b832de6e8..c6a8f17b3c 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -124,6 +124,8 @@ export const ko: TranslationResources = { input: { accessibilityLabel: "에이전트에게 메시지...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "전체 화면", + exitFullscreen: "전체 화면 종료", focusHint: "{{shortcut}}로 포커스", addAttachment: "첨부 추가", interruptAgent: "에이전트 중단", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 0bc2968560..66a25e9f03 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -125,6 +125,8 @@ export const ptBR: TranslationResources = { input: { accessibilityLabel: "Enviar mensagem ao agente...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Tela cheia", + exitFullscreen: "Sair da tela cheia", focusHint: "{{shortcut}} para focar", addAttachment: "Adicionar anexo", interruptAgent: "Interromper agente", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index afd0ffad95..c06e53eda0 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -125,6 +125,8 @@ export const ru: TranslationResources = { input: { accessibilityLabel: "Написать агенту...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Полный экран", + exitFullscreen: "Выйти из полноэкранного режима", focusHint: "{{shortcut}}, для фокуса", addAttachment: "Добавить вложение", interruptAgent: "Прервать агента", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 9398e6020d..27a0d62ac1 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -124,6 +124,8 @@ export const zhCN: TranslationResources = { input: { accessibilityLabel: "给 Agent 发消息...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "全屏", + exitFullscreen: "退出全屏", focusHint: "{{shortcut}} 聚焦", addAttachment: "添加附件", interruptAgent: "中断 Agent", diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 27c96c08b8..1cf8d76f23 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1723,7 +1723,7 @@ export function NewWorkspaceScreen({ [terminalComposerValue], ); const terminalTextReplacement = useMemo( - () => ({ key: launchFocusKey, text: terminalComposerValue }), + () => ({ kind: "replace" as const, key: launchFocusKey, text: terminalComposerValue }), [launchFocusKey, terminalComposerValue], ); diff --git a/packages/app/src/screens/workspace/workspace-pane-content.tsx b/packages/app/src/screens/workspace/workspace-pane-content.tsx index e9534769b3..0ef496a94b 100644 --- a/packages/app/src/screens/workspace/workspace-pane-content.tsx +++ b/packages/app/src/screens/workspace/workspace-pane-content.tsx @@ -1,3 +1,4 @@ +import { ComposerEditingScope } from "@/composer/editing-session"; import React, { useMemo, type ComponentType } from "react"; import invariant from "tiny-invariant"; import { @@ -156,7 +157,12 @@ export function WorkspacePaneContent({ > - + + + diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index dacc17fd3b..55a186cbab 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,3 +1,4 @@ +import { WorkspaceComposerEditingSessionsProvider } from "@/composer/editing-session"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels"; @@ -753,14 +754,20 @@ function WorkspaceContentProviders({ function WorkspacePanelContent({ launcher, content, + tabIds, + workspaceKey, }: { + workspaceKey: string; launcher: NewTabLauncher; content: ReactNode; + tabIds: readonly string[]; }) { return ( - - {content} - + + + {content} + + ); } @@ -3491,6 +3498,8 @@ function WorkspaceScreenContent({ const workspacePanelContent = ( tab.tabId)} content={isMobile ? content : desktopContent} /> );