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}