From b05f5292fa57d8d9c72cf9953051846e46e273a6 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Sun, 27 Sep 2026 01:50:59 +0200 Subject: [PATCH] feat(mobile): refine dark chat styling and transcript controls Add persistent compact and detailed transcript preferences with independent reasoning visibility and a versioned database migration. Group consecutive tool-only messages across assistant boundaries while preserving visible failures and replies. Use neutral dark chat surfaces and replace Latest with a floating scroll-to-bottom arrow. Include regression coverage for grouping, preference persistence, and migration rollback. --- apps/mobile/src/app.tsx | 5 +- .../navigation/workspace-header-actions.tsx | 9 +- apps/mobile/src/screens/app-shell.tsx | 38 ++++ apps/mobile/src/screens/session-composer.tsx | 18 +- .../src/screens/session-transcript.test.tsx | 74 ++++++- .../mobile/src/screens/session-transcript.tsx | 195 ++++++++++++++++-- .../workspace-screen.integration.test.tsx | 67 +++++- apps/mobile/src/screens/workspace-screen.tsx | 101 +++++---- .../src/state/transcript-preferences.test.tsx | 60 ++++++ .../src/state/transcript-preferences.tsx | 67 ++++++ apps/mobile/src/storage/database.test.ts | 50 ++++- apps/mobile/src/storage/database.ts | 20 +- apps/mobile/src/theme.ts | 45 ++-- 13 files changed, 634 insertions(+), 115 deletions(-) create mode 100644 apps/mobile/src/state/transcript-preferences.test.tsx create mode 100644 apps/mobile/src/state/transcript-preferences.tsx diff --git a/apps/mobile/src/app.tsx b/apps/mobile/src/app.tsx index 679d649..fa0ff0f 100644 --- a/apps/mobile/src/app.tsx +++ b/apps/mobile/src/app.tsx @@ -15,6 +15,7 @@ import { NotificationRoutingProvider } from "./notifications/notification-routin import { AppLockProvider } from "./security/app-lock-context"; import { ConnectionRuntimeProvider } from "./state/connection-runtime-context"; import { FollowedProjectsProvider } from "./state/followed-projects-context"; +import { TranscriptPreferencesProvider } from "./state/transcript-preferences"; import { migrateMobileDatabase, mobileDatabaseName } from "./storage/database"; import { palette } from "./theme"; import { AppUpdateBanner, AppUpdateCard, AppUpdatesProvider } from "./updates/app-updates"; @@ -61,7 +62,9 @@ export default function App() { - + + + diff --git a/apps/mobile/src/navigation/workspace-header-actions.tsx b/apps/mobile/src/navigation/workspace-header-actions.tsx index 9de04cb..0dcd3dc 100644 --- a/apps/mobile/src/navigation/workspace-header-actions.tsx +++ b/apps/mobile/src/navigation/workspace-header-actions.tsx @@ -149,7 +149,14 @@ function MenuButton({ } const styles = StyleSheet.create({ - actions: { alignItems: "center", flexDirection: "row" }, + actions: { + alignItems: "center", + backgroundColor: palette.card, + borderColor: palette.border, + borderRadius: 24, + borderWidth: StyleSheet.hairlineWidth, + flexDirection: "row", + }, disclosure: { color: palette.dim, fontSize: 18, marginLeft: space.sm }, menuButton: { alignItems: "center", diff --git a/apps/mobile/src/screens/app-shell.tsx b/apps/mobile/src/screens/app-shell.tsx index f439234..1526a85 100644 --- a/apps/mobile/src/screens/app-shell.tsx +++ b/apps/mobile/src/screens/app-shell.tsx @@ -32,6 +32,7 @@ import { import { useAppLock } from "../security/app-lock-context"; import { useConnectionRuntime } from "../state/connection-runtime-context"; import type { ConnectionTransportStatus } from "../state/connection-transport-coordinator"; +import { useTranscriptPreferences } from "../state/transcript-preferences"; import { useWorkspaceSelection } from "../state/workspace-selection-context"; import { palette, radius, space, typeRamp, usesLargeTextLayout } from "../theme"; import { AppUpdateCard } from "../updates/app-updates"; @@ -246,6 +247,7 @@ export function PendingInteractionsScreen({ navigation }: ScreenProps<"Pending"> } export function SettingsScreen({ navigation }: ScreenProps<"Settings">) { + const transcript = useTranscriptPreferences(); const db = useSQLiteContext(); const appLock = useAppLock(); const runtime = useConnectionRuntime(); @@ -337,6 +339,42 @@ export function SettingsScreen({ navigation }: ScreenProps<"Settings">) { + + + Detailed transcript + + {transcript.detailed + ? "Detailed: individual tool executions and system notices." + : "Compact: group activity and collapse routine details."} + + + void transcript.update({ detailed })} + /> + + + + Show reasoning + + Display model reasoning when provided by the server. + + + void transcript.update({ reasoning })} + /> + + {transcript.error ? ( + + Transcript preferences could not be loaded or saved. Try changing the setting again. + + ) : null} + App lock diff --git a/apps/mobile/src/screens/session-composer.tsx b/apps/mobile/src/screens/session-composer.tsx index 8fe5bc4..b225b3a 100644 --- a/apps/mobile/src/screens/session-composer.tsx +++ b/apps/mobile/src/screens/session-composer.tsx @@ -761,7 +761,7 @@ const styles = StyleSheet.create({ input: { color: palette.ink, flex: 1, - fontSize: 16, + fontSize: 17, lineHeight: 23, }, inputCollapsed: { height: 42, paddingHorizontal: space.sm, paddingVertical: 0 }, @@ -803,7 +803,7 @@ const styles = StyleSheet.create({ minHeight: 44, paddingHorizontal: 4, }, - selectorLabel: { color: palette.ink, flexShrink: 1, fontSize: 12, fontWeight: "700" }, + selectorLabel: { color: palette.dim, flexShrink: 1, fontSize: 12, fontWeight: "500" }, selectorRow: { alignItems: "center", gap: space.xs, paddingRight: space.xs }, selectorScroller: { flex: 1 }, sendButton: { @@ -817,26 +817,24 @@ const styles = StyleSheet.create({ sendButtonDisabled: { backgroundColor: palette.border, opacity: 0.68 }, shell: { backgroundColor: palette.background, - borderTopColor: palette.border, - borderTopWidth: StyleSheet.hairlineWidth, gap: space.xs, - paddingHorizontal: space.sm, - paddingVertical: space.xs, + paddingHorizontal: 12, + paddingVertical: 8, }, surface: { backgroundColor: palette.card, borderColor: palette.border, - borderWidth: 1, + borderWidth: StyleSheet.hairlineWidth, overflow: "hidden", }, surfaceCollapsed: { borderRadius: 999, - minHeight: 52, + minHeight: 60, paddingLeft: 12, - paddingRight: 5, + paddingRight: 8, }, surfaceExpanded: { - borderRadius: 26, + borderRadius: 28, gap: 2, paddingBottom: 6, paddingHorizontal: 14, diff --git a/apps/mobile/src/screens/session-transcript.test.tsx b/apps/mobile/src/screens/session-transcript.test.tsx index 29f359b..9710dd3 100644 --- a/apps/mobile/src/screens/session-transcript.test.tsx +++ b/apps/mobile/src/screens/session-transcript.test.tsx @@ -5,10 +5,76 @@ import { Alert, Linking, View } from "react-native"; import { resetTranscriptPerformanceMetrics } from "../state/transcript-performance"; import { markdownPalette, palette } from "../theme"; -import { SessionTranscriptRow } from "./session-transcript"; +import { groupTranscriptMessages, SessionTranscriptRow } from "./session-transcript"; afterEach(resetTranscriptPerformanceMetrics); +test("cross-message grouping respects replies, errors, reasoning visibility and detailed mode", () => { + const original = messages.find((message) => message.type === "assistant"); + if (!original) throw new Error("fixture"); + const tool = original.content.find( + (part) => part.type === "tool" && part.state.status === "completed", + ); + if (!tool) throw new Error("fixture"); + const first = { ...original, retry: undefined, content: [tool] }; + // Remove the retry field so this fixture represents a normal successful execution. + const { retry: _retry, ...clean } = first; + const second = { ...clean, id: "msg_second" }; + const reasoning = { + ...clean, + id: "msg_reasoning", + content: [{ type: "reasoning" as const, text: "Thinking" }], + }; + const reply = { ...clean, id: "msg_reply", content: [{ type: "text" as const, text: "Reply" }] }; + expect(groupTranscriptMessages([clean, reasoning, second], false, false)).toMatchObject([ + { type: "activity-group", count: 2 }, + ]); + expect(groupTranscriptMessages([clean, reasoning, second], false, true)).toHaveLength(3); + expect(groupTranscriptMessages([clean, reply, second], false, false)).toHaveLength(3); + expect(groupTranscriptMessages([clean, second], true, false)).toEqual([clean, second]); + const failed = { ...second, error: { type: "ToolError", message: "Failed" } }; + expect(groupTranscriptMessages([clean, failed], false, false)).toEqual([clean, failed]); +}); + +test("reasoning can be hidden without hiding replies or tool failures", () => { + const message = messages.find((item) => item.type === "assistant"); + if (!message) throw new Error("fixture"); + const view = render(); + expect(screen.getByText("Answer")).toBeOnTheScreen(); + expect(screen.getByText("tool failed")).toBeOnTheScreen(); + expect(screen.queryByText("Reasoning detail")).toBeNull(); + view.rerender(); + expect(screen.getByText("Reasoning detail")).toBeOnTheScreen(); +}); + +test("compact system notices expand and detailed mode shows their content", () => { + const message = messages.find((item) => item.type === "synthetic"); + if (!message) throw new Error("fixture"); + const view = render(); + expect(screen.queryByText("Generated")).toBeNull(); + fireEvent.press(screen.getByRole("button", { name: /Generated context/ })); + expect(screen.getByText("Generated")).toBeOnTheScreen(); + view.rerender(); + expect(screen.getByText("Generated")).toBeOnTheScreen(); +}); + +test("detailed mode renders grouped tool executions individually", () => { + const original = messages.find((item) => item.type === "assistant"); + if (original?.type !== "assistant") throw new Error("fixture"); + const tool = original.content.find( + (part) => part.type === "tool" && part.state.status === "completed", + ); + if (tool?.type !== "tool") throw new Error("fixture"); + const message = { + ...original, + content: [tool, { ...tool, id: "tool-second", name: "second-tool" }], + }; + const view = render(); + expect(screen.queryByText("Used Second-tool")).toBeNull(); + view.rerender(); + expect(screen.getByText("Used Second-tool")).toBeOnTheScreen(); +}); + test.each([ { outcome: "succeeded", label: "Turn completed" }, { outcome: "failed", label: "Turn failed" }, @@ -130,7 +196,7 @@ test("renders every current message and tool state with large details collapsed" render( {messages.map((message) => ( - + ))} , ); @@ -213,7 +279,7 @@ test("opens HTTP and HTTPS transcript URLs as confirmed external links", () => { }); expect(screen.getByRole("link", { name: "http://localhost:4096/status" })).toBeOnTheScreen(); expect(screen.getByRole("link", { name: "https://assistant.test/guide" })).toHaveStyle({ - fontWeight: "800", + fontWeight: "700", }); fireEvent.press(secureLink); @@ -317,7 +383,7 @@ test("renders finished short reasoning inline with bold markdown", () => { expect(screen.getByText("THOUGHT")).toBeOnTheScreen(); expect(screen.queryByText("THINKING")).toBeNull(); - expect(screen.getByText("Adding mocks to repository tests")).toHaveStyle({ fontWeight: "800" }); + expect(screen.getByText("Adding mocks to repository tests")).toHaveStyle({ fontWeight: "700" }); expect(screen.queryByText(/\*\*/)).toBeNull(); expect(screen.queryByRole("button", { name: /Thought/ })).toBeNull(); }); diff --git a/apps/mobile/src/screens/session-transcript.tsx b/apps/mobile/src/screens/session-transcript.tsx index d9fb357..3bcd4ca 100644 --- a/apps/mobile/src/screens/session-transcript.tsx +++ b/apps/mobile/src/screens/session-transcript.tsx @@ -29,12 +29,123 @@ type AssistantPart = AssistantMessage["content"][number]; type ShellMessage = Extract; type ToolOutput = Extract["content"][number]; +export type TranscriptItem = + | SessionMessageInfo + | { + type: "activity-group"; + id: string; + messages: SessionMessageInfo[]; + count: number; + running: boolean; + }; + +export function groupTranscriptMessages( + messages: SessionMessageInfo[], + detailed: boolean, + showReasoning: boolean, +): TranscriptItem[] { + if (detailed) return messages; + const result: TranscriptItem[] = []; + let pending: SessionMessageInfo[] = []; + let count = 0; + let running = false; + const flush = () => { + const first = pending[0]; + if (first && count > 1) { + result.push({ + type: "activity-group", + id: `activity:${first.id}`, + messages: pending, + count, + running, + }); + } else if (count > 0) result.push(...pending); + pending = []; + count = 0; + running = false; + }; + for (const message of messages) { + if ( + message.type === "assistant" && + !message.error && + !message.retry && + message.content.length > 0 && + message.content.every((part) => + part.type === "reasoning" + ? !showReasoning + : part.type === "tool" && part.state.status !== "error" && !getSubagentPresentation(part), + ) + ) { + pending.push(message); + for (const part of message.content) { + if (part.type !== "tool") continue; + count += 1; + running ||= part.state.status === "running" || part.state.status === "streaming"; + } + } else { + flush(); + result.push(message); + } + } + flush(); + return result; +} + +export function TranscriptActivityGroup({ + item, + largeText, + showReasoning, + onOpenDiff, + onOpenSubagent, +}: { + item: Extract; + largeText: boolean; + showReasoning: boolean; + onOpenDiff: () => void; + onOpenSubagent: (sessionID: string) => void; +}) { + const [expanded, setExpanded] = useState(false); + return ( + + setExpanded((value) => !value)} + style={styles.activityGroupHeader} + > + + {item.running ? "Working" : "Activity"} ยท {item.count} tool calls + + {expanded ? "Hide" : "Show"} + + {expanded + ? item.messages.map((message) => ( + + )) + : null} + + ); +} + export const SessionTranscriptRow = memo(function SessionTranscriptRow({ + detailed = false, + showReasoning = true, largeText = false, message, onOpenDiff, onOpenSubagent, }: { + detailed?: boolean; + showReasoning?: boolean; largeText?: boolean; message: SessionMessageInfo; onOpenDiff?: (() => void) | undefined; @@ -61,7 +172,16 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({ const visibleContent = message.content.slice(0, maxAssistantParts); return ( - {groupAssistantParts(visibleContent).map((item) => { + {(detailed + ? visibleContent.map( + (part, index): AssistantPresentationItem => ({ + type: "part", + key: `part:${index}`, + part, + }), + ) + : groupAssistantParts(visibleContent) + ).map((item) => { if (item.type === "exploration") { return ( ; case "synthetic": - return ; + return ( + + ); case "system": - return ; + return ( + + ); case "skill": - return ; + return ; case "agent-switched": - return ; + return ; case "model-switched": - return ; + return ; case "location-switched": - return ; + return ( + + ); case "idle": return ( visibleContent.length ? ( Additional tool output omitted on this device. ) : null} - {expanded && error ? : null} + {error ? : null} {!nested && category === "edit" && onOpenDiff ? : null} ); @@ -743,7 +878,23 @@ function Disclosure({ ); } -function Notice({ error, label, text }: { error?: boolean; label: string; text?: string }) { +function Notice({ + compact, + error, + label, + text, +}: { + compact?: boolean; + error?: boolean; + label: string; + text?: string; +}) { + if (compact && text && !error) + return ( + + + + ); return ( ({ + useTranscriptPreferences: () => ({ detailed: false, reasoning: true }), +})); + const location = { directory: "/workspace", project: { canonical: "/workspace", directory: "/workspace", id: "project-1" }, @@ -525,6 +529,60 @@ test("retains the in-content Sessions title in the tablet shell", async () => { } }); +test("compact mode groups consecutive tool calls across assistant messages", async () => { + mockListMessages.mockImplementationOnce(async () => ({ + cursor: {}, + data: ["shell", "glob", "grep"].map((name, index) => ({ + agent: "build", + id: `msg_tools_${index}`, + type: "assistant" as const, + model: { id: "model-1", providerID: "provider" }, + time: { created: index + 1 }, + content: [ + { + type: "tool" as const, + id: `tool_${index}`, + name, + time: { created: index + 1 }, + state: { + status: "completed" as const, + input: {}, + content: [{ type: "text" as const, text: `Result ${index}` }], + }, + }, + ], + })), + })); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + const view = render( + + + , + ); + const group = await screen.findByRole("button", { name: "3 tool calls" }); + expect(screen.queryByText("Result 0")).toBeNull(); + expect(screen.getByLabelText("Session transcript").props.data).toHaveLength(1); + fireEvent.press(group); + expect(screen.getByRole("button", { name: "3 tool calls" }).props.accessibilityState).toEqual({ + expanded: true, + }); + view.unmount(); + queryClient.clear(); +}); + test("renders short thoughts inline and keeps detailed thoughts collapsed", async () => { const scrollToOffset = jest .spyOn(FlatList.prototype, "scrollToOffset") @@ -596,9 +654,10 @@ test("renders short thoughts inline and keeps detailed thoughts collapsed", asyn fireEvent.scroll(transcript, justAwayFromLiveEdge); fireEvent(transcript, "momentumScrollEnd", justAwayFromLiveEdge); expect(screen.getByRole("button", { name: "Scroll to latest" })).toHaveStyle({ - position: "relative", + position: "absolute", + alignSelf: "center", }); - expect(screen.getByText("Latest").props.dynamicTypeRamp).toBe("footnote"); + expect(screen.queryByText("Latest")).toBeNull(); fireEvent(transcript, "scrollBeginDrag", justAwayFromLiveEdge); fireEvent.scroll(transcript, liveEdgeEvent); @@ -977,7 +1036,7 @@ test("remeasures the transcript when the system font scale changes", async () => fireEvent(normalScaleTranscript, "scrollBeginDrag", normalScaleAwayFromLiveEdge); fireEvent.scroll(normalScaleTranscript, normalScaleAwayFromLiveEdge); expect(screen.getByRole("button", { name: "Scroll to latest" })).toHaveStyle({ - position: "relative", + position: "absolute", }); fireEvent(normalScaleTranscript, "momentumScrollEnd", normalScaleLiveEdge); fireEvent.press(screen.getByRole("button", { name: /Thought/ })); @@ -1009,7 +1068,7 @@ test("remeasures the transcript when the system font scale changes", async () => fireEvent(transcript, "scrollBeginDrag", awayFromLiveEdge); fireEvent.scroll(transcript, awayFromLiveEdge); expect(screen.getByRole("button", { name: "Scroll to latest" })).toHaveStyle({ - position: "relative", + position: "absolute", }); } finally { act(() => { diff --git a/apps/mobile/src/screens/workspace-screen.tsx b/apps/mobile/src/screens/workspace-screen.tsx index a058cf4..24c7877 100644 --- a/apps/mobile/src/screens/workspace-screen.tsx +++ b/apps/mobile/src/screens/workspace-screen.tsx @@ -1,3 +1,4 @@ +import Feather from "@expo/vector-icons/Feather"; import { findOpenCodeFiles, getDefaultOpenCodeLocation, @@ -9,7 +10,6 @@ import { listOpenCodeProjects, removeOpenCodeSession, type SessionInfo, - type SessionMessageInfo, type SessionMessagesResponse, } from "@opencode2-mobile/opencode-adapter"; import type { NativeStackScreenProps } from "@react-navigation/native-stack"; @@ -50,6 +50,7 @@ import { recordTranscriptLatestJump, recordTranscriptResidentSet, } from "../state/transcript-performance"; +import { useTranscriptPreferences } from "../state/transcript-preferences"; import { useWorkspaceSelection } from "../state/workspace-selection-context"; import { deleteSessionLocalState } from "../storage/prompt-admission-repository"; import { @@ -65,7 +66,12 @@ import { FormRequestList } from "./form-request-list"; import { SessionComposer } from "./session-composer"; import { loadOpenCodeSessionTreeIds } from "./session-deletion"; import { SessionExecutionPanel } from "./session-execution-panel"; -import { SessionTranscriptRow } from "./session-transcript"; +import { + groupTranscriptMessages, + SessionTranscriptRow, + TranscriptActivityGroup, + type TranscriptItem, +} from "./session-transcript"; import { resolveTranscriptLiveFollow, type TranscriptLiveFollowEvent, @@ -456,6 +462,7 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { } export function SessionScreen({ navigation, route }: SessionProps) { + const transcriptPreferences = useTranscriptPreferences(); const runtime = useConnectionRuntime(); const workspaceSelection = useWorkspaceSelection(); const queryClient = useQueryClient(); @@ -483,7 +490,7 @@ export function SessionScreen({ navigation, route }: SessionProps) { const deferredMentionSearch = useDeferredValue(mentionSearch); const composerDockRef = useRef(null); const measuredComposerDockScreenHeightRef = useRef(undefined); - const transcriptListRef = useRef>(null); + const transcriptListRef = useRef>(null); const liveFollowEnabledRef = useRef(true); const latestJumpPendingRef = useRef(false); const userScrollSessionRef = useRef(false); @@ -569,6 +576,11 @@ export function SessionScreen({ navigation, route }: SessionProps) { ? ({ state: "loading" } as const) : ({ state: "none" } as const); const messages = flattenTranscriptPages(messagesQuery.data?.pages); + const transcriptItems = groupTranscriptMessages( + messages, + transcriptPreferences.detailed, + transcriptPreferences.reasoning, + ); const draft = useSessionDraft(routeConnectionId, sessionID); const execution = useSessionExecution({ client: sessionLocationReady ? client : undefined, @@ -915,7 +927,7 @@ export function SessionScreen({ navigation, route }: SessionProps) { ( - - )} + renderItem={({ item }) => + item.type === "activity-group" ? ( + + ) : ( + + ) + } scrollEventThrottle={16} style={styles.transcriptList} updateCellsBatchingPeriod={40} @@ -993,13 +1017,17 @@ export function SessionScreen({ navigation, route }: SessionProps) { onPress={scrollToLatest} style={({ pressed }) => [ styles.latestButton, - largeText && styles.latestButtonLargeText, + { bottom: composerDockHeight + composerKeyboardOffset + space.sm }, pressed && styles.pressed, ]} > - - Latest - + ) : null} Promise<{ detailed: number; reasoning: number }>>(); +const mockWrite = jest.fn<(...args: unknown[]) => Promise>(); +const mockDb = { getFirstAsync: mockRead, runAsync: mockWrite }; +jest.mock("expo-sqlite", () => ({ useSQLiteContext: () => mockDb })); + +function Controls() { + const preferences = useTranscriptPreferences(); + return ( + <> + {`${preferences.detailed}/${preferences.reasoning}`} + {preferences.error ? "Save failed" : "OK"} +