diff --git a/packages/app/e2e/browser/composer-control-density.spec.ts b/packages/app/e2e/browser/composer-control-density.spec.ts index aad17cd1ae..b904536ef4 100644 --- a/packages/app/e2e/browser/composer-control-density.spec.ts +++ b/packages/app/e2e/browser/composer-control-density.spec.ts @@ -1,13 +1,20 @@ import { test, expect } from "../support/fixtures"; import { openWorkspaceWithAgents } from "../support/helpers/archive-tab"; -import { fillComposerDraft } from "../support/helpers/composer"; +import { + composerLocator, + expectComposerVisible, + fillComposerDraft, +} from "../support/helpers/composer"; import { expectComposerToolbarOnTextRails, + expectTrailingActionSwap, expectNoCollapsedComposerToolbarFrame, expectNoFrameTruncatesModelBesideThinking, readSettledModelTriggerLabels, + readTrailingActionFrames, recordComposerToolbarFrames, recordModelTriggerFramesFromLoad, + recordTrailingActionFrames, sweepModelTriggerLabels, } from "../support/helpers/composer-control-density"; import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; @@ -15,6 +22,9 @@ import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock- import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; const SETTLE_MS = 1_000; +const TRAILING_ACTION_SETTLE_MS = 500; +const VOICE_MODE = "Enable Voice mode"; +const SEND = "Send message"; async function seedSettledMockAgent(workspace: SeededWorkspace, title: string) { const agent = await workspace.client.createAgent({ @@ -37,6 +47,52 @@ function visibleAgentTab(page: import("@playwright/test").Page, agentId: string) return page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first(); } +test.describe("Composer trailing action", () => { + test.describe.configure({ timeout: 120_000 }); + + for (const viewport of [ + { name: "wide", width: 1280, height: 800 }, + { name: "compact", width: 390, height: 844 }, + ]) { + test(`swaps in place between voice mode and Send at ${viewport.name} width`, async ({ + page, + }) => { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-trailing-action-", + title: "Trailing action", + }); + try { + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await expect(page.getByRole("button", { name: VOICE_MODE, exact: true })).toBeVisible(); + const composer = composerLocator(page); + await composer.focus(); + + await recordTrailingActionFrames(page); + await composer.press("x"); + await expect(composer).toHaveValue("x"); + await page.waitForTimeout(TRAILING_ACTION_SETTLE_MS); + expectTrailingActionSwap(await readTrailingActionFrames(page), { + from: VOICE_MODE, + to: SEND, + }); + + await recordTrailingActionFrames(page); + await composer.press("Backspace"); + await expect(composer).toHaveValue(""); + await page.waitForTimeout(TRAILING_ACTION_SETTLE_MS); + expectTrailingActionSwap(await readTrailingActionFrames(page), { + from: SEND, + to: VOICE_MODE, + }); + } finally { + await agent.cleanup(); + } + }); + } +}); + test.describe("Composer control density across tab switches", () => { test.describe.configure({ timeout: 180_000 }); diff --git a/packages/app/e2e/support/helpers/composer-control-density.ts b/packages/app/e2e/support/helpers/composer-control-density.ts index b8ebc22ab6..089b112b74 100644 --- a/packages/app/e2e/support/helpers/composer-control-density.ts +++ b/packages/app/e2e/support/helpers/composer-control-density.ts @@ -54,6 +54,116 @@ export async function expectNoCollapsedComposerToolbarFrame(page: Page): Promise expect(collapsed, `collapsed toolbar frames: ${JSON.stringify(collapsed)}`).toEqual([]); } +/** One painted frame of the composer's trailing controls: the slot and what sits beside it. */ +export interface TrailingActionFrame { + atMs: number; + /** Every trailing action on screen, as `label` or `label (disabled)`. */ + actions: string[]; + slotX: number | null; + micX: number | null; + ringX: number | null; + ringWidth: number | null; +} + +declare global { + interface Window { + __trailingActionFrames?: TrailingActionFrame[]; + __trailingActionStop?: () => void; + } +} + +const TRAILING_ACTION_LABELS = [ + "Enable Voice mode", + "Send message", + "Queue message", + "Send and interrupt", + "Send and steer", + "Interrupt agent", + "Stop agent", +]; + +/** + * Records every painted frame of the visible composer's trailing controls. Takes one sample before + * the first paint, so input that lands right after this resolves still has a frame to compare to. + */ +export async function recordTrailingActionFrames(page: Page): Promise { + await page.evaluate((labels) => { + const frames: TrailingActionFrame[] = []; + window.__trailingActionFrames = frames; + const start = performance.now(); + const visibleRoot = () => + Array.from(document.querySelectorAll('[data-testid="message-input-root"]')).find( + (root) => root.getClientRects().length > 0, + ); + const sample = () => { + const root = visibleRoot(); + const control = (selector: string) => + Array.from(root?.querySelectorAll(selector) ?? []).find( + (node) => node.getClientRects().length > 0, + ); + const actions = labels.flatMap((label) => { + const node = control(`[aria-label="${label}"]`); + if (!node) return []; + return [node.getAttribute("aria-disabled") === "true" ? `${label} (disabled)` : label]; + }); + const slot = labels.map((label) => control(`[aria-label="${label}"]`)).find(Boolean); + const mic = control('[aria-label="Start dictation"]'); + const ring = control('[aria-label^="Context window"]'); + frames.push({ + atMs: Math.round(performance.now() - start), + actions, + slotX: slot ? slot.getBoundingClientRect().x : null, + micX: mic ? mic.getBoundingClientRect().x : null, + ringX: ring ? ring.getBoundingClientRect().x : null, + ringWidth: ring ? ring.getBoundingClientRect().width : null, + }); + }; + sample(); + let running = true; + const tick = () => { + if (!running) return; + sample(); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + window.__trailingActionStop = () => { + running = false; + }; + }, TRAILING_ACTION_LABELS); +} + +export async function readTrailingActionFrames(page: Page): Promise { + return page.evaluate(() => { + window.__trailingActionStop?.(); + return window.__trailingActionFrames ?? []; + }); +} + +/** + * Every frame of the span shows exactly one trailing action, in the one slot, moving straight from + * `from` to `to`, with the mic and context ring where they started. + */ +export function expectTrailingActionSwap( + frames: TrailingActionFrame[], + expected: { from: string; to: string }, +): void { + expect(frames.length).toBeGreaterThan(1); + const [first] = frames; + const wrongFrames = frames.filter( + (frame) => + frame.actions.length !== 1 || + frame.slotX !== first.slotX || + frame.micX !== first.micX || + frame.ringX !== first.ringX || + frame.ringWidth !== first.ringWidth, + ); + expect(wrongFrames, `frames that moved or showed the wrong action`).toEqual([]); + const sequence = frames + .map((frame) => frame.actions[0]) + .filter((action, index, all) => action !== all[index - 1]); + expect(sequence).toEqual([expected.from, expected.to]); +} + /** What the folded model control's labels show, read from the DOM. */ export interface ModelTriggerLabelState { thinkingShown: boolean; diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 11479e1346..ec0c688176 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1150,21 +1150,14 @@ interface ComposerVoiceModeButtonProps { t: TFunction; } -function showsVoiceModeButton(input: { +/** Whether voice mode can take the trailing disc; MessageInput decides when it does. */ +function offersVoiceMode(input: { isVoiceModeForAgent: boolean; hasAgent: boolean; isAgentRunning: boolean; - hasSendableContent: boolean; showVoice: boolean; }): boolean { - // Voice mode holds the trailing disc until there is something to send, then Send takes it. - return ( - input.showVoice && - !input.isVoiceModeForAgent && - input.hasAgent && - !input.isAgentRunning && - !input.hasSendableContent - ); + return input.showVoice && !input.isVoiceModeForAgent && input.hasAgent && !input.isAgentRunning; } function ComposerVoiceModeButton({ @@ -1326,11 +1319,6 @@ function ComposerContentImpl({ const { finePointer: hasFinePointer } = usePointerCapabilities(); const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout); const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder); - const hasText = useSyncExternalStore( - textSource.subscribe, - () => textSource.getSnapshot().trim().length > 0, - () => textSource.getSnapshot().trim().length > 0, - ); const setUserInput = onChangeText; const workspaceAttachments = useWorkspaceAttachmentsForScopes(attachmentScopeKeys); const { @@ -2042,8 +2030,6 @@ function ComposerContentImpl({ ], ); - const hasSendableContent = hasText || selectedAttachments.length > 0; - // An open autocomplete owns the arrows; otherwise they may recall sent messages. const handleCommandKeyPress = useCallback( (event: ComposerKeyPressEvent) => { @@ -2098,11 +2084,10 @@ function ComposerContentImpl({ const voiceModeContent = useMemo( () => - showsVoiceModeButton({ + offersVoiceMode({ isVoiceModeForAgent, hasAgent, isAgentRunning, - hasSendableContent, showVoice: mode.showVoice, }) ? ( @@ -2121,7 +2106,6 @@ function ComposerContentImpl({ toolbarGlyph, handleToggleRealtimeVoice, hasAgent, - hasSendableContent, isAgentRunning, isConnected, isVoiceModeForAgent, diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 5cb380c34b..48c4132b9d 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -185,7 +185,7 @@ export interface MessageInputProps { leftContent?: React.ReactNode; /** Content to render on the right side before the voice button (e.g., context window meter) */ beforeVoiceContent?: React.ReactNode; - /** The realtime voice mode button after the dictation button, or null when the row hides it. */ + /** The realtime voice mode button, which takes the trailing disc while there is nothing to send. */ voiceModeContent?: React.ReactNode; /** Primary action to render when the agent is active and the composer has no sendable content. */ activeActionContent?: React.ReactNode; @@ -898,7 +898,7 @@ function SendButtonTooltip({ ); } -type PrimaryActionKind = "send" | "active" | "none"; +type PrimaryActionKind = "send" | "active" | "voiceMode" | "none"; function hasSendableComposerContent(input: { hasText: boolean; @@ -913,22 +913,26 @@ function resolvePrimaryActionKind(input: { allowEmptySubmit: boolean; isAgentRunning: boolean; isSubmitLoading: boolean; + offersVoiceMode: boolean; }): PrimaryActionKind { if (input.hasSendableContent || input.allowEmptySubmit) return "send"; if (input.isAgentRunning) return "active"; if (input.isSubmitLoading) return "send"; - return "none"; + return input.offersVoiceMode ? "voiceMode" : "none"; } function PrimaryAction({ kind, activeActionContent, + voiceModeContent, ...sendButtonProps }: { kind: PrimaryActionKind; activeActionContent: React.ReactNode; + voiceModeContent: React.ReactNode; } & React.ComponentProps) { if (kind === "active") return activeActionContent; + if (kind === "voiceMode") return voiceModeContent; if (kind === "send") return ; return null; } @@ -1886,6 +1890,7 @@ export const MessageInput = forwardRef( allowEmptySubmit, isAgentRunning, isSubmitLoading, + offersVoiceMode: voiceModeContent != null, }); const { canPressLoadingButton, isSendButtonDisabled, defaultActionQueues } = computeSendButtonState({ @@ -2023,7 +2028,7 @@ export const MessageInput = forwardRef( // Fullscreen always shows Send; without content it is disabled, and looks it. // With nothing to send and no voice mode, a disabled Send holds the trailing disc so the row // does not shift when typing starts. - const sendStandsIn = isFullscreen || (primaryActionKind === "none" && voiceModeContent == null); + const sendStandsIn = isFullscreen || primaryActionKind === "none"; const presentedActionKind: PrimaryActionKind = sendStandsIn ? "send" : primaryActionKind; const isPresentedSendDisabled = resolvePresentationSendDisabled({ standsIn: sendStandsIn, @@ -2075,6 +2080,7 @@ export const MessageInput = forwardRef( ( [ presentedActionKind, activeActionContent, + voiceModeContent, canPressLoadingButton, onSubmitLoadingPress, handleDefaultSendAction, @@ -2146,7 +2153,7 @@ export const MessageInput = forwardRef( fullscreen={isFullscreen} compact={isCompact} onRenderedLinesChange={setRenderedLines} - focusHintVisible={!isFullscreen && isWeb && !isInputFocused && !value} + focusHintVisible={!isFullscreen && isWeb && !isInputFocused && !hasLiveText} focusInputKeys={focusInputKeys} focusHintLabel={t("composer.input.focusHint", { shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "", @@ -2178,6 +2185,7 @@ export const MessageInput = forwardRef( isFullscreen, isCompact, isInputFocused, + hasLiveText, value, focusInputKeys, ], @@ -2215,7 +2223,7 @@ export const MessageInput = forwardRef( {leftContent} - {/* Right: voice button, contextual button (realtime/send/cancel) */} + {/* Right: dictation, then the trailing action (voice mode, send or stop) */} {beforeVoiceContent} ( voiceMuteToggleKeys={voiceMuteToggleKeys} dictationToggleKeys={dictationToggleKeys} /> - {voiceModeContent} {sendButton} @@ -2256,7 +2263,6 @@ export const MessageInput = forwardRef( isRealtimeVoiceForCurrentAgent, voiceMuteToggleKeys, dictationToggleKeys, - voiceModeContent, sendButton, ], );