From 9dac81e43cfe993a343e94ba23a0b358265a656f Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Sun, 27 Sep 2026 01:05:44 +0200 Subject: [PATCH] feat(mobile): refine composer and session interface Match the resolved terminal system theme, align activity indicators, simplify session statuses, and move new-session creation into the header. Keep recent output above the keyboard and use native multiline input sizing. A fixed input height depended on iOS Fabric content-size events emitted during layout, preventing the composer from growing. Record the user-confirmed iPhone correction. --- .../src/components/working-indicator.tsx | 45 ++++- .../src/navigation/root-navigation.test.tsx | 18 +- .../mobile/src/navigation/root-navigation.tsx | 9 +- .../navigation/workspace-header-actions.tsx | 26 +++ apps/mobile/src/screens/app-shell.tsx | 8 +- apps/mobile/src/screens/connection-screen.tsx | 36 ++-- apps/mobile/src/screens/diff-screen.test.tsx | 11 +- apps/mobile/src/screens/diff-screen.tsx | 14 +- apps/mobile/src/screens/form-request-card.tsx | 2 +- .../src/screens/session-composer.test.tsx | 33 +++- apps/mobile/src/screens/session-composer.tsx | 59 +++--- .../screens/session-execution-panel.test.tsx | 4 +- .../src/screens/session-execution-panel.tsx | 50 ++--- .../src/screens/session-transcript.test.tsx | 8 +- .../mobile/src/screens/session-transcript.tsx | 34 ++-- .../workspace-screen.integration.test.tsx | 23 ++- apps/mobile/src/screens/workspace-screen.tsx | 177 ++++++------------ apps/mobile/src/security/app-lock-context.tsx | 2 +- apps/mobile/src/theme.ts | 43 ++++- docs/COMPATIBILITY.md | 19 ++ 20 files changed, 368 insertions(+), 253 deletions(-) diff --git a/apps/mobile/src/components/working-indicator.tsx b/apps/mobile/src/components/working-indicator.tsx index 51abe31..ab67f99 100644 --- a/apps/mobile/src/components/working-indicator.tsx +++ b/apps/mobile/src/components/working-indicator.tsx @@ -16,7 +16,21 @@ const dots = [0, 1, 2, 3, 4, 5].map((bit) => ({ ) as CSSAnimationKeyframes, })); -export function WorkingIndicator() { +const blockPath = [0, 1, 2, 3, 4, 5, 6, 7, 6, 5, 4, 3, 2, 1]; +const blocks = Array.from({ length: 8 }, (_, index) => ({ + index, + animation: Object.fromEntries( + [...blockPath, blockPath[0]].map((head, frame) => { + const distance = frame < 8 ? (head ?? 0) - index : index - (head ?? 0); + return [ + `${(frame / blockPath.length) * 100}%`, + { opacity: distance >= 0 && distance < 4 ? 1 - distance * 0.22 : 0.15 }, + ]; + }), + ) as CSSAnimationKeyframes, +})); + +export function WorkingIndicator({ variant = "dots" }: { variant?: "dots" | "blocks" }) { const [reducedMotion, setReducedMotion] = useState(true); const [foreground, setForeground] = useState(AppState.currentState === "active"); @@ -36,6 +50,33 @@ export function WorkingIndicator() { }; }, []); + if (variant === "blocks") { + return ( + + {blocks.map(({ index, animation }) => ( + + ))} + + ); + } + return ( {dots.map(({ bit, animation }) => ( @@ -63,6 +104,8 @@ export function WorkingIndicator() { } const styles = StyleSheet.create({ + blocks: { flexDirection: "row", flexShrink: 0, gap: 1 }, + block: { width: 5, height: 6, backgroundColor: palette.activity }, icon: { width: 10, height: 16, flexShrink: 0 }, dot: { position: "absolute", width: 4, height: 4, backgroundColor: palette.signal }, }); diff --git a/apps/mobile/src/navigation/root-navigation.test.tsx b/apps/mobile/src/navigation/root-navigation.test.tsx index 428edc5..de9a878 100644 --- a/apps/mobile/src/navigation/root-navigation.test.tsx +++ b/apps/mobile/src/navigation/root-navigation.test.tsx @@ -35,7 +35,13 @@ jest.mock("../screens/new-session-screen", () => { const { Text } = jest.requireActual("react-native"); return { NewSessionScreen: () => New session screen }; }); -jest.mock("./workspace-header-actions", () => ({ WorkspaceHeaderActions: () => null })); +jest.mock("@expo/vector-icons/Feather", () => () => null); +jest.mock("../state/workspace-selection-context", () => ({ + useWorkspaceSelection: () => ({ + attentionCoverage: { completeness: "complete", freshness: "fresh" }, + pendingCount: 0, + }), +})); jest.mock("../screens/workspace-screen", () => { const { Text } = jest.requireActual("react-native"); return { @@ -88,7 +94,7 @@ test("shows loading, failure, and first-run onboarding gates", () => { expect(screen.getByText("Connection manager")).toBeOnTheScreen(); }); -test("opens the configured workspace and returns from connection management", async () => { +test("opens new sessions from the header and connections from workspace options", async () => { mockConnections = { profiles: [{ id: "connection-1" }], ready: true }; const navigation = createNavigationContainerRef(); render( @@ -98,7 +104,13 @@ test("opens the configured workspace and returns from connection management", as ); expect(await screen.findByText("Workspace shell")).toBeOnTheScreen(); - act(() => navigation.navigate("Connections")); + fireEvent.press(screen.getByRole("button", { name: "New session" })); + expect(await screen.findByText("New session screen")).toBeOnTheScreen(); + act(() => navigation.goBack()); + await screen.findByText("Workspace shell"); + + fireEvent.press(screen.getByRole("button", { name: "Workspace options" })); + fireEvent.press(screen.getByRole("button", { name: "Connections" })); expect(await screen.findByText("Connection manager")).toBeOnTheScreen(); fireEvent.press(screen.getByRole("button", { name: "Connection manager" })); diff --git a/apps/mobile/src/navigation/root-navigation.tsx b/apps/mobile/src/navigation/root-navigation.tsx index acc684a..e4cd761 100644 --- a/apps/mobile/src/navigation/root-navigation.tsx +++ b/apps/mobile/src/navigation/root-navigation.tsx @@ -97,6 +97,7 @@ export function RootNavigation() { header: () => ( navigation.navigate(destination)} + onNewSession={() => navigation.navigate("NewSession")} title="Sessions" /> ), @@ -105,6 +106,7 @@ export function RootNavigation() { headerRight: () => ( navigation.navigate(destination)} + onNewSession={() => navigation.navigate("NewSession")} /> ), }), @@ -201,10 +203,12 @@ export function RootNavigation() { function WorkspaceHeader({ navigate, onBack, + onNewSession, title, }: { navigate: (destination: "Connections" | "FollowedProjects" | "Pending" | "Settings") => void; onBack?: () => void; + onNewSession?: () => void; title: string; }) { return ( @@ -226,12 +230,12 @@ function WorkspaceHeader({ /> ) : ( - + )} {title} - + ); @@ -261,6 +265,7 @@ function useReducedMotion() { } const styles = StyleSheet.create({ + headerActionsSpacer: { width: 88 }, header: { alignItems: "center", flexDirection: "row", diff --git a/apps/mobile/src/navigation/workspace-header-actions.tsx b/apps/mobile/src/navigation/workspace-header-actions.tsx index 6a31a16..9de04cb 100644 --- a/apps/mobile/src/navigation/workspace-header-actions.tsx +++ b/apps/mobile/src/navigation/workspace-header-actions.tsx @@ -10,8 +10,10 @@ type HeaderDestination = "Connections" | "FollowedProjects" | "Pending" | "Setti export function WorkspaceHeaderActions({ navigate, + onNewSession, }: { navigate: (destination: HeaderDestination) => void; + onNewSession?: (() => void) | undefined; }) { const selection = useWorkspaceSelection(); const [menuOpen, setMenuOpen] = useState(false); @@ -36,6 +38,7 @@ export function WorkspaceHeaderActions({ return ( + {onNewSession ? : null} void }) { + return ( + { + Keyboard.dismiss(); + onPress(); + }} + style={({ pressed }) => [styles.optionsButton, pressed && styles.optionsButtonPressed]} + > + + + ); +} + function MenuButton({ description, label, diff --git a/apps/mobile/src/screens/app-shell.tsx b/apps/mobile/src/screens/app-shell.tsx index daf4751..f439234 100644 --- a/apps/mobile/src/screens/app-shell.tsx +++ b/apps/mobile/src/screens/app-shell.tsx @@ -486,7 +486,7 @@ export function getConnectionPresentation( status: ConnectionTransportStatus, reconnectAttempt: number, ) { - if (status === "connected") return { color: palette.signal, label: "LIVE" }; + if (status === "connected") return { color: palette.success, label: "LIVE" }; if (status === "connecting") return { color: palette.warm, label: "CONNECTING" }; if (status === "reconnecting") { return { color: palette.warm, label: `RECONNECTING ${reconnectAttempt}` }; @@ -786,7 +786,7 @@ export function WorkspaceStateCard({ state }: { state: ReturnType - + Loading server state Waiting for the first authoritative snapshot. @@ -926,7 +926,7 @@ const styles = StyleSheet.create({ }, brand: { color: palette.signal, fontSize: 15, fontWeight: "700" }, cacheCard: { - backgroundColor: "#211B11", + backgroundColor: palette.background, borderColor: palette.warm, borderRadius: radius.lg, borderWidth: 1, @@ -1010,7 +1010,7 @@ const styles = StyleSheet.create({ errorText: { color: palette.danger, fontSize: 14, lineHeight: 20, marginTop: space.sm }, eyebrow: { color: palette.signal, fontSize: 11, fontWeight: "900", letterSpacing: 1.4 }, failureCard: { - backgroundColor: "#251411", + backgroundColor: palette.background, borderColor: palette.danger, borderRadius: radius.lg, borderWidth: 1, diff --git a/apps/mobile/src/screens/connection-screen.tsx b/apps/mobile/src/screens/connection-screen.tsx index 3704e87..bf28d1a 100644 --- a/apps/mobile/src/screens/connection-screen.tsx +++ b/apps/mobile/src/screens/connection-screen.tsx @@ -1184,8 +1184,8 @@ const styles = StyleSheet.create({ maxWidth: 680, }, error: { - backgroundColor: "#281513", - borderColor: "#5C2D27", + backgroundColor: palette.background, + borderColor: palette.danger, borderRadius: radius.sm, borderWidth: 1, color: palette.danger, @@ -1302,8 +1302,8 @@ const styles = StyleSheet.create({ letterSpacing: 1.3, }, lifecyclePrompt: { - backgroundColor: "#21190F", - borderColor: "#4B3820", + backgroundColor: palette.background, + borderColor: palette.warm, borderRadius: radius.md, borderWidth: 1, marginTop: space.md, @@ -1331,11 +1331,11 @@ const styles = StyleSheet.create({ fontWeight: "600", }, notice: { - backgroundColor: palette.signalDark, - borderColor: "#425E26", + backgroundColor: palette.background, + borderColor: palette.success, borderRadius: radius.sm, borderWidth: 1, - color: palette.signal, + color: palette.success, fontSize: 14, lineHeight: 20, padding: space.md, @@ -1360,7 +1360,7 @@ const styles = StyleSheet.create({ paddingHorizontal: space.md, }, primaryButtonDisabled: { opacity: 0.65 }, - primaryButtonPressed: { backgroundColor: "#9BD955" }, + primaryButtonPressed: { opacity: 0.7 }, primaryLabel: { color: palette.background, fontSize: 14, @@ -1426,8 +1426,8 @@ const styles = StyleSheet.create({ letterSpacing: 1.4, }, result: { - backgroundColor: palette.signalDark, - borderColor: "#425E26", + backgroundColor: palette.background, + borderColor: palette.success, borderRadius: radius.lg, borderWidth: 1, gap: space.lg, @@ -1435,7 +1435,7 @@ const styles = StyleSheet.create({ padding: space.lg, }, resultDot: { - backgroundColor: palette.signal, + backgroundColor: palette.success, borderRadius: 5, height: 10, width: 10, @@ -1446,7 +1446,7 @@ const styles = StyleSheet.create({ gap: space.sm, }, resultTitle: { - color: palette.signal, + color: palette.success, fontSize: 12, fontWeight: "900", letterSpacing: 1.2, @@ -1478,7 +1478,7 @@ const styles = StyleSheet.create({ height: 8, width: 8, }, - runtimeDotConnected: { backgroundColor: palette.signal }, + runtimeDotConnected: { backgroundColor: palette.success }, runtimeStatus: { borderBottomColor: palette.border, borderBottomWidth: 1, @@ -1498,7 +1498,7 @@ const styles = StyleSheet.create({ minHeight: 40, paddingHorizontal: space.sm, }, - removeButtonPressed: { backgroundColor: "#281513" }, + removeButtonPressed: { backgroundColor: palette.card }, removeLabel: { color: palette.danger, fontSize: 10, @@ -1596,7 +1596,7 @@ const styles = StyleSheet.create({ segmentLabelSelected: { color: palette.background }, segmentSelected: { backgroundColor: palette.signal }, statusMark: { - backgroundColor: palette.signal, + backgroundColor: palette.success, borderRadius: 4, height: 8, width: 8, @@ -1639,8 +1639,8 @@ const styles = StyleSheet.create({ warningCopy: { flex: 1 }, warningRow: { alignItems: "flex-start", - backgroundColor: "#21190F", - borderColor: "#4B3820", + backgroundColor: palette.background, + borderColor: palette.warm, borderRadius: radius.md, borderWidth: 1, flexDirection: "row", @@ -1648,7 +1648,7 @@ const styles = StyleSheet.create({ padding: space.md, }, warningText: { - color: "#C8B79E", + color: palette.dim, fontSize: 13, lineHeight: 19, marginTop: space.xs, diff --git a/apps/mobile/src/screens/diff-screen.test.tsx b/apps/mobile/src/screens/diff-screen.test.tsx index 0a68cb3..aadea39 100644 --- a/apps/mobile/src/screens/diff-screen.test.tsx +++ b/apps/mobile/src/screens/diff-screen.test.tsx @@ -3,6 +3,7 @@ import type { FileDiffInfo } from "@opencode2-mobile/opencode-adapter"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react-native"; +import { diffPalette } from "../theme"; import { buildDiffRows, DiffScreen } from "./diff-screen"; const mockGetDiff = @@ -53,8 +54,14 @@ test("renders an authoritative working-tree diff", async () => { "Current working tree. This may include changes made after the selected tool call.", ), ).toBeOnTheScreen(); - expect(screen.getByText("+new value")).toHaveStyle({ backgroundColor: "#172B38" }); - expect(screen.getByText("-old value")).toHaveStyle({ backgroundColor: "#2A1714" }); + expect(screen.getByText("+new value")).toHaveStyle({ + backgroundColor: diffPalette.addedBackground, + color: diffPalette.addedText, + }); + expect(screen.getByText("-old value")).toHaveStyle({ + backgroundColor: diffPalette.removedBackground, + color: diffPalette.removedText, + }); expect(mockGetDiff).toHaveBeenCalledWith( {}, { directory: "/workspace" }, diff --git a/apps/mobile/src/screens/diff-screen.tsx b/apps/mobile/src/screens/diff-screen.tsx index 07684fb..8c9b534 100644 --- a/apps/mobile/src/screens/diff-screen.tsx +++ b/apps/mobile/src/screens/diff-screen.tsx @@ -14,7 +14,7 @@ import { import type { RootStackParamList } from "../navigation/root-navigation"; import { useConnectionRuntime } from "../state/connection-runtime-context"; import { openCodeQueryKeys } from "../state/open-code-query-keys"; -import { palette, space, typeRamp } from "../theme"; +import { diffPalette, palette, space, typeRamp } from "../theme"; import { sanitizeTranscriptText } from "./session-transcript-model"; type Props = NativeStackScreenProps; @@ -242,9 +242,9 @@ function diffLineKind(line: string): Extract["kind"] } const styles = StyleSheet.create({ - additions: { color: palette.signal, fontWeight: "800" }, + additions: { color: diffPalette.addedText, fontWeight: "800" }, content: { paddingBottom: space.xl }, - deletions: { color: palette.danger, fontWeight: "800" }, + deletions: { color: diffPalette.removedText, fontWeight: "800" }, emptyContent: { flexGrow: 1, justifyContent: "center", padding: space.lg }, explanation: { color: palette.dim, fontSize: 12, lineHeight: 18 }, fileHeader: { @@ -261,16 +261,16 @@ const styles = StyleSheet.create({ fileName: { color: palette.ink, fontFamily: "monospace", fontSize: 13, fontWeight: "700" }, fileStatus: { color: palette.dim, fontSize: 10, fontWeight: "800", letterSpacing: 0.6 }, line: { - color: palette.ink, + color: diffPalette.contextText, fontFamily: "monospace", fontSize: 12, lineHeight: 18, paddingHorizontal: space.md, paddingVertical: 1, }, - lineAddition: { backgroundColor: palette.signalDark, color: palette.ink }, - lineDeletion: { backgroundColor: "#2A1714", color: palette.ink }, - lineHunk: { color: palette.warm, marginTop: space.xs }, + lineAddition: { backgroundColor: diffPalette.addedBackground, color: diffPalette.addedText }, + lineDeletion: { backgroundColor: diffPalette.removedBackground, color: diffPalette.removedText }, + lineHunk: { color: diffPalette.hunkHeader, marginTop: space.xs }, lineMeta: { color: palette.dim }, pressed: { opacity: 0.7 }, retry: { justifyContent: "center", minHeight: 44, paddingRight: space.md }, diff --git a/apps/mobile/src/screens/form-request-card.tsx b/apps/mobile/src/screens/form-request-card.tsx index 43b2002..dfa521f 100644 --- a/apps/mobile/src/screens/form-request-card.tsx +++ b/apps/mobile/src/screens/form-request-card.tsx @@ -416,7 +416,7 @@ const styles = StyleSheet.create({ buttonDangerLabel: { color: palette.danger }, buttonLabel: { color: palette.ink, fontSize: 13, fontWeight: "800" }, card: { - backgroundColor: "#211B11", + backgroundColor: palette.background, borderColor: palette.warm, borderRadius: radius.md, borderWidth: 1, diff --git a/apps/mobile/src/screens/session-composer.test.tsx b/apps/mobile/src/screens/session-composer.test.tsx index 0de24b1..ddd5285 100644 --- a/apps/mobile/src/screens/session-composer.test.tsx +++ b/apps/mobile/src/screens/session-composer.test.tsx @@ -9,7 +9,7 @@ import type { } from "@opencode2-mobile/opencode-adapter"; import { fireEvent, render, screen, within } from "@testing-library/react-native"; import { useState } from "react"; -import { Keyboard } from "react-native"; +import { Keyboard, StyleSheet } from "react-native"; import type { PromptDelivery } from "./prompt-admission-model"; import { SessionComposer } from "./session-composer"; @@ -53,7 +53,11 @@ test("keeps the native prompt multiline and submits through an explicit control" const input = screen.getByLabelText("Prompt"); expect(input.props.multiline).toBe(true); expect(input.props.submitBehavior).toBe("newline"); + fireEvent(input, "focus"); + // On iOS Fabric, numberOfLines is a maximum, not an initial editor height. + expect(input.props.numberOfLines).toBeUndefined(); fireEvent.changeText(input, "First line\nSecond line"); + expect(input.props.value).toBe("First line\nSecond line"); fireEvent.press(screen.getByRole("button", { name: "Send" })); expect(onSubmit).toHaveBeenCalledTimes(1); @@ -66,12 +70,12 @@ test("dismisses the keyboard and collapses the composer after sending", () => { const input = screen.getByLabelText("Prompt"); fireEvent.changeText(input, "Ship it"); fireEvent(input, "focus"); - expect(input.props.numberOfLines).toBe(4); + expect(screen.getByRole("button", { name: "Model: Choose model" })).toBeOnTheScreen(); fireEvent.press(screen.getByRole("button", { name: "Send" })); expect(dismissKeyboard).toHaveBeenCalledTimes(1); - expect(screen.getByLabelText("Prompt").props.numberOfLines).toBe(1); + expect(screen.getByLabelText("Prompt")).toHaveStyle({ height: 42 }); dismissKeyboard.mockRestore(); }); @@ -91,7 +95,7 @@ test("closes before publishing an immediate active-session transition", () => { fireEvent.press(screen.getByRole("button", { name: "Send" })); expect(onSubmit).toHaveBeenCalledTimes(1); - expect(screen.getByLabelText("Prompt").props.numberOfLines).toBe(1); + expect(screen.getByLabelText("Prompt")).toHaveStyle({ height: 42 }); dismissKeyboard.mockRestore(); }); @@ -99,17 +103,34 @@ test("keeps controls collapsed until the editor is focused", () => { render(); const input = screen.getByLabelText("Prompt"); - expect(input.props.numberOfLines).toBe(1); + expect(input).toHaveStyle({ height: 42 }); expect(screen.queryByRole("button", { name: "Model: Choose model" })).not.toBeOnTheScreen(); expect(screen.queryByRole("button", { name: "Agent: Choose agent" })).not.toBeOnTheScreen(); fireEvent(input, "focus"); - expect(input.props.numberOfLines).toBe(4); + expect(input).toHaveStyle({ minHeight: 54, maxHeight: 120 }); expect(screen.getByRole("button", { name: "Model: Choose model" })).toBeOnTheScreen(); expect(screen.getByRole("button", { name: "Agent: Choose agent" })).toBeOnTheScreen(); }); +test("lets native multiline layout grow without waiting for a size-change event", () => { + render(); + const input = screen.getByLabelText("Prompt"); + fireEvent(input, "focus"); + // The native test environment uses 200% font scaling. + expect(input).toHaveStyle({ minHeight: 54, maxHeight: 120 }); + expect(StyleSheet.flatten(input.props.style).height).toBeUndefined(); + fireEvent.changeText(input, "First line\nSecond line\nThird line"); + expect(input.props.value).toBe("First line\nSecond line\nThird line"); + expect(StyleSheet.flatten(input.props.style).height).toBeUndefined(); + // Native scrolling must remain available once the maximum height is reached, + // even if iOS does not emit another content-size event at that fixed limit. + expect(input.props.scrollEnabled).toBe(true); + fireEvent.changeText(input, ""); + expect(input).toHaveStyle({ minHeight: 54, maxHeight: 120 }); +}); + test("keeps send in the focused composer toolbar", () => { render(); diff --git a/apps/mobile/src/screens/session-composer.tsx b/apps/mobile/src/screens/session-composer.tsx index 357ddb4..8fe5bc4 100644 --- a/apps/mobile/src/screens/session-composer.tsx +++ b/apps/mobile/src/screens/session-composer.tsx @@ -1,3 +1,4 @@ +import Feather from "@expo/vector-icons/Feather"; import type { AgentInfo, CommandInfo, @@ -17,6 +18,7 @@ import { StyleSheet, Text, TextInput, + useWindowDimensions, View, } from "react-native"; @@ -99,6 +101,7 @@ export function SessionComposer({ skills: SkillInfo[]; }) { const inputRef = useRef(null); + const { fontScale } = useWindowDimensions(); const [agentPickerOpen, setAgentPickerOpen] = useState(false); const [agentSearch, setAgentSearch] = useState(""); const [focused, setFocused] = useState(false); @@ -126,6 +129,8 @@ export function SessionComposer({ ) : agents; const expanded = largeText || focused || agentPickerOpen || modelPickerOpen; + const minimumInputHeight = Math.max(40, 23 * fontScale + 8); + const maximumInputHeight = Math.max(120, minimumInputHeight * 2); const completions = listSlashCompletions(draft, commands); const mentionTrigger = findMentionTrigger(draft, selection); const mentionCompletions = mentionTrigger @@ -214,7 +219,6 @@ export function SessionComposer({ editable={editable} maxLength={maximumDraftLength} multiline - numberOfLines={expanded ? 4 : 1} onBlur={() => setFocused(false)} onChangeText={changeDraft} onFocus={() => setFocused(true)} @@ -226,7 +230,18 @@ export function SessionComposer({ scrollEnabled={expanded} selectionColor={palette.signal} selection={selection} - style={[styles.input, expanded ? styles.inputExpanded : styles.inputCollapsed]} + style={[ + styles.input, + // iOS Fabric emits content-size changes during layout. Let native + // text measurement grow the editor instead of fixing its height + // and waiting for an event that requires that height to change. + expanded + ? [ + styles.inputExpanded, + { minHeight: minimumInputHeight, maxHeight: maximumInputHeight }, + ] + : styles.inputCollapsed, + ]} submitBehavior="newline" textAlignVertical={expanded ? "top" : "center"} value={draft} @@ -589,6 +604,9 @@ function SendButton({ }) { return ( - - {active && delivery === "queue" - ? "Queue" - : active && delivery === "steer" - ? "Steer" - : "Send"} - + ); } @@ -629,12 +641,10 @@ function SelectorButton({ onPress={onPress} style={({ pressed }) => [styles.selectorButton, pressed && styles.pressed]} > - - {prefix.toUpperCase()} - {label} + ); } @@ -756,8 +766,6 @@ const styles = StyleSheet.create({ }, inputCollapsed: { height: 42, paddingHorizontal: space.sm, paddingVertical: 0 }, inputExpanded: { - maxHeight: 160, - minHeight: 72, paddingHorizontal: 4, paddingVertical: 4, }, @@ -789,18 +797,13 @@ const styles = StyleSheet.create({ }, selectorButton: { alignItems: "center", - backgroundColor: palette.background, - borderColor: palette.border, - borderRadius: 999, - borderWidth: 1, flexDirection: "row", gap: 5, maxWidth: 180, - minHeight: 40, - paddingHorizontal: space.sm, + minHeight: 44, + paddingHorizontal: 4, }, selectorLabel: { color: palette.ink, flexShrink: 1, fontSize: 12, fontWeight: "700" }, - selectorPrefix: { color: palette.dim, fontSize: 9, fontWeight: "800", letterSpacing: 0.5 }, selectorRow: { alignItems: "center", gap: space.xs, paddingRight: space.xs }, selectorScroller: { flex: 1 }, sendButton: { @@ -809,11 +812,9 @@ const styles = StyleSheet.create({ borderRadius: 999, justifyContent: "center", minHeight: 44, - minWidth: 68, - paddingHorizontal: space.sm, + width: 44, }, sendButtonDisabled: { backgroundColor: palette.border, opacity: 0.68 }, - sendLabel: { color: palette.background, fontSize: 14, fontWeight: "900" }, shell: { backgroundColor: palette.background, borderTopColor: palette.border, @@ -823,8 +824,8 @@ const styles = StyleSheet.create({ paddingVertical: space.xs, }, surface: { - backgroundColor: "#171C19", - borderColor: "#46514B", + backgroundColor: palette.card, + borderColor: palette.border, borderWidth: 1, overflow: "hidden", }, @@ -835,13 +836,11 @@ const styles = StyleSheet.create({ paddingRight: 5, }, surfaceExpanded: { - borderColor: "#59665F", borderRadius: 26, - gap: space.xs, - minHeight: 140, + gap: 2, paddingBottom: 6, paddingHorizontal: 14, - paddingTop: 14, + paddingTop: 8, }, toolbar: { alignItems: "center", flexDirection: "row", gap: space.xs }, }); diff --git a/apps/mobile/src/screens/session-execution-panel.test.tsx b/apps/mobile/src/screens/session-execution-panel.test.tsx index aa0ff19..cd5ed0a 100644 --- a/apps/mobile/src/screens/session-execution-panel.test.tsx +++ b/apps/mobile/src/screens/session-execution-panel.test.tsx @@ -40,7 +40,9 @@ test("renders active execution and mutable queued inbox work", () => { />, ); - expect(screen.getByText("OpenCode is working")).toBeOnTheScreen(); + expect(screen.getByText("Working")).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "Stop" })); + expect(callbacks.onInterrupt).toHaveBeenCalledTimes(1); expect(screen.getByText("Queued prompt")).toBeOnTheScreen(); fireEvent.press(screen.getByRole("button", { name: "Steer now" })); fireEvent.press(screen.getByRole("button", { name: "Cancel" })); diff --git a/apps/mobile/src/screens/session-execution-panel.tsx b/apps/mobile/src/screens/session-execution-panel.tsx index 3636149..3139875 100644 --- a/apps/mobile/src/screens/session-execution-panel.tsx +++ b/apps/mobile/src/screens/session-execution-panel.tsx @@ -75,29 +75,37 @@ export function SessionExecutionPanel({ style={styles.shell} > {active ? ( - + {permissions.length > 0 || formRequests ? ( ) : ( - + )} {permissions.length > 0 ? "Waiting for permission" : formRequests ? "Waiting for input" - : "OpenCode is working"} + : "Working"} - - - + [ + styles.stopButton, + busyAction && styles.disabled, + pressed && styles.pressed, + ]} + > + + {busyAction === "interrupt" ? "Stopping" : "Stop"} + + ) : null} @@ -304,9 +312,9 @@ const styles = StyleSheet.create({ actionLabel: { color: palette.ink, fontSize: 12, fontWeight: "800" }, actionLabelDanger: { color: palette.danger }, actionRow: { flexDirection: "row", flexWrap: "wrap", gap: space.sm }, - activeDot: { backgroundColor: palette.signal, borderRadius: 99, height: 8, width: 8 }, + activeDot: { backgroundColor: palette.warm, borderRadius: 99, height: 6, width: 6 }, admissionCard: { - backgroundColor: "#211B11", + backgroundColor: palette.background, borderColor: palette.warm, borderRadius: radius.md, borderWidth: 1, @@ -316,19 +324,17 @@ const styles = StyleSheet.create({ cardCopy: { color: palette.dim, fontSize: 13, lineHeight: 19 }, cardEyebrow: { color: palette.warm, fontSize: 10, fontWeight: "900", letterSpacing: 1 }, disabled: { opacity: 0.5 }, - executionCard: { + executionRow: { alignItems: "center", - backgroundColor: palette.signalDark, - borderColor: palette.signal, - borderRadius: radius.md, - borderWidth: 1, flexDirection: "row", gap: space.sm, justifyContent: "space-between", paddingHorizontal: space.sm, - paddingVertical: space.xs, + minHeight: 44, }, - executionTitle: { color: palette.signal, fontSize: 14, fontWeight: "800" }, + executionTitle: { color: palette.dim, flexShrink: 1, fontSize: 12 }, + stopButton: { minHeight: 44, minWidth: 44, alignItems: "center", justifyContent: "center" }, + stopLabel: { color: palette.ink, fontSize: 13 }, headingRow: { alignItems: "center", flexDirection: "row", flexShrink: 1, gap: space.sm }, inboxCard: { backgroundColor: palette.card, @@ -343,7 +349,7 @@ const styles = StyleSheet.create({ pressed: { opacity: 0.62 }, permissionAction: { color: palette.ink, fontSize: 16, fontWeight: "800" }, permissionCard: { - backgroundColor: "#211B11", + backgroundColor: palette.background, borderColor: palette.warm, borderRadius: radius.md, borderWidth: 1, @@ -367,6 +373,6 @@ const styles = StyleSheet.create({ }, permissionWarning: { color: palette.warm, fontSize: 12, lineHeight: 17 }, promptPreview: { color: palette.ink, fontSize: 14, lineHeight: 20 }, - content: { gap: space.sm, padding: space.md }, + content: { gap: space.sm, paddingHorizontal: space.md, paddingVertical: space.xs }, shell: { flexGrow: 0, flexShrink: 1, maxHeight: 280 }, }); diff --git a/apps/mobile/src/screens/session-transcript.test.tsx b/apps/mobile/src/screens/session-transcript.test.tsx index fbc4e7c..29f359b 100644 --- a/apps/mobile/src/screens/session-transcript.test.tsx +++ b/apps/mobile/src/screens/session-transcript.test.tsx @@ -4,6 +4,7 @@ import { fireEvent, render, screen } from "@testing-library/react-native"; import { Alert, Linking, View } from "react-native"; import { resetTranscriptPerformanceMetrics } from "../state/transcript-performance"; +import { markdownPalette, palette } from "../theme"; import { SessionTranscriptRow } from "./session-transcript"; afterEach(resetTranscriptPerformanceMetrics); @@ -206,7 +207,10 @@ test("opens HTTP and HTTPS transcript URLs as confirmed external links", () => { ); const secureLink = screen.getByRole("link", { name: "https://example.test/docs" }); - expect(secureLink).toHaveStyle({ color: "#36C5E5", textDecorationLine: "underline" }); + expect(secureLink).toHaveStyle({ + color: markdownPalette.linkText, + textDecorationLine: "underline", + }); expect(screen.getByRole("link", { name: "http://localhost:4096/status" })).toBeOnTheScreen(); expect(screen.getByRole("link", { name: "https://assistant.test/guide" })).toHaveStyle({ fontWeight: "800", @@ -267,7 +271,7 @@ test("renders fenced assistant code without markdown fence markers", () => { expect(screen.getByText("Then inspect the stack.")).toBeOnTheScreen(); expect(screen.queryByText(/```/)).toBeNull(); expect(screen.getByLabelText("Code block, gdb")).toHaveStyle({ - backgroundColor: "#241830", + backgroundColor: palette.card, borderWidth: 1, }); }); diff --git a/apps/mobile/src/screens/session-transcript.tsx b/apps/mobile/src/screens/session-transcript.tsx index 1dc1f17..d9fb357 100644 --- a/apps/mobile/src/screens/session-transcript.tsx +++ b/apps/mobile/src/screens/session-transcript.tsx @@ -4,7 +4,7 @@ import { Alert, Linking, Pressable, StyleSheet, Text, View } from "react-native" import { applicationName } from "../application-name"; import { recordTranscriptRowCommit } from "../state/transcript-performance"; -import { palette, radius, space, typeRamp } from "../theme"; +import { markdownPalette, palette, radius, space, typeRamp } from "../theme"; import { getSubagentPresentation, parseSubagentProtocolText, @@ -165,6 +165,7 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({ case "idle": return ( + ) : ( - + {sanitizeTranscriptText(label, 256)} {text ? : null} @@ -1357,7 +1361,7 @@ const styles = StyleSheet.create({ attachmentLabel: { color: palette.dim, fontSize: 11, fontWeight: "600" }, attachments: { flexDirection: "row", flexWrap: "wrap", gap: space.xs, marginTop: space.sm }, bodyText: { color: palette.ink, fontSize: 16, lineHeight: 24 }, - boldText: { fontWeight: "800" }, + boldText: { color: markdownPalette.strong, fontWeight: "800" }, codeBlock: { backgroundColor: palette.card, borderColor: palette.border, @@ -1367,7 +1371,7 @@ const styles = StyleSheet.create({ padding: 12, }, codeLanguage: { - color: palette.signal, + color: palette.dim, fontSize: 10, fontWeight: "800", letterSpacing: 0.8, @@ -1415,14 +1419,14 @@ const styles = StyleSheet.create({ diffActionLabel: { color: palette.signal, fontSize: 13, fontWeight: "700" }, errorText: { color: palette.danger, fontSize: 14, lineHeight: 21 }, markdownBlockSpacing: { marginTop: space.sm }, - linkText: { color: palette.signal, textDecorationLine: "underline" }, + linkText: { color: markdownPalette.linkText, textDecorationLine: "underline" }, notice: { borderBottomColor: palette.border, borderBottomWidth: StyleSheet.hairlineWidth, marginHorizontal: space.lg, paddingVertical: 12, }, - noticeLabel: { color: palette.warm, fontSize: 10, fontWeight: "900", letterSpacing: 0.8 }, + noticeLabel: { color: palette.dim, fontSize: 10, fontWeight: "900", letterSpacing: 0.8 }, noticeText: { color: palette.dim, fontSize: 13, lineHeight: 19, marginTop: 5 }, omittedText: { color: palette.dim, fontSize: 11, marginTop: 7 }, outputText: { @@ -1435,12 +1439,12 @@ const styles = StyleSheet.create({ padding: 12, }, pressed: { opacity: 0.7 }, - reasoningLabel: { color: palette.warm, fontSize: 9, fontWeight: "900", letterSpacing: 0.8 }, + reasoningLabel: { color: palette.dim, fontSize: 9, fontWeight: "900", letterSpacing: 0.8 }, reasoningText: { color: palette.dim, fontSize: 13, lineHeight: 19 }, statusText: { color: palette.dim, fontSize: 12 }, subagent: { - backgroundColor: palette.signalDark, - borderColor: palette.signal, + backgroundColor: palette.card, + borderColor: palette.activity, borderRadius: radius.md, borderWidth: 1, gap: space.xs, @@ -1468,7 +1472,7 @@ const styles = StyleSheet.create({ justifyContent: "space-between", }, subagentHeadingLargeText: { alignItems: "flex-start", flexDirection: "column", gap: space.xs }, - subagentLabel: { color: palette.signal, fontSize: 10, fontWeight: "900", letterSpacing: 0.8 }, + subagentLabel: { color: palette.activity, fontSize: 10, fontWeight: "900", letterSpacing: 0.8 }, subagentResult: { borderTopColor: palette.border, borderTopWidth: StyleSheet.hairlineWidth, @@ -1479,12 +1483,12 @@ const styles = StyleSheet.create({ }, subagentState: { color: palette.dim, fontSize: 10, fontWeight: "900", letterSpacing: 0.6 }, subagentStateError: { color: palette.danger }, - subagentStateRunning: { color: palette.warm }, + subagentStateRunning: { color: palette.activity }, subagentTitle: { color: palette.ink, fontSize: 16, fontWeight: "700" }, textAction: { alignSelf: "flex-start", minHeight: 40, paddingVertical: 10 }, textActionLabel: { color: palette.signal, fontSize: 12, fontWeight: "700" }, userBubble: { - backgroundColor: palette.signalDark, + backgroundColor: palette.card, borderColor: palette.border, borderRadius: radius.lg, borderWidth: 1, @@ -1492,7 +1496,7 @@ const styles = StyleSheet.create({ padding: 14, }, userBubbleLargeText: { maxWidth: "100%" }, - userLabel: { color: palette.warm, fontSize: 10, fontWeight: "900", letterSpacing: 1 }, + userLabel: { color: palette.dim, fontSize: 10, fontWeight: "900", letterSpacing: 1 }, userRow: { alignItems: "flex-end", paddingHorizontal: space.lg, paddingVertical: space.sm }, userText: { color: palette.ink, fontSize: 16, lineHeight: 23, marginTop: 7 }, }); diff --git a/apps/mobile/src/screens/workspace-screen.integration.test.tsx b/apps/mobile/src/screens/workspace-screen.integration.test.tsx index 4574399..c55f00a 100644 --- a/apps/mobile/src/screens/workspace-screen.integration.test.tsx +++ b/apps/mobile/src/screens/workspace-screen.integration.test.tsx @@ -442,7 +442,7 @@ test("keeps session-list chrome stable during background location updates", asyn await screen.findByText("Session 0"); await waitFor(() => expect(queryClient.getQueryState(locationKey)?.fetchStatus).toBe("fetching")); - expect(screen.getByRole("button", { name: "New" })).toBeOnTheScreen(); + expect(screen.getByLabelText("Search sessions")).toBeOnTheScreen(); expect(screen.queryByLabelText(/Change new session location/)).toBeNull(); expect(screen.UNSAFE_getByType(FlatList).props.maintainVisibleContentPosition).toBeUndefined(); @@ -451,7 +451,7 @@ test("keeps session-list chrome stable during background location updates", asyn queryClient.clear(); }); -test("opens project selection before creating a session", async () => { +test("keeps connection management and new-session controls out of the phone list body", async () => { const navigation = { navigate: jest.fn() }; mockWorkspaceRefetch.mockClear(); const queryClient = new QueryClient({ @@ -479,10 +479,8 @@ test("opens project selection before creating a session", async () => { expect(screen.getByText("Recent")).toBeOnTheScreen(); expect(screen.queryByText("Succeeded")).toBeNull(); - const newButton = screen.getByRole("button", { name: "New" }); - expect(newButton).toBeEnabled(); - fireEvent.press(newButton); - expect(navigation.navigate).toHaveBeenCalledWith("NewSession"); + expect(screen.queryByRole("button", { name: "New" })).toBeNull(); + expect(screen.queryByRole("button", { name: /Manage connections/ })).toBeNull(); expect(mockWorkspaceRefetch).not.toHaveBeenCalled(); view.unmount(); @@ -490,6 +488,7 @@ test("opens project selection before creating a session", async () => { }); test("retains the in-content Sessions title in the tablet shell", async () => { + const navigate = jest.fn(); const originalScreen = Dimensions.get("screen"); const originalWindow = Dimensions.get("window"); const queryClient = new QueryClient({ @@ -509,7 +508,7 @@ test("retains the in-content Sessions title in the tablet shell", async () => { @@ -517,6 +516,8 @@ test("retains the in-content Sessions title in the tablet shell", async () => { ); expect(await screen.findByRole("header", { name: "Sessions" })).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "New session" })); + expect(navigate).toHaveBeenCalledWith("NewSession"); } finally { view?.unmount(); queryClient.clear(); @@ -607,12 +608,20 @@ test("renders short thoughts inline and keeps detailed thoughts collapsed", asyn scrollToOffset.mockClear(); fireEvent(transcript, "contentSizeChange", 320, 1_100); await waitFor(() => expect(scrollToOffset).toHaveBeenCalledWith({ animated: false, offset: 0 })); + scrollToOffset.mockClear(); + fireEvent(transcript, "layout", { + nativeEvent: { layout: { x: 0, y: 0, width: 320, height: 220 } }, + }); + await waitFor(() => expect(scrollToOffset).toHaveBeenCalledWith({ animated: false, offset: 0 })); fireEvent(transcript, "scrollBeginDrag", liveEdgeEvent); fireEvent.scroll(transcript, justAwayFromLiveEdge); fireEvent(transcript, "momentumScrollEnd", justAwayFromLiveEdge); scrollToOffset.mockClear(); fireEvent(transcript, "contentSizeChange", 320, 1_200); + fireEvent(transcript, "layout", { + nativeEvent: { layout: { x: 0, y: 0, width: 320, height: 500 } }, + }); expect(scrollToOffset).not.toHaveBeenCalled(); fireEvent.press(screen.getByRole("button", { name: "Scroll to latest" })); fireEvent.scroll(transcript, scrollEvent(120)); diff --git a/apps/mobile/src/screens/workspace-screen.tsx b/apps/mobile/src/screens/workspace-screen.tsx index 7933625..a058cf4 100644 --- a/apps/mobile/src/screens/workspace-screen.tsx +++ b/apps/mobile/src/screens/workspace-screen.tsx @@ -41,6 +41,7 @@ import { KeyboardStickyView } from "react-native-keyboard-controller"; import { WorkingIndicator } from "../components/working-indicator"; import { useConnections } from "../connections/connections-context"; import type { RootStackParamList } from "../navigation/root-navigation"; +import { NewSessionButton } from "../navigation/workspace-header-actions"; import { useConnectionRuntime } from "../state/connection-runtime-context"; import type { FollowedInboxRow } from "../state/followed-project-inbox"; import { openCodeQueryKeys } from "../state/open-code-query-keys"; @@ -59,7 +60,7 @@ import { typeRamp, usesLargeTextLayout, } from "../theme"; -import { ActionButton, getConnectionPresentation, isTabletShell, ShellFrame } from "./app-shell"; +import { ActionButton, isTabletShell, ShellFrame } from "./app-shell"; import { FormRequestList } from "./form-request-list"; import { SessionComposer } from "./session-composer"; import { loadOpenCodeSessionTreeIds } from "./session-deletion"; @@ -307,23 +308,11 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { ); } - const connectionPresentation = getConnectionPresentation( - runtime.status, - runtime.reconnectAttempt, - ); - const connectionName = selectedConnection?.name ?? "OpenCode"; const header = ( {tablet ? ( - - {connectionName} - - navigation.navigate("NewSession")} - /> + navigation.navigate("NewSession")} /> ) : null} - - navigation.navigate("Connections")} - style={({ pressed }) => [styles.connectionLine, pressed && styles.pressed]} - > - 0 ? ( + + 99 + ? "Needs you 99+" + : `Needs you ${workspaceSelection.pendingCount}` + } + onPress={() => navigation.navigate("Pending")} /> - - {connectionName} - - {connectionPresentation.label} - - > - - - {!tablet ? ( - <> - {workspaceSelection.pendingCount > 0 ? ( - 99 - ? "Needs you 99+" - : `Needs you ${workspaceSelection.pendingCount}` - } - onPress={() => navigation.navigate("Pending")} - /> - ) : null} - navigation.navigate("NewSession")} - /> - - ) : null} - + + ) : null} {runtime.status !== "connected" ? ( {runtime.cacheMetadata @@ -494,7 +444,6 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { }} onPress={() => openSession(item.row)} row={item.row} - section={item.section} showLocation={ambiguousProjectIDs.has(item.row.session.projectID)} /> ) @@ -1016,6 +965,7 @@ export function SessionScreen({ navigation, route }: SessionProps) { maintainVisibleContentPosition={{ minIndexForVisible: 0 }} maxToRenderPerBatch={12} onContentSizeChange={scheduleLiveEdgeScroll} + onLayout={scheduleLiveEdgeScroll} onMomentumScrollBegin={handleMomentumScrollBegin} onMomentumScrollEnd={handleMomentumScrollEnd} onScroll={handleTranscriptScroll} @@ -1052,7 +1002,10 @@ export function SessionScreen({ navigation, route }: SessionProps) { ) : null} - + {Platform.OS === "android" ? ( void; onPress: () => void; row: FollowedInboxRow; - section: WorkspaceInboxSection; showLocation: boolean; }) { const { session } = row; + const { fontScale } = useWindowDimensions(); const active = row.active; const blocked = row.attentionCount > 0; const showOutcome = + !active && + !blocked && Boolean(session.outcome) && - (section !== "recent" || session.outcome?.toLocaleLowerCase() !== "succeeded"); + session.outcome?.toLocaleLowerCase() !== "succeeded"; const showMetadata = - active || blocked || row.activeChildCount > 0 || row.attentionCount > 1 || @@ -1146,7 +1099,6 @@ function SessionRow({ pressed && styles.pressed, ]} > - {active && !blocked ? : null} @@ -1154,18 +1106,22 @@ function SessionRow({ {formatSessionTime(session.time.updated)} - - {session.title || "Untitled session"} - + + {active && !blocked ? ( + + + + ) : null} + + {session.title || "Untitled session"} + + {showMetadata ? ( - {active ? ( - Working - ) : null} {blocked ? ( Needs input @@ -1208,9 +1164,11 @@ function SessionRow({ {child.session.title || "Untitled child session"} - 0 ? styles.childAttention : styles.childState}> - {child.attentionCount > 0 ? "Needs input" : child.active ? "Working" : "Child"} - + {child.attentionCount > 0 || !child.active ? ( + 0 ? styles.childAttention : styles.childState}> + {child.attentionCount > 0 ? "Needs input" : "Child"} + + ) : null} ))} @@ -1261,7 +1219,6 @@ function HeaderAction({ accessibilityLabel, attention, disabled, - emphasized, label, onPress, }: { @@ -1269,7 +1226,6 @@ function HeaderAction({ accessibilityLabel?: string; attention?: boolean; disabled?: boolean; - emphasized?: boolean; label: string; onPress: () => void; }) { @@ -1284,18 +1240,13 @@ function HeaderAction({ style={({ pressed }) => [ styles.headerAction, attention && styles.headerActionAttention, - emphasized && styles.headerActionEmphasized, disabled && styles.disabled, pressed && styles.pressed, ]} > {label} @@ -1427,7 +1378,7 @@ const styles = StyleSheet.create({ }, childState: { color: palette.signal, fontSize: 11, fontWeight: "800" }, childTitle: { color: palette.ink, flex: 1, fontSize: 13, fontWeight: "700" }, - backgroundCount: { color: palette.warm, fontSize: 12, fontWeight: "700", marginTop: 3 }, + backgroundCount: { color: palette.dim, fontSize: 12, fontWeight: "700", marginTop: 3 }, badge: { backgroundColor: palette.signalDark, borderColor: palette.signal, @@ -1442,25 +1393,6 @@ const styles = StyleSheet.create({ badges: { alignItems: "center", flexDirection: "row", flexWrap: "wrap", gap: space.xs }, clearSearch: { justifyContent: "center", minHeight: 44, paddingLeft: space.sm }, clearSearchLabel: { color: palette.signal, fontSize: 13, fontWeight: "700" }, - connectionDot: { borderRadius: 4, height: 7, marginRight: 8, width: 7 }, - connectionDotLive: { backgroundColor: palette.signal }, - connectionDotMuted: { backgroundColor: palette.warm }, - connectionLabel: { color: palette.dim, fontSize: 12, fontWeight: "600", marginLeft: 6 }, - connectionDisclosure: { color: palette.dim, fontSize: 14, marginLeft: 8 }, - connectionLine: { - alignItems: "center", - backgroundColor: palette.card, - borderColor: palette.border, - borderRadius: 999, - borderWidth: 1, - flexGrow: 1, - flexShrink: 1, - flexDirection: "row", - minHeight: 44, - minWidth: 0, - paddingHorizontal: 12, - }, - connectionName: { color: palette.ink, flexShrink: 1, fontSize: 12, fontWeight: "700" }, connectionNotice: { color: palette.warm, fontSize: 12, lineHeight: 18, marginBottom: 2 }, contextLabel: { color: palette.dim, fontSize: 12, fontWeight: "600" }, contextRow: { @@ -1511,10 +1443,8 @@ const styles = StyleSheet.create({ paddingHorizontal: 15, }, headerActionAttention: { borderColor: palette.warm }, - headerActionEmphasized: { backgroundColor: palette.signal, borderColor: palette.signal }, headerActionLabel: { color: palette.ink, fontSize: 14, fontWeight: "700" }, headerActionLabelAttention: { color: palette.warm }, - headerActionLabelEmphasized: { color: palette.background }, headingCopy: { flex: 1, minWidth: 180 }, homeHeader: { paddingHorizontal: space.lg, paddingTop: space.lg }, homeHeaderPhone: { paddingTop: space.sm }, @@ -1625,7 +1555,7 @@ const styles = StyleSheet.create({ padding: space.md, }, sectionHeading: { alignItems: "center", flexDirection: "row", flexWrap: "wrap", gap: space.sm }, - sectionLabel: { color: palette.warm, fontSize: 10, fontWeight: "900", letterSpacing: 1 }, + sectionLabel: { color: palette.dim, fontSize: 10, fontWeight: "900", letterSpacing: 1 }, sectionTitle: { color: palette.ink, fontSize: 18, @@ -1634,7 +1564,6 @@ const styles = StyleSheet.create({ marginTop: 3, }, selectionMark: { color: palette.signal, fontSize: 12, fontWeight: "600", marginLeft: space.sm }, - serverLabel: { color: palette.dim, fontSize: 12, fontWeight: "600" }, sessionMain: { flex: 1, minWidth: 0 }, sessionGroup: { backgroundColor: palette.card }, sessionLocation: { @@ -1658,16 +1587,21 @@ const styles = StyleSheet.create({ }, sessionRowLargeText: { alignItems: "flex-start" }, sessionStatus: { fontSize: 12, fontWeight: "700" }, - sessionStatusActive: { color: palette.signal }, sessionStatusBlocked: { color: palette.warm }, sessionTime: { color: palette.dim, fontSize: 12, marginLeft: space.sm }, + sessionIndicator: { justifyContent: "center", flexShrink: 0 }, + sessionTitleRow: { + alignItems: "flex-start", + flexDirection: "row", + gap: space.sm, + marginTop: 4, + }, sessionTitle: { color: palette.ink, flex: 1, fontSize: 16, fontWeight: "700", lineHeight: 21, - marginTop: 4, minWidth: 0, }, sessionTopRow: { @@ -1676,7 +1610,6 @@ const styles = StyleSheet.create({ justifyContent: "space-between", }, workspaceActions: { alignItems: "center", flexDirection: "row", gap: space.sm }, - workspaceActionsLargeText: { alignItems: "stretch", flexDirection: "column" }, sheetRow: { alignItems: "center", borderBottomColor: palette.border, diff --git a/apps/mobile/src/security/app-lock-context.tsx b/apps/mobile/src/security/app-lock-context.tsx index 455fd0c..3428d43 100644 --- a/apps/mobile/src/security/app-lock-context.tsx +++ b/apps/mobile/src/security/app-lock-context.tsx @@ -237,7 +237,7 @@ const styles = StyleSheet.create({ fontWeight: "900", letterSpacing: 0.9, }, - buttonPressed: { backgroundColor: "#9BD955" }, + buttonPressed: { opacity: 0.7 }, copy: { color: palette.dim, fontSize: 16, diff --git a/apps/mobile/src/theme.ts b/apps/mobile/src/theme.ts index 6c747d1..ec4b4a7 100644 --- a/apps/mobile/src/theme.ts +++ b/apps/mobile/src/theme.ts @@ -1,13 +1,38 @@ +// OpenCode V2's `system` dark theme, resolved from the current Foot ANSI palette. +// https://github.com/anomalyco/opencode/blob/095bc2e20bf087f926d40cbc0afa7804564cf48e/packages/tui/src/theme/system.ts +// Values include OpenCode's V1-to-V2 theme migration. The terminal's transparent +// background becomes its opaque default background on mobile. +// Native filled controls use the TUI's focused primary-action colors. export const palette = { - background: "#100B18", - border: "#392C48", - card: "#241830", - danger: "#FF8E7A", - dim: "#A69BAF", - ink: "#EAE2F0", - signal: "#36C5E5", - signalDark: "#172B38", - warm: "#FFB86B", + accent: "#31bcdb", // hue.accent.200 + activity: "#d65adf", // categorical[0].200 + background: "#100b17", // background.base + border: "#503774", // border.base + card: "#221731", // background.raised.base + danger: "#ff6b81", // text.feedback.error.base + dim: "#a4a4a4", // text.muted + info: "#31bcdb", // text.feedback.info.base + ink: "#f3eff5", // text.base + raised: "#2b1e3f", // background.raised.high + signal: "#31bcdb", // text.formfield.$selected / background.action.primary.$focused + signalDark: "#221731", // background.action.primary.$hovered + success: "#7bd88f", // text.feedback.success.base + warm: "#f6c85f", // text.feedback.warning.base +} as const; + +export const diffPalette = { + addedText: "#7bd88f", + removedText: "#ff6b81", + contextText: "#503774", + hunkHeader: "#503774", + addedBackground: "#283831", + removedBackground: "#45202e", +} as const; + +export const markdownPalette = { + link: "#0a7fd4", + linkText: "#31bcdb", + strong: "#f3eff5", } as const; export const space = { diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index b3e8299..5454216 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -12,6 +12,25 @@ the fail-closed database backup-exclusion startup guard. Statements marked pending in older dated entries describe the status at the time of that probe; later entries supersede them. +## 2026-09-27: iOS multiline composer sizing + +A physical iPhone recording showed typed text wrapping beyond the visible input +while the composer retained its initial height. Removing `numberOfLines` alone +did not resolve the issue. The fixed-height input depended on +`onContentSizeChange`, which the installed iOS Fabric implementation emits during +layout updates. + +The composer now uses native text measurement with minimum and maximum heights, +and enables native scrolling while expanded. It no longer sets a fixed expanded +height or depends on a content-size callback. The regression check verifies these +native layout constraints without injecting synthetic content-size events. + +The full checks, both Hermes exports, Expo Doctor, and native-runtime compatibility +gate passed before direct preview OTA publication for runtime 0.1.4. The user +confirmed that the correction worked on the phone. This confirms the reported +iOS wrapping issue is resolved; Android device behavior and all font-scale and +keyboard combinations were not verified by this follow-up. + ## 2026-09-18: server 2.0.8 identity compatibility After the first automated preview publication, an iOS diagnostic export showed