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>(() => new Map()); + const now = Date.now(); + + useEffect(() => { + const ids = inbox + .filter( + (item) => + item.type === "user" && item.delivery === "queue" && !projectedMessageIds.has(item.id), + ) + .map((item) => item.id); + setInboxDeadlines((current) => { + if (ids.length === current.size && ids.every((id) => current.has(id))) return current; + return new Map(ids.map((id) => [id, current.get(id) ?? Date.now() + promptCardDelayMs])); + }); + }, [inbox, projectedMessageIds]); + + const settlingAdmissionIds = new Set( + admissions + .filter( + (admission) => + admission.status !== "unknown-delivery" && + now < admission.submittedAtMs + promptCardDelayMs, + ) + .map((admission) => admission.id), + ); + + useEffect(() => { + const admissionDeadlines = admissions + .filter( + (admission) => + admission.status !== "unknown-delivery" && + admission.status !== "cancelled" && + admission.status !== "completed" && + !projectedMessageIds.has(admission.id), + ) + .map((admission) => admission.submittedAtMs + promptCardDelayMs); + const deadlines = [...admissionDeadlines, ...inboxDeadlines.values()].filter( + (deadline) => deadline > now, + ); + if (deadlines.length === 0) return; + const timer = setTimeout( + () => refreshDisplay((revision) => revision + 1), + Math.max(0, Math.min(...deadlines) - Date.now()), + ); + return () => clearTimeout(timer); + }, [admissions, projectedMessageIds, inboxDeadlines, now]); + + // Steering is already selected by the delivery preference. Only queued prompts + // need persistent controls while they wait for the current execution to finish. + const promptInbox = inbox.filter( + (item) => + item.type === "user" && + item.delivery === "queue" && + !projectedMessageIds.has(item.id) && + now >= (inboxDeadlines.get(item.id) ?? Infinity) && + !settlingAdmissionIds.has(item.id), + ); const inboxIds = new Set(inbox.map((item) => item.id)); const localOverlays = admissions.filter( (admission) => + admission.status === "unknown-delivery" && + !inboxIds.has(admission.id) && + promptAdmissionNeedsOverlay(admission, projectedMessageIds.has(admission.id)), + ); + const sending = admissions.some( + (admission) => + admission.status === "submitting" && !inboxIds.has(admission.id) && + !settlingAdmissionIds.has(admission.id) && promptAdmissionNeedsOverlay(admission, projectedMessageIds.has(admission.id)), ); if ( !active && + !sending && promptInbox.length === 0 && localOverlays.length === 0 && permissions.length === 0 && @@ -142,30 +203,36 @@ export function SessionExecutionPanel({ {formRequests} + {!active && sending ? ( + + Sending + + ) : null} + {localOverlays.map((admission) => ( - {promptAdmissionLabel(admission.status).toUpperCase()} + DELIVERY UNKNOWN - {admission.status === "unknown-delivery" - ? admission.kind === "command" - ? "The server may have run this command. Check the transcript before sending it again." - : "The server may have admitted this prompt. Check inbox and transcript state before sending it again." - : "Waiting for the durable inbox item or projected message."} + {admission.kind === "command" + ? "The server may have run this command. Check the transcript before sending it again." + : "The server may have admitted this prompt. Check inbox and transcript state before sending it again."} - {admission.status === "unknown-delivery" ? ( - - onCheckAdmission(admission.id)} /> - {admission.retryOffered ? ( - onAllowRetry(admission.id)} - /> - ) : null} - - ) : null} + + onCheckAdmission(admission.id)} /> + {admission.retryOffered ? ( + onAllowRetry(admission.id)} + /> + ) : null} + ))} @@ -173,7 +240,7 @@ export function SessionExecutionPanel({ - {item.delivery === "queue" ? "QUEUED" : "STEERING"} + QUEUED {inboxTypeLabel(item)} @@ -185,11 +252,7 @@ export function SessionExecutionPanel({ ) : null} - {item.delivery === "queue" ? ( - onSteerInbox(item.id)} /> - ) : ( - onQueueInbox(item.id)} /> - )} + onSteerInbox(item.id)} /> onCancelInbox(item.id)} /> @@ -258,7 +321,7 @@ const styles = StyleSheet.create({ borderWidth: 1, justifyContent: "center", }, - actionButtonDanger: { borderColor: palette.danger }, + actionButtonDanger: { borderColor: palette.border }, actionLabel: { ...typography.control, color: palette.ink }, actionLabelDanger: { color: palette.danger }, actionRow: { flexDirection: "row", flexWrap: "wrap", gap: space.sm }, @@ -271,8 +334,9 @@ const styles = StyleSheet.create({ gap: space.sm, padding: space.md, }, - cardCopy: { color: palette.dim, fontSize: 14, lineHeight: 19 }, - cardEyebrow: { ...typography.label, color: palette.warm }, + cardCopy: { ...typography.body, color: palette.dim }, + sending: { ...typography.caption, color: palette.dim }, + cardEyebrow: { ...typography.label, color: palette.dim }, disabled: { opacity: 0.5 }, executionRow: { alignItems: "center", @@ -283,9 +347,9 @@ const styles = StyleSheet.create({ paddingHorizontal: space.sm, minHeight: 44, }, - executionTitle: { color: palette.dim, flexShrink: 1, fontSize: 12 }, + executionTitle: { ...typography.caption, color: palette.dim, flexShrink: 1 }, stopButton: { minHeight: 44, minWidth: 44, alignItems: "center", justifyContent: "center" }, - stopLabel: { color: palette.ink, fontSize: 14 }, + stopLabel: { ...typography.control, color: palette.ink }, headingRow: { alignItems: "center", flexDirection: "row", flexShrink: 1, gap: space.sm }, inboxCard: { backgroundColor: palette.card, @@ -302,9 +366,9 @@ const styles = StyleSheet.create({ gap: space.xs, justifyContent: "space-between", }, - inboxType: { color: palette.dim, fontSize: 11, fontWeight: "700" }, + inboxType: { ...typography.label, color: palette.dim }, pressed: { opacity: 0.62 }, - promptPreview: { color: palette.ink, fontSize: 14, lineHeight: 20 }, + promptPreview: { ...typography.body, color: palette.ink }, content: { gap: space.sm, paddingHorizontal: space.md, paddingVertical: space.xs }, shell: { flexGrow: 0, flexShrink: 1, maxHeight: 280 }, }); diff --git a/apps/mobile/src/screens/use-session-execution.test.tsx b/apps/mobile/src/screens/use-session-execution.test.tsx index 59a60da..78a373e 100644 --- a/apps/mobile/src/screens/use-session-execution.test.tsx +++ b/apps/mobile/src/screens/use-session-execution.test.tsx @@ -20,6 +20,13 @@ import type { PromptAdmission } from "./prompt-admission-model"; import { resolveSessionAgent, useSessionExecution } from "./use-session-execution"; const mockLocation = { directory: "/workspace" } satisfies LocationRef; +const mockPreferences: { defaultDelivery: "queue" | "steer"; busy: boolean } = { + defaultDelivery: "steer", + busy: false, +}; +jest.mock("../state/transcript-preferences", () => ({ + useTranscriptPreferences: () => mockPreferences, +})); const mockAdmissionDb = { getAllAsync: jest.fn<(...args: unknown[]) => Promise>(async () => []), runAsync: jest.fn<(...args: unknown[]) => Promise>(async () => undefined), @@ -104,6 +111,8 @@ beforeAll(() => { }); beforeEach(() => { + mockPreferences.defaultDelivery = "steer"; + mockPreferences.busy = false; jest.clearAllMocks(); mockListActive.mockResolvedValue({}); mockListInbox.mockResolvedValue([]); @@ -235,7 +244,8 @@ test("preserves an unknown draft when the latest server snapshot has no matching expect(clearDraft).not.toHaveBeenCalled(); }); -test("requires explicit active-turn delivery and applies inbox and execution controls", async () => { +test("uses the saved queue default and applies inbox and execution controls", async () => { + mockPreferences.defaultDelivery = "queue"; mockListActive.mockResolvedValue({ ses_a: { type: "running" } }); mockListInbox.mockResolvedValue([userInbox("msg_queued", "queue")]); mockPrompt.mockImplementation(async (...args) => { @@ -245,9 +255,6 @@ test("requires explicit active-turn delivery and applies inbox and execution con const hook = renderExecutionHook(); await waitFor(() => expect(hook.result.current.active).toBe(true)); - act(() => hook.result.current.submit("Follow up")); - expect(mockPrompt).not.toHaveBeenCalled(); - act(() => hook.result.current.setDelivery("queue")); await waitFor(() => expect(hook.result.current.submitDisabled).toBe(false)); act(() => hook.result.current.submit("Follow up")); await waitFor(() => expect(mockPrompt).toHaveBeenCalledTimes(1)); @@ -505,7 +512,8 @@ test("keeps a late admission result scoped to the session that sent it", async ( expect(clearB).not.toHaveBeenCalled(); }); -test("resets delivery and visible errors when the session scope changes", async () => { +test("retains the saved delivery default and clears errors when the session scope changes", async () => { + mockPreferences.defaultDelivery = "queue"; mockListActive.mockResolvedValue({ ses_a: { type: "running" }, ses_b: { type: "running" }, @@ -519,13 +527,12 @@ test("resets delivery and visible errors when the session scope changes", async }, ); await waitFor(() => expect(hook.result.current.active).toBe(true)); - act(() => hook.result.current.setDelivery("queue")); act(() => hook.result.current.switchAgent("build")); await waitFor(() => expect(hook.result.current.error).toMatch(/agent/i)); hook.rerender({ sessionID: "ses_b" }); - await waitFor(() => expect(hook.result.current.delivery).toBeUndefined()); + await waitFor(() => expect(hook.result.current.delivery).toBe("queue")); expect(hook.result.current.error).toBeUndefined(); expect(hook.result.current.busyAction).toBeUndefined(); }); diff --git a/apps/mobile/src/screens/use-session-execution.ts b/apps/mobile/src/screens/use-session-execution.ts index 424add6..25781fc 100644 --- a/apps/mobile/src/screens/use-session-execution.ts +++ b/apps/mobile/src/screens/use-session-execution.ts @@ -37,6 +37,7 @@ import { useSQLiteContext } from "expo-sqlite"; import { useCallback, useEffect, useRef, useState } from "react"; import { openCodeQueryKeys } from "../state/open-code-query-keys"; +import { useTranscriptPreferences } from "../state/transcript-preferences"; import { deleteUnresolvedPromptAdmission, listUnresolvedPromptAdmissions, @@ -51,7 +52,6 @@ import { markPromptInterrupted, markPromptRetryOffered, type PromptAdmission, - type PromptDelivery, reconcilePromptAdmission, } from "./prompt-admission-model"; import type { ComposerSubmitIntent } from "./session-composer-model"; @@ -94,7 +94,8 @@ export function useSessionExecution({ const inboxKey = openCodeQueryKeys.inbox(scopedConnectionId, location, sessionID); const sessionKey = openCodeQueryKeys.session(scopedConnectionId, location, sessionID); const executionScope = `${routeConnectionId}\u0000${sessionID}`; - const [delivery, setDelivery] = useState(); + const preferences = useTranscriptPreferences(); + const delivery = preferences.defaultDelivery ?? "steer"; const [error, setError] = useState(); const [busyAction, setBusyAction] = useState<"background" | "interrupt" | "wait">(); const controllersRef = useRef(new Set()); @@ -206,7 +207,6 @@ export function useSessionExecution({ const controllers = controllersRef.current; submittingRef.current = false; setBusyAction(undefined); - setDelivery(undefined); setError(undefined); return () => { for (const controller of controllers) controller.abort(); @@ -214,11 +214,6 @@ export function useSessionExecution({ }; }, [executionScope]); - useEffect(() => { - if (active) return; - setDelivery(undefined); - }, [active]); - useEffect(() => { if (!draftReady || admissions.length === 0) return; const inboxById = new Map(inbox.map((item) => [item.id, item])); @@ -656,7 +651,7 @@ export function useSessionExecution({ !client || !draftReady || !executionStateReady || - (active && !delivery) || + preferences.busy || submittingRef.current || submissionMutation.isPending || unresolvedAdmission @@ -823,14 +818,13 @@ export function useSessionExecution({ mentionLoading: agentsQuery.isPending || skillsQuery.isPending, mentionUnavailable: agentsQuery.isError || skillsQuery.isError, projectedMessageIds, - setDelivery, submit, submitDisabled: submissionMutation.isPending || switchAgentMutation.isPending || switchModelMutation.isPending || unresolvedAdmission || - (active && !delivery) || + preferences.busy || !executionStateReady || !admissionsQuery.isSuccess || !draftReady || diff --git a/apps/mobile/src/screens/workspace-screen.tsx b/apps/mobile/src/screens/workspace-screen.tsx index c600e18..91d3469 100644 --- a/apps/mobile/src/screens/workspace-screen.tsx +++ b/apps/mobile/src/screens/workspace-screen.tsx @@ -907,7 +907,6 @@ export function SessionScreen({ navigation, route }: SessionProps) { onCheckAdmission={execution.reconcileAdmission} onInterrupt={execution.interrupt} onBackground={execution.background} - onQueueInbox={execution.queueInbox} onReplyPermission={workspaceSelection.replyPermission} onSteerInbox={execution.steerInbox} permissionReplyError={workspaceSelection.permissionReplyError} @@ -947,7 +946,6 @@ export function SessionScreen({ navigation, route }: SessionProps) { model={execution.selectedModel} models={execution.models} onAgentChange={execution.switchAgent} - onDeliveryChange={execution.setDelivery} onDraftChange={draft.setDraft} onModelChange={execution.switchModel} onMentionSearchChange={setMentionSearch} @@ -1538,7 +1536,7 @@ const styles = StyleSheet.create({ clearSearch: { justifyContent: "center", minHeight: 44, paddingLeft: space.sm }, clearSearchLabel: { color: palette.signal, fontSize: 14, fontWeight: "700" }, connectionNotice: { color: palette.warm, fontSize: 12, lineHeight: 18, marginBottom: 2 }, - contextLabel: { color: palette.dim, fontSize: 12, fontWeight: "600" }, + contextLabel: { ...typography.label, color: palette.dim }, contextRow: { alignItems: "center", borderBottomColor: palette.border, @@ -1551,8 +1549,8 @@ const styles = StyleSheet.create({ }, contextRowLargeText: { alignItems: "flex-start", flexDirection: "column", gap: space.xs }, contextRowCopy: { flex: 1, minWidth: 0 }, - contextValue: { color: palette.ink, fontSize: 15, marginTop: 4 }, - countLabel: { color: palette.dim, fontSize: 14, fontWeight: "600" }, + contextValue: { ...typography.body, color: palette.ink, marginTop: 4 }, + countLabel: { ...typography.control, color: palette.dim }, deleteButton: { alignItems: "center", borderColor: palette.danger, @@ -1574,7 +1572,7 @@ const styles = StyleSheet.create({ detailContentLargeText: { paddingBottom: 140 }, detailHeader: { paddingHorizontal: space.md, paddingTop: space.md }, emptyState: { alignItems: "center", minHeight: 160, padding: space.xl }, - error: { color: palette.danger, fontSize: 14, lineHeight: 20 }, + error: { ...typography.body, color: palette.danger }, eyebrow: { ...typography.label, color: palette.dim }, headerAction: { alignItems: "center", @@ -1604,12 +1602,12 @@ const styles = StyleSheet.create({ homeTitleRow: { alignItems: "center", flexDirection: "row", gap: space.sm }, homeTitleRowLargeText: { alignItems: "flex-start", flexDirection: "column" }, input: { + ...typography.body, backgroundColor: palette.card, borderColor: palette.border, borderRadius: radius.md, borderWidth: 1, color: palette.ink, - fontSize: 15, minHeight: 48, paddingHorizontal: 14, paddingVertical: 12, @@ -1642,8 +1640,8 @@ const styles = StyleSheet.create({ paddingBottom: space.sm, }, listSectionTitle: { color: palette.dim, flex: 1, fontSize: 14, fontWeight: "500" }, - muted: { color: palette.dim, fontSize: 14, lineHeight: 21, marginTop: space.xs }, - newSessionCopy: { color: palette.dim, fontSize: 15, lineHeight: 22, marginTop: space.xs }, + muted: { ...typography.body, color: palette.dim, marginTop: space.xs }, + newSessionCopy: { ...typography.body, color: palette.dim, marginTop: space.xs }, newSessionIntro: { marginBottom: space.sm }, newSessionPrompt: { color: palette.ink, @@ -1658,7 +1656,7 @@ const styles = StyleSheet.create({ borderBottomWidth: StyleSheet.hairlineWidth, paddingBottom: space.md, }, - pathText: { color: palette.dim, fontSize: 12, lineHeight: 18, marginTop: space.xs }, + pathText: { ...typography.caption, color: palette.dim, marginTop: space.xs }, pickerRow: { borderColor: palette.border, borderRadius: radius.md, @@ -1667,7 +1665,7 @@ const styles = StyleSheet.create({ padding: 12, }, pickerRowSelected: { backgroundColor: palette.signalDark, borderColor: palette.signal }, - pickerTitle: { color: palette.ink, fontSize: 15, fontWeight: "700" }, + pickerTitle: { ...typography.heading, color: palette.ink }, pressed: { opacity: 0.7 }, scopeAction: { color: palette.signal, fontSize: 14, fontWeight: "700", marginLeft: space.sm }, scopeActionLargeText: { marginLeft: 0, marginTop: space.xs }, @@ -1682,7 +1680,13 @@ const styles = StyleSheet.create({ minHeight: 48, paddingHorizontal: 14, }, - searchInput: { color: palette.ink, flex: 1, fontSize: 15, minHeight: 46, paddingVertical: 10 }, + searchInput: { + ...typography.body, + color: palette.ink, + flex: 1, + minHeight: 46, + paddingVertical: 10, + }, sectionCard: { backgroundColor: palette.card, borderColor: palette.border, @@ -1754,13 +1758,12 @@ const styles = StyleSheet.create({ }, sheetRowCopy: { flex: 1, minWidth: 0 }, sheetRowSelected: { backgroundColor: palette.signalDark }, - sheetRowSubtitle: { color: palette.dim, fontSize: 12, lineHeight: 17, marginTop: 3 }, + sheetRowSubtitle: { ...typography.caption, color: palette.dim, marginTop: 3 }, sheetRowTitle: { ...typography.heading, color: palette.ink }, sheetSection: { marginTop: space.xs }, sheetSectionLabel: { + ...typography.label, color: palette.dim, - fontSize: 12, - fontWeight: "700", marginBottom: space.xs, }, smallButton: { @@ -1769,7 +1772,7 @@ const styles = StyleSheet.create({ minHeight: 44, paddingHorizontal: space.sm, }, - smallButtonLabel: { color: palette.signal, fontSize: 14, fontWeight: "700" }, + smallButtonLabel: { ...typography.control, color: palette.signal }, staleRoute: { alignSelf: "center", maxWidth: 640, padding: space.lg, width: "100%" }, swipeActions: { flexDirection: "row" }, swipeContainer: { backgroundColor: palette.background }, @@ -1783,13 +1786,12 @@ const styles = StyleSheet.create({ swipeDeleteLabel: { color: palette.background, fontSize: 12, fontWeight: "700" }, title: { color: palette.ink, fontSize: 34, fontWeight: "700", letterSpacing: -1, lineHeight: 40 }, titleInput: { + ...typography.body, backgroundColor: palette.card, borderColor: palette.border, borderRadius: radius.lg, borderWidth: 1, color: palette.ink, - fontSize: 18, - lineHeight: 25, minHeight: 118, padding: space.md, }, diff --git a/apps/mobile/src/security/app-lock-context.tsx b/apps/mobile/src/security/app-lock-context.tsx index 2a530ee..51c94e9 100644 --- a/apps/mobile/src/security/app-lock-context.tsx +++ b/apps/mobile/src/security/app-lock-context.tsx @@ -237,16 +237,14 @@ const styles = StyleSheet.create({ }, buttonPressed: { opacity: 0.7 }, copy: { + ...typography.body, color: palette.dim, - fontSize: 16, - lineHeight: 24, marginTop: space.md, maxWidth: 440, }, error: { + ...typography.body, color: palette.danger, - fontSize: 14, - lineHeight: 20, marginTop: space.md, }, errorEyebrow: { diff --git a/apps/mobile/src/state/transcript-preferences.test.tsx b/apps/mobile/src/state/transcript-preferences.test.tsx index 9f18093..4724ba6 100644 --- a/apps/mobile/src/state/transcript-preferences.test.tsx +++ b/apps/mobile/src/state/transcript-preferences.test.tsx @@ -3,7 +3,8 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react-nativ import { Button, Text } from "react-native"; import { TranscriptPreferencesProvider, useTranscriptPreferences } from "./transcript-preferences"; -const mockRead = jest.fn<() => Promise<{ detailed: number; reasoning: number }>>(); +const mockRead = + jest.fn<() => Promise<{ detailed: number; reasoning: number; default_delivery?: string }>>(); const mockWrite = jest.fn<(...args: unknown[]) => Promise>(); const mockDb = { getFirstAsync: mockRead, runAsync: mockWrite }; jest.mock("expo-sqlite", () => ({ useSQLiteContext: () => mockDb })); @@ -14,6 +15,12 @@ function Controls() { <> {`${preferences.detailed}/${preferences.reasoning}`} {preferences.error ? "Save failed" : "OK"} + {preferences.defaultDelivery} +