From 750cb82fbb296b66d38198b07ac632c7dc6efd7a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 12 Oct 2026 16:26:01 +0700 Subject: [PATCH] Keep long mobile drafts readable with fullscreen editing (#6531) * feat(app): add fullscreen editing to the compact composer * fix(app): preserve read-only terminal command previews Static command previews must render their published source; the live editor buffer is updated after render and has no mounted input to repaint read-only content. * fix(app): import React for composer editing scope rendering Workspace pane unit tests use the classic JSX transform, so the editing-scope provider needs React in scope when mounted. * fix(app): preserve fullscreen composer controls and editing Keep the fullscreen frame inside the visual viewport, gate hidden autocomplete keys, apply replacement commands to local line measurement, and return to inline before starting voice so its controls stay accessible. * test(app): cover draft replacement through the production composer Replace the measurement-hook fixture with profile switching and typing through the real composer. Keep measurement state inside its production surface; Android manual red/green evidence covers the native replacement flow. --- .../browser/composer-email-mentions.spec.ts | 49 ++ .../e2e/browser/composer-whitespace.spec.ts | 321 ++++++++- .../new-workspace-dictation-submit.spec.ts | 58 ++ .../new-workspace-launch-terminal.spec.ts | 57 +- packages/app/e2e/support/helpers/composer.ts | 130 ++++ packages/app/src/app/_layout.tsx | 7 +- .../ui/text-input/text-input.native.test.tsx | 38 ++ .../ui/text-input/text-input.native.tsx | 96 +-- .../ui/text-input/text-input.web.tsx | 54 +- .../app/src/components/ui/text-input/types.ts | 1 + .../app/src/composer/dock/index.native.tsx | 5 +- packages/app/src/composer/dock/index.tsx | 22 +- .../composer/dock/internal/capacity.test.ts | 13 + .../src/composer/dock/internal/capacity.ts | 10 + .../app/src/composer/draft/input-draft.ts | 2 + .../composer/editing-session/index.test.ts | 52 ++ .../src/composer/editing-session/index.tsx | 142 ++++ packages/app/src/composer/index.tsx | 15 +- .../composer/input/fullscreen-host.native.tsx | 57 ++ .../src/composer/input/fullscreen-host.tsx | 77 +++ .../app/src/composer/input/height.native.ts | 2 + packages/app/src/composer/input/height.web.ts | 19 +- packages/app/src/composer/input/input.tsx | 633 +++++++++++++----- .../app/src/composer/input/presentation.tsx | 146 ++++ packages/app/src/composer/input/state.test.ts | 24 + packages/app/src/composer/input/state.ts | 10 +- .../input/text-input.web.browser.test.tsx | 29 + packages/app/src/composer/types.ts | 1 + packages/app/src/i18n/resources/ar.ts | 2 + packages/app/src/i18n/resources/en.ts | 2 + packages/app/src/i18n/resources/es.ts | 2 + packages/app/src/i18n/resources/fr.ts | 2 + packages/app/src/i18n/resources/ja.ts | 2 + packages/app/src/i18n/resources/ko.ts | 2 + packages/app/src/i18n/resources/pt-BR.ts | 2 + packages/app/src/i18n/resources/ru.ts | 2 + packages/app/src/i18n/resources/zh-CN.ts | 2 + .../app/src/screens/new-workspace-screen.tsx | 2 +- .../workspace/workspace-pane-content.tsx | 8 +- .../screens/workspace/workspace-screen.tsx | 15 +- 40 files changed, 1852 insertions(+), 261 deletions(-) create mode 100644 packages/app/src/composer/editing-session/index.test.ts create mode 100644 packages/app/src/composer/editing-session/index.tsx create mode 100644 packages/app/src/composer/input/fullscreen-host.native.tsx create mode 100644 packages/app/src/composer/input/fullscreen-host.tsx create mode 100644 packages/app/src/composer/input/presentation.tsx 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} /> );