diff --git a/apps/mobile/screens/(authenticated)/(home)/home/components/ProjectSectionHeader/ProjectSectionHeader.tsx b/apps/mobile/screens/(authenticated)/(home)/home/components/ProjectSectionHeader/ProjectSectionHeader.tsx index 05d3d9dda2b..94c4a2bb272 100644 --- a/apps/mobile/screens/(authenticated)/(home)/home/components/ProjectSectionHeader/ProjectSectionHeader.tsx +++ b/apps/mobile/screens/(authenticated)/(home)/home/components/ProjectSectionHeader/ProjectSectionHeader.tsx @@ -13,9 +13,10 @@ interface ProjectSectionHeaderProps { } /** - * A quiet band, not a title: the workspaces under it are what you came to tap, - * so the header labels them rather than competing with them. The count is what - * keeps a collapsed section informative. + * Reads as the parent of the rows under it through the type scale alone — + * `large` against their 15px — rather than through caps and letterspacing, + * which shout at a list you're meant to scan. The count is what keeps a + * collapsed section informative. */ export function ProjectSectionHeader({ name, @@ -39,10 +40,7 @@ export function ProjectSectionHeader({ - + {name} diff --git a/apps/mobile/screens/(authenticated)/components/GlassComposer/GlassComposer.tsx b/apps/mobile/screens/(authenticated)/components/GlassComposer/GlassComposer.tsx index 8da1edabc75..2da254bd67c 100644 --- a/apps/mobile/screens/(authenticated)/components/GlassComposer/GlassComposer.tsx +++ b/apps/mobile/screens/(authenticated)/components/GlassComposer/GlassComposer.tsx @@ -12,6 +12,7 @@ import { } from "@expo/ui/swift-ui"; import { Animation, + accessibilityLabel, animation, aspectRatio, buttonBorderShape, @@ -153,6 +154,18 @@ export const GlassComposer = forwardRef< setHasText(text.trim().length > 0); }; + // Appends to the draft and leaves the caret after it. The field never + // reports where its cursor actually is — both onSelectionChange and a + // `selection` state read back {0,0} once you've typed — so the end is the + // only position an insert can be sure of. Setting the caret does work. + const appendToDraft = (insert: string) => { + const next = draftRef.current + insert; + writeDraft(next); + void fieldRef.current + ?.setText(next) + .then(() => fieldRef.current?.setSelection(next.length, next.length)); + }; + const attachments = showAttachments ? controller.attachments.attachments : NO_ATTACHMENTS; @@ -276,6 +289,7 @@ export const GlassComposer = forwardRef< buttonStyle("bordered"), buttonBorderShape("circle"), tint(FOREGROUND), + accessibilityLabel("Add attachment"), ]} > ); + // Explicit line break for multi-line messages: expo-ui's TextField exposes + // nothing for the soft keyboard's return key, so this is the only way to + // put a newline in a draft. + const newlineButton = ( + + ); + const voiceControl = ; // Inserted beside the mic when a draft exists: the animated layout change @@ -298,6 +336,9 @@ export const GlassComposer = forwardRef< buttonBorderShape("circle"), tint("#ffffff"), disabled(isSending), + // Distinct from the terminal quick-key row's up arrow, which is + // otherwise the same `arrow.up` symbol to VoiceOver. + accessibilityLabel("Send"), ]} > {plusButton} + {newlineButton} {toolbarLeading} {/* Bordered buttons carry ~6pt of invisible tap-target inset diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx index a8871f5a157..667ab568e1d 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/WorkspaceScreen/WorkspaceScreen.tsx @@ -198,12 +198,25 @@ export function WorkspaceScreen() { [invalidateTerminals], ); - const handleSubmit = useCallback((text: string) => { - const framed = text.includes("\n") - ? `\u001b[200~${text}\u001b[201~\r` - : `${text}\r`; - terminalRef.current?.sendInput(framed); - }, []); + // Submits go through the host's terminal.send instead of the attached + // stream. An Enter written together with the text arrives in the same read, + // and a TUI agent takes that burst for a paste — the message lands in the + // draft with a newline appended instead of being submitted (#6284). The host + // separates and delays the Enter, and frames the text as a bracketed paste + // only when the running program actually has that mode on. + const handleSubmit = useCallback( + async (text: string) => { + if (!hostUrl || !activeTerminalId || !id) { + throw new Error("Terminal is not connected"); + } + await getHostServiceClientByUrl(hostUrl).terminal.send.mutate({ + terminalId: activeTerminalId, + workspaceId: id, + text, + }); + }, + [hostUrl, activeTerminalId, id], + ); const handleQuickKey = useCallback((key: TerminalQuickKey) => { if (key.data) terminalRef.current?.sendInput(key.data); diff --git a/apps/mobile/screens/(authenticated)/workspace/[id]/components/TerminalComposer/TerminalComposer.tsx b/apps/mobile/screens/(authenticated)/workspace/[id]/components/TerminalComposer/TerminalComposer.tsx index 797e3baa0bc..84ba89e9253 100644 --- a/apps/mobile/screens/(authenticated)/workspace/[id]/components/TerminalComposer/TerminalComposer.tsx +++ b/apps/mobile/screens/(authenticated)/workspace/[id]/components/TerminalComposer/TerminalComposer.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useImperativeHandle, useRef } from "react"; +import { forwardRef, useImperativeHandle, useRef, useState } from "react"; import { Alert, View } from "react-native"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { @@ -14,8 +14,8 @@ import { interface TerminalComposerProps { placeholder?: string; - /** Submit the current draft to the PTY. */ - onSubmit: (text: string) => void; + /** Submit the current draft to the PTY. Rejects if it never got there. */ + onSubmit: (text: string) => Promise; onQuickKey: (key: TerminalQuickKey) => void; /** Where attachments land; null while the workspace or host is unresolved. */ attachmentTarget: TerminalAttachmentTarget | null; @@ -61,25 +61,36 @@ export const TerminalComposer = forwardRef< })); const writeAttachments = useWriteTerminalAttachments(); - const submit = ({ text, attachments }: PromptInputMessage) => { - if (attachments.length === 0) { - onSubmit(text); - composerRef.current?.clear(); - return; + const [isSubmitting, setIsSubmitting] = useState(false); + + const submit = async ({ text, attachments }: PromptInputMessage) => { + let body = text; + if (attachments.length > 0) { + if (!attachmentTarget) { + Alert.alert("Attachments need an online host"); + return; + } + // A PTY takes bytes, not files: the agent gets the attachments as + // worktree-relative paths appended to the message. The hook alerts on + // its own failures. + const paths = await writeAttachments + .mutateAsync({ target: attachmentTarget, attachments }) + .catch(() => null); + if (!paths) return; + body = text ? `${text}\n\n${paths.join("\n")}` : paths.join("\n"); } - if (!attachmentTarget) { - Alert.alert("Attachments need an online host"); - return; + setIsSubmitting(true); + try { + await onSubmit(body); + composerRef.current?.clear(); + } catch (cause) { + Alert.alert( + "Could not send", + cause instanceof Error ? cause.message : String(cause), + ); + } finally { + setIsSubmitting(false); } - writeAttachments - .mutateAsync({ target: attachmentTarget, attachments }) - .then((paths) => { - // A PTY takes bytes, not files: the agent gets the attachments as - // worktree-relative paths appended to the message. - onSubmit(text ? `${text}\n\n${paths.join("\n")}` : paths.join("\n")); - composerRef.current?.clear(); - }) - .catch(() => {}); }; return ( @@ -87,7 +98,7 @@ export const TerminalComposer = forwardRef< } - isSending={writeAttachments.isPending} + isSending={writeAttachments.isPending || isSubmitting} showAttachments={allowAttachments} onActiveChange={onActiveChange} onSubmit={submit}