From f286e03761f27485e00909aa0a764ae22e6bcd36 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 16 Aug 2026 10:46:23 -0700 Subject: [PATCH 1/4] fix(mobile): submit terminal messages through terminal.send so they actually run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer wrote `text\r` (or a bracketed-paste frame plus `\r`) straight into the attached stream as one write. A TUI agent reads that burst as a paste, so the Enter landed in the draft as a newline instead of submitting — the message sat in Claude's input box fully typed and never ran. Route submits through the host's `terminal.send` instead, the same path FinishReviewSheet and the desktop use: it writes the Enter as its own delayed write (#6284), frames the text as a bracketed paste only when the running program has that mode on, and serialises against concurrent sends. Quick keys still go over the stream as raw bytes. The composer now clears only once the send resolves, per GlassComposer's onSubmit contract, so a failed send keeps the draft instead of eating it. --- .../[id]/WorkspaceScreen/WorkspaceScreen.tsx | 25 ++++++--- .../TerminalComposer/TerminalComposer.tsx | 53 +++++++++++-------- 2 files changed, 51 insertions(+), 27 deletions(-) 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} From c6c06df483dafd358f9458f231819d90eb2b5831 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 16 Aug 2026 11:10:43 -0700 Subject: [PATCH 2/4] feat(mobile): add a newline button to the glass composer expo-ui's SwiftUI TextField exposes nothing for the soft keyboard's return key, so there was no way to put a line break in a draft. Adds a return button to the expanded toolbar that inserts one at the caret (replacing any selection), tracking the caret through the field's onSelectionChange. Shared by both composers, so home prompts get multi-line too. --- .../GlassComposer/GlassComposer.tsx | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/apps/mobile/screens/(authenticated)/components/GlassComposer/GlassComposer.tsx b/apps/mobile/screens/(authenticated)/components/GlassComposer/GlassComposer.tsx index 8da1edabc75..59390a22118 100644 --- a/apps/mobile/screens/(authenticated)/components/GlassComposer/GlassComposer.tsx +++ b/apps/mobile/screens/(authenticated)/components/GlassComposer/GlassComposer.tsx @@ -146,6 +146,7 @@ export const GlassComposer = forwardRef< // The draft lives in a ref, never state — otherwise each keystroke // re-renders the SwiftUI Host. const draftRef = useRef(""); + const selectionRef = useRef({ start: 0, end: 0 }); const [hasText, setHasText] = useState(false); const writeDraft = (text: string) => { @@ -153,6 +154,22 @@ export const GlassComposer = forwardRef< setHasText(text.trim().length > 0); }; + // Replaces the current selection (or splits at the caret) and leaves the + // caret after the inserted text. + const replaceSelection = (insert: string) => { + const text = draftRef.current; + const length = text.length; + const start = Math.min(selectionRef.current.start, length); + const end = Math.min(Math.max(selectionRef.current.end, start), length); + const next = text.slice(0, start) + insert + text.slice(end); + const caret = start + insert.length; + writeDraft(next); + selectionRef.current = { start: caret, end: caret }; + void fieldRef.current + ?.setText(next) + .then(() => fieldRef.current?.setSelection(caret, caret)); + }; + const attachments = showAttachments ? controller.attachments.attachments : NO_ATTACHMENTS; @@ -184,6 +201,7 @@ export const GlassComposer = forwardRef< }, clear: () => { writeDraft(""); + selectionRef.current = { start: 0, end: 0 }; controller.attachments.clear(); void fieldRef.current?.clear(); }, @@ -240,6 +258,7 @@ export const GlassComposer = forwardRef< read: () => draftRef.current, write: (text) => { writeDraft(text); + selectionRef.current = { start: text.length, end: text.length }; void fieldRef.current ?.setText(text) .then(() => fieldRef.current?.setSelection(text.length, text.length)); @@ -286,6 +305,29 @@ export const GlassComposer = forwardRef< ); + // 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 @@ -458,6 +500,9 @@ export const GlassComposer = forwardRef< placeholder={placeholder ?? "Plan, ask, build..."} onTextChange={writeDraft} onFocusChange={setFocused} + onSelectionChange={(selection) => { + selectionRef.current = selection; + }} modifiers={[ padding({ horizontal: expanded ? 12 : 4 }), frame({ minHeight: expanded ? 56 : 38 }), @@ -495,6 +540,7 @@ export const GlassComposer = forwardRef< > {plusButton} + {newlineButton} {toolbarLeading} {/* Bordered buttons carry ~6pt of invisible tap-target inset From 1dff390796c99207f2503cabc89f356e4c85580e Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 16 Aug 2026 12:16:17 -0700 Subject: [PATCH 3/4] fix(mobile): insert the newline at the end, and label the composer buttons Verified on device: the field never reports where its cursor is. Both onSelectionChange and a `selection` ObservableState read back {0,0} once you've typed, so inserting "at the caret" put every line break at position 0. Writing the caret does work, so append and then place it after the insert. Also labels +, the newline button and send for VoiceOver. The send button and the terminal's quick-key up arrow were both a bare `arrow.up` symbol, so they were indistinguishable to assistive tech (and to a UI test). --- .../GlassComposer/GlassComposer.tsx | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/apps/mobile/screens/(authenticated)/components/GlassComposer/GlassComposer.tsx b/apps/mobile/screens/(authenticated)/components/GlassComposer/GlassComposer.tsx index 59390a22118..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, @@ -146,7 +147,6 @@ export const GlassComposer = forwardRef< // The draft lives in a ref, never state — otherwise each keystroke // re-renders the SwiftUI Host. const draftRef = useRef(""); - const selectionRef = useRef({ start: 0, end: 0 }); const [hasText, setHasText] = useState(false); const writeDraft = (text: string) => { @@ -154,20 +154,16 @@ export const GlassComposer = forwardRef< setHasText(text.trim().length > 0); }; - // Replaces the current selection (or splits at the caret) and leaves the - // caret after the inserted text. - const replaceSelection = (insert: string) => { - const text = draftRef.current; - const length = text.length; - const start = Math.min(selectionRef.current.start, length); - const end = Math.min(Math.max(selectionRef.current.end, start), length); - const next = text.slice(0, start) + insert + text.slice(end); - const caret = start + insert.length; + // 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); - selectionRef.current = { start: caret, end: caret }; void fieldRef.current ?.setText(next) - .then(() => fieldRef.current?.setSelection(caret, caret)); + .then(() => fieldRef.current?.setSelection(next.length, next.length)); }; const attachments = showAttachments @@ -201,7 +197,6 @@ export const GlassComposer = forwardRef< }, clear: () => { writeDraft(""); - selectionRef.current = { start: 0, end: 0 }; controller.attachments.clear(); void fieldRef.current?.clear(); }, @@ -258,7 +253,6 @@ export const GlassComposer = forwardRef< read: () => draftRef.current, write: (text) => { writeDraft(text); - selectionRef.current = { start: text.length, end: text.length }; void fieldRef.current ?.setText(text) .then(() => fieldRef.current?.setSelection(text.length, text.length)); @@ -295,6 +289,7 @@ export const GlassComposer = forwardRef< buttonStyle("bordered"), buttonBorderShape("circle"), tint(FOREGROUND), + accessibilityLabel("Add attachment"), ]} > { void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - replaceSelection("\n"); + appendToDraft("\n"); }} modifiers={[ buttonStyle("bordered"), buttonBorderShape("circle"), tint(FOREGROUND), + accessibilityLabel("New line"), ]} > { - selectionRef.current = selection; - }} modifiers={[ padding({ horizontal: expanded ? 12 : 4 }), frame({ minHeight: expanded ? 56 : 38 }), From b5b6e0b6a8422e5b086f41f6f7c87d9aff66e270 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 16 Aug 2026 12:16:18 -0700 Subject: [PATCH 4/4] style(mobile): drop the caps from the home project headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header hand-rolled `text-[15px] uppercase tracking-wider`, none of which is in the design system — and at 15px it was the same size as the workspace row title, leaving the caps to carry the whole parent/child signal. Use the `large` text variant so the hierarchy comes off the type scale instead. --- .../ProjectSectionHeader/ProjectSectionHeader.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) 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}