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