mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 09:54:40 +00:00
Keep the composer's trailing action in place while typing (#9151)
The waveform and Send decided "is there content" separately: Send read the input's live text, while the waveform was gated on the draft store, whose web write lands after paint for typing performance. Between the two commits the toolbar showed both discs, moved the mic and context ring, or painted a disabled Send. MessageInput now picks voice mode, Send or stop from its live text. The composer only says whether voice mode is on offer. The focus hint reads the same live text, since the composer no longer rerenders when the draft gains or loses text.
This commit is contained in:
@@ -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 });
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<TrailingActionFrame[]> {
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
}) ? (
|
||||
<View style={styles.rightControls}>
|
||||
@@ -2121,7 +2106,6 @@ function ComposerContentImpl({
|
||||
toolbarGlyph,
|
||||
handleToggleRealtimeVoice,
|
||||
hasAgent,
|
||||
hasSendableContent,
|
||||
isAgentRunning,
|
||||
isConnected,
|
||||
isVoiceModeForAgent,
|
||||
|
||||
@@ -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<typeof SendButtonTooltip>) {
|
||||
if (kind === "active") return activeActionContent;
|
||||
if (kind === "voiceMode") return voiceModeContent;
|
||||
if (kind === "send") return <SendButtonTooltip {...sendButtonProps} />;
|
||||
return null;
|
||||
}
|
||||
@@ -1886,6 +1890,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
allowEmptySubmit,
|
||||
isAgentRunning,
|
||||
isSubmitLoading,
|
||||
offersVoiceMode: voiceModeContent != null,
|
||||
});
|
||||
const { canPressLoadingButton, isSendButtonDisabled, defaultActionQueues } =
|
||||
computeSendButtonState({
|
||||
@@ -2023,7 +2028,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
// 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<MessageInputRef, MessageInputProps>(
|
||||
<PrimaryAction
|
||||
kind={presentedActionKind}
|
||||
activeActionContent={activeActionContent}
|
||||
voiceModeContent={voiceModeContent}
|
||||
shouldShow
|
||||
canPressLoadingButton={canPressLoadingButton}
|
||||
onSubmitLoadingPress={onSubmitLoadingPress}
|
||||
@@ -2096,6 +2102,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
[
|
||||
presentedActionKind,
|
||||
activeActionContent,
|
||||
voiceModeContent,
|
||||
canPressLoadingButton,
|
||||
onSubmitLoadingPress,
|
||||
handleDefaultSendAction,
|
||||
@@ -2146,7 +2153,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
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<MessageInputRef, MessageInputProps>(
|
||||
isFullscreen,
|
||||
isCompact,
|
||||
isInputFocused,
|
||||
hasLiveText,
|
||||
value,
|
||||
focusInputKeys,
|
||||
],
|
||||
@@ -2215,7 +2223,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
{leftContent}
|
||||
</View>
|
||||
|
||||
{/* Right: voice button, contextual button (realtime/send/cancel) */}
|
||||
{/* Right: dictation, then the trailing action (voice mode, send or stop) */}
|
||||
<View style={styles.rightButtonGroup}>
|
||||
{beforeVoiceContent}
|
||||
<VoiceButtonTooltip
|
||||
@@ -2230,7 +2238,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
voiceMuteToggleKeys={voiceMuteToggleKeys}
|
||||
dictationToggleKeys={dictationToggleKeys}
|
||||
/>
|
||||
{voiceModeContent}
|
||||
{sendButton}
|
||||
</View>
|
||||
</View>
|
||||
@@ -2256,7 +2263,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
voiceMuteToggleKeys,
|
||||
dictationToggleKeys,
|
||||
voiceModeContent,
|
||||
sendButton,
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user