diff --git a/TODO.md b/TODO.md
index 2ba7870..44249f8 100644
--- a/TODO.md
+++ b/TODO.md
@@ -265,11 +265,12 @@ measurements remain pending before final budgets are set.
held in SecureStore, with backup exclusion and deletion tests.
- [x] Load and select agents and models.
- [x] Generate a stable admission ID before every prompt submission.
-- [x] Render the admitted inbox item as a temporary overlay and replace it by ID
- when the projected transcript message appears.
+- [x] Keep normal admission progress compact, delay queued inbox controls through
+ transient handoffs, and remove them by ID when the transcript message appears.
- [x] Track admitted, queued or steered, promoted, executing, cancelled,
completed, and unknown-delivery states separately.
-- [x] Expose queue and steer choices while execution is active.
+- [x] Apply the device-owned queue or steer default from Settings while execution
+ is active, with Steer as the initial default and no per-send choice card.
- [x] Display queued and active execution state.
- [x] Add interrupt, background, and wait behavior where useful.
- [x] Prevent accidental duplicate submission without hiding server conflicts.
diff --git a/apps/mobile/src/app.tsx b/apps/mobile/src/app.tsx
index 4c06933..5344455 100644
--- a/apps/mobile/src/app.tsx
+++ b/apps/mobile/src/app.tsx
@@ -152,9 +152,8 @@ class RootErrorBoundary extends Component<{ children: ReactNode }, ErrorBoundary
const styles = StyleSheet.create({
appRoot: { flex: 1 },
errorCopy: {
+ ...typography.body,
color: palette.dim,
- fontSize: 16,
- lineHeight: 24,
marginTop: 12,
},
errorEyebrow: {
diff --git a/apps/mobile/src/components/modal-sheet.tsx b/apps/mobile/src/components/modal-sheet.tsx
index b1dcca2..3c102e1 100644
--- a/apps/mobile/src/components/modal-sheet.tsx
+++ b/apps/mobile/src/components/modal-sheet.tsx
@@ -14,7 +14,7 @@ import {
} from "react-native";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
-import { palette, space, typeRamp, typography, usesLargeTextLayout } from "../theme";
+import { palette, radius, space, typeRamp, typography, usesLargeTextLayout } from "../theme";
export function ModalSheet({
children,
@@ -150,7 +150,13 @@ const styles = StyleSheet.create({
frame: { flex: 1 },
overlay: { backgroundColor: "rgba(0,0,0,0.6)", justifyContent: "center", alignItems: "center" },
bottom: { justifyContent: "flex-end" },
- panel: { width: "100%", maxWidth: 640, maxHeight: "90%", borderRadius: 18, overflow: "hidden" },
+ panel: {
+ width: "100%",
+ maxWidth: 640,
+ maxHeight: "90%",
+ borderRadius: radius.lg,
+ overflow: "hidden",
+ },
compactPanel: { flex: 0, flexShrink: 1 },
compactKeyboardView: { flex: 0, flexShrink: 1 },
compactScroll: { flexGrow: 0, flexShrink: 1 },
diff --git a/apps/mobile/src/screens/app-shell.tsx b/apps/mobile/src/screens/app-shell.tsx
index e5de558..4eb4a72 100644
--- a/apps/mobile/src/screens/app-shell.tsx
+++ b/apps/mobile/src/screens/app-shell.tsx
@@ -375,6 +375,35 @@ export function SettingsScreen({ navigation }: ScreenProps<"Settings">) {
+
+
+ Default follow-up delivery
+
+ Steer updates the current work. Queue waits until it finishes.
+
+
+ {(["steer", "queue"] as const).map((delivery) => (
+ void transcript.update({ defaultDelivery: delivery })}
+ style={{ minHeight: 48, justifyContent: "center" }}
+ >
+
+ {transcript.defaultDelivery === delivery ? "✓ " : ""}
+ {delivery === "steer" ? "Steer" : "Queue"}
+
+
+ ))}
+
+
+
+
Detailed transcript
@@ -409,7 +438,7 @@ export function SettingsScreen({ navigation }: ScreenProps<"Settings">) {
{transcript.error ? (
- Transcript preferences could not be loaded or saved. Try changing the setting again.
+ Message preferences could not be loaded or saved. Try changing the setting again.
) : null}
@@ -1075,7 +1104,7 @@ const styles = StyleSheet.create({
marginTop: space.lg,
padding: space.lg,
},
- cardCopy: { color: palette.dim, fontSize: 15, lineHeight: 22, marginTop: space.xs },
+ cardCopy: { ...typography.body, color: palette.dim, marginTop: space.xs },
cardLabel: { ...typography.label, color: palette.dim },
cardTitle: {
...typography.heading,
@@ -1140,7 +1169,7 @@ const styles = StyleSheet.create({
},
emptyMark: { color: palette.signal, fontSize: 34, fontWeight: "300", letterSpacing: -1.2 },
errorEyebrow: { ...typography.label, color: palette.danger },
- errorText: { color: palette.danger, fontSize: 14, lineHeight: 20, marginTop: space.sm },
+ errorText: { ...typography.body, color: palette.danger, marginTop: space.sm },
eyebrow: { ...typography.label, color: palette.dim },
failureCard: {
backgroundColor: palette.background,
@@ -1150,7 +1179,7 @@ const styles = StyleSheet.create({
marginTop: space.lg,
padding: space.lg,
},
- lede: { color: palette.dim, fontSize: 16, lineHeight: 24, marginTop: space.sm, maxWidth: 620 },
+ lede: { ...typography.body, color: palette.dim, marginTop: space.sm, maxWidth: 620 },
main: { flex: 1, minWidth: 0 },
metric: {
backgroundColor: palette.card,
@@ -1162,7 +1191,7 @@ const styles = StyleSheet.create({
padding: space.md,
},
metricGrid: { flexDirection: "row", flexWrap: "wrap", gap: space.sm, marginTop: space.sm },
- metricLabel: { color: palette.dim, fontSize: 11, fontWeight: "800", letterSpacing: 1 },
+ metricLabel: { ...typography.label, color: palette.dim },
metricValue: { color: palette.ink, fontSize: 34, fontWeight: "300", lineHeight: 41 },
navigationItem: {
alignItems: "center",
@@ -1236,17 +1265,14 @@ const styles = StyleSheet.create({
},
stateCardText: { flex: 1 },
stateCopy: {
+ ...typography.body,
color: palette.dim,
- fontSize: 16,
- lineHeight: 24,
marginTop: space.sm,
maxWidth: 480,
},
stateTitle: {
+ ...typography.title,
color: palette.ink,
- fontSize: 28,
- fontWeight: "700",
- lineHeight: 34,
marginTop: space.sm,
},
statusDot: { borderRadius: 4, height: 8, marginRight: space.sm, width: 8 },
diff --git a/apps/mobile/src/screens/connection-screen.test.tsx b/apps/mobile/src/screens/connection-screen.test.tsx
index 48fc63b..d7f9185 100644
--- a/apps/mobile/src/screens/connection-screen.test.tsx
+++ b/apps/mobile/src/screens/connection-screen.test.tsx
@@ -1,6 +1,7 @@
import { beforeEach, expect, jest, test } from "@jest/globals";
import { act, fireEvent, render, screen } from "@testing-library/react-native";
+import { palette, typography } from "../theme";
import { ConnectionScreen } from "./connection-screen";
const mockCreateOpenCodeClient = jest.fn();
@@ -113,6 +114,10 @@ beforeEach(() => {
test("requires approval before sending credentials over HTTP", async () => {
await render();
+ expect(screen.getByLabelText("Server origin")).toHaveStyle({
+ ...typography.body,
+ color: palette.ink,
+ });
await fireEvent.changeText(
screen.getByPlaceholderText("http://100.64.0.10:4096"),
@@ -126,6 +131,7 @@ test("requires approval before sending credentials over HTTP", async () => {
"Approve private-network HTTP before connecting without TLS.",
);
expect(mockCreateOpenCodeClient).not.toHaveBeenCalled();
+ expect(screen.getByRole("alert")).toHaveStyle({ ...typography.body, color: palette.danger });
});
test("offers an opt-in device authentication lock", async () => {
diff --git a/apps/mobile/src/screens/connection-screen.tsx b/apps/mobile/src/screens/connection-screen.tsx
index 4432c15..440a650 100644
--- a/apps/mobile/src/screens/connection-screen.tsx
+++ b/apps/mobile/src/screens/connection-screen.tsx
@@ -1165,9 +1165,8 @@ function ptyProbeErrorMessage(error: unknown) {
const styles = StyleSheet.create({
appLockError: {
+ ...typography.caption,
color: palette.danger,
- fontSize: 12,
- lineHeight: 18,
marginTop: space.sm,
},
appLockRow: { marginTop: space.lg },
@@ -1180,19 +1179,17 @@ const styles = StyleSheet.create({
maxWidth: 680,
},
error: {
+ ...typography.body,
backgroundColor: palette.background,
borderColor: palette.danger,
borderRadius: radius.sm,
borderWidth: 1,
color: palette.danger,
- fontSize: 14,
- lineHeight: 20,
padding: space.md,
},
emptyCopy: {
+ ...typography.body,
color: palette.dim,
- fontSize: 14,
- lineHeight: 20,
},
diagnosticsText: {
...typography.code,
@@ -1219,10 +1216,8 @@ const styles = StyleSheet.create({
},
flex: { flex: 1 },
footer: {
+ ...typography.caption,
color: palette.dim,
- fontFamily: Platform.select({ android: "monospace", ios: "Menlo" }),
- fontSize: 10,
- letterSpacing: 1.2,
marginTop: space.lg,
textAlign: "center",
},
@@ -1234,9 +1229,8 @@ const styles = StyleSheet.create({
paddingBottom: space.lg,
},
guidanceText: {
+ ...typography.body,
color: palette.dim,
- fontSize: 14,
- lineHeight: 20,
},
guidanceTitle: {
...typography.label,
@@ -1267,20 +1261,19 @@ const styles = StyleSheet.create({
color: palette.ink,
},
input: {
+ ...typography.body,
backgroundColor: palette.background,
borderColor: palette.border,
borderRadius: radius.sm,
borderWidth: 1,
color: palette.ink,
- fontSize: 15,
minHeight: 48,
paddingHorizontal: 14,
paddingVertical: 12,
},
intro: {
+ ...typography.body,
color: palette.dim,
- fontSize: 16,
- lineHeight: 24,
marginTop: space.md,
maxWidth: 560,
},
@@ -1310,19 +1303,16 @@ const styles = StyleSheet.create({
gap: space.lg,
},
metricValue: {
+ ...typography.heading,
color: palette.ink,
- fontFamily: Platform.select({ android: "monospace", ios: "Menlo" }),
- fontSize: 17,
- fontWeight: "600",
},
notice: {
+ ...typography.body,
backgroundColor: palette.background,
borderColor: palette.success,
borderRadius: radius.sm,
borderWidth: 1,
color: palette.success,
- fontSize: 14,
- lineHeight: 20,
padding: space.md,
},
panel: {
@@ -1382,10 +1372,9 @@ const styles = StyleSheet.create({
padding: space.md,
},
profileName: {
+ ...typography.heading,
color: palette.ink,
flex: 1,
- fontSize: 16,
- fontWeight: "700",
},
profileNameRow: {
alignItems: "center",
@@ -1393,20 +1382,16 @@ const styles = StyleSheet.create({
gap: space.sm,
},
profileOrigin: {
+ ...typography.code,
color: palette.dim,
- fontFamily: Platform.select({ android: "monospace", ios: "Menlo" }),
- fontSize: 12,
},
profileSection: {
gap: space.sm,
marginTop: space.xl,
},
product: {
+ ...typography.label,
color: palette.signal,
- fontFamily: Platform.select({ android: "monospace", ios: "Menlo" }),
- fontSize: 12,
- fontWeight: "700",
- letterSpacing: 1.4,
},
result: {
backgroundColor: palette.background,
@@ -1433,9 +1418,8 @@ const styles = StyleSheet.create({
color: palette.success,
},
runtimeCopy: {
+ ...typography.caption,
color: palette.dim,
- fontFamily: Platform.select({ android: "monospace", ios: "Menlo" }),
- fontSize: 11,
},
runtimeActions: {
flexDirection: "row",
@@ -1567,14 +1551,12 @@ const styles = StyleSheet.create({
width: 8,
},
streamCode: {
+ ...typography.code,
color: palette.signal,
- fontFamily: Platform.select({ android: "monospace", ios: "Menlo" }),
- fontWeight: "700",
},
streamCopy: {
+ ...typography.body,
color: palette.ink,
- fontSize: 14,
- lineHeight: 21,
},
streamResult: {
backgroundColor: palette.card,
@@ -1608,9 +1590,8 @@ const styles = StyleSheet.create({
padding: space.md,
},
warningText: {
+ ...typography.body,
color: palette.dim,
- fontSize: 14,
- lineHeight: 19,
marginTop: space.xs,
},
warningTitle: {
diff --git a/apps/mobile/src/screens/diff-screen.tsx b/apps/mobile/src/screens/diff-screen.tsx
index ba1bcbd..09c9280 100644
--- a/apps/mobile/src/screens/diff-screen.tsx
+++ b/apps/mobile/src/screens/diff-screen.tsx
@@ -332,7 +332,7 @@ const styles = StyleSheet.create({
content: { paddingBottom: space.xl },
deletions: { color: diffPalette.removedText, fontWeight: "800" },
emptyContent: { flexGrow: 1, justifyContent: "center", padding: space.lg },
- explanation: { color: palette.dim, fontSize: 12, lineHeight: 18 },
+ explanation: { ...typography.caption, color: palette.dim },
fileHeader: {
backgroundColor: palette.card,
borderBottomColor: palette.border,
@@ -359,9 +359,9 @@ const styles = StyleSheet.create({
lineMeta: { color: palette.dim },
pressed: { opacity: 0.7 },
retry: { justifyContent: "center", minHeight: 44, paddingRight: space.md },
- retryLabel: { color: palette.signal, fontSize: 14, fontWeight: "700" },
+ retryLabel: { ...typography.control, color: palette.signal },
state: { gap: space.sm },
- stateDetail: { color: palette.dim, fontSize: 15, lineHeight: 22 },
+ stateDetail: { ...typography.body, color: palette.dim },
stateTitle: { ...typography.heading, color: palette.ink },
summary: { gap: space.xs, padding: space.md },
title: { ...typography.heading, color: palette.ink },
diff --git a/apps/mobile/src/screens/followed-projects-screen.test.tsx b/apps/mobile/src/screens/followed-projects-screen.test.tsx
index b81e100..88c9977 100644
--- a/apps/mobile/src/screens/followed-projects-screen.test.tsx
+++ b/apps/mobile/src/screens/followed-projects-screen.test.tsx
@@ -3,6 +3,7 @@ import { fireEvent, render, screen } from "@testing-library/react-native";
import type { ReactNode } from "react";
import { useWorkspaceSelection } from "../state/workspace-selection-context";
+import { palette, typography } from "../theme";
import { FollowedProjectsScreen } from "./followed-projects-screen";
jest.mock("../state/workspace-selection-context", () => ({ useWorkspaceSelection: jest.fn() }));
@@ -28,7 +29,7 @@ test.each(["projectsError", "empty"])("offers recovery for %s", (state) => {
/>,
);
if (state === "empty") expect(screen.getByText("No server projects found")).toBeOnTheScreen();
- else expect(screen.getByRole("alert")).toBeOnTheScreen();
+ else expect(screen.getByRole("alert")).toHaveStyle({ ...typography.body, color: palette.danger });
fireEvent.press(
screen.getByRole("button", {
name: state === "empty" ? "Refresh projects" : "Retry loading projects",
diff --git a/apps/mobile/src/screens/followed-projects-screen.tsx b/apps/mobile/src/screens/followed-projects-screen.tsx
index 93fc8b7..df21f0c 100644
--- a/apps/mobile/src/screens/followed-projects-screen.tsx
+++ b/apps/mobile/src/screens/followed-projects-screen.tsx
@@ -222,9 +222,9 @@ function projectLabel(project: { canonical: string; id: string; name?: string })
const styles = StyleSheet.create({
content: { gap: space.md, padding: space.lg, paddingBottom: space.xl },
- copy: { color: palette.dim, fontSize: 14, lineHeight: 21 },
+ copy: { ...typography.body, color: palette.dim },
disabled: { opacity: 0.5 },
- error: { color: palette.danger, fontSize: 14, lineHeight: 20 },
+ error: { ...typography.body, color: palette.danger },
eyebrow: { ...typography.label, color: palette.dim },
followState: { ...typography.label, color: palette.dim },
followStateSelected: { color: palette.signal },
@@ -245,7 +245,7 @@ const styles = StyleSheet.create({
borderWidth: 1,
},
projectMain: { gap: space.xs, minHeight: 72, padding: space.md },
- projectPath: { color: palette.dim, fontSize: 12, lineHeight: 17 },
+ projectPath: { ...typography.caption, color: palette.dim },
projectSelected: { borderColor: palette.signal },
projectTitle: { ...typography.heading, color: palette.ink },
state: { alignItems: "center", gap: space.sm, paddingVertical: space.xl },
diff --git a/apps/mobile/src/screens/model-picker.tsx b/apps/mobile/src/screens/model-picker.tsx
index 88a4340..7f7fccf 100644
--- a/apps/mobile/src/screens/model-picker.tsx
+++ b/apps/mobile/src/screens/model-picker.tsx
@@ -14,7 +14,7 @@ import {
import { ModalSheet } from "../components/modal-sheet";
import type { useModelFavorites } from "../state/use-model-favorites";
import { modelIdentityKey } from "../storage/model-favorites-repository";
-import { palette, space, typography } from "../theme";
+import { palette, radius, space, typography } from "../theme";
export type FavoriteControls = ReturnType;
export type CatalogState = { loading: boolean; error: boolean; retry: () => void };
@@ -186,11 +186,11 @@ export function PickerNotice({ text, retry }: { text: string; retry: () => void
const styles = StyleSheet.create({
search: {
+ ...typography.body,
borderWidth: 1,
borderColor: palette.border,
- borderRadius: 8,
+ borderRadius: radius.sm,
color: palette.ink,
- fontSize: 16,
minHeight: 48,
paddingHorizontal: space.md,
},
@@ -199,7 +199,7 @@ const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
- borderRadius: 6,
+ borderRadius: radius.sm,
paddingHorizontal: space.sm,
},
choice: {
diff --git a/apps/mobile/src/screens/new-session-screen.tsx b/apps/mobile/src/screens/new-session-screen.tsx
index 4654a74..575509e 100644
--- a/apps/mobile/src/screens/new-session-screen.tsx
+++ b/apps/mobile/src/screens/new-session-screen.tsx
@@ -557,12 +557,12 @@ const styles = StyleSheet.create({
},
actionLabel: { ...typography.control, color: palette.signal, textAlign: "center" },
backButton: { alignSelf: "flex-start", justifyContent: "center", minHeight: 44 },
- backLabel: { color: palette.signal, fontSize: 15, fontWeight: "700" },
+ backLabel: { ...typography.control, color: palette.signal },
body: { flex: 1, gap: space.md, padding: space.lg, paddingTop: space.md },
cancelButton: { justifyContent: "center", minHeight: 44, paddingHorizontal: space.xs },
cancelLabel: { ...typography.control, color: palette.signal },
clearButton: { justifyContent: "center", minHeight: 44, paddingHorizontal: space.md },
- clearLabel: { color: palette.signal, fontSize: 14, fontWeight: "800" },
+ clearLabel: { ...typography.control, color: palette.signal },
creatingState: {
alignItems: "center",
borderTopColor: palette.border,
@@ -574,13 +574,12 @@ const styles = StyleSheet.create({
},
disabled: { opacity: 0.45 },
emptyState: { alignItems: "center", gap: space.xs, paddingVertical: space.xl },
- error: { color: palette.danger, fontSize: 14, lineHeight: 20, textAlign: "center" },
- followNote: { color: palette.signal, fontSize: 11, fontWeight: "800", marginTop: space.xs },
- followingCopy: { color: palette.dim, fontSize: 14, lineHeight: 19 },
+ error: { ...typography.body, color: palette.danger, textAlign: "center" },
+ followNote: { ...typography.label, color: palette.signal, marginTop: space.xs },
+ followingCopy: { ...typography.body, color: palette.dim },
footerError: {
+ ...typography.body,
color: palette.danger,
- fontSize: 14,
- lineHeight: 18,
paddingHorizontal: space.lg,
paddingVertical: space.md,
},
@@ -606,7 +605,7 @@ const styles = StyleSheet.create({
minHeight: 72,
padding: space.md,
},
- projectPath: { color: palette.dim, fontSize: 12, lineHeight: 18, marginTop: 3 },
+ projectPath: { ...typography.caption, color: palette.dim, marginTop: 3 },
projectTitle: { ...typography.heading, color: palette.ink },
screen: { backgroundColor: palette.background, flex: 1 },
searchField: {
@@ -618,9 +617,9 @@ const styles = StyleSheet.create({
minHeight: 48,
},
searchInput: {
+ ...typography.body,
color: palette.ink,
flex: 1,
- fontSize: 16,
minHeight: 48,
paddingHorizontal: space.md,
},
@@ -638,8 +637,8 @@ const styles = StyleSheet.create({
justifyContent: "center",
padding: space.xl,
},
- stateCopy: { color: palette.dim, fontSize: 14, lineHeight: 20, textAlign: "center" },
+ stateCopy: { ...typography.body, color: palette.dim, textAlign: "center" },
stateTitle: { ...typography.heading, color: palette.ink, textAlign: "center" },
- subtitle: { color: palette.dim, fontSize: 14, lineHeight: 20, marginTop: 3 },
+ subtitle: { ...typography.caption, color: palette.dim, marginTop: 3 },
title: { ...typography.sheetTitle, color: palette.ink },
});
diff --git a/apps/mobile/src/screens/notification-pairing-screen.tsx b/apps/mobile/src/screens/notification-pairing-screen.tsx
index f3ca35b..29fc720 100644
--- a/apps/mobile/src/screens/notification-pairing-screen.tsx
+++ b/apps/mobile/src/screens/notification-pairing-screen.tsx
@@ -527,7 +527,7 @@ const styles = StyleSheet.create({
marginTop: space.lg,
overflow: "hidden",
},
- cameraHint: { color: palette.dim, fontSize: 14, padding: space.md, textAlign: "center" },
+ cameraHint: { ...typography.body, color: palette.dim, padding: space.md, textAlign: "center" },
closeButton: { justifyContent: "center", minHeight: 44, paddingLeft: space.md },
closeLabel: { ...typography.control, color: palette.signal },
codeInput: {
@@ -542,7 +542,7 @@ const styles = StyleSheet.create({
textAlignVertical: "top",
},
content: { padding: space.lg, paddingBottom: 48 },
- copy: { color: palette.dim, fontSize: 16, lineHeight: 24, marginTop: space.md },
+ copy: { ...typography.body, color: palette.dim, marginTop: space.md },
divider: { backgroundColor: palette.border, flex: 1, height: 1 },
dividerLabel: { ...typography.label, color: palette.dim },
dividerRow: {
@@ -551,7 +551,7 @@ const styles = StyleSheet.create({
gap: space.sm,
marginVertical: space.lg,
},
- error: { color: palette.danger, fontSize: 14, lineHeight: 20, marginTop: space.md },
+ error: { ...typography.body, color: palette.danger, marginTop: space.md },
eyebrow: { ...typography.label, color: palette.dim },
flex: { flex: 1 },
headerCopy: { flex: 1 },
@@ -584,9 +584,9 @@ const styles = StyleSheet.create({
padding: space.lg,
},
previewEyebrow: { ...typography.label, color: palette.success },
- previewMeta: { color: palette.dim, fontSize: 14, lineHeight: 19, marginTop: space.sm },
+ previewMeta: { ...typography.caption, color: palette.dim, marginTop: space.sm },
previewName: { ...typography.heading, color: palette.ink, marginTop: space.xs },
- previewOrigin: { color: palette.ink, fontSize: 14, marginTop: space.xs },
+ previewOrigin: { ...typography.body, color: palette.ink, marginTop: space.xs },
safeArea: { backgroundColor: palette.background, flex: 1 },
scanButton: {
...control,
diff --git a/apps/mobile/src/screens/session-composer.test.tsx b/apps/mobile/src/screens/session-composer.test.tsx
index 1d05e70..9e616cb 100644
--- a/apps/mobile/src/screens/session-composer.test.tsx
+++ b/apps/mobile/src/screens/session-composer.test.tsx
@@ -11,6 +11,7 @@ import { fireEvent, render, screen, within } from "@testing-library/react-native
import { useState } from "react";
import { Keyboard, StyleSheet } from "react-native";
+import { typography } from "../theme";
import type { PromptDelivery } from "./prompt-admission-model";
import { SessionComposer } from "./session-composer";
import type { ComposerMention, ComposerSubmitIntent } from "./session-composer-model";
@@ -143,16 +144,17 @@ test("keeps send in the focused composer toolbar", () => {
).toBeOnTheScreen();
});
-test("requires an explicit queue or steer choice while execution is active", () => {
+test("uses the selected default without showing queue or steer choices", () => {
const onSubmit = jest.fn();
render();
const input = screen.getByLabelText("Prompt");
+ expect(input).toHaveStyle(typography.body);
fireEvent.changeText(input, "Follow-up");
- expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
fireEvent(input, "focus");
- fireEvent.press(screen.getByRole("radio", { name: "Queue next" }));
- fireEvent.press(screen.getByRole("button", { name: "Queue" }));
+ expect(screen.queryByRole("radio", { name: "Queue next" })).toBeNull();
+ expect(screen.queryByRole("radio", { name: "Steer now" })).toBeNull();
+ fireEvent.press(screen.getByRole("button", { name: "Steer" }));
expect(onSubmit).toHaveBeenCalledTimes(1);
});
@@ -172,7 +174,6 @@ test("keeps the editor read-only until its encrypted draft has loaded", () => {
mentions={[]}
models={models}
onAgentChange={jest.fn()}
- onDeliveryChange={jest.fn()}
onDraftChange={jest.fn()}
onModelChange={jest.fn()}
onMentionSearchChange={jest.fn()}
@@ -200,7 +201,6 @@ test("selects a server agent and model", () => {
mentions={[]}
models={models}
onAgentChange={onAgentChange}
- onDeliveryChange={jest.fn()}
onDraftChange={jest.fn()}
onModelChange={onModelChange}
onMentionSearchChange={jest.fn()}
@@ -329,7 +329,7 @@ function ComposerHarness({
onSubmit: (intent: ComposerSubmitIntent) => void;
}) {
const [draft, setDraft] = useState("");
- const [delivery, setDelivery] = useState();
+ const delivery: PromptDelivery = "steer";
const [agent, setAgent] = useState();
const [model, setModel] = useState();
const [mentions, setMentions] = useState([]);
@@ -351,7 +351,6 @@ function ComposerHarness({
model={model}
models={models}
onAgentChange={setAgent}
- onDeliveryChange={setDelivery}
onDraftChange={(content, nextMentions) => {
setDraft(content);
setMentions(nextMentions);
diff --git a/apps/mobile/src/screens/session-composer.tsx b/apps/mobile/src/screens/session-composer.tsx
index 102b0bf..dc389f0 100644
--- a/apps/mobile/src/screens/session-composer.tsx
+++ b/apps/mobile/src/screens/session-composer.tsx
@@ -73,7 +73,6 @@ export function SessionComposer({
model,
models,
onAgentChange,
- onDeliveryChange,
onDraftChange,
onModelChange,
onMentionSearchChange,
@@ -105,7 +104,6 @@ export function SessionComposer({
model?: ModelRef | undefined;
models: ModelInfo[];
onAgentChange: (agent: string) => void;
- onDeliveryChange: (delivery: PromptDelivery) => void;
onDraftChange: (draft: string, mentions: ComposerMention[]) => void;
onModelChange: (model: ModelRef) => void;
onMentionSearchChange: (query: string | undefined) => void;
@@ -316,21 +314,6 @@ export function SessionComposer({
) : null}
- {expanded && active ? (
-
- onDeliveryChange("steer")}
- />
- onDeliveryChange("queue")}
- />
-
- ) : null}
-
void;
-}) {
- return (
- [
- styles.deliveryButton,
- active && styles.deliveryButtonActive,
- pressed && styles.pressed,
- ]}
- >
-
- {label}
-
-
- );
-}
-
function OptionButton({
compact,
description,
@@ -756,37 +709,27 @@ const styles = StyleSheet.create({
paddingHorizontal: space.sm,
paddingVertical: 7,
},
- completionDetail: { color: palette.dim, fontSize: 12, lineHeight: 16 },
+ completionDetail: { ...typography.caption, color: palette.dim },
completionHeading: { alignItems: "center", flexDirection: "row", gap: space.xs },
completionKind: { ...typography.label, color: palette.dim },
completionList: { maxHeight: 220 },
completionListContent: { gap: 4 },
completionName: { ...typography.control, color: palette.ink, flex: 1 },
completionState: { color: palette.dim, paddingVertical: space.sm, textAlign: "center" },
- count: { alignSelf: "center", color: palette.dim, fontSize: 10, paddingHorizontal: space.xs },
- deliveryButton: {
- alignItems: "center",
- borderColor: palette.border,
- borderRadius: radius.sm,
- borderWidth: 1,
- flex: 1,
- justifyContent: "center",
- minHeight: 48,
- paddingHorizontal: space.sm,
+ count: {
+ ...typography.caption,
+ alignSelf: "center",
+ color: palette.dim,
+ paddingHorizontal: space.xs,
},
- deliveryButtonActive: { backgroundColor: palette.signalDark, borderColor: palette.signal },
- deliveryLabel: { color: palette.dim, fontSize: 14, fontWeight: "700" },
- deliveryLabelActive: { color: palette.signal },
- deliveryRow: { flexDirection: "row", gap: space.xs },
editorRow: { alignItems: "center", flexDirection: "row", minWidth: 0 },
editorRowExpanded: { alignItems: "stretch" },
emptyResults: { color: palette.dim, paddingVertical: space.lg, textAlign: "center" },
- error: { color: palette.danger, fontSize: 14, lineHeight: 18 },
+ error: { ...typography.body, color: palette.danger },
input: {
+ ...typography.body,
color: palette.ink,
flex: 1,
- fontSize: 16,
- lineHeight: 23,
},
inputCollapsed: { height: 42, paddingHorizontal: 4, paddingVertical: 0 },
inputExpanded: {
@@ -803,7 +746,7 @@ const styles = StyleSheet.create({
padding: space.md,
},
optionCompact: { marginLeft: space.md, minHeight: 50, paddingVertical: space.sm },
- optionDescription: { color: palette.dim, fontSize: 12, marginTop: 3 },
+ optionDescription: { ...typography.caption, color: palette.dim, marginTop: 3 },
optionLabel: { ...typography.heading, color: palette.ink },
optionSelected: { backgroundColor: palette.signalDark, borderColor: palette.signal },
optionSeparator: { height: space.xs },
@@ -811,11 +754,11 @@ const styles = StyleSheet.create({
pickerListContent: { flexGrow: 1, justifyContent: "flex-start" },
pressed: { opacity: 0.62 },
searchInput: {
+ ...typography.body,
borderColor: palette.border,
borderRadius: radius.sm,
borderWidth: 1,
color: palette.ink,
- fontSize: 16,
minHeight: 48,
paddingHorizontal: space.md,
},
@@ -827,7 +770,7 @@ const styles = StyleSheet.create({
minHeight: 44,
paddingHorizontal: 4,
},
- selectorLabel: { color: palette.dim, flexShrink: 1, fontSize: 14, fontWeight: "400" },
+ selectorLabel: { ...typography.body, color: palette.dim, flexShrink: 1 },
selectorRow: { alignItems: "center", gap: space.xs, paddingRight: space.xs },
selectorScroller: { flex: 1 },
sendButton: {
diff --git a/apps/mobile/src/screens/session-execution-panel.test.tsx b/apps/mobile/src/screens/session-execution-panel.test.tsx
index 121c549..e61b0d5 100644
--- a/apps/mobile/src/screens/session-execution-panel.test.tsx
+++ b/apps/mobile/src/screens/session-execution-panel.test.tsx
@@ -1,7 +1,7 @@
import { expect, jest, test } from "@jest/globals";
import type { SessionInboxInfo } from "@opencode2-mobile/opencode-adapter";
-import { fireEvent, render, screen } from "@testing-library/react-native";
-
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react-native";
+import { palette, typography } from "../theme";
import type { PromptAdmission } from "./prompt-admission-model";
import { SessionExecutionPanel } from "./session-execution-panel";
@@ -17,6 +17,231 @@ const callbacks = {
onWait: jest.fn(),
};
+test.each(["admitted", "queued", "steered", "promoted", "executing"] as const)(
+ "the %s handoff keeps Working without a non-actionable admission card",
+ (status) => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Working")).toBeOnTheScreen();
+ expect(
+ screen.queryByText("Waiting for the durable inbox item or projected message."),
+ ).toBeNull();
+ expect(screen.queryByText(status.toUpperCase())).toBeNull();
+ },
+);
+
+test("a brief steering inbox snapshot without a local admission never flashes controls", () => {
+ jest.useFakeTimers({ now: 1_000 });
+ try {
+ const props = {
+ active: false,
+ admissions: [],
+ permissions: [],
+ permissionReplyError: false,
+ projectedMessageIds: new Set(),
+ ...callbacks,
+ };
+ const view = render(
+ ,
+ );
+ expect(screen.queryByText("STEERING")).toBeNull();
+ expect(screen.queryByRole("button", { name: "Queue next" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
+ act(() => jest.advanceTimersByTime(100));
+ view.rerender();
+ act(() => jest.advanceTimersByTime(500));
+ expect(screen.queryByLabelText("Session execution")).toBeNull();
+ view.unmount();
+ } finally {
+ jest.useRealTimers();
+ }
+});
+
+test("a prompt promoted within 200ms never flashes admission or inbox action cards", () => {
+ jest.useFakeTimers({ now: 1_000 });
+ try {
+ const admission: PromptAdmission = {
+ id: "msg_fast",
+ kind: "prompt",
+ durable: false,
+ status: "submitting",
+ submittedAtMs: Date.now(),
+ };
+ const props = {
+ active: false,
+ admissions: [admission],
+ inbox: [] as SessionInboxInfo[],
+ permissions: [],
+ permissionReplyError: false,
+ projectedMessageIds: new Set(),
+ ...callbacks,
+ };
+ const view = render();
+ expect(screen.queryByText("SENDING")).toBeNull();
+ act(() => jest.advanceTimersByTime(100));
+ view.rerender(
+ ,
+ );
+ expect(screen.queryByText("QUEUED")).toBeNull();
+ expect(screen.queryByRole("button", { name: "Steer now" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
+ act(() => jest.advanceTimersByTime(100));
+ view.rerender(
+ ,
+ );
+ act(() => jest.advanceTimersByTime(1_000));
+ expect(screen.queryByText("SENDING")).toBeNull();
+ expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
+ view.unmount();
+ } finally {
+ jest.useRealTimers();
+ }
+});
+
+test.each(["submitting", "queued"] as const)(
+ "a persistent %s prompt appears after the grace period even across refetches",
+ (status) => {
+ jest.useFakeTimers({ now: 1_000 });
+ try {
+ const admission: PromptAdmission = {
+ id: "msg_slow",
+ kind: "prompt",
+ durable: status === "queued",
+ status,
+ submittedAtMs: Date.now(),
+ };
+ const props = {
+ active: false,
+ admissions: [admission],
+ inbox:
+ status === "queued"
+ ? ([
+ {
+ id: admission.id,
+ type: "user",
+ sessionID: "ses_test",
+ delivery: "queue",
+ payload: { text: "Waiting prompt" },
+ time: { created: Date.now() },
+ },
+ ] satisfies SessionInboxInfo[])
+ : [],
+ permissions: [],
+ permissionReplyError: false,
+ projectedMessageIds: new Set(),
+ ...callbacks,
+ };
+ const view = render();
+ act(() => jest.advanceTimersByTime(300));
+ view.rerender();
+ act(() => jest.advanceTimersByTime(199));
+ expect(screen.queryByLabelText("Session execution")).toBeNull();
+ act(() => jest.advanceTimersByTime(1));
+ expect(screen.getByText(status === "queued" ? "QUEUED" : "Sending")).toBeOnTheScreen();
+ if (status === "queued") {
+ fireEvent.press(screen.getByRole("button", { name: "Cancel" }));
+ expect(callbacks.onCancelInbox).toHaveBeenCalledWith(admission.id);
+ }
+ view.unmount();
+ expect(jest.getTimerCount()).toBe(0);
+ } finally {
+ jest.useRealTimers();
+ }
+ },
+);
+
+test("a projected prompt never retains stale inbox action cards", () => {
+ render(
+ ,
+ );
+ expect(screen.queryByLabelText("Session execution")).toBeNull();
+});
+
+test("unknown delivery bypasses the grace period", () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Check delivery" })).toBeOnTheScreen();
+ expect(screen.getByRole("button", { name: "Allow retry (may duplicate)" })).toBeOnTheScreen();
+});
+
test("ordinary active execution does not advertise a background action", () => {
render(
{
expect(screen.queryByRole("button", { name: "Move to background" })).toBeNull();
});
-test("renders active execution and mutable queued inbox work", () => {
+test("renders active execution and mutable queued inbox work", async () => {
const inbox = [
{
delivery: "queue",
@@ -62,7 +287,12 @@ test("renders active execution and mutable queued inbox work", () => {
expect(callbacks.onBackground).toHaveBeenCalledTimes(1);
fireEvent.press(screen.getByRole("button", { name: "Stop" }));
expect(callbacks.onInterrupt).toHaveBeenCalledTimes(1);
- expect(screen.getByText("Queued prompt")).toBeOnTheScreen();
+ await waitFor(() => expect(screen.getByText("Queued prompt")).toBeOnTheScreen());
+ expect(screen.getByText("Queued prompt")).toHaveStyle({ ...typography.body, color: palette.ink });
+ expect(screen.getByText("QUEUED")).toHaveStyle({ ...typography.label, color: palette.dim });
+ expect(screen.getByRole("button", { name: "Cancel" })).toHaveStyle({
+ borderColor: palette.border,
+ });
fireEvent.press(screen.getByRole("button", { name: "Steer now" }));
fireEvent.press(screen.getByRole("button", { name: "Cancel" }));
expect(callbacks.onSteerInbox).toHaveBeenCalledWith("msg_queued");
@@ -95,8 +325,9 @@ test("agent-switch reminders do not become actionable steering cards", () => {
expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
});
-test("keeps a real steering prompt actionable alongside internal mode reminders", () => {
- render(
+test("steering never offers Queue next, even when it remains pending", () => {
+ jest.useFakeTimers();
+ const view = render(
,
);
- expect(screen.getAllByText("STEERING")).toHaveLength(1);
- expect(screen.getByText("Please check the tests")).toBeOnTheScreen();
- fireEvent.press(screen.getByRole("button", { name: "Queue next" }));
- expect(callbacks.onQueueInbox).toHaveBeenLastCalledWith("msg_followup");
- fireEvent.press(screen.getByRole("button", { name: "Cancel" }));
- expect(callbacks.onCancelInbox).toHaveBeenLastCalledWith("msg_followup");
+ try {
+ act(() => jest.advanceTimersByTime(5_000));
+ expect(screen.queryByText("STEERING")).toBeNull();
+ expect(screen.queryByText("Please check the tests")).toBeNull();
+ expect(screen.queryByRole("button", { name: "Queue next" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
+ } finally {
+ view.unmount();
+ jest.useRealTimers();
+ }
});
test("keeps unknown delivery visible until reconciliation finds the stable ID", () => {
@@ -153,6 +388,10 @@ test("keeps unknown delivery visible until reconciliation finds the stable ID",
);
expect(screen.getByText("DELIVERY UNKNOWN")).toBeOnTheScreen();
+ expect(screen.getByText(/server may have admitted this prompt/)).toHaveStyle({
+ ...typography.body,
+ color: palette.dim,
+ });
fireEvent.press(screen.getByRole("button", { name: "Check delivery" }));
expect(callbacks.onCheckAdmission).toHaveBeenCalledWith("msg_unknown");
diff --git a/apps/mobile/src/screens/session-execution-panel.tsx b/apps/mobile/src/screens/session-execution-panel.tsx
index e7e0a1d..95be9f0 100644
--- a/apps/mobile/src/screens/session-execution-panel.tsx
+++ b/apps/mobile/src/screens/session-execution-panel.tsx
@@ -3,19 +3,18 @@ import type {
PermissionRequest,
SessionInboxInfo,
} from "@opencode2-mobile/opencode-adapter";
-import type { ReactNode } from "react";
+import { type ReactNode, useEffect, useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, useWindowDimensions, View } from "react-native";
import { WorkingIndicator } from "../components/working-indicator";
import { control, palette, radius, space, typeRamp, typography } from "../theme";
import { PermissionRequestCard } from "./permission-request-card";
-import {
- type PromptAdmission,
- promptAdmissionLabel,
- promptAdmissionNeedsOverlay,
-} from "./prompt-admission-model";
+import { type PromptAdmission, promptAdmissionNeedsOverlay } from "./prompt-admission-model";
import { sanitizeTranscriptText } from "./session-transcript-model";
+// Fast admissions normally reach the transcript before a card can be read.
+const promptCardDelayMs = 500;
+
export function SessionExecutionPanel({
active,
admissions,
@@ -28,7 +27,6 @@ export function SessionExecutionPanel({
onCheckAdmission,
onInterrupt,
onBackground,
- onQueueInbox,
onReplyPermission,
onSteerInbox,
permissionReplyError,
@@ -47,7 +45,6 @@ export function SessionExecutionPanel({
onCheckAdmission: (admissionID: string) => void;
onInterrupt: () => void;
onBackground?: () => void;
- onQueueInbox: (inboxID: string) => void;
onReplyPermission: (requestID: string, sessionID: string, reply: PermissionReply) => void;
onSteerInbox: (inboxID: string) => void;
permissionReplyError: boolean;
@@ -56,17 +53,81 @@ export function SessionExecutionPanel({
replyingPermissionId?: string | undefined;
}) {
const { height } = useWindowDimensions();
- // Agent-mode reminders and other control inputs are server-owned inbox work,
- // not user prompts that should offer steering or cancellation controls.
- const promptInbox = inbox.filter((item) => item.type === "user");
+ const [, refreshDisplay] = useState(0);
+ const [inboxDeadlines, setInboxDeadlines] = useState