From b5c0878fb7bfa64361600b065cdaed6149417620 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Mon, 28 Sep 2026 22:43:38 +0200 Subject: [PATCH] Improve mobile session UI, model favorites, and changes navigation Add native Markdown rendering, session titles, searchable pickers, connection-scoped model favorites, and expandable changed-file rows. Simplify session connection status and keep app updates in Settings. Give native modal sheets their own safe-area provider to address headers overlapping the iOS status bar. Extend confirmed-missing worktree handling to registered sandboxes and document the recent-session limit. --- apps/mobile/package.json | 7 +- apps/mobile/src/components/modal-sheet.tsx | 135 +++++--- .../connections/connection-repository.test.ts | 6 + .../src/connections/connection-repository.ts | 1 + .../mobile/src/navigation/root-navigation.tsx | 4 +- apps/mobile/src/screens/app-shell.test.ts | 35 ++ apps/mobile/src/screens/app-shell.tsx | 137 +++++--- apps/mobile/src/screens/connection-screen.tsx | 2 - apps/mobile/src/screens/diff-screen.test.tsx | 38 ++- apps/mobile/src/screens/diff-screen.tsx | 107 +++++- apps/mobile/src/screens/model-picker.test.tsx | 64 ++++ apps/mobile/src/screens/model-picker.tsx | 229 +++++++++++++ .../src/screens/session-composer.test.tsx | 29 +- apps/mobile/src/screens/session-composer.tsx | 285 ++++++++-------- .../src/screens/session-transcript.test.tsx | 44 ++- .../mobile/src/screens/session-transcript.tsx | 216 ++---------- .../src/screens/transcript-markdown.test.tsx | 87 +++++ .../src/screens/transcript-markdown.tsx | 316 ++++++++++++++++++ .../src/screens/use-session-execution.ts | 14 + .../workspace-screen.integration.test.tsx | 96 +++++- apps/mobile/src/screens/workspace-screen.tsx | 73 +++- .../src/state/followed-projects-context.tsx | 13 +- .../src/state/use-model-favorites.test.tsx | 82 +++++ apps/mobile/src/state/use-model-favorites.ts | 47 +++ .../workspace-selection-context.test.tsx | 103 +++--- apps/mobile/src/storage/database.test.ts | 56 +++- apps/mobile/src/storage/database.ts | 21 +- .../model-favorites-repository.test.ts | 59 ++++ .../src/storage/model-favorites-repository.ts | 58 ++++ apps/mobile/test/setup.ts | 5 + docs/SPEC.md | 5 +- pnpm-lock.yaml | 66 ++++ 32 files changed, 1940 insertions(+), 500 deletions(-) create mode 100644 apps/mobile/src/screens/model-picker.test.tsx create mode 100644 apps/mobile/src/screens/model-picker.tsx create mode 100644 apps/mobile/src/screens/transcript-markdown.test.tsx create mode 100644 apps/mobile/src/screens/transcript-markdown.tsx create mode 100644 apps/mobile/src/state/use-model-favorites.test.tsx create mode 100644 apps/mobile/src/state/use-model-favorites.ts create mode 100644 apps/mobile/src/storage/model-favorites-repository.test.ts create mode 100644 apps/mobile/src/storage/model-favorites-repository.ts create mode 100644 apps/mobile/test/setup.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c92dbff..0547f78 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -43,6 +43,7 @@ "expo-system-ui": "~6.0.9", "expo-updates": "~29.0.20", "js-base64": "3.7.8", + "markdown-it": "14.1.1", "react": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", @@ -56,6 +57,7 @@ "@jest/globals": "29.7.0", "@testing-library/react-native": "13.3.3", "@types/jest": "29.5.14", + "@types/markdown-it": "14.1.2", "@types/react": "~19.1.17", "expo-doctor": "1.20.2", "jest-expo": "~54.0.18", @@ -63,6 +65,9 @@ "typescript": "~5.9.3" }, "jest": { - "preset": "jest-expo" + "preset": "jest-expo", + "setupFilesAfterEnv": [ + "/test/setup.ts" + ] } } diff --git a/apps/mobile/src/components/modal-sheet.tsx b/apps/mobile/src/components/modal-sheet.tsx index 9d832c5..3581c02 100644 --- a/apps/mobile/src/components/modal-sheet.tsx +++ b/apps/mobile/src/components/modal-sheet.tsx @@ -1,6 +1,7 @@ -import { type ReactNode, useEffect, useState } from "react"; +import { type ReactNode, type RefObject, useEffect, useRef, useState } from "react"; import { AccessibilityInfo, + findNodeHandle, KeyboardAvoidingView, Modal, Platform, @@ -11,7 +12,7 @@ import { useWindowDimensions, View, } from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; +import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; import { palette, space, typeRamp, typography, usesLargeTextLayout } from "../theme"; @@ -22,6 +23,8 @@ export function ModalSheet({ subtitle, title, visible, + size = "page", + returnFocusRef, }: { children: ReactNode; onClose: () => void; @@ -29,10 +32,31 @@ export function ModalSheet({ subtitle?: string; title: string; visible: boolean; + size?: "page" | "full" | "compact"; + returnFocusRef?: RefObject | undefined; }) { const [reducedMotion, setReducedMotion] = useState(false); - const { fontScale } = useWindowDimensions(); + const { fontScale, width } = useWindowDimensions(); const largeText = usesLargeTextLayout(fontScale); + const compact = size === "compact" && !largeText; + const overlay = compact || (size !== "page" && width >= 700); + const headingRef = useRef(null); + const wasVisible = useRef(visible); + const restoreFocus = () => { + if (!returnFocusRef?.current) return; + const handle = findNodeHandle(returnFocusRef.current); + if (handle) AccessibilityInfo.setAccessibilityFocus(handle); + }; + useEffect(() => { + const closed = wasVisible.current && !visible; + wasVisible.current = visible; + if (!closed || Platform.OS === "ios" || !returnFocusRef) return; + const frame = requestAnimationFrame(() => { + const handle = returnFocusRef.current ? findNodeHandle(returnFocusRef.current) : null; + if (handle) AccessibilityInfo.setAccessibilityFocus(handle); + }); + return () => cancelAnimationFrame(frame); + }, [visible, returnFocusRef]); useEffect(() => { let active = true; @@ -55,58 +79,81 @@ export function ModalSheet({ { + const handle = findNodeHandle(headingRef.current); + if (handle) AccessibilityInfo.setAccessibilityFocus(handle); + }} + presentationStyle={overlay ? "overFullScreen" : size === "page" ? "pageSheet" : "fullScreen"} + transparent={overlay} visible={visible} > - - + - - - + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + [styles.closeButton, pressed && styles.pressed]} > - {title} - - {subtitle ? ( - - {subtitle} + + Done - ) : null} + - [styles.closeButton, pressed && styles.pressed]} - > - - Done - - - - {scrollable ? ( - - {children} - - ) : ( - {children} - )} - - + {scrollable ? ( + + {children} + + ) : ( + {children} + )} + + + ); } const styles = StyleSheet.create({ + frame: { flex: 1 }, + overlay: { backgroundColor: "rgba(0,0,0,0.6)", justifyContent: "center", alignItems: "center" }, + bottom: { justifyContent: "flex-end" }, + panel: { width: "100%", maxWidth: 640, maxHeight: "90%", borderRadius: 18, overflow: "hidden" }, + compactPanel: { flex: 0, flexShrink: 1 }, + compactKeyboardView: { flex: 0, flexShrink: 1 }, + compactScroll: { flexGrow: 0, flexShrink: 1 }, closeButton: { justifyContent: "center", minHeight: 44, paddingHorizontal: space.sm }, closeLabel: { ...typography.control, color: palette.signal }, content: { gap: space.md, padding: space.lg, paddingBottom: space.xl }, diff --git a/apps/mobile/src/connections/connection-repository.test.ts b/apps/mobile/src/connections/connection-repository.test.ts index 70dc981..5c5b165 100644 --- a/apps/mobile/src/connections/connection-repository.test.ts +++ b/apps/mobile/src/connections/connection-repository.test.ts @@ -190,6 +190,12 @@ test("deletes server-scoped local state when an existing profile changes origin" String(sql).includes("DELETE FROM followed_projects") && profileId === "profile-id", ), ).toBe(true); + expect( + runAsync.mock.calls.some( + ([sql, profileId]) => + String(sql).includes("DELETE FROM model_favorites") && profileId === "profile-id", + ), + ).toBe(true); expect( runAsync.mock.calls.some( ([sql, profileId]) => diff --git a/apps/mobile/src/connections/connection-repository.ts b/apps/mobile/src/connections/connection-repository.ts index bd49884..4cb3461 100644 --- a/apps/mobile/src/connections/connection-repository.ts +++ b/apps/mobile/src/connections/connection-repository.ts @@ -164,6 +164,7 @@ export async function saveConnectionProfile(db: SQLiteDatabase, input: SaveConne await txn.runAsync("DELETE FROM session_drafts WHERE connection_id = ?", id); await txn.runAsync("DELETE FROM unresolved_prompt_admissions WHERE connection_id = ?", id); await txn.runAsync("DELETE FROM followed_projects WHERE connection_id = ?", id); + await txn.runAsync("DELETE FROM model_favorites WHERE connection_id = ?", id); await txn.runAsync("DELETE FROM followed_project_preferences WHERE connection_id = ?", id); } }); diff --git a/apps/mobile/src/navigation/root-navigation.tsx b/apps/mobile/src/navigation/root-navigation.tsx index 22b0983..b0c97e8 100644 --- a/apps/mobile/src/navigation/root-navigation.tsx +++ b/apps/mobile/src/navigation/root-navigation.tsx @@ -119,14 +119,14 @@ export function RootNavigation() { options={({ navigation }) => ({ ...(customWorkspaceHeader ? { - header: () => ( + header: ({ options }) => ( navigation.navigate(destination)} onBack={() => { if (navigation.canGoBack()) navigation.goBack(); else navigation.popTo("Workspace"); }} - title="Session" + title={options.title ?? "Session"} /> ), } diff --git a/apps/mobile/src/screens/app-shell.test.ts b/apps/mobile/src/screens/app-shell.test.ts index f9da592..de13ad9 100644 --- a/apps/mobile/src/screens/app-shell.test.ts +++ b/apps/mobile/src/screens/app-shell.test.ts @@ -97,6 +97,41 @@ test("uses native navigation on phones and retains the tablet rail", () => { } }); +test("session tabs replace healthy connection and server labels but retain connection problems", () => { + jest.mocked(useConnections).mockReturnValue({ + profiles: [{ id: "connection-1", name: "Test server" }], + selectedProfileId: "connection-1", + } as never); + jest + .mocked(useConnectionRuntime) + .mockReturnValue({ reconnectAttempt: 0, status: "connected" } as never); + jest + .mocked(useWorkspaceSelection) + .mockReturnValue({ attentionCoverage: { completeness: "complete" }, pendingCount: 0 } as never); + const onSelect = jest.fn(); + const navigate = jest.fn(); + const element = () => + createElement(ShellFrame, { + active: "Workspace", + navigate, + branch: { state: "known", name: "main" }, + sessionTabs: { active: "session", onSelect }, + }); + const view = render(element()); + expect(screen.queryByText("LIVE")).toBeNull(); + expect(screen.queryByText("Test server")).toBeNull(); + expect(screen.getByRole("tab", { name: "Session", selected: true })).toBeOnTheScreen(); + expect(screen.getByRole("button", { name: "Current branch, main" })).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("tab", { name: "Changes" })); + expect(onSelect).toHaveBeenCalledWith("changes"); + jest + .mocked(useConnectionRuntime) + .mockReturnValue({ reconnectAttempt: 0, status: "offline" } as never); + view.rerender(element()); + fireEvent.press(screen.getByRole("button", { name: /Connection problem: OFFLINE/ })); + expect(navigate).toHaveBeenCalledWith("Settings"); +}); + test("reveals and copies the full session branch name", async () => { jest.mocked(useConnections).mockReturnValue({ profiles: [{ id: "connection-1", name: "Test server" }], diff --git a/apps/mobile/src/screens/app-shell.tsx b/apps/mobile/src/screens/app-shell.tsx index 08bf387..fd79bae 100644 --- a/apps/mobile/src/screens/app-shell.tsx +++ b/apps/mobile/src/screens/app-shell.tsx @@ -588,12 +588,14 @@ export function ShellFrame({ children, hideConnectionBar, navigate, + sessionTabs, }: { active: Section; branch?: SessionBranch; children?: ReactNode; hideConnectionBar?: boolean; navigate: (screen: Section) => void; + sessionTabs?: { active: "session" | "changes"; onSelect: (tab: "session" | "changes") => void }; }) { const { fontScale, width } = useWindowDimensions(); const tablet = isTabletShell(width); @@ -641,9 +643,11 @@ export function ShellFrame({ OpenCode - - {selected?.name ?? "NO SERVER"} - + {!sessionTabs ? ( + + {selected?.name ?? "NO SERVER"} + + ) : null} navigate("Settings")} /> - navigate("Settings")} - style={styles.railFooter} - > - - - {connection.label} - - + {!sessionTabs ? ( + navigate("Settings")} + style={styles.railFooter} + > + + + {connection.label} + + + ) : null} ) : null} @@ -687,12 +693,38 @@ export function ShellFrame({ accessibilityLiveRegion="polite" style={[styles.connectionBar, largeText && styles.connectionBarLargeText]} > - - - - {connection.label} - - + {sessionTabs ? ( + + {(["session", "changes"] as const).map((tab) => ( + sessionTabs.onSelect(tab)} + style={[ + styles.sessionTab, + sessionTabs.active === tab && styles.sessionTabSelected, + ]} + > + + {tab === "session" ? "Session" : "Changes"} + + + ))} + + ) : ( + + + + {connection.label} + + + )} {branch ? ( branch.state === "known" && branch.name ? ( ) ) : null} - showDetail("server")} - style={({ pressed }) => [ - styles.connectionNameControl, - branch && styles.connectionNameControlWithBranch, - largeText && styles.connectionNameControlLargeText, - pressed && styles.branchControlPressed, - ]} - > - showDetail("server")} + style={({ pressed }) => [ + styles.connectionNameControl, + branch && styles.connectionNameControlWithBranch, + largeText && styles.connectionNameControlLargeText, + pressed && styles.branchControlPressed, + ]} > - {serverName} - - + + {serverName} + + + ) : null} ) : null} + {sessionTabs && runtime.status !== "connected" && runtime.status !== "connecting" ? ( + navigate("Settings")} + style={styles.sessionConnectionIssue} + > + + {connection.label}. Check connection settings. + + + ) : null} {children} @@ -946,6 +995,18 @@ export function ActionButton({ } const styles = StyleSheet.create({ + sessionTabs: { flexDirection: "row", gap: space.md }, + sessionTab: { + minHeight: 44, + justifyContent: "center", + borderBottomWidth: 2, + borderBottomColor: "transparent", + paddingHorizontal: space.xs, + }, + sessionTabSelected: { borderBottomColor: palette.ink }, + sessionTabText: { ...typography.control, color: palette.dim }, + sessionTabTextSelected: { color: palette.ink }, + sessionConnectionIssue: { minHeight: 44, justifyContent: "center", paddingHorizontal: space.md }, locationFailure: { backgroundColor: palette.card, borderColor: palette.border, diff --git a/apps/mobile/src/screens/connection-screen.tsx b/apps/mobile/src/screens/connection-screen.tsx index b04de37..dfc8302 100644 --- a/apps/mobile/src/screens/connection-screen.tsx +++ b/apps/mobile/src/screens/connection-screen.tsx @@ -37,7 +37,6 @@ import { boundedOpenCodeFetch, expoOpenCodeFetch } from "../expo-open-code-fetch import { useAppLock } from "../security/app-lock-context"; import { useConnectionRuntime } from "../state/connection-runtime-context"; import { control, palette, radius, space, switchColors, typography } from "../theme"; -import { AppUpdateCard } from "../updates/app-updates"; import { type LifecycleTransportPhase, type LifecycleTransportResult, @@ -497,7 +496,6 @@ export function ConnectionScreen({ onDone, onPair }: { onDone?: () => void; onPa Connections Switch servers or add a direct OpenCode V2 connection. - {onPair ? ( { renderDiffScreen(); expect(await screen.findByText("src/app.ts")).toBeOnTheScreen(); + expect(screen.queryByText("+new value")).toBeNull(); + fireEvent.press( + screen.getByRole("button", { name: "modified file, src/app.ts, 1 additions, 1 deletions" }), + ); expect( screen.getByText( "Current working tree. This may include changes made after the selected tool call.", @@ -68,6 +72,38 @@ test("renders an authoritative working-tree diff", async () => { "working", expect.objectContaining({ context: 5 }), ); + fireEvent.press(screen.getByRole("button", { name: "Collapse all" })); + expect(screen.queryByText("+new value")).toBeNull(); +}); + +test("keeps later file headers reachable even when an expanded patch hits the line bound", () => { + const files: FileDiffInfo[] = [ + { + file: "large.ts", + status: "modified", + additions: 20001, + deletions: 0, + patch: "+line\n".repeat(20001), + }, + { file: "later.ts", status: "added", additions: 1, deletions: 0, patch: "+later" }, + ]; + const rows = buildDiffRows(files, new Set(["large.ts"])); + expect(rows).toContainEqual(expect.objectContaining({ type: "file", file: "later.ts" })); + expect(rows).toContainEqual(expect.objectContaining({ key: "line:omitted" })); + expect(buildDiffRows(files, new Set())).toHaveLength(2); +}); + +test("offers bounded expand all and keeps unavailable patches explicit", async () => { + mockGetDiff.mockResolvedValue({ + data: [ + { file: "new.ts", status: "added", additions: 1, deletions: 0, patch: "+new" }, + { file: "image.png", status: "modified", additions: 0, deletions: 0, patch: "" }, + ], + }); + renderDiffScreen(); + fireEvent.press(await screen.findByRole("button", { name: "Expand all" })); + expect(screen.getByText("+new")).toBeOnTheScreen(); + expect(screen.getByText("Diff unavailable for this file.")).toBeOnTheScreen(); }); test("shows empty and mismatched-connection states", async () => { diff --git a/apps/mobile/src/screens/diff-screen.tsx b/apps/mobile/src/screens/diff-screen.tsx index 6d1e9d3..a7daef0 100644 --- a/apps/mobile/src/screens/diff-screen.tsx +++ b/apps/mobile/src/screens/diff-screen.tsx @@ -1,6 +1,7 @@ import { type FileDiffInfo, getOpenCodeVcsDiff } from "@opencode2-mobile/opencode-adapter"; import type { NativeStackScreenProps } from "@react-navigation/native-stack"; import { useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; import { ActivityIndicator, FlatList, @@ -39,8 +40,15 @@ const maxDiffLines = 20_000; const maxDiffLineCharacters = 4_000; export function DiffScreen({ route }: Props) { + return ; +} + +export function SessionChanges({ + connectionId: routeConnectionId, + location, + mode, +}: RootStackParamList["Diff"]) { const runtime = useConnectionRuntime(); - const { connectionId: routeConnectionId, location, mode } = route.params; const client = runtime.restClient; const connectedToRoute = runtime.connectionId === routeConnectionId; const query = useQuery({ @@ -52,7 +60,17 @@ export function DiffScreen({ route }: Props) { queryKey: openCodeQueryKeys.vcsDiff(routeConnectionId, location, mode), }); const files = connectedToRoute ? (query.data?.data ?? []) : []; - const rows = buildDiffRows(files); + const scope = JSON.stringify([routeConnectionId, location, mode]); + const [expansion, setExpansion] = useState<{ scope: string; files: Set }>({ + scope, + files: new Set(), + }); + const expanded = expansion.scope === scope ? expansion.files : new Set(); + const rows = useMemo(() => buildDiffRows(files, expanded), [files, expanded]); + const canExpandAll = + files.length > 1 && + files.length <= 20 && + files.reduce((total, file) => total + file.patch.length, 0) <= 200_000; const additions = files.reduce((total, file) => total + file.additions, 0); const deletions = files.reduce((total, file) => total + file.deletions, 0); @@ -100,6 +118,30 @@ export function DiffScreen({ route }: Props) { Current working tree. This may include changes made after the selected tool call. + {query.isError ? ( + void query.refetch()} + /> + ) : null} + {expanded.size > 0 || canExpandAll ? ( + + setExpansion({ + scope, + files: expanded.size > 0 ? new Set() : new Set(files.map((file) => file.file)), + }) + } + > + + {expanded.size > 0 ? "Collapse all" : "Expand all"} + + + ) : null} ) : null } @@ -112,7 +154,20 @@ export function DiffScreen({ route }: Props) { /> } renderItem={({ item }) => - item.type === "file" ? : + item.type === "file" ? ( + + setExpansion({ + scope, + files: expanded.has(item.file) ? new Set() : new Set([item.file]), + }) + } + /> + ) : ( + + ) } updateCellsBatchingPeriod={40} windowSize={9} @@ -120,13 +175,33 @@ export function DiffScreen({ route }: Props) { ); } -function DiffFileHeader({ row }: { row: Extract }) { +function DiffFileHeader({ + row, + expanded, + onPress, +}: { + row: Extract; + expanded: boolean; + onPress: () => void; +}) { + const path = sanitizeTranscriptText(row.file, 1_024); + const separator = path.lastIndexOf("/"); return ( - - - {sanitizeTranscriptText(row.file, 1_024)} + [styles.fileHeader, pressed && styles.pressed]} + > + + {path.slice(0, separator + 1)} + {path.slice(separator + 1)} + + {expanded ? "⌄" : "›"} + {row.status.toLocaleUpperCase()} @@ -137,7 +212,7 @@ function DiffFileHeader({ row }: { row: Extract }) { -{row.deletions} - + ); } @@ -146,6 +221,7 @@ function DiffLine({ row }: { row: Extract }) { ) { const rows: DiffRow[] = []; let lineCount = 0; const visibleFiles = files.slice(0, maxDiffFiles); @@ -210,6 +286,17 @@ export function buildDiffRows(files: FileDiffInfo[]) { status: file.status, type: "file", }); + if (expanded && !expanded.has(file.file)) continue; + if (!file.patch.trim()) { + rows.push({ + key: `unavailable:${fileIndex}`, + kind: "meta", + type: "line", + text: "Diff unavailable for this file.", + }); + continue; + } + if (lineCount >= maxDiffLines) continue; for (const [lineIndex, line] of file.patch.split(/\r?\n/).entries()) { if (lineCount >= maxDiffLines) break; rows.push({ @@ -220,7 +307,6 @@ export function buildDiffRows(files: FileDiffInfo[]) { }); lineCount += 1; } - if (lineCount >= maxDiffLines) break; } if (files.length > visibleFiles.length || lineCount >= maxDiffLines) { rows.push({ @@ -258,6 +344,7 @@ const styles = StyleSheet.create({ paddingVertical: 12, }, fileMeta: { flexDirection: "row", gap: space.sm }, + fileParent: { color: palette.dim, fontWeight: "400" }, fileName: { ...typography.code, color: palette.ink, fontWeight: "600" }, fileStatus: { ...typography.label, color: palette.dim }, line: { diff --git a/apps/mobile/src/screens/model-picker.test.tsx b/apps/mobile/src/screens/model-picker.test.tsx new file mode 100644 index 0000000..4f03ea0 --- /dev/null +++ b/apps/mobile/src/screens/model-picker.test.tsx @@ -0,0 +1,64 @@ +import { expect, jest, test } from "@jest/globals"; +import type { ModelInfo } from "@opencode2-mobile/opencode-adapter"; +import { fireEvent, render, screen } from "@testing-library/react-native"; +import { ModelPicker } from "./model-picker"; + +const models = [ + { id: "one", name: "Model One", providerID: "p", variants: [], enabled: true, status: "active" }, + { id: "two", name: "Model Two", providerID: "p", variants: [], enabled: true, status: "active" }, +] as unknown as ModelInfo[]; + +test("stars do not select or dismiss and matching favorites stay first", () => { + const onSelect = jest.fn(); + const onClose = jest.fn(); + const toggle = jest.fn(); + render( + , + ); + expect(screen.getAllByRole("button", { name: /^Model/ })[0]).toHaveProp( + "accessibilityLabel", + "Model Two, p", + ); + fireEvent.press(screen.getByRole("checkbox", { name: "Add Model One to favorites" })); + expect(toggle).toHaveBeenCalledWith(models[0], true); + expect(onSelect).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + fireEvent.changeText(screen.getByLabelText("Search models"), "two"); + expect(screen.getAllByRole("button", { name: /^Model/ })).toHaveLength(1); + fireEvent.press(screen.getByRole("button", { name: "Model Two, p" })); + expect(onSelect).toHaveBeenCalledWith({ id: "two", providerID: "p" }); + expect(onClose).toHaveBeenCalledTimes(1); +}); + +test("loading and failure are distinct from an empty model catalog", () => { + const retry = jest.fn(); + const props = { + models: [], + model: undefined, + visible: true, + onSelect: jest.fn(), + onClose: jest.fn(), + favorites: undefined, + }; + const view = render(); + expect(screen.getByLabelText("Loading models")).toBeOnTheScreen(); + expect(screen.queryByText("No enabled models")).toBeNull(); + view.rerender(); + expect(screen.getByText("Models could not be loaded.")).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "Retry" })); + expect(retry).toHaveBeenCalled(); +}); diff --git a/apps/mobile/src/screens/model-picker.tsx b/apps/mobile/src/screens/model-picker.tsx new file mode 100644 index 0000000..3e31bcc --- /dev/null +++ b/apps/mobile/src/screens/model-picker.tsx @@ -0,0 +1,229 @@ +import Feather from "@expo/vector-icons/Feather"; +import type { ModelInfo, ModelRef } from "@opencode2-mobile/opencode-adapter"; +import { type RefObject, useDeferredValue, useState } from "react"; +import { + ActivityIndicator, + Platform, + Pressable, + SectionList, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { ModalSheet } from "../components/modal-sheet"; +import type { useModelFavorites } from "../state/use-model-favorites"; +import { modelIdentityKey } from "../storage/model-favorites-repository"; +import { palette, space, typography } from "../theme"; + +export type FavoriteControls = ReturnType; +export type CatalogState = { loading: boolean; error: boolean; retry: () => void }; + +export function ModelPicker({ + models, + model, + favorites, + state, + onSelect, + onClose, + visible, + returnFocusRef, +}: { + models: ModelInfo[]; + model: ModelRef | undefined; + favorites: FavoriteControls | undefined; + state: CatalogState | undefined; + onSelect: (model: ModelRef) => void; + onClose: () => void; + visible: boolean; + returnFocusRef?: RefObject; +}) { + const [search, setSearch] = useState(""); + const query = useDeferredValue(search.trim().toLocaleLowerCase()); + const favoritesByID = new Map( + favorites?.models.map((item, index) => [modelIdentityKey(item), index]), + ); + const matching = models.filter((item) => + `${item.name}\n${item.providerID}\n${item.id}`.toLocaleLowerCase().includes(query), + ); + const favoriteRows = matching + .filter((item) => favoritesByID.has(modelIdentityKey(item))) + .sort( + (a, b) => + (favoritesByID.get(modelIdentityKey(a)) ?? 0) - + (favoritesByID.get(modelIdentityKey(b)) ?? 0), + ); + const providers = new Map(); + for (const item of matching) { + if (favoritesByID.has(modelIdentityKey(item))) continue; + const rows = providers.get(item.providerID) ?? []; + rows.push(item); + providers.set(item.providerID, rows); + } + const sections = [ + ...(favoriteRows.length ? [{ title: "Favorites", key: "favorites", data: favoriteRows }] : []), + ...[...providers] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([provider, data]) => ({ + title: provider, + key: `provider:${provider}`, + data: data.sort((a, b) => a.name.localeCompare(b.name)), + })), + ]; + const close = () => { + setSearch(""); + onClose(); + }; + return ( + + + {state?.error ? ( + + ) : null} + {favorites?.error ? ( + + ) : null} + + ) : !state?.error ? ( + + {search.trim() ? "No matching models" : "No enabled models"} + + ) : null + } + renderSectionHeader={({ section }) => ( + + {section.title} + + )} + renderItem={({ item }) => { + const favorite = favoritesByID.has(modelIdentityKey(item)); + const selected = item.id === model?.id && item.providerID === model.providerID; + return ( + + { + onSelect({ id: item.id, providerID: item.providerID }); + close(); + }} + style={({ pressed }) => [styles.choice, pressed && styles.pressed]} + > + + {item.name} + {item.providerID} + + {selected ? ( + + ) : null} + + favorites?.toggle(item, !favorite)} + style={({ pressed }) => [styles.star, pressed && styles.pressed]} + > + + {favorite ? "★" : "☆"} + + + + ); + }} + /> + + ); +} + +export function PickerNotice({ text, retry }: { text: string; retry: () => void }) { + return ( + + + {text} + + + Retry + + + ); +} + +const styles = StyleSheet.create({ + search: { + borderWidth: 1, + borderColor: palette.border, + borderRadius: 8, + color: palette.ink, + fontSize: 16, + minHeight: 48, + paddingHorizontal: space.md, + }, + list: { flex: 1 }, + section: { ...typography.label, color: palette.dim, paddingVertical: space.sm }, + row: { + flexDirection: "row", + alignItems: "center", + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: palette.border, + }, + choice: { + flex: 1, + flexDirection: "row", + alignItems: "center", + minHeight: 64, + paddingVertical: space.sm, + paddingRight: space.sm, + gap: space.sm, + }, + copy: { flex: 1, minWidth: 0 }, + label: { ...typography.heading, color: palette.ink }, + provider: { ...typography.caption, color: palette.dim, marginTop: 3 }, + star: { minWidth: 48, minHeight: 48, alignItems: "center", justifyContent: "center" }, + starText: { fontSize: 26, color: palette.dim }, + favorite: { color: palette.ink }, + pressed: { opacity: 0.6 }, + empty: { ...typography.body, color: palette.dim, paddingVertical: space.lg }, + notice: { gap: space.xs }, + retry: { + minHeight: 44, + justifyContent: "center", + alignSelf: "flex-start", + paddingRight: space.md, + }, +}); diff --git a/apps/mobile/src/screens/session-composer.test.tsx b/apps/mobile/src/screens/session-composer.test.tsx index ddd5285..1d05e70 100644 --- a/apps/mobile/src/screens/session-composer.test.tsx +++ b/apps/mobile/src/screens/session-composer.test.tsx @@ -99,13 +99,14 @@ test("closes before publishing an immediate active-session transition", () => { dismissKeyboard.mockRestore(); }); -test("keeps controls collapsed until the editor is focused", () => { +test("keeps model and variant controls visible before the editor is focused", () => { render(); const input = screen.getByLabelText("Prompt"); 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(); + expect(screen.getByRole("button", { name: "Model: Choose model" })).toBeOnTheScreen(); + expect(screen.getByRole("button", { name: "Variant: Default" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Agent: Choose agent" })).toBeOnTheScreen(); fireEvent(input, "focus"); @@ -183,7 +184,7 @@ test("keeps the editor read-only until its encrypted draft has loaded", () => { expect(screen.getByLabelText("Prompt").props.editable).toBe(false); }); -test("selects a server agent and model variant", () => { +test("selects a server agent and model", () => { const onAgentChange = jest.fn(); const onModelChange = jest.fn(); render( @@ -210,21 +211,33 @@ test("selects a server agent and model variant", () => { fireEvent(screen.getByLabelText("Prompt"), "focus"); fireEvent.press(screen.getByRole("button", { name: "Agent: Choose agent" })); - expect(screen.getByLabelText("Agent results").props.inverted).toBe(true); + expect(screen.getByLabelText("Agent results").props.inverted).toBeFalsy(); fireEvent.changeText(screen.getByLabelText("Search agents"), "build"); fireEvent.press(screen.getByRole("button", { name: "Build" })); expect(onAgentChange).toHaveBeenCalledWith("build"); fireEvent.press(screen.getByRole("button", { name: "Model: Choose model" })); - expect(screen.getByLabelText("Model results").props.inverted).toBe(true); - fireEvent.press(screen.getByRole("button", { name: /Model One \/ deep/ })); + expect(screen.getByLabelText("Model results").props.inverted).toBeFalsy(); + fireEvent.press(screen.getByRole("button", { name: /Model One/ })); expect(onModelChange).toHaveBeenCalledWith({ id: "model-1", providerID: "provider", - variant: "deep", }); }); +test("selects a variant separately and can return to the model default", () => { + render(); + fireEvent.press(screen.getByRole("button", { name: "Model: Choose model" })); + fireEvent.press(screen.getByRole("button", { name: /Model One/ })); + fireEvent.press(screen.getByRole("button", { name: "Variant: Default" })); + fireEvent.press(screen.getByRole("button", { name: "deep" })); + expect(screen.getByRole("button", { name: "Variant: deep" })).toBeOnTheScreen(); + expect(screen.getByRole("button", { name: "Model: Model One" })).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "Variant: deep" })); + fireEvent.press(screen.getByRole("button", { name: "Default" })); + expect(screen.getByRole("button", { name: "Variant: Default" })).toBeOnTheScreen(); +}); + test("completes and submits a command with multiline Unicode arguments", () => { const onSubmit = jest.fn<(intent: ComposerSubmitIntent) => void>(); render(); diff --git a/apps/mobile/src/screens/session-composer.tsx b/apps/mobile/src/screens/session-composer.tsx index e8449a4..63134f7 100644 --- a/apps/mobile/src/screens/session-composer.tsx +++ b/apps/mobile/src/screens/session-composer.tsx @@ -8,7 +8,7 @@ import type { ModelRef, SkillInfo, } from "@opencode2-mobile/opencode-adapter"; -import { useDeferredValue, useEffect, useRef, useState } from "react"; +import { type RefObject, useDeferredValue, useEffect, useRef, useState } from "react"; import { FlatList, Keyboard, @@ -24,6 +24,12 @@ import { import { ModalSheet } from "../components/modal-sheet"; import { palette, radius, space, typeRamp, typography } from "../theme"; +import { + type CatalogState, + type FavoriteControls, + ModelPicker, + PickerNotice, +} from "./model-picker"; import type { PromptDelivery } from "./prompt-admission-model"; import { applyMentionCompletion, @@ -45,6 +51,9 @@ export function SessionComposer({ active, agent, agents, + agentCatalog, + modelCatalog, + favorites, commands, completionLoading, completionUnavailable, @@ -74,6 +83,9 @@ export function SessionComposer({ active: boolean; agent?: string | undefined; agents: AgentInfo[]; + agentCatalog?: CatalogState; + modelCatalog?: CatalogState; + favorites?: FavoriteControls; commands: CommandInfo[]; completionLoading?: boolean | undefined; completionUnavailable?: boolean | undefined; @@ -101,26 +113,21 @@ export function SessionComposer({ skills: SkillInfo[]; }) { const inputRef = useRef(null); + const modelSelectorRef = useRef(null); + const agentSelectorRef = useRef(null); + const variantSelectorRef = useRef(null); const { fontScale } = useWindowDimensions(); const [agentPickerOpen, setAgentPickerOpen] = useState(false); const [agentSearch, setAgentSearch] = useState(""); const [focused, setFocused] = useState(false); const [modelPickerOpen, setModelPickerOpen] = useState(false); - const [modelSearch, setModelSearch] = useState(""); + const [variantPickerOpen, setVariantPickerOpen] = useState(false); const [selection, setSelection] = useState({ end: draft.length, start: draft.length }); - const deferredModelSearch = useDeferredValue(modelSearch.trim().toLocaleLowerCase()); const deferredAgentSearch = useDeferredValue(agentSearch.trim().toLocaleLowerCase()); const selectedAgent = agents.find((candidate) => candidate.id === agent); const selectedModel = models.find( (candidate) => candidate.id === model?.id && candidate.providerID === model.providerID, ); - const visibleModels = deferredModelSearch - ? models.filter((candidate) => - `${candidate.name}\n${candidate.providerID}\n${candidate.id}` - .toLocaleLowerCase() - .includes(deferredModelSearch), - ) - : models; const visibleAgents = deferredAgentSearch ? agents.filter((candidate) => `${candidate.name}\n${candidate.id}\n${candidate.description ?? ""}` @@ -128,7 +135,7 @@ export function SessionComposer({ .includes(deferredAgentSearch), ) : agents; - const expanded = largeText || focused || agentPickerOpen || modelPickerOpen; + const expanded = largeText || focused || agentPickerOpen || modelPickerOpen || variantPickerOpen; const minimumInputHeight = Math.max(40, 23 * fontScale + 8); const maximumInputHeight = Math.max(120, minimumInputHeight * 2); const completions = listSlashCompletions(draft, commands); @@ -207,7 +214,7 @@ export function SessionComposer({ return ( - + - {!expanded ? ( - - ) : null} {expanded && /^\/[^\s/]*$/.test(draft) ? ( @@ -333,43 +331,57 @@ export function SessionComposer({ ) : null} - {expanded ? ( - - - setModelPickerOpen(true)} - prefix="Model" - /> - { - setAgentSearch(""); - setAgentPickerOpen(true); - }} - prefix="Agent" - /> - {draft.length > 0 ? ( - - {draft.length.toLocaleString()} / {maximumDraftLength.toLocaleString()} - - ) : null} - - + + { + Keyboard.dismiss(); + setModelPickerOpen(true); + }} + prefix="Model" + buttonRef={modelSelectorRef} /> - - ) : null} + { + Keyboard.dismiss(); + setVariantPickerOpen(true); + }} + prefix="Variant" + buttonRef={variantSelectorRef} + disabled={!selectedModel?.variants.length} + /> + { + setAgentSearch(""); + Keyboard.dismiss(); + setAgentPickerOpen(true); + }} + prefix="Agent" + buttonRef={agentSelectorRef} + /> + {draft.length > 0 ? ( + + {draft.length.toLocaleString()} / {maximumDraftLength.toLocaleString()} + + ) : null} + + + {error ? ( @@ -380,21 +392,48 @@ export function SessionComposer({ setAgentPickerOpen(false)} + size="full" scrollable={false} subtitle="Primary agents available at this session location" title="Choose agent" + returnFocusRef={agentSelectorRef} visible={agentPickerOpen} > + + {agentCatalog?.error ? ( + + ) : null} candidate.id} - ListEmptyComponent={} + ListEmptyComponent={ + + } renderItem={({ item: candidate }) => ( - - setModelPickerOpen(false)} - scrollable={false} - subtitle="Enabled models and variants from the server catalog" - title="Choose model" + onSelect={onModelChange} + models={models} + returnFocusRef={modelSelectorRef} + model={model} + state={modelCatalog} + favorites={favorites} visible={modelPickerOpen} + /> + setVariantPickerOpen(false)} + title="Choose variant" + returnFocusRef={variantSelectorRef} + size={(selectedModel?.variants.length ?? 0) > 8 ? "full" : "compact"} + subtitle={selectedModel?.name ?? "Select a model first"} + visible={variantPickerOpen} > - `${candidate.providerID}/${candidate.id}`} - ListEmptyComponent={} - renderItem={({ item: candidate }) => ( - + {selectedModel ? ( + <> + { + onModelChange({ id: selectedModel.id, providerID: selectedModel.providerID }); + setVariantPickerOpen(false); + }} + /> + {selectedModel.variants.map((variant) => ( { - onModelChange({ id: candidate.id, providerID: candidate.providerID }); - setModelPickerOpen(false); + onModelChange({ + id: selectedModel.id, + providerID: selectedModel.providerID, + variant: variant.id, + }); + setVariantPickerOpen(false); }} - selected={ - candidate.id === model?.id && - candidate.providerID === model.providerID && - model.variant === undefined - } /> - {candidate.variants.map((variant) => ( - { - onModelChange({ - id: candidate.id, - providerID: candidate.providerID, - variant: variant.id, - }); - setModelPickerOpen(false); - }} - selected={ - candidate.id === model?.id && - candidate.providerID === model.providerID && - variant.id === model.variant - } - /> - ))} - - )} - style={styles.pickerList} - /> - + ))} + + ) : null} ); @@ -632,10 +641,14 @@ function SendButton({ } function SelectorButton({ + buttonRef, + disabled = false, label, onPress, prefix, }: { + buttonRef?: RefObject; + disabled?: boolean; label: string; onPress: () => void; prefix: string; @@ -643,7 +656,10 @@ function SelectorButton({ return ( [styles.selectorButton, pressed && styles.pressed]} > @@ -702,6 +718,7 @@ function OptionButton({ [ styles.option, @@ -711,10 +728,11 @@ function OptionButton({ ]} > + {selected ? "✓ " : ""} {label} {description ? ( - + {description} ) : null} @@ -724,7 +742,7 @@ function OptionButton({ function modelLabel(model: ModelInfo | undefined, ref: ModelRef | undefined) { if (!model) return ref ? `${ref.providerID}/${ref.id}` : "Choose model"; - return ref?.variant ? `${model.name} / ${ref.variant}` : model.name; + return model.name; } const styles = StyleSheet.create({ @@ -809,7 +827,7 @@ const styles = StyleSheet.create({ minHeight: 44, paddingHorizontal: 4, }, - selectorLabel: { color: palette.dim, flexShrink: 1, fontSize: 12, fontWeight: "500" }, + selectorLabel: { color: palette.dim, flexShrink: 1, fontSize: 15, fontWeight: "500" }, selectorRow: { alignItems: "center", gap: space.xs, paddingRight: space.xs }, selectorScroller: { flex: 1 }, sendButton: { @@ -833,13 +851,6 @@ const styles = StyleSheet.create({ borderWidth: StyleSheet.hairlineWidth, overflow: "hidden", }, - surfaceCollapsed: { - borderRadius: 999, - justifyContent: "center", - minHeight: 60, - paddingLeft: 12, - paddingRight: 8, - }, surfaceExpanded: { borderRadius: 28, gap: 2, diff --git a/apps/mobile/src/screens/session-transcript.test.tsx b/apps/mobile/src/screens/session-transcript.test.tsx index 7330a26..6d9c8d7 100644 --- a/apps/mobile/src/screens/session-transcript.test.tsx +++ b/apps/mobile/src/screens/session-transcript.test.tsx @@ -30,7 +30,7 @@ test("inline code preserves literal markup and does not create links inside code />, ); expect(screen.getByText("pnpm check")).toHaveStyle({ - color: markdownPalette.code, + color: palette.ink, fontFamily: "Menlo", }); expect(screen.getByText("**literal**")).toBeOnTheScreen(); @@ -360,6 +360,48 @@ test("keeps URLs in fenced code blocks inert", () => { expect(screen.queryByRole("link")).toBeNull(); }); +test("renders nested bold code and table cells without leaking Markdown markers", () => { + render( + , + ); + expect(screen.getByText("Change")).toBeOnTheScreen(); + expect(screen.getByText("Rebuild needed?")).toBeOnTheScreen(); + expect(screen.getByText("IMAGE_TAG")).toHaveStyle({ fontWeight: "700" }); + expect(screen.queryByText(/\*\*/)).toBeNull(); + expect(screen.queryByText(/\|---/)).toBeNull(); +}); + +test("shows the responding model display name and measured runtime in the footer", () => { + render( + , + ); + expect(screen.getByText("Plan · Model One · 28s")).toHaveStyle({ fontSize: 15, lineHeight: 22 }); +}); + test("renders fenced assistant code without markdown fence markers", () => { render( void) | undefined; onOpenSubagent?: ((sessionID: string) => void) | undefined; }) { @@ -294,7 +297,9 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({ {message.content.length === 0 && !message.error ? ( No projected content ) : null} - {hasNarrativeContent(message) ? : null} + {hasNarrativeContent(message) ? ( + + ) : null} ); } @@ -584,7 +589,13 @@ function ToolDisclosure({ : presentation.label; const visibleContent = content.slice(0, maxToolOutputs); return ( - + {sanitizeTranscriptText(sentenceCase(message.agent || "Assistant"), 128)} ·{" "} - {sanitizeTranscriptText(message.model.id, 128)} + {sanitizeTranscriptText(modelName || message.model.id, 128)} {duration ? ` · ${duration}` : ""} ); @@ -986,40 +1003,8 @@ function ExpandableText({ ); } -type MarkdownBlock = - | { text: string; type: "text" } - | { language?: string; text: string; type: "code" }; - function MarkdownText({ style, text }: { style: object; text: string }) { - const blocks = keyMarkdownBlocks(splitCodeBlocks(text)); - return ( - - {blocks.map(({ block, key }, index) => - block.type === "text" ? ( - 0 && styles.markdownBlockSpacing]} - text={block.text} - /> - ) : ( - 0 && styles.markdownBlockSpacing]} - > - {block.language ? ( - - {block.language.toLocaleUpperCase()} - - ) : null} - - {block.text} - - - ), - )} - - ); + return ; } function InlineMarkdownText({ @@ -1034,22 +1019,13 @@ function InlineMarkdownText({ text: string; }) { return ( - - {prefix ? {`${prefix} `} : null} - {splitInlineText(text).map((token) => - token.code ? ( - - {token.text} - - ) : ( - - ), - )} - + ); } @@ -1149,108 +1125,6 @@ function openTranscriptUrl(url: string) { ); } -function splitBoldText(text: string) { - const tokens: { bold: boolean; key: string; text: string }[] = []; - let cursor = 0; - let ordinal = 0; - while (cursor < text.length) { - const opening = text.indexOf("**", cursor); - const closing = opening >= 0 ? text.indexOf("**", opening + 2) : -1; - if (opening < 0 || closing < 0) { - tokens.push({ bold: false, key: `text:${ordinal}`, text: text.slice(cursor) }); - break; - } - if (opening > cursor) { - tokens.push({ bold: false, key: `text:${ordinal}`, text: text.slice(cursor, opening) }); - ordinal += 1; - } - tokens.push({ bold: true, key: `bold:${ordinal}`, text: text.slice(opening + 2, closing) }); - ordinal += 1; - cursor = closing + 2; - } - return tokens; -} - -function splitInlineText(text: string) { - const tokens: { bold: boolean; code: boolean; key: string; text: string }[] = []; - const pattern = /(`+)([\s\S]*?)\1(?!`)/g; - let cursor = 0; - const prose = (value: string) => { - for (const token of splitBoldText(value)) - tokens.push({ ...token, code: false, key: `inline:${tokens.length}` }); - }; - for (const match of text.matchAll(pattern)) { - prose(text.slice(cursor, match.index)); - tokens.push({ - bold: false, - code: true, - key: `inline:${tokens.length}`, - text: (match[2] ?? "").replace(/\n/g, " "), - }); - cursor = match.index + match[0].length; - } - prose(text.slice(cursor)); - return tokens; -} - -function splitCodeBlocks(text: string): MarkdownBlock[] { - const blocks: MarkdownBlock[] = []; - const prose: string[] = []; - const code: string[] = []; - let fence: string | undefined; - let language: string | undefined; - - const flushProse = () => { - const value = prose.join("\n").replace(/^\n+|\n+$/g, ""); - if (value) blocks.push({ text: value, type: "text" }); - prose.length = 0; - }; - const flushCode = () => { - blocks.push({ ...(language ? { language } : {}), text: code.join("\n"), type: "code" }); - code.length = 0; - }; - - for (const line of text.split(/\r?\n/)) { - if (!fence) { - const opening = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/); - if (!opening || (opening[1]?.startsWith("`") && opening[2]?.includes("`"))) { - prose.push(line); - continue; - } - flushProse(); - fence = opening[1]; - language = opening[2]?.trim().split(/\s+/, 1)[0]?.slice(0, 32) || undefined; - continue; - } - - const closing = line.match(/^ {0,3}(`+|~+)\s*$/)?.[1]; - if (closing && closing[0] === fence[0] && closing.length >= fence.length) { - flushCode(); - fence = undefined; - language = undefined; - } else { - code.push(line); - } - } - - if (fence) flushCode(); - flushProse(); - return blocks; -} - -function keyMarkdownBlocks(blocks: MarkdownBlock[]) { - let textOrdinal = 0; - let codeOrdinal = 0; - return blocks.map((block) => { - if (block.type === "text") { - textOrdinal += 1; - return { block, key: `text:${textOrdinal}` }; - } - codeOrdinal += 1; - return { block, key: `code:${codeOrdinal}` }; - }); -} - const explorationToolNames = new Set([ "find", "glob", @@ -1520,11 +1394,6 @@ const styles = StyleSheet.create({ marginVertical: space.xs, overflow: "hidden", }, - inlineCode: { - backgroundColor: palette.raised, - color: markdownPalette.code, - fontFamily: typography.code.fontFamily, - }, commandText: { ...typography.code, backgroundColor: palette.raised, @@ -1549,6 +1418,13 @@ const styles = StyleSheet.create({ borderBottomWidth: StyleSheet.hairlineWidth, }, activityAction: { color: palette.signal, fontSize: 11, fontWeight: "700" }, + activityError: { + borderBottomWidth: 0, + marginHorizontal: -space.sm, + paddingHorizontal: space.lg, + paddingTop: space.xs, + paddingBottom: space.md, + }, activityActionLargeText: { alignSelf: "flex-start" }, activityCopy: { alignItems: "baseline", @@ -1580,7 +1456,7 @@ const styles = StyleSheet.create({ activityLabel: { ...typography.control, color: palette.ink }, activityNested: { marginLeft: 12 }, activityStandalone: { marginHorizontal: space.lg, paddingVertical: space.xs }, - assistantFooter: { color: palette.dim, fontSize: 11, marginTop: space.xs }, + assistantFooter: { color: palette.dim, fontSize: 15, lineHeight: 22, marginTop: space.sm }, assistantRow: { gap: space.sm, paddingHorizontal: space.lg, @@ -1598,23 +1474,6 @@ 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: 17, lineHeight: 26 }, - boldText: { color: markdownPalette.strong, fontWeight: "700" }, - codeBlock: { - backgroundColor: palette.card, - borderColor: palette.border, - borderRadius: radius.sm, - borderWidth: 1, - gap: space.xs, - padding: 12, - }, - codeLanguage: { - ...typography.label, - color: palette.dim, - }, - codeText: { - ...typography.code, - color: markdownPalette.code, - }, disclosure: { backgroundColor: palette.card, borderColor: palette.border, @@ -1651,7 +1510,6 @@ 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: markdownPalette.linkText, textDecorationLine: "underline" }, notice: { borderBottomColor: palette.border, diff --git a/apps/mobile/src/screens/transcript-markdown.test.tsx b/apps/mobile/src/screens/transcript-markdown.test.tsx new file mode 100644 index 0000000..33647fc --- /dev/null +++ b/apps/mobile/src/screens/transcript-markdown.test.tsx @@ -0,0 +1,87 @@ +import { expect, jest, test } from "@jest/globals"; +import { fireEvent, render, screen } from "@testing-library/react-native"; +import { TranscriptMarkdown } from "./transcript-markdown"; + +const body = { fontSize: 17, lineHeight: 26 }; + +test("keeps emphasis local to its nested content and paragraph", () => { + render( + , + ); + expect(screen.getByText("IMAGE_TAG")).toHaveStyle({ fontWeight: "700" }); + expect(screen.getByText(" to select this image.")).not.toHaveStyle({ fontWeight: "700" }); + expect(screen.getByText("Normal paragraph with ")).not.toHaveStyle({ fontWeight: "700" }); + expect(screen.getByText("emphasis")).toHaveStyle({ fontStyle: "italic" }); + expect(screen.getByText("old")).toHaveStyle({ textDecorationLine: "line-through" }); +}); + +test("renders headings, nested lists, blockquotes, entities and escaped markers", () => { + render( + Quoted & safe\n\n\\*literal\\*" + } + />, + ); + expect(screen.getByRole("header", { name: "Summary" })).toBeOnTheScreen(); + expect(screen.getByText("3.")).toBeOnTheScreen(); + expect(screen.getByText("4.")).toBeOnTheScreen(); + expect(screen.getByText("Nested")).toBeOnTheScreen(); + expect(screen.getByText("Quoted & safe")).toBeOnTheScreen(); + expect(screen.getByText("*literal*")).toBeOnTheScreen(); +}); + +test("renders escaped table pipes, code, alignment and streamed rows", () => { + const header = "| Change | Needed |\n|:---|---:|\n"; + const props = { style: body, onOpenLink: jest.fn() }; + const view = render(); + expect(screen.getByText("A | B")).toBeOnTheScreen(); + expect(screen.getByRole("header", { name: "Needed" })).toHaveStyle({ textAlign: "right" }); + expect(screen.getByText("No")).toHaveStyle({ fontWeight: "700" }); + view.rerender( + , + ); + expect(screen.getByText("ARGS")).toBeOnTheScreen(); + expect(screen.queryByText(/:---/)).toBeNull(); +}); + +test("wide tables scroll without squeezing columns or truncating text", () => { + render( + , + ); + expect(screen.getByLabelText("Markdown table")).toHaveProp("horizontal", true); + expect(screen.getByText("Long final cell")).toBeOnTheScreen(); +}); + +test("only HTTP links are actionable and remote images remain text", () => { + const open = jest.fn(); + render( + literal `https://code.test`" + } + />, + ); + fireEvent.press(screen.getByRole("link", { name: "Docs" })); + expect(open).toHaveBeenCalledWith("https://example.test/docs"); + expect(screen.getAllByRole("link")).toHaveLength(1); + expect(screen.getByText("Remote image")).toBeOnTheScreen(); + expect(screen.queryByRole("image")).toBeNull(); +}); diff --git a/apps/mobile/src/screens/transcript-markdown.tsx b/apps/mobile/src/screens/transcript-markdown.tsx new file mode 100644 index 0000000..1dfcd62 --- /dev/null +++ b/apps/mobile/src/screens/transcript-markdown.tsx @@ -0,0 +1,316 @@ +import MarkdownIt from "markdown-it"; +import type Token from "markdown-it/lib/token.mjs"; +import { useMemo, useState } from "react"; +import { + ScrollView, + type StyleProp, + StyleSheet, + Text, + type TextStyle, + useWindowDimensions, + View, +} from "react-native"; + +import { markdownPalette, palette, radius, space, typeRamp, typography } from "../theme"; + +// Parse Markdown only. HTML stays literal and images never make network requests. +const parserOptions = { html: false, linkify: true, maxNesting: 32 }; +const parser = new MarkdownIt(parserOptions); +parser.linkify.set({ fuzzyLink: false, fuzzyEmail: false }); + +type Node = { token: Token; children: Node[]; key: string }; +type Props = { style: StyleProp; text: string; onOpenLink: (url: string) => void }; + +function nodes(tokens: Token[]): Node[] { + const root: Node[] = []; + const stack = [root]; + for (const [index, token] of tokens.entries()) { + if (token.nesting === -1) { + if (stack.length > 1) stack.pop(); + continue; + } + const node = { token, children: [] as Node[], key: `${token.type}:${index}` }; + stack[stack.length - 1]?.push(node); + if (token.nesting === 1) stack.push(node.children); + } + return root; +} + +function safeLink(href: string | null) { + if (!href) return undefined; + try { + const url = new URL(href); + return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : undefined; + } catch { + return undefined; + } +} + +function InlineContent({ + tokens, + onOpenLink, +}: { + tokens: Token[]; + onOpenLink: Props["onOpenLink"]; +}) { + let bold = 0; + let italic = 0; + let strike = 0; + let href: string | undefined; + let ordinal = 0; + return tokens.map((token) => { + const key = `${token.type}:${ordinal++}`; + if (token.type === "strong_open") { + bold++; + return null; + } + if (token.type === "strong_close") { + bold--; + return null; + } + if (token.type === "em_open") { + italic++; + return null; + } + if (token.type === "em_close") { + italic--; + return null; + } + if (token.type === "s_open") { + strike++; + return null; + } + if (token.type === "s_close") { + strike--; + return null; + } + if (token.type === "link_open") { + href = safeLink(token.attrGet("href")); + return null; + } + if (token.type === "link_close") { + href = undefined; + return null; + } + const code = token.type === "code_inline"; + const link = code ? undefined : href; + const content = + token.type === "softbreak" ? " " : token.type === "hardbreak" ? "\n" : token.content; + if (!content) return null; + return ( + onOpenLink(link) } : {})} + style={[ + bold > 0 && styles.bold, + italic > 0 && styles.italic, + strike > 0 && styles.strike, + code && styles.inlineCode, + link && styles.link, + ]} + > + {content} + + ); + }); +} + +export function InlineTranscriptMarkdown({ + prefix, + prefixStyle, + ...props +}: Props & { + prefix?: string | undefined; + prefixStyle?: StyleProp; +}) { + const tokens = useMemo( + () => parser.parseInline(props.text, {}).flatMap((token) => token.children ?? []), + [props.text], + ); + return ( + + {prefix ? {`${prefix} `} : null} + + + ); +} + +export function TranscriptMarkdown({ text, ...props }: Props) { + const blocks = useMemo(() => nodes(parser.parse(text, {})), [text]); + return ( + + + + ); +} + +function Blocks({ blocks, ...props }: Omit & { blocks: Node[] }) { + return blocks.map(({ token, children, key }) => { + switch (token.type) { + case "inline": + return ( + + ); + case "paragraph_open": + case "heading_open": + return ( + + + + ); + case "fence": + case "code_block": { + const language = token.info.trim().split(/\s+/)[0]?.slice(0, 32); + return ( + + {language ? ( + + {language.toLocaleUpperCase()} + + ) : null} + + + {token.content.replace(/\n$/, "")} + + + + ); + } + case "table_open": + return ; + case "bullet_list_open": + case "ordered_list_open": + return ( + + {children.map((item, index) => ( + + + {token.type === "ordered_list_open" + ? `${Number(token.attrGet("start") ?? 1) + index}.` + : "•"} + + + + + + ))} + + ); + case "blockquote_open": + return ( + + + + ); + case "hr": + return ; + default: + return ( + + {token.content} + + ); + } + }); +} + +function MarkdownTable({ sections, ...props }: Omit & { sections: Node[] }) { + const { width, fontScale } = useWindowDimensions(); + const [measuredWidth, setMeasuredWidth] = useState(); + const rows = sections.flatMap((section) => section.children); + const columns = rows[0]?.children.length ?? 1; + const availableWidth = measuredWidth ?? width - space.lg * 2; + const columnWidth = Math.max(availableWidth / columns, 140 * fontScale); + return ( + setMeasuredWidth(event.nativeEvent.layout.width)}> + + + {rows.map((row) => ( + + {row.children.map((cell) => ( + + + + + + ))} + + ))} + + + + ); +} + +const styles = StyleSheet.create({ + blocks: { gap: space.md }, + bold: { color: markdownPalette.strong, fontWeight: "700" }, + italic: { fontStyle: "italic" }, + strike: { textDecorationLine: "line-through" }, + inlineCode: { + fontFamily: typography.code.fontFamily, + color: palette.ink, + backgroundColor: palette.raised, + }, + link: { color: markdownPalette.linkText, textDecorationLine: "underline" }, + heading: { fontWeight: "600", fontSize: 21, lineHeight: 29, marginTop: space.sm }, + heading1: { fontSize: 26, lineHeight: 34 }, + heading2: { fontSize: 23, lineHeight: 31 }, + headingSmall: { fontSize: 18, lineHeight: 26 }, + codeBlock: { + backgroundColor: palette.card, + borderColor: palette.border, + borderWidth: 1, + borderRadius: radius.sm, + padding: 12, + gap: space.xs, + }, + codeLanguage: { ...typography.label, color: palette.dim }, + codeText: { ...typography.code, color: palette.ink }, + list: { gap: space.sm }, + listRow: { flexDirection: "row", gap: space.sm }, + marker: { minWidth: 20 }, + listBody: { flex: 1, minWidth: 0, gap: space.sm }, + quote: { + borderLeftWidth: 3, + borderLeftColor: palette.border, + paddingLeft: space.md, + gap: space.sm, + }, + rule: { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: palette.border }, + tableRow: { + flexDirection: "row", + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: palette.border, + }, + tableCell: { paddingHorizontal: 12, paddingVertical: space.md }, + tableHeading: { fontWeight: "600" }, + alignRight: { textAlign: "right" }, + alignCenter: { textAlign: "center" }, +}); diff --git a/apps/mobile/src/screens/use-session-execution.ts b/apps/mobile/src/screens/use-session-execution.ts index d92d912..5f5c85b 100644 --- a/apps/mobile/src/screens/use-session-execution.ts +++ b/apps/mobile/src/screens/use-session-execution.ts @@ -794,6 +794,20 @@ export function useSessionExecution({ commands: commandsQuery.data?.data ?? [], completionLoading: commandsQuery.isPending, completionUnavailable: commandsQuery.isError, + agentCatalog: { + loading: agentsQuery.isPending, + error: agentsQuery.isError, + retry: () => { + void agentsQuery.refetch(); + }, + }, + modelCatalog: { + loading: modelsQuery.isPending, + error: modelsQuery.isError, + retry: () => { + void modelsQuery.refetch(); + }, + }, defaultModel: defaultModelQuery.data?.data ?? undefined, delivery, error, diff --git a/apps/mobile/src/screens/workspace-screen.integration.test.tsx b/apps/mobile/src/screens/workspace-screen.integration.test.tsx index 3c375dc..d94c485 100644 --- a/apps/mobile/src/screens/workspace-screen.integration.test.tsx +++ b/apps/mobile/src/screens/workspace-screen.integration.test.tsx @@ -58,6 +58,7 @@ jest.mock("@opencode2-mobile/opencode-adapter", () => ({ tokens: { cache: { read: 0, write: 0 }, input: 0, output: 0, reasoning: 0 }, })), getOpenCodeSessionMessage: jest.fn(), + getOpenCodeVcsDiff: jest.fn(async () => ({ data: [] })), getOpenCodeVcs: jest.fn(async () => ({ data: { branch: { current: "docs/mobile-workflow-screenshots" } }, location, @@ -252,6 +253,54 @@ const mockGetLocation = jest.mocked(getOpenCodeLocation); const mockListAgents = jest.mocked(listOpenCodeAgents); const mockListMessages = jest.mocked(listOpenCodeMessages); +test("updates the navigation title when the server session name changes", async () => { + const setOptions = jest.fn(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { gcTime: Infinity, retry: false } }, + }); + const view = render( + + + , + ); + await waitFor(() => expect(setOptions).toHaveBeenCalledWith({ title: "Transcript session" })); + const key = openCodeQueryKeys.session( + "connection-1", + { directory: "/workspace" }, + "ses_transcript", + ); + act(() => + queryClient.setQueryData(key, (session) => + session ? { ...session, title: "Renamed session" } : session, + ), + ); + await waitFor(() => expect(setOptions).toHaveBeenLastCalledWith({ title: "Renamed session" })); + act(() => + queryClient.setQueryData(key, (session) => + session ? { ...session, title: " " } : session, + ), + ); + await waitFor(() => expect(setOptions).toHaveBeenLastCalledWith({ title: "Untitled session" })); + fireEvent.press(screen.getByRole("tab", { name: "Changes" })); + expect(await screen.findByText("No changes")).toBeOnTheScreen(); + expect(screen.queryByLabelText("Prompt")).toBeNull(); + fireEvent.press(screen.getByRole("tab", { name: "Session" })); + expect(screen.getByLabelText("Prompt")).toBeOnTheScreen(); + view.unmount(); + queryClient.clear(); +}); + test("moves only the Android composer dock with the keyboard", async () => { const platformOS = Platform.OS; Object.defineProperty(Platform, "OS", { configurable: true, value: "android" }); @@ -264,7 +313,7 @@ test("moves only the Android composer dock with the keyboard", async () => { const view = render( { const view = render( item.type === "session")).toHaveLength( + 20, + ); + expect(screen.getByText("20")).toBeOnTheScreen(); + expect(screen.queryByRole("button", { name: "Load older" })).toBeNull(); + expect(screen.getByRole("button", { name: "Search older sessions" })).toBeOnTheScreen(); + fireEvent.changeText(screen.getByLabelText("Search sessions"), "Session"); + await waitFor(() => expect(screen.getByText("Search results")).toBeOnTheScreen()); + expect(list.props.data.filter((item: { type: string }) => item.type === "session")).toHaveLength( + 120, + ); + fireEvent.press(screen.getByRole("button", { name: "Clear session search" })); + await waitFor(() => expect(screen.getByText("Recent")).toBeOnTheScreen()); expect(screen.queryByText("Succeeded")).toBeNull(); expect(screen.queryByRole("button", { name: "New" })).toBeNull(); @@ -559,7 +622,7 @@ test("compact mode groups consecutive tool calls across assistant messages", asy const view = render( const view = render( expect(screen.getByRole("button", { name: /Thought/ })).toHaveStyle({ flexDirection: "row", }); - expect(screen.getByText("Test server").props.numberOfLines).toBe(1); + expect(screen.queryByText("Test server")).toBeNull(); const normalScaleAwayFromLiveEdge = { nativeEvent: { contentOffset: { x: 0, y: 120 }, @@ -1054,8 +1128,8 @@ test("remeasures the transcript when the system font scale changes", async () => flexDirection: "column", }); expect(screen.queryByText("Detailed reasoning\nSecond step")).toBeNull(); - expect(screen.getByText("Test server").props.numberOfLines).toBeUndefined(); - expect(screen.getByText("Test server")).toHaveStyle({ flex: 0, width: "100%" }); + expect(screen.queryByText("Test server")).toBeNull(); + expect(screen.getByRole("tab", { name: "Changes" })).toBeOnTheScreen(); const awayFromLiveEdge = { nativeEvent: { diff --git a/apps/mobile/src/screens/workspace-screen.tsx b/apps/mobile/src/screens/workspace-screen.tsx index a40ccde..568c6d0 100644 --- a/apps/mobile/src/screens/workspace-screen.tsx +++ b/apps/mobile/src/screens/workspace-screen.tsx @@ -51,6 +51,7 @@ import { recordTranscriptResidentSet, } from "../state/transcript-performance"; import { useTranscriptPreferences } from "../state/transcript-preferences"; +import { useModelFavorites } from "../state/use-model-favorites"; import { useWorkspaceSelection } from "../state/workspace-selection-context"; import { deleteSessionLocalState } from "../storage/prompt-admission-repository"; import { @@ -63,6 +64,7 @@ import { usesLargeTextLayout, } from "../theme"; import { ActionButton, isTabletShell, ShellFrame } from "./app-shell"; +import { SessionChanges } from "./diff-screen"; import { FormRequestList } from "./form-request-list"; import { SessionComposer } from "./session-composer"; import { loadOpenCodeSessionTreeIds } from "./session-deletion"; @@ -89,6 +91,7 @@ type WorkspaceProps = NativeStackScreenProps; type SessionProps = NativeStackScreenProps; const messagePageSize = 40; +const recentSessionLimit = 20; const maxTranscriptPages = 5; const iosKeyboardTransparentTopInset = 32; const liveEdgeThreshold = 2; @@ -110,6 +113,7 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { const [selectedProjectId, setSelectedProjectId] = useState(); const [selectedDirectory, setSelectedDirectory] = useState(); const [sessionSearch, setSessionSearch] = useState(""); + const searchInputRef = useRef(null); const [refreshing, setRefreshing] = useState(false); const removeAbortRef = useRef(null); const refreshGenerationRef = useRef(0); @@ -358,12 +362,13 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { 0} keyboardAppearance="dark" onChangeText={setSessionSearch} - placeholder="Search sessions" + placeholder="Search all sessions" placeholderTextColor={palette.dim} style={styles.searchInput} value={sessionSearch} @@ -405,7 +410,7 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { /> } ListFooterComponent={ - workspaceSelection.hasNextPage ? ( + deferredSessionSearch && workspaceSelection.hasNextPage ? ( + ) : !deferredSessionSearch && + (workspaceSelection.inbox.recent.length >= recentSessionLimit || + workspaceSelection.hasNextPage) ? ( + + searchInputRef.current?.focus()} + secondary + /> + ) : null } ListHeaderComponent={header} @@ -465,6 +480,7 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { export function SessionScreen({ navigation, route }: SessionProps) { const transcriptPreferences = useTranscriptPreferences(); const runtime = useConnectionRuntime(); + const modelFavorites = useModelFavorites(runtime.connectionId, runtime.connectionUpdatedAtMs); const workspaceSelection = useWorkspaceSelection(); const queryClient = useQueryClient(); const { fontScale } = useWindowDimensions(); @@ -474,6 +490,11 @@ export function SessionScreen({ navigation, route }: SessionProps) { const connectionId = runtime.connectionId; const client = runtime.restClient; const routeSessionScope = `${routeConnectionId}\u0000${sessionID}\u0000${location.directory}\u0000${location.workspaceID ?? ""}`; + const [sessionTab, setSessionTab] = useState<{ scope: string; tab: "session" | "changes" }>({ + scope: routeSessionScope, + tab: "session", + }); + const selectedTab = sessionTab.scope === routeSessionScope ? sessionTab.tab : "session"; const [sessionQueryScope, setSessionQueryScope] = useState({ location, routeSessionScope, @@ -511,6 +532,10 @@ export function SessionScreen({ navigation, route }: SessionProps) { ), }); const session = sessionQuery.data; + const sessionTitle = connectionId === routeConnectionId ? session?.title?.trim() : undefined; + useEffect(() => { + navigation.setOptions({ title: sessionTitle || "Untitled session" }); + }, [navigation, sessionTitle]); const sessionLocation = session?.location ?? sessionQueryLocation; const sessionLocationReady = Boolean( sessionQuery.isSuccess && session && locationsEqual(session.location, sessionQueryLocation), @@ -881,6 +906,9 @@ export function SessionScreen({ navigation, route }: SessionProps) { active={execution.active} agent={execution.selectedAgent} agents={execution.agents} + agentCatalog={execution.agentCatalog} + modelCatalog={execution.modelCatalog} + favorites={modelFavorites} commands={execution.commands} completionLoading={execution.completionLoading} completionUnavailable={execution.completionUnavailable} @@ -920,11 +948,30 @@ export function SessionScreen({ navigation, route }: SessionProps) { { + Keyboard.dismiss(); + setSessionTab({ scope: routeSessionScope, tab }); + }, + }} navigate={(section) => section === "Workspace" ? navigation.popTo("Workspace") : navigation.navigate(section) } > - + {selectedTab === "changes" ? ( + + ) : null} + + model.id === item.model.id && model.providerID === item.model.providerID, + )?.name + : undefined + } onOpenDiff={openDiff} onOpenSubagent={openSubagent} /> @@ -1365,10 +1420,20 @@ function workspaceInboxItems( flattenSections: boolean, ) { const items: WorkspaceInboxItem[] = []; + const recentIDs = new Set( + [...inbox.recent] + .sort((first, second) => second.session.time.updated - first.session.time.updated) + .slice(0, recentSessionLimit) + .map((row) => row.session.id), + ); for (const [key, label, rows] of [ ["needs-you", "Needs you", inbox.needsYou], ["working", "Working", inbox.working], - ["recent", "Recent", inbox.recent], + [ + "recent", + "Recent", + flattenSections ? inbox.recent : inbox.recent.filter((row) => recentIDs.has(row.session.id)), + ], ] as const) { if (rows.length === 0) continue; if (!flattenSections) { diff --git a/apps/mobile/src/state/followed-projects-context.tsx b/apps/mobile/src/state/followed-projects-context.tsx index 55bba34..15dde31 100644 --- a/apps/mobile/src/state/followed-projects-context.tsx +++ b/apps/mobile/src/state/followed-projects-context.tsx @@ -414,7 +414,10 @@ export function FollowedProjectsProvider({ children }: { children: ReactNode }) discoveredLocationsRef.current = { scopeKey: followedScopeKey, locations: candidateLocations }; const protectedLocationKeys = new Set( [ - ...projectLocations, + ...availableFollowedProjectIDs.flatMap((projectID) => { + const project = projectByID.get(projectID); + return project ? [{ directory: project.canonical }] : []; + }), ...runtime.attentionLocations, ...runtime.eventLocations, ...Object.values(activeAncestryQuery.data?.sessions ?? {}).map((session) => session.location), @@ -426,7 +429,13 @@ export function FollowedProjectsProvider({ children }: { children: ReactNode }) const session = rootSessions.find( (item) => locationKey(item.location) === locationKey(candidate), ); - const project = session ? projectByID.get(session.projectID) : undefined; + const project = session + ? projectByID.get(session.projectID) + : projects.find( + (project) => + availableFollowedProjectIDs.includes(project.id) && + project.sandboxes.includes(candidate.directory), + ); if (!project || !candidate.directory.startsWith(`${project.canonical.replace(/\/$/, "")}/`)) return []; // Previously observed blocked work takes precedence over retirement. Active diff --git a/apps/mobile/src/state/use-model-favorites.test.tsx b/apps/mobile/src/state/use-model-favorites.test.tsx new file mode 100644 index 0000000..fef9ebe --- /dev/null +++ b/apps/mobile/src/state/use-model-favorites.test.tsx @@ -0,0 +1,82 @@ +import { expect, jest, test } from "@jest/globals"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react-native"; +import type { ReactNode } from "react"; +import type { ModelIdentity } from "../storage/model-favorites-repository"; +import { useModelFavorites } from "./use-model-favorites"; + +const mockRead = jest.fn<(db: unknown, connectionID: string) => Promise>(); +const mockWrite = jest.fn<(...args: unknown[]) => Promise>(); +jest.mock("expo-sqlite", () => ({ useSQLiteContext: () => ({}) })); +jest.mock("../storage/model-favorites-repository", () => ({ + readModelFavorites: (...args: Parameters) => mockRead(...args), + setModelFavorite: (...args: unknown[]) => mockWrite(...args), +})); + +test("a late favorite write cannot cross into a newly selected connection", async () => { + mockRead.mockImplementation(async (_db, connectionID) => [{ providerID: "p", id: connectionID }]); + let finish!: () => void; + mockWrite.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity }, + mutations: { gcTime: Infinity }, + }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const view = renderHook( + ({ connectionID }: { connectionID: string }) => useModelFavorites(connectionID, 7), + { + wrapper, + initialProps: { connectionID: "a" }, + }, + ); + await waitFor(() => expect(view.result.current.models).toEqual([{ providerID: "p", id: "a" }])); + act(() => view.result.current.toggle({ providerID: "p", id: "favorite" }, true)); + await waitFor(() => + expect(mockWrite).toHaveBeenCalledWith( + expect.anything(), + "a", + 7, + { providerID: "p", id: "favorite" }, + true, + ), + ); + view.rerender({ connectionID: "b" }); + await waitFor(() => expect(view.result.current.models).toEqual([{ providerID: "p", id: "b" }])); + await act(async () => finish()); + await waitFor(() => expect(view.result.current.disabled).toBe(false)); + expect(view.result.current.models).toEqual([{ providerID: "p", id: "b" }]); + view.unmount(); + client.clear(); +}); + +test("failed saves retain the loaded favorites and expose retry", async () => { + mockRead.mockResolvedValue([{ providerID: "p", id: "existing" }]); + mockWrite.mockRejectedValue(new Error("write failed")); + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity }, + mutations: { gcTime: Infinity }, + }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const view = renderHook(() => useModelFavorites("a", 7), { wrapper }); + await waitFor(() => expect(view.result.current.disabled).toBe(false)); + act(() => view.result.current.toggle({ providerID: "p", id: "existing" }, false)); + await waitFor(() => expect(view.result.current.error).toBe(true)); + expect(view.result.current.models).toEqual([{ providerID: "p", id: "existing" }]); + act(() => view.result.current.retry()); + await waitFor(() => expect(view.result.current.error).toBe(false)); + view.unmount(); + client.clear(); +}); diff --git a/apps/mobile/src/state/use-model-favorites.ts b/apps/mobile/src/state/use-model-favorites.ts new file mode 100644 index 0000000..ab3a3ee --- /dev/null +++ b/apps/mobile/src/state/use-model-favorites.ts @@ -0,0 +1,47 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSQLiteContext } from "expo-sqlite"; +import { + type ModelIdentity, + readModelFavorites, + setModelFavorite, +} from "../storage/model-favorites-repository"; + +const favoriteKey = (connectionID: string | undefined, updatedAt: number | undefined) => + ["device-model-favorites", connectionID, updatedAt] as const; + +export function useModelFavorites(connectionID: string | undefined, updatedAt: number | undefined) { + const db = useSQLiteContext(); + const queryClient = useQueryClient(); + const query = useQuery({ + queryKey: favoriteKey(connectionID, updatedAt), + enabled: Boolean(connectionID && updatedAt !== undefined), + queryFn: () => readModelFavorites(db, connectionID as string), + }); + const mutation = useMutation({ + mutationFn: (input: { + connectionID: string; + updatedAt: number; + model: ModelIdentity; + favorite: boolean; + }) => setModelFavorite(db, input.connectionID, input.updatedAt, input.model, input.favorite), + onSuccess: (_data, input) => + queryClient.invalidateQueries({ queryKey: favoriteKey(input.connectionID, input.updatedAt) }), + }); + const scopedMutation = + mutation.variables?.connectionID === connectionID && + mutation.variables?.updatedAt === updatedAt; + return { + models: query.data ?? [], + disabled: !query.isSuccess || mutation.isPending || !connectionID || updatedAt === undefined, + error: query.isError || (scopedMutation && mutation.isError), + retry: () => { + mutation.reset(); + void query.refetch(); + }, + toggle: (model: ModelIdentity, favorite: boolean) => { + if (!connectionID || updatedAt === undefined || !query.isSuccess || mutation.isPending) + return; + mutation.mutate({ connectionID, updatedAt, model, favorite }); + }, + }; +} diff --git a/apps/mobile/src/state/workspace-selection-context.test.tsx b/apps/mobile/src/state/workspace-selection-context.test.tsx index 2bb355d..6e3f633 100644 --- a/apps/mobile/src/state/workspace-selection-context.test.tsx +++ b/apps/mobile/src/state/workspace-selection-context.test.tsx @@ -64,6 +64,7 @@ const mockListProjectSessions = jest.fn(defaultListProj const mockReplyPermission = jest.fn(); let mockEventLocations: LocationRef[] = []; let mockAttentionLocations: LocationRef[] = []; +let mockSandboxes: string[] = []; let mockRevision = 1; let mockStatus: "connected" | "offline" = "connected"; let mockConnectionUpdatedAtMs = 1; @@ -110,7 +111,7 @@ jest.mock("@opencode2-mobile/opencode-adapter", () => ({ canonical: "/b", id: "project-b", name: "Beta", - sandboxes: [], + sandboxes: mockSandboxes, time: { created: 1, updated: 1 }, }, ]), @@ -137,6 +138,7 @@ beforeEach(() => { jest.useFakeTimers(); mockEventLocations = []; mockAttentionLocations = []; + mockSandboxes = []; mockRevision = 1; mockStatus = "connected"; mockConnectionUpdatedAtMs = 1; @@ -569,51 +571,62 @@ test.each([ queryClient.clear(); }); -test("retires missing historical directories and checks them again after restoration", async () => { - const directory = "/b/.worktree/retired"; - mockListProjectSessions.mockImplementation(async (_client, projectID) => ({ - cursor: {}, - data: [ - projectID === "project-b" - ? mockSession("ses_retired", projectID, directory, 2) - : mockSession("ses_alpha", projectID, "/a", 3), - ], - })); - mockDirectoryExists.mockImplementation( - async (_client, _root, candidate) => candidate !== directory, - ); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: Infinity } }, - }); - const view = render( - - - - - , - ); - await waitFor(() => expect(screen.getByText("complete:1:1:0")).toBeOnTheScreen()); - expect(mockDirectoryExists).toHaveBeenCalledWith( - expect.anything(), - { directory: "/b" }, - directory, - expect.anything(), - ); - expect(mockListPermissions.mock.calls.some((call) => call[1].directory === directory)).toBe( - false, - ); - expect(mockListForms.mock.calls.some((call) => call[1].directory === directory)).toBe(false); - expect(screen.getByText("recent:ses_retired")).toBeOnTheScreen(); - mockDirectoryExists.mockResolvedValue(true); - fireEvent.press(screen.getByRole("button", { name: "Refresh sessions" })); - await waitFor(() => +test.each([false, true])( + "retires missing directories and checks restoration, registered worktree: %s", + async (registered) => { + const directory = "/b/.worktree/retired"; + if (registered) mockSandboxes = [directory]; + const listPermissions = mockListPermissions.getMockImplementation(); + mockListPermissions.mockImplementation(async (client, location, options) => { + if (location.directory === directory) throw new Error("HTTP 500"); + if (!listPermissions) throw new Error("Missing permission fixture"); + return listPermissions(client, location, options); + }); + mockListProjectSessions.mockImplementation(async (_client, projectID) => ({ + cursor: {}, + data: [ + projectID === "project-b" + ? mockSession("ses_retired", projectID, directory, 2) + : mockSession("ses_alpha", projectID, "/a", 3), + ], + })); + mockDirectoryExists.mockImplementation( + async (_client, _root, candidate) => candidate !== directory, + ); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + const view = render( + + + + + , + ); + await waitFor(() => expect(screen.getByText("complete:1:1:0")).toBeOnTheScreen()); + expect(screen.queryByText(/^unavailable:/)).toBeNull(); + expect(mockDirectoryExists).toHaveBeenCalledWith( + expect.anything(), + { directory: "/b" }, + directory, + expect.anything(), + ); expect(mockListPermissions.mock.calls.some((call) => call[1].directory === directory)).toBe( - true, - ), - ); - view.unmount(); - queryClient.clear(); -}); + false, + ); + expect(mockListForms.mock.calls.some((call) => call[1].directory === directory)).toBe(false); + expect(screen.getByText("recent:ses_retired")).toBeOnTheScreen(); + mockDirectoryExists.mockResolvedValue(true); + fireEvent.press(screen.getByRole("button", { name: "Refresh sessions" })); + await waitFor(() => + expect(mockListPermissions.mock.calls.some((call) => call[1].directory === directory)).toBe( + true, + ), + ); + view.unmount(); + queryClient.clear(); + }, +); test.each(["notification", "event", "cached-form"])( "keeps %s attention at a missing historical directory", diff --git a/apps/mobile/src/storage/database.test.ts b/apps/mobile/src/storage/database.test.ts index 2cc3a82..83bcf29 100644 --- a/apps/mobile/src/storage/database.test.ts +++ b/apps/mobile/src/storage/database.test.ts @@ -12,7 +12,8 @@ test("creates the current mobile database schema", async () => { await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(12); + expect(execAsync).toHaveBeenCalledTimes(13); + expect(execAsync.mock.calls[12]?.[0]).toContain("CREATE TABLE model_favorites"); expect(execAsync.mock.calls[11]?.[0]).toContain("CREATE TABLE transcript_preferences"); expect(execAsync.mock.calls[1]?.[0]).toContain("CREATE TABLE IF NOT EXISTS connection_profiles"); expect(execAsync.mock.calls[2]?.[0]).toContain("CREATE TABLE IF NOT EXISTS app_preferences"); @@ -47,7 +48,7 @@ test("migrates an existing profile database to app-lock preferences", async () = await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(11); + expect(execAsync).toHaveBeenCalledTimes(12); expect(execAsync.mock.calls[1]?.[0]).toContain("CREATE TABLE IF NOT EXISTS app_preferences"); expect(execAsync.mock.calls[1]?.[0]).not.toContain("connection_profiles"); expect(execAsync.mock.calls[2]?.[0]).toContain("CREATE TABLE IF NOT EXISTS session_drafts"); @@ -66,7 +67,7 @@ test("migrates app-lock databases to encrypted draft storage", async () => { await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(10); + expect(execAsync).toHaveBeenCalledTimes(11); expect(execAsync.mock.calls[1]?.[0]).toContain("ciphertext BLOB NOT NULL"); expect(execAsync.mock.calls[1]?.[0]).toContain("ON DELETE CASCADE"); expect(execAsync.mock.calls[1]?.[0]).not.toContain("app_preferences"); @@ -85,7 +86,7 @@ test("migrates encrypted draft databases to unresolved admission storage", async await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(9); + expect(execAsync).toHaveBeenCalledTimes(10); expect(execAsync.mock.calls[1]?.[0]).toContain("status IN ('submitting', 'unknown-delivery')"); expect(execAsync.mock.calls[1]?.[0]).toContain("ADD COLUMN revision"); expect(execAsync.mock.calls[2]?.[0]).toContain("followed_projects"); @@ -101,7 +102,7 @@ test("migrates admission databases to followed project preferences", async () => await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(8); + expect(execAsync).toHaveBeenCalledTimes(9); expect(execAsync.mock.calls[1]?.[0]).toContain("followed_project_preferences"); expect(execAsync.mock.calls[1]?.[0]).toContain("PRIMARY KEY (connection_id, project_id)"); expect(execAsync.mock.calls[1]?.[0]).toContain("UNIQUE (connection_id, position)"); @@ -117,7 +118,7 @@ test("migrates followed project databases to notification pairing storage", asyn await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(7); + expect(execAsync).toHaveBeenCalledTimes(8); expect(execAsync.mock.calls[1]?.[0]).toContain("pending_notification_secret_deletions"); expect(execAsync.mock.calls[1]?.[0]).toContain("BEGIN IMMEDIATE"); expect(execAsync.mock.calls[1]?.[0]).toContain("COMMIT"); @@ -133,7 +134,7 @@ test("migrates notification pairings to handled event replay storage", async () await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(6); + expect(execAsync).toHaveBeenCalledTimes(7); expect(execAsync.mock.calls[1]?.[0]).toContain("handled_notification_events"); expect(execAsync.mock.calls[1]?.[0]).toContain("ON DELETE CASCADE"); expect(execAsync.mock.calls[1]?.[0]).toContain("COMMIT"); @@ -149,7 +150,7 @@ test("migrates handled events to pending notification revocation storage", async await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(5); + expect(execAsync).toHaveBeenCalledTimes(6); expect(execAsync.mock.calls[1]?.[0]).toContain("pending_notification_revocations"); expect(execAsync.mock.calls[1]?.[0]).toContain("PRAGMA user_version = 8"); expect(execAsync.mock.calls[1]?.[0]).toContain("COMMIT"); @@ -164,7 +165,7 @@ test("migrates legacy encrypted drafts to explicit payload versioning", async () await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(4); + expect(execAsync).toHaveBeenCalledTimes(5); expect(execAsync.mock.calls[1]?.[0]).toContain("ADD COLUMN payload_version"); expect(execAsync.mock.calls[1]?.[0]).toContain("DEFAULT 1"); expect(execAsync.mock.calls[1]?.[0]).toContain("BEGIN IMMEDIATE"); @@ -181,7 +182,7 @@ test("migrates admission recovery metadata to distinguish commands", async () => await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(3); + expect(execAsync).toHaveBeenCalledTimes(4); expect(execAsync.mock.calls[1]?.[0]).toContain("ADD COLUMN submission_kind"); expect(execAsync.mock.calls[1]?.[0]).toContain("DEFAULT 'prompt'"); expect(execAsync.mock.calls[1]?.[0]).toContain("BEGIN IMMEDIATE"); @@ -192,7 +193,7 @@ test("migrates admission recovery metadata to distinguish commands", async () => test("rejects a database created by a newer app", async () => { const db = { execAsync: jest.fn(async () => undefined), - getFirstAsync: jest.fn(async () => ({ user_version: 12 })), + getFirstAsync: jest.fn(async () => ({ user_version: 13 })), } as unknown as SQLiteDatabase; await expect(migrateMobileDatabase(db)).rejects.toThrow("DATABASE_VERSION_TOO_NEW"); @@ -204,12 +205,43 @@ test("upgrades version 10 with compact transcript defaults", async () => { execAsync, getFirstAsync: async () => ({ user_version: 10 }), } as unknown as SQLiteDatabase); - expect(execAsync).toHaveBeenCalledTimes(2); + expect(execAsync).toHaveBeenCalledTimes(3); expect(execAsync.mock.calls[1]?.[0]).toContain("CREATE TABLE transcript_preferences"); expect(execAsync.mock.calls[1]?.[0]).toContain("reasoning INTEGER NOT NULL DEFAULT 0"); expect(execAsync.mock.calls[1]?.[0]).toContain("PRAGMA user_version = 11"); }); +test("upgrades version 11 with identity-only connection-scoped model favorites", async () => { + const execAsync = jest.fn<(source: string) => Promise>(async () => undefined); + await migrateMobileDatabase({ + execAsync, + getFirstAsync: async () => ({ user_version: 11 }), + } as unknown as SQLiteDatabase); + expect(execAsync).toHaveBeenCalledTimes(2); + const sql = execAsync.mock.calls[1]?.[0]; + expect(sql).toContain("PRIMARY KEY (connection_id, provider_id, model_id)"); + expect(sql).toContain("ON DELETE CASCADE"); + expect(sql).toContain("BEGIN IMMEDIATE"); + expect(sql).toContain("PRAGMA user_version = 12"); + expect(sql).toContain("COMMIT"); + expect(sql).not.toContain("ALTER TABLE"); +}); + +test("rolls back failed model favorites migration", async () => { + const execAsync = jest + .fn<(source: string) => Promise>() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("interrupted")) + .mockResolvedValueOnce(undefined); + await expect( + migrateMobileDatabase({ + execAsync, + getFirstAsync: async () => ({ user_version: 11 }), + } as unknown as SQLiteDatabase), + ).rejects.toThrow("interrupted"); + expect(execAsync).toHaveBeenLastCalledWith("ROLLBACK;"); +}); + test("rolls back failed transcript preference migration", async () => { const execAsync = jest .fn<(source: string) => Promise>() diff --git a/apps/mobile/src/storage/database.ts b/apps/mobile/src/storage/database.ts index 4184dde..9fbd000 100644 --- a/apps/mobile/src/storage/database.ts +++ b/apps/mobile/src/storage/database.ts @@ -1,7 +1,7 @@ import type { SQLiteDatabase } from "expo-sqlite"; export const mobileDatabaseName = "opencode-mobile.db"; -export const mobileDatabaseSchemaVersion = 11; +export const mobileDatabaseSchemaVersion = 12; const maxDraftCiphertextBytes = 256 * 1024 + 16; @@ -246,4 +246,23 @@ export async function migrateMobileDatabase(db: SQLiteDatabase) { throw caught; } } + if (version < 12) { + try { + await db.execAsync(` + BEGIN IMMEDIATE; + CREATE TABLE model_favorites ( + connection_id TEXT NOT NULL REFERENCES connection_profiles(id) ON DELETE CASCADE, + provider_id TEXT NOT NULL CHECK (length(provider_id) > 0), + model_id TEXT NOT NULL CHECK (length(model_id) > 0), + position INTEGER NOT NULL CHECK (position >= 0), + PRIMARY KEY (connection_id, provider_id, model_id) + ); + PRAGMA user_version = 12; + COMMIT; + `); + } catch (caught) { + await db.execAsync("ROLLBACK;").catch(() => undefined); + throw caught; + } + } } diff --git a/apps/mobile/src/storage/model-favorites-repository.test.ts b/apps/mobile/src/storage/model-favorites-repository.test.ts new file mode 100644 index 0000000..bd515f1 --- /dev/null +++ b/apps/mobile/src/storage/model-favorites-repository.test.ts @@ -0,0 +1,59 @@ +import { expect, jest, test } from "@jest/globals"; +import type { SQLiteDatabase } from "expo-sqlite"; +import { + modelIdentityKey, + readModelFavorites, + setModelFavorite, +} from "./model-favorites-repository"; + +test("scopes reads and parameterized writes to the exact connection and model identity", async () => { + const transaction = { + getFirstAsync: jest.fn(async () => ({ updated_at_ms: 7 })), + runAsync: jest.fn(async (..._args: unknown[]) => undefined), + }; + const getAllAsync = jest.fn(async (..._args: unknown[]) => [ + { provider_id: "provider", model_id: "model" }, + ]); + const db = { + getAllAsync, + withExclusiveTransactionAsync: async (fn: (txn: typeof transaction) => Promise) => + fn(transaction), + } as unknown as SQLiteDatabase; + expect(await readModelFavorites(db, "connection-a")).toEqual([ + { providerID: "provider", id: "model" }, + ]); + expect(getAllAsync).toHaveBeenCalledWith( + expect.stringContaining("WHERE connection_id = ?"), + "connection-a", + ); + await setModelFavorite(db, "connection-b", 7, { providerID: "provider", id: "model" }, true); + expect(transaction.runAsync).toHaveBeenLastCalledWith( + expect.stringContaining("INSERT OR IGNORE"), + "connection-b", + "provider", + "model", + "connection-b", + ); + await setModelFavorite(db, "connection-b", 7, { providerID: "provider", id: "model" }, false); + expect(transaction.runAsync).toHaveBeenLastCalledWith( + expect.stringContaining("provider_id = ? AND model_id = ?"), + "connection-b", + "provider", + "model", + ); +}); + +test("rejects writes after a connection profile is replaced", async () => { + const runAsync = jest.fn(); + const db = { + withExclusiveTransactionAsync: async (fn: (txn: unknown) => Promise) => + fn({ getFirstAsync: async () => ({ updated_at_ms: 8 }), runAsync }), + } as unknown as SQLiteDatabase; + await expect( + setModelFavorite(db, "connection", 7, { providerID: "provider", id: "model" }, true), + ).rejects.toThrow("CONNECTION_PROFILE_CHANGED"); + expect(runAsync).not.toHaveBeenCalled(); + expect(modelIdentityKey({ providerID: "a/b", id: "c" })).not.toBe( + modelIdentityKey({ providerID: "a", id: "b/c" }), + ); +}); diff --git a/apps/mobile/src/storage/model-favorites-repository.ts b/apps/mobile/src/storage/model-favorites-repository.ts new file mode 100644 index 0000000..6b40ed5 --- /dev/null +++ b/apps/mobile/src/storage/model-favorites-repository.ts @@ -0,0 +1,58 @@ +import type { ModelRef } from "@opencode2-mobile/opencode-adapter"; +import type { SQLiteDatabase } from "expo-sqlite"; + +export type ModelIdentity = Pick; + +export function modelIdentityKey(model: ModelIdentity) { + return JSON.stringify([model.providerID, model.id]); +} + +export async function readModelFavorites( + db: SQLiteDatabase, + connectionID: string, +): Promise { + const rows = await db.getAllAsync<{ provider_id: string; model_id: string }>( + "SELECT provider_id, model_id FROM model_favorites WHERE connection_id = ? ORDER BY position, provider_id, model_id", + connectionID, + ); + return rows.map((row) => ({ providerID: row.provider_id, id: row.model_id })); +} + +export async function setModelFavorite( + db: SQLiteDatabase, + connectionID: string, + updatedAt: number, + model: ModelIdentity, + favorite: boolean, +) { + if ( + ![connectionID, model.providerID, model.id].every( + (value) => value.trim() && !value.includes("\u0000"), + ) + ) + throw new Error("INVALID_MODEL_IDENTITY"); + await db.withExclusiveTransactionAsync(async (transaction) => { + const profile = await transaction.getFirstAsync<{ updated_at_ms: number }>( + "SELECT updated_at_ms FROM connection_profiles WHERE id = ?", + connectionID, + ); + if (profile?.updated_at_ms !== updatedAt) throw new Error("CONNECTION_PROFILE_CHANGED"); + if (favorite) { + await transaction.runAsync( + `INSERT OR IGNORE INTO model_favorites(connection_id, provider_id, model_id, position) + SELECT ?, ?, ?, COALESCE(MAX(position), -1) + 1 FROM model_favorites WHERE connection_id = ?`, + connectionID, + model.providerID, + model.id, + connectionID, + ); + } else { + await transaction.runAsync( + "DELETE FROM model_favorites WHERE connection_id = ? AND provider_id = ? AND model_id = ?", + connectionID, + model.providerID, + model.id, + ); + } + }); +} diff --git a/apps/mobile/test/setup.ts b/apps/mobile/test/setup.ts new file mode 100644 index 0000000..4d1ec1c --- /dev/null +++ b/apps/mobile/test/setup.ts @@ -0,0 +1,5 @@ +import { jest } from "@jest/globals"; +import mockSafeAreaContext from "react-native-safe-area-context/jest/mock"; + +// Jest has no native window to deliver safe-area measurements. +jest.mock("react-native-safe-area-context", () => mockSafeAreaContext); diff --git a/docs/SPEC.md b/docs/SPEC.md index cc3f8cd..c460f84 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -384,8 +384,9 @@ The default inbox has three non-overlapping sections: 1. `Needs you`: sessions with pending permissions or forms. 2. `Working`: sessions present in the connection-wide active-session snapshot. -3. `Recent`: remaining followed-project sessions, initially in server recency - order. +3. `Recent`: the 20 most recent remaining followed-project sessions, initially in + server recency order. Search queries older sessions on the server and supports + cursor pagination beyond this default display limit. Attention takes presentation priority over execution, so a running session with a pending interaction appears only in `Needs you`. Keep attention, execution, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60fc9cb..df1e5c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: js-base64: specifier: 3.7.8 version: 3.7.8 + markdown-it: + specifier: 14.1.1 + version: 14.1.1 react: specifier: 19.1.0 version: 19.1.0 @@ -140,6 +143,9 @@ importers: '@types/jest': specifier: 29.5.14 version: 29.5.14 + '@types/markdown-it': + specifier: 14.1.2 + version: 14.1.2 '@types/react': specifier: ~19.1.17 version: 19.1.17 @@ -1725,6 +1731,15 @@ packages: '@types/jsdom@20.0.1': resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/node@26.0.0': resolution: {integrity: sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==} @@ -2526,6 +2541,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -3558,6 +3577,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -3599,6 +3621,10 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} @@ -3609,6 +3635,9 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} @@ -4067,6 +4096,10 @@ packages: psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -4646,6 +4679,9 @@ packages: resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -6869,6 +6905,15 @@ snapshots: '@types/tough-cookie': 4.0.5 parse5: 7.3.0 + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@2.0.0': {} + '@types/node@26.0.0': dependencies: undici-types: 8.3.0 @@ -7653,6 +7698,8 @@ snapshots: encodeurl@2.0.0: {} + entities@4.5.0: {} + entities@6.0.1: {} entities@8.0.0: @@ -9009,6 +9056,10 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -9047,6 +9098,15 @@ snapshots: dependencies: tmpl: 1.0.5 + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + marky@1.3.0: {} math-intrinsics@1.1.0: {} @@ -9054,6 +9114,8 @@ snapshots: mdn-data@2.27.1: optional: true + mdurl@2.1.0: {} + memoize-one@5.2.1: {} merge-stream@2.0.0: {} @@ -9732,6 +9794,8 @@ snapshots: dependencies: punycode: 2.3.1 + punycode.js@2.3.1: {} + punycode@2.3.1: {} pure-rand@6.1.0: {} @@ -10367,6 +10431,8 @@ snapshots: ua-parser-js@0.7.41: {} + uc.micro@2.1.0: {} + undici-types@8.3.0: {} undici@6.28.0: {}