From aeaac0c8be05d2937017093c001791292ea458fc Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Thu, 27 Aug 2026 14:47:11 +0200 Subject: [PATCH] Add session branch context and diff review --- apps/mobile/package.json | 1 + .../src/navigation/root-navigation.test.tsx | 15 + .../mobile/src/navigation/root-navigation.tsx | 11 + apps/mobile/src/screens/app-shell.test.ts | 66 ++++ apps/mobile/src/screens/app-shell.tsx | 182 ++++++++++- apps/mobile/src/screens/diff-screen.test.tsx | 122 ++++++++ apps/mobile/src/screens/diff-screen.tsx | 284 ++++++++++++++++++ .../src/screens/session-transcript.test.tsx | 53 +++- .../mobile/src/screens/session-transcript.tsx | 231 ++++++++++++-- .../workspace-screen.integration.test.tsx | 4 + apps/mobile/src/screens/workspace-screen.tsx | 33 +- .../connection-event-query-bridge.test.ts | 36 ++- .../state/connection-event-query-bridge.ts | 5 +- .../src/state/open-code-query-keys.test.ts | 16 + apps/mobile/src/state/open-code-query-keys.ts | 6 + packages/opencode-adapter/src/index.test.ts | 63 ++++ packages/opencode-adapter/src/index.ts | 60 ++++ packages/test-fixtures/src/index.ts | 11 + pnpm-lock.yaml | 16 + 19 files changed, 1171 insertions(+), 44 deletions(-) create mode 100644 apps/mobile/src/screens/diff-screen.test.tsx create mode 100644 apps/mobile/src/screens/diff-screen.tsx diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a1e7c5c..c92dbff 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -26,6 +26,7 @@ "expo": "~54.0.37", "expo-build-properties": "~1.0.10", "expo-camera": "~17.0.10", + "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.14", "expo-crypto": "~15.0.9", "expo-dev-client": "~6.0.21", diff --git a/apps/mobile/src/navigation/root-navigation.test.tsx b/apps/mobile/src/navigation/root-navigation.test.tsx index 3cdd921..428edc5 100644 --- a/apps/mobile/src/navigation/root-navigation.test.tsx +++ b/apps/mobile/src/navigation/root-navigation.test.tsx @@ -27,6 +27,10 @@ jest.mock("../screens/followed-projects-screen", () => { const { Text } = jest.requireActual("react-native"); return { FollowedProjectsScreen: () => Followed projects screen }; }); +jest.mock("../screens/diff-screen", () => { + const { Text } = jest.requireActual("react-native"); + return { DiffScreen: () => Diff screen }; +}); jest.mock("../screens/new-session-screen", () => { const { Text } = jest.requireActual("react-native"); return { NewSessionScreen: () => New session screen }; @@ -120,6 +124,17 @@ test("pushes session detail and presents workspace management routes over it", a ); expect(await screen.findByText("Session screen")).toBeOnTheScreen(); + act(() => + navigation.navigate("Diff", { + connectionId: "connection-1", + location: { directory: "/workspace" }, + mode: "working", + }), + ); + expect(await screen.findByText("Diff screen")).toBeOnTheScreen(); + act(() => navigation.goBack()); + expect(await screen.findByText("Session screen")).toBeOnTheScreen(); + act(() => navigation.navigate("NewSession")); expect(await screen.findByText("New session screen")).toBeOnTheScreen(); act(() => navigation.goBack()); diff --git a/apps/mobile/src/navigation/root-navigation.tsx b/apps/mobile/src/navigation/root-navigation.tsx index d179982..acc684a 100644 --- a/apps/mobile/src/navigation/root-navigation.tsx +++ b/apps/mobile/src/navigation/root-navigation.tsx @@ -22,6 +22,7 @@ import { SettingsScreen, } from "../screens/app-shell"; import { ConnectionScreen } from "../screens/connection-screen"; +import { DiffScreen } from "../screens/diff-screen"; import { FollowedProjectsScreen } from "../screens/followed-projects-screen"; import { NewSessionScreen } from "../screens/new-session-screen"; import { NotificationPairingScreen } from "../screens/notification-pairing-screen"; @@ -31,6 +32,11 @@ import { WorkspaceHeaderActions } from "./workspace-header-actions"; export type RootStackParamList = { Connections: undefined; + Diff: { + connectionId: string; + location: LocationRef; + mode: "branch" | "working"; + }; FollowedProjects: undefined; NewSession: undefined; Pending: undefined; @@ -148,6 +154,11 @@ export function RootNavigation() { title: "Session", })} /> + ({ useAppLock: jest.fn() })); jest.mock("../state/connection-runtime-context", () => ({ useConnectionRuntime: jest.fn() })); jest.mock("../state/workspace-selection-context", () => ({ useWorkspaceSelection: jest.fn() })); jest.mock("./form-request-list", () => ({ FormRequestList: () => null })); +jest.mock("expo-clipboard", () => ({ setStringAsync: jest.fn(async () => undefined) })); test("communicates every transport status without relying on color", () => { expect(getConnectionPresentation("connected", 0).label).toBe("LIVE"); @@ -95,6 +97,70 @@ test("uses native navigation on phones and retains the tablet rail", () => { } }); +test("reveals and copies the full session branch name", async () => { + 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 branchName = "docs/a-very-long-mobile-workflow-screenshots-branch"; + + const view = render( + createElement( + ShellFrame, + { + active: "Workspace", + branch: { name: branchName, state: "known" }, + navigate: jest.fn(), + }, + createElement(Text, null, "Session content"), + ), + ); + + fireEvent.press(screen.getByRole("button", { name: `Current branch, ${branchName}` })); + expect(screen.getByRole("header", { name: "Current branch" })).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "Copy branch name" })); + await waitFor(() => expect(Clipboard.setStringAsync).toHaveBeenCalledWith(branchName)); + expect(screen.getByRole("button", { name: "Copied" })).toBeOnTheScreen(); + view.unmount(); +}); + +test("reveals and copies the full server name", async () => { + jest.mocked(useConnections).mockReturnValue({ + profiles: [{ id: "connection-1", name: "A server name too long for the metadata bar" }], + 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 serverName = "A server name too long for the metadata bar"; + + const view = render( + createElement( + ShellFrame, + { active: "Workspace", navigate: jest.fn() }, + createElement(Text, null, "Session content"), + ), + ); + + fireEvent.press(screen.getByRole("button", { name: `Server, ${serverName}` })); + expect(screen.getByRole("header", { name: "Server name" })).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "Copy server name" })); + await waitFor(() => expect(Clipboard.setStringAsync).toHaveBeenCalledWith(serverName)); + view.unmount(); +}); + test("allows a permission owned by a background child session from Pending", () => { const replyPermission = jest.fn(); jest.mocked(useConnections).mockReturnValue({ diff --git a/apps/mobile/src/screens/app-shell.tsx b/apps/mobile/src/screens/app-shell.tsx index 640e62c..2b295c4 100644 --- a/apps/mobile/src/screens/app-shell.tsx +++ b/apps/mobile/src/screens/app-shell.tsx @@ -1,9 +1,12 @@ +import Feather from "@expo/vector-icons/Feather"; import type { NotificationDeliveryState } from "@opencode2-mobile/notification-protocol"; import type { NativeStackScreenProps } from "@react-navigation/native-stack"; +import * as Clipboard from "expo-clipboard"; import { useSQLiteContext } from "expo-sqlite"; import { StatusBar } from "expo-status-bar"; import { type ReactNode, useEffect, useState } from "react"; import { + AccessibilityInfo, ActivityIndicator, AppState, Pressable, @@ -17,6 +20,7 @@ import { } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; +import { ModalSheet } from "../components/modal-sheet"; import { useConnections } from "../connections/connections-context"; import type { RootStackParamList } from "../navigation/root-navigation"; import { sendNotificationDeviceCommand } from "../notifications/notification-client"; @@ -35,6 +39,11 @@ import { permissionActionExplanation } from "./permission-presentation"; import { sanitizeTranscriptText } from "./session-transcript-model"; type Section = "Pending" | "Settings" | "Workspace"; +type SessionBranch = { + name?: string; + stale?: boolean; + state: "known" | "loading" | "none" | "unavailable"; +}; type ScreenProps = NativeStackScreenProps< RootStackParamList, RouteName @@ -498,11 +507,13 @@ export function getWorkspaceState(status: ConnectionTransportStatus, hasCache: b export function ShellFrame({ active, + branch, children, hideConnectionBar, navigate, }: { active: Section; + branch?: SessionBranch; children?: ReactNode; hideConnectionBar?: boolean; navigate: (screen: Section) => void; @@ -517,6 +528,33 @@ export function ShellFrame({ (profile) => profile.id === connections.selectedProfileId, ); const connection = getConnectionPresentation(runtime.status, runtime.reconnectAttempt); + const [openDetail, setOpenDetail] = useState<"branch" | "server">(); + const [copyState, setCopyState] = useState<"copied" | "error" | "idle">("idle"); + const serverName = selected?.name ?? "No server selected"; + const detailName = openDetail === "branch" ? branch?.name : serverName; + + function showDetail(detail: "branch" | "server") { + setCopyState("idle"); + setOpenDetail(detail); + } + + async function copyDetail() { + if (!detailName) return; + try { + await Clipboard.setStringAsync(detailName); + setCopyState("copied"); + AccessibilityInfo.announceForAccessibility( + openDetail === "branch" ? "Branch name copied" : "Server name copied", + ); + } catch { + setCopyState("error"); + AccessibilityInfo.announceForAccessibility( + openDetail === "branch" + ? "Branch name could not be copied" + : "Server name could not be copied", + ); + } + } return ( @@ -578,18 +616,108 @@ export function ShellFrame({ {connection.label} - showDetail("branch")} + style={({ pressed }) => [ + styles.branchControl, + largeText && styles.branchControlLargeText, + pressed && styles.branchControlPressed, + ]} + > + + + {branch.name} + + + ) : ( + + + + {branch.state === "loading" + ? "Checking branch" + : branch.state === "none" + ? "No branch" + : "Branch unavailable"} + + + ) + ) : null} + showDetail("server")} + style={({ pressed }) => [ + styles.connectionNameControl, + branch && styles.connectionNameControlWithBranch, + largeText && styles.connectionNameControlLargeText, + pressed && styles.branchControlPressed, + ]} > - {selected?.name ?? "No server selected"} - + + {serverName} + + ) : null} {children} + { + setOpenDetail(undefined); + setCopyState("idle"); + }} + title={openDetail === "branch" ? "Current branch" : "Server name"} + visible={Boolean(openDetail && detailName)} + > + + {detailName} + + {openDetail === "branch" && branch?.stale ? ( + + This branch may be outdated while the server reconnects. + + ) : null} + + ); } @@ -763,6 +891,26 @@ const styles = StyleSheet.create({ borderColor: palette.border, borderWidth: 1, }, + branchControl: { + alignItems: "center", + flex: 1, + flexDirection: "row", + gap: space.xs, + justifyContent: "center", + marginHorizontal: space.sm, + minHeight: 44, + minWidth: 0, + }, + branchControlLargeText: { + flex: 0, + justifyContent: "flex-start", + marginHorizontal: 0, + width: "100%", + }, + branchControlPressed: { opacity: 0.55 }, + branchDetailName: { color: palette.ink, fontSize: 17, lineHeight: 25 }, + branchDetailNote: { color: palette.warm, fontSize: 14, lineHeight: 20 }, + branchName: { color: palette.dim, flexShrink: 1, fontSize: 13, minWidth: 0 }, actionCard: { backgroundColor: palette.card, borderColor: palette.border, @@ -819,12 +967,28 @@ const styles = StyleSheet.create({ }, connectionName: { color: palette.dim, - flex: 1, fontSize: 13, - marginLeft: space.sm, textAlign: "right", }, - connectionNameLargeText: { flex: 0, marginLeft: 0, textAlign: "left", width: "100%" }, + connectionNameControl: { + flex: 1, + justifyContent: "center", + marginLeft: space.sm, + minHeight: 44, + minWidth: 0, + }, + connectionNameControlLargeText: { + flex: 0, + marginLeft: 0, + maxWidth: "100%", + width: "100%", + }, + connectionNameControlWithBranch: { flex: 0, flexShrink: 1, marginLeft: 0, maxWidth: "30%" }, + connectionNameLargeText: { + flex: 0, + textAlign: "left", + width: "100%", + }, connectionState: { alignItems: "center", flexDirection: "row" }, connectionStatus: { color: palette.ink, fontSize: 11, fontWeight: "700" }, content: { flex: 1 }, diff --git a/apps/mobile/src/screens/diff-screen.test.tsx b/apps/mobile/src/screens/diff-screen.test.tsx new file mode 100644 index 0000000..a3c876a --- /dev/null +++ b/apps/mobile/src/screens/diff-screen.test.tsx @@ -0,0 +1,122 @@ +import { beforeEach, expect, jest, test } from "@jest/globals"; +import type { FileDiffInfo } from "@opencode2-mobile/opencode-adapter"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react-native"; + +import { buildDiffRows, DiffScreen } from "./diff-screen"; + +const mockGetDiff = + jest.fn< + ( + client: unknown, + location: unknown, + mode: unknown, + options: unknown, + ) => Promise<{ data: FileDiffInfo[] }> + >(); +let mockRuntime = { + connectionId: "connection-1", + restClient: {}, +}; + +jest.mock("@opencode2-mobile/opencode-adapter", () => ({ + getOpenCodeVcsDiff: (client: unknown, location: unknown, mode: unknown, options: unknown) => + mockGetDiff(client, location, mode, options), +})); +jest.mock("../state/connection-runtime-context", () => ({ + useConnectionRuntime: () => mockRuntime, +})); + +beforeEach(() => { + mockGetDiff.mockReset(); + mockRuntime = { connectionId: "connection-1", restClient: {} }; +}); + +test("renders an authoritative working-tree diff", async () => { + mockGetDiff.mockResolvedValue({ + data: [ + { + additions: 1, + deletions: 1, + file: "src/app.ts", + patch: "@@ -1 +1 @@\n-old value\n+new value", + status: "modified", + }, + ], + }); + + renderDiffScreen(); + + expect(await screen.findByText("src/app.ts")).toBeOnTheScreen(); + expect( + screen.getByText( + "Current working tree. This may include changes made after the selected tool call.", + ), + ).toBeOnTheScreen(); + expect(screen.getByText("+new value")).toHaveStyle({ backgroundColor: "#18230E" }); + expect(screen.getByText("-old value")).toHaveStyle({ backgroundColor: "#2A1714" }); + expect(mockGetDiff).toHaveBeenCalledWith( + {}, + { directory: "/workspace" }, + "working", + expect.objectContaining({ context: 5 }), + ); +}); + +test("shows empty and mismatched-connection states", async () => { + mockGetDiff.mockResolvedValue({ data: [] }); + const view = renderDiffScreen(); + expect(await screen.findByText("No changes")).toBeOnTheScreen(); + + mockRuntime = { connectionId: "connection-2", restClient: {} }; + view.rerender(diffElement()); + expect(await screen.findByText("Connection unavailable")).toBeOnTheScreen(); +}); + +test("classifies unified diff lines without retaining unbounded line content", () => { + const files: FileDiffInfo[] = [ + { + additions: 1, + deletions: 1, + file: "src/app.ts", + patch: `--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1 @@\n-${"a".repeat(5_000)}\n+new`, + status: "modified", + }, + ]; + + const rows = buildDiffRows(files); + expect(rows[0]).toMatchObject({ file: "src/app.ts", type: "file" }); + expect(rows).toContainEqual(expect.objectContaining({ kind: "hunk", type: "line" })); + expect(rows).toContainEqual(expect.objectContaining({ kind: "addition", text: "+new" })); + const deletion = rows.find((row) => row.type === "line" && row.kind === "deletion") as Extract< + (typeof rows)[number], + { type: "line" } + >; + expect(deletion.text).toHaveLength(4_000); +}); + +function renderDiffScreen() { + return render(diffElement()); +} + +function diffElement() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { gcTime: Infinity, retry: false } }, + }); + return ( + + + + ); +} diff --git a/apps/mobile/src/screens/diff-screen.tsx b/apps/mobile/src/screens/diff-screen.tsx new file mode 100644 index 0000000..07684fb --- /dev/null +++ b/apps/mobile/src/screens/diff-screen.tsx @@ -0,0 +1,284 @@ +import { type FileDiffInfo, getOpenCodeVcsDiff } from "@opencode2-mobile/opencode-adapter"; +import type { NativeStackScreenProps } from "@react-navigation/native-stack"; +import { useQuery } from "@tanstack/react-query"; +import { + ActivityIndicator, + FlatList, + Pressable, + RefreshControl, + StyleSheet, + Text, + View, +} from "react-native"; + +import type { RootStackParamList } from "../navigation/root-navigation"; +import { useConnectionRuntime } from "../state/connection-runtime-context"; +import { openCodeQueryKeys } from "../state/open-code-query-keys"; +import { palette, space, typeRamp } from "../theme"; +import { sanitizeTranscriptText } from "./session-transcript-model"; + +type Props = NativeStackScreenProps; +type DiffRow = + | { + additions: number; + deletions: number; + file: string; + key: string; + status: FileDiffInfo["status"]; + type: "file"; + } + | { + key: string; + kind: "addition" | "deletion" | "hunk" | "meta" | "plain"; + text: string; + type: "line"; + }; + +const maxDiffFiles = 500; +const maxDiffLines = 20_000; +const maxDiffLineCharacters = 4_000; + +export function DiffScreen({ route }: Props) { + const runtime = useConnectionRuntime(); + const { connectionId: routeConnectionId, location, mode } = route.params; + const client = runtime.restClient; + const connectedToRoute = runtime.connectionId === routeConnectionId; + const query = useQuery({ + enabled: Boolean(client && connectedToRoute), + queryFn: ({ signal }) => { + if (!client) throw new Error("CONNECTION_NOT_READY"); + return getOpenCodeVcsDiff(client, location, mode, { context: 5, signal }); + }, + queryKey: openCodeQueryKeys.vcsDiff(routeConnectionId, location, mode), + }); + const files = connectedToRoute ? (query.data?.data ?? []) : []; + const rows = buildDiffRows(files); + const additions = files.reduce((total, file) => total + file.additions, 0); + const deletions = files.reduce((total, file) => total + file.deletions, 0); + + return ( + row.key} + ListEmptyComponent={ + !connectedToRoute ? ( + + ) : query.isPending ? ( + + ) : query.isError ? ( + void query.refetch()} + title="Changes unavailable" + /> + ) : ( + + ) + } + ListHeaderComponent={ + rows.length > 0 ? ( + + + {files.length} {files.length === 1 ? "file" : "files"} + + + +{additions} + {" "} + -{deletions} + + + Current working tree. This may include changes made after the selected tool call. + + + ) : null + } + maxToRenderPerBatch={60} + refreshControl={ + void query.refetch()} + refreshing={query.isRefetching} + tintColor={palette.signal} + /> + } + renderItem={({ item }) => + item.type === "file" ? : + } + updateCellsBatchingPeriod={40} + windowSize={9} + /> + ); +} + +function DiffFileHeader({ row }: { row: Extract }) { + return ( + + + {sanitizeTranscriptText(row.file, 1_024)} + + + + {row.status.toLocaleUpperCase()} + + + +{row.additions} + + + -{row.deletions} + + + + ); +} + +function DiffLine({ row }: { row: Extract }) { + return ( + + {row.text} + + ); +} + +function DiffState({ + action, + detail, + onPress, + title, +}: { + action?: string; + detail: string; + onPress?: () => void; + title: string; +}) { + return ( + + + {title} + + + {detail} + + {action && onPress ? ( + [styles.retry, pressed && styles.pressed]} + > + + {action} + + + ) : null} + + ); +} + +export function buildDiffRows(files: FileDiffInfo[]) { + const rows: DiffRow[] = []; + let lineCount = 0; + const visibleFiles = files.slice(0, maxDiffFiles); + for (const [fileIndex, file] of visibleFiles.entries()) { + rows.push({ + additions: file.additions, + deletions: file.deletions, + file: file.file, + key: `file:${fileIndex}:${file.file}`, + status: file.status, + type: "file", + }); + for (const [lineIndex, line] of file.patch.split(/\r?\n/).entries()) { + if (lineCount >= maxDiffLines) break; + rows.push({ + key: `line:${fileIndex}:${lineIndex}`, + kind: diffLineKind(line), + text: sanitizeTranscriptText(line, maxDiffLineCharacters), + type: "line", + }); + lineCount += 1; + } + if (lineCount >= maxDiffLines) break; + } + if (files.length > visibleFiles.length || lineCount >= maxDiffLines) { + rows.push({ + key: "line:omitted", + kind: "meta", + text: "Additional diff content omitted on this device.", + type: "line", + }); + } + return rows; +} + +function diffLineKind(line: string): Extract["kind"] { + if (line.startsWith("@@")) return "hunk"; + if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("diff ")) return "meta"; + if (line.startsWith("+")) return "addition"; + if (line.startsWith("-")) return "deletion"; + return "plain"; +} + +const styles = StyleSheet.create({ + additions: { color: palette.signal, fontWeight: "800" }, + content: { paddingBottom: space.xl }, + deletions: { color: palette.danger, fontWeight: "800" }, + emptyContent: { flexGrow: 1, justifyContent: "center", padding: space.lg }, + explanation: { color: palette.dim, fontSize: 12, lineHeight: 18 }, + fileHeader: { + backgroundColor: palette.card, + borderBottomColor: palette.border, + borderBottomWidth: 1, + borderTopColor: palette.border, + borderTopWidth: 1, + gap: space.xs, + paddingHorizontal: space.md, + paddingVertical: 12, + }, + fileMeta: { flexDirection: "row", gap: space.sm }, + fileName: { color: palette.ink, fontFamily: "monospace", fontSize: 13, fontWeight: "700" }, + fileStatus: { color: palette.dim, fontSize: 10, fontWeight: "800", letterSpacing: 0.6 }, + line: { + color: palette.ink, + fontFamily: "monospace", + fontSize: 12, + lineHeight: 18, + paddingHorizontal: space.md, + paddingVertical: 1, + }, + lineAddition: { backgroundColor: palette.signalDark, color: palette.ink }, + lineDeletion: { backgroundColor: "#2A1714", color: palette.ink }, + lineHunk: { color: palette.warm, marginTop: space.xs }, + lineMeta: { color: palette.dim }, + pressed: { opacity: 0.7 }, + retry: { justifyContent: "center", minHeight: 44, paddingRight: space.md }, + retryLabel: { color: palette.signal, fontSize: 13, fontWeight: "700" }, + state: { gap: space.sm }, + stateDetail: { color: palette.dim, fontSize: 15, lineHeight: 22 }, + stateTitle: { color: palette.ink, fontSize: 18, fontWeight: "800" }, + summary: { gap: space.xs, padding: space.md }, + title: { color: palette.ink, fontSize: 18, fontWeight: "800" }, + totals: { fontFamily: "monospace", fontSize: 13 }, +}); diff --git a/apps/mobile/src/screens/session-transcript.test.tsx b/apps/mobile/src/screens/session-transcript.test.tsx index 07f71cc..aea6ced 100644 --- a/apps/mobile/src/screens/session-transcript.test.tsx +++ b/apps/mobile/src/screens/session-transcript.test.tsx @@ -136,9 +136,9 @@ test("renders every current message and tool state with large details collapsed" expect(screen.queryByText("c2VjcmV0")).toBeNull(); fireEvent.press(screen.getByRole("button", { name: /Retry 2 scheduled/ })); - fireEvent.press(screen.getByRole("button", { name: /completed-tool/ })); + fireEvent.press(screen.getByRole("button", { name: /completed-tool/i })); fireEvent.press(screen.getByRole("button", { name: /error-tool/ })); - fireEvent.press(screen.getByRole("button", { name: /Shell/ })); + fireEvent.press(screen.getByRole("button", { name: /Ran/ })); fireEvent.press(screen.getByRole("button", { name: /Compaction \/ Completed/ })); expect(screen.getByText("retry")).toBeOnTheScreen(); @@ -249,7 +249,8 @@ test("keeps multiline reasoning in a disclosure", () => { expect(screen.getByText("First step\nSecond step")).toBeOnTheScreen(); }); -test("groups assistant activity and places agent metadata in the footer", () => { +test("groups completed assistant activity and places narrative metadata in the footer", () => { + const openDiff = jest.fn(); render( time: { completed: 3_000, created: 1_000 }, type: "assistant", }} + onOpenDiff={openDiff} />, ); @@ -319,17 +321,51 @@ test("groups assistant activity and places agent metadata in the footer", () => expect(screen.getByText("Read")).toBeOnTheScreen(); expect(screen.getByText("Grep")).toBeOnTheScreen(); - expect(screen.getByText("Patch")).toBeOnTheScreen(); + expect(screen.getByText("Edited")).toBeOnTheScreen(); expect(screen.getByText("2 files")).toBeOnTheScreen(); - fireEvent.press(screen.getByRole("button", { name: /Patch/ })); + fireEvent.press(screen.getByRole("button", { name: /^Edited/ })); expect(screen.getAllByText("src/a.ts")).not.toHaveLength(0); expect(screen.getByText("src/b.ts")).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "Review current changes" })); + expect(openDiff).toHaveBeenCalledTimes(1); - expect(screen.getByText("Shell")).toBeOnTheScreen(); + expect(screen.getByText("Ran")).toBeOnTheScreen(); expect(screen.getByText("pnpm test")).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: /^Ran/ })); + expect(screen.getByText("$ pnpm test")).toBeOnTheScreen(); expect(screen.getByText("Build · model-1 · 2s")).toBeOnTheScreen(); }); +test("hides repeated assistant metadata for a tool-only turn", () => { + render( + , + ); + + expect(screen.getByText("Used Skill")).toBeOnTheScreen(); + expect(screen.queryByText("Build · model-1 · 500ms")).toBeNull(); +}); + test("does not render an unchanged transcript row again", () => { let typeReads = 0; const message = new Proxy( @@ -422,6 +458,11 @@ test("projects injected background results into subagent cards", () => { expect(screen.getByText("Background task completed: inspect code")).toBeOnTheScreen(); expect(screen.getByText("COMPLETED")).toBeOnTheScreen(); + expect(screen.getByLabelText(/Subagent Background task completed/)).toHaveStyle({ + backgroundColor: "transparent", + borderRadius: 0, + paddingHorizontal: 0, + }); expect(screen.queryByText(/["cont export const SessionTranscriptRow = memo(function SessionTranscriptRow({ largeText = false, message, + onOpenDiff, onOpenSubagent, }: { largeText?: boolean; message: SessionMessageInfo; + onOpenDiff?: (() => void) | undefined; onOpenSubagent?: ((sessionID: string) => void) | undefined; }) { useEffect(() => { @@ -69,6 +71,18 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({ /> ); } + if (item.type === "tools") { + return ( + + ); + } const { key, part } = item; if (part.type === "text") { const protocol = parseSubagentProtocolText(part.text); @@ -106,6 +120,7 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({ @@ -127,7 +142,7 @@ export const SessionTranscriptRow = memo(function SessionTranscriptRow({ {message.content.length === 0 && !message.error ? ( No projected content ) : null} - + {hasNarrativeContent(message) ? : null} ); } @@ -206,26 +221,44 @@ function AttachmentLabels({ type AssistantPresentationItem = | { key: string; part: AssistantPart; type: "part" } - | { key: string; tools: AssistantTool[]; type: "exploration" }; + | { key: string; tools: AssistantTool[]; type: "exploration" } + | { category: ToolGroupCategory; key: string; tools: AssistantTool[]; type: "tools" }; +type ToolCategory = "edit" | "exploration" | "other" | "shell" | "skill"; +type ToolGroupCategory = Exclude; function groupAssistantParts(content: AssistantMessage["content"]): AssistantPresentationItem[] { const items: AssistantPresentationItem[] = []; let textOrdinal = 0; let reasoningOrdinal = 0; - let exploration: AssistantTool[] = []; - const flushExploration = () => { - const first = exploration[0]; - if (first) - items.push({ key: `exploration:${first.id}`, tools: exploration, type: "exploration" }); - exploration = []; + let tools: AssistantTool[] = []; + const flushTools = () => { + let start = 0; + while (start < tools.length) { + const category = toolCategory(tools[start] as AssistantTool); + let end = start + 1; + while (end < tools.length && toolCategory(tools[end] as AssistantTool) === category) end += 1; + const run = tools.slice(start, end); + const first = run[0] as AssistantTool; + if (category === "exploration") { + items.push({ key: `exploration:${first.id}`, tools: run, type: "exploration" }); + } else if (run.length > 1 && run.every((tool) => tool.state.status === "completed")) { + items.push({ category, key: `tools:${first.id}`, tools: run, type: "tools" }); + } else { + for (const tool of run) { + items.push({ key: `tool:${tool.id}`, part: tool, type: "part" }); + } + } + start = end; + } + tools = []; }; for (const part of content) { - if (part.type === "tool" && isExplorationTool(part)) { - exploration.push(part); + if (part.type === "tool" && !getSubagentPresentation(part)) { + tools.push(part); continue; } - flushExploration(); + flushTools(); if (part.type === "tool") { items.push({ key: `tool:${part.id}`, part, type: "part" }); continue; @@ -238,10 +271,14 @@ function groupAssistantParts(content: AssistantMessage["content"]): AssistantPre reasoningOrdinal += 1; items.push({ key: `reasoning:${reasoningOrdinal}`, part, type: "part" }); } - flushExploration(); + flushTools(); return items; } +function hasNarrativeContent(message: AssistantMessage) { + return message.content.some((part) => part.type === "text" || part.type === "reasoning"); +} + function withOccurrenceKeys(labels: string[]) { const occurrences = new Map(); return labels.map((label) => { @@ -291,14 +328,59 @@ function ExplorationDisclosure({ ); } +function ToolGroupDisclosure({ + category, + largeText, + onOpenDiff, + onOpenSubagent, + tools, +}: { + category: ToolGroupCategory; + largeText: boolean; + onOpenDiff?: (() => void) | undefined; + onOpenSubagent?: ((sessionID: string) => void) | undefined; + tools: AssistantTool[]; +}) { + const [expanded, setExpanded] = useState(false); + const { detail, label } = toolGroupPresentation(category, tools); + const canExpand = tools.some(canExpandTool); + return ( + + setExpanded((current) => !current)} + /> + {expanded + ? tools.map((tool) => ( + + )) + : null} + {category === "edit" && onOpenDiff ? : null} + + ); +} + function ToolDisclosure({ largeText, nested = false, + onOpenDiff, onOpenSubagent, tool, }: { largeText: boolean; nested?: boolean; + onOpenDiff?: (() => void) | undefined; onOpenSubagent?: ((sessionID: string) => void) | undefined; tool: AssistantTool; }) { @@ -315,7 +397,12 @@ function ToolDisclosure({ : []; const error = tool.state.status === "error" ? tool.state.error.message : undefined; const presentation = toolPresentation(tool); - const canExpand = content.length > 0 || Boolean(error) || presentation.files.length > 0; + const canExpand = canExpandTool(tool); + const category = toolCategory(tool); + const label = + !nested && tool.state.status === "completed" + ? completedToolLabel(category, presentation.label) + : presentation.label; const visibleContent = content.slice(0, maxToolOutputs); return ( @@ -323,7 +410,7 @@ function ToolDisclosure({ canExpand={canExpand} detail={presentation.detail} expanded={expanded} - label={presentation.label} + label={label} largeText={largeText} onPress={() => setExpanded((current) => !current)} /> @@ -339,6 +426,11 @@ function ToolDisclosure({ )) : null} + {expanded && presentation.command ? ( + + {`$ ${presentation.command}`} + + ) : null} {expanded ? keyToolContent(visibleContent).map(({ item, key }) => item.type === "text" ? ( @@ -361,10 +453,25 @@ function ToolDisclosure({ Additional tool output omitted on this device. ) : null} {expanded && error ? : null} + {!nested && category === "edit" && onOpenDiff ? : null} ); } +function DiffAction({ onPress }: { onPress: () => void }) { + return ( + [styles.diffAction, pressed && styles.pressed]} + > + + Review current changes + + + ); +} + function ActivityHeader({ canExpand, detail, @@ -430,10 +537,15 @@ function ShellDisclosure({ largeText, message }: { largeText: boolean; message: canExpand={canExpand} detail={detail} expanded={expanded} - label="Shell" + label={message.status === "exited" ? "Ran" : "Shell"} largeText={largeText} onPress={() => setExpanded((current) => !current)} /> + {expanded ? ( + + {`$ ${message.command}`} + + ) : null} {expanded && message.output?.output ? ( ) : null} @@ -496,7 +608,7 @@ function SubagentCard({ return ( @@ -839,9 +951,57 @@ const patchToolNames = new Set([ "write", ]); const shellToolNames = new Set(["bash", "command", "exec", "shell", "terminal"]); +const skillToolNames = new Set(["skill", "use_skill"]); + +function toolCategory(tool: AssistantTool): ToolCategory { + const name = tool.name.trim().toLocaleLowerCase(); + if (explorationToolNames.has(name)) return "exploration"; + if (patchToolNames.has(name)) return "edit"; + if (shellToolNames.has(name)) return "shell"; + if (skillToolNames.has(name)) return "skill"; + return "other"; +} -function isExplorationTool(tool: AssistantTool) { - return explorationToolNames.has(tool.name.trim().toLocaleLowerCase()); +function toolGroupPresentation(category: ToolGroupCategory, tools: AssistantTool[]) { + if (category === "edit") { + const files = new Set(tools.flatMap((tool) => toolPresentation(tool).files)); + return { + detail: + files.size > 0 + ? `${files.size} ${files.size === 1 ? "file" : "files"}` + : `${tools.length} edits`, + label: "Edited", + }; + } + if (category === "shell") { + return { detail: `${tools.length} commands`, label: "Ran shell" }; + } + if (category === "skill") { + const skills = [...new Set(tools.map((tool) => toolPresentation(tool).detail).filter(Boolean))]; + return { + detail: skills.length > 0 ? skills.join(", ") : `${tools.length} uses`, + label: "Used Skill", + }; + } + + const labels: string[] = []; + for (const tool of tools) { + const label = capitalize(toolPresentation(tool).label); + if (!labels.includes(label)) labels.push(label); + } + const visibleLabels = labels.slice(0, 3); + const omittedLabels = labels.length - visibleLabels.length; + return { + detail: `${tools.length} calls`, + label: `Used ${visibleLabels.join(", ")}${omittedLabels > 0 ? `, +${omittedLabels}` : ""}`, + }; +} + +function completedToolLabel(category: ToolCategory, fallback: string) { + if (category === "edit") return "Edited"; + if (category === "shell") return "Ran"; + if (category === "skill") return "Used Skill"; + return `Used ${capitalize(fallback)}`; } function toolPresentation(tool: AssistantTool) { @@ -849,6 +1009,7 @@ function toolPresentation(tool: AssistantTool) { const input = toolInputRecord(tool); const files = patchToolNames.has(name) ? patchFiles(input) : []; const status = toolStatusLabel(tool); + let command: string | undefined; let label = tool.name.trim() || "Tool"; let detail: string | undefined; @@ -863,16 +1024,27 @@ function toolPresentation(tool: AssistantTool) { detail = files.length > 1 ? `${files.length} files` : files[0]; } else if (shellToolNames.has(name)) { label = "Shell"; - detail = firstInputString(input, ["command", "cmd"]); + command = firstInputString(input, ["command", "cmd"]); + detail = command; + } else if (skillToolNames.has(name)) { + label = "Skill"; + detail = firstInputString(input, ["name", "skill"]); } return { + command, detail: [detail, status].filter(Boolean).join(" · ") || undefined, files, label, }; } +function canExpandTool(tool: AssistantTool) { + if (tool.state.status === "error") return true; + if (tool.state.status !== "completed") return false; + return Boolean(tool.state.content?.length || toolPresentation(tool).files.length); +} + function toolInputRecord(tool: AssistantTool): Record | undefined { if (tool.state.status !== "streaming") return tool.state.input; if (tool.state.input.length > 16_384) return undefined; @@ -973,6 +1145,10 @@ function sentenceCase(value: string) { return value ? `${value[0]?.toLocaleUpperCase()}${value.slice(1).toLocaleLowerCase()}` : value; } +function capitalize(value: string) { + return value ? `${value[0]?.toLocaleUpperCase()}${value.slice(1)}` : value; +} + function subagentStateLabel(state: SubagentPresentation["state"]) { switch (state) { case "streaming": @@ -1113,6 +1289,13 @@ const styles = StyleSheet.create({ minWidth: 0, }, disclosureLabelLargeText: { flex: 0, width: "100%" }, + diffAction: { + alignSelf: "flex-start", + justifyContent: "center", + minHeight: 44, + paddingRight: space.md, + }, + diffActionLabel: { color: palette.signal, fontSize: 13, fontWeight: "700" }, errorText: { color: palette.danger, fontSize: 14, lineHeight: 21 }, markdownBlockSpacing: { marginTop: space.sm }, notice: { @@ -1145,6 +1328,16 @@ const styles = StyleSheet.create({ gap: space.xs, padding: 12, }, + subagentCompleted: { + backgroundColor: "transparent", + borderBottomWidth: StyleSheet.hairlineWidth, + borderColor: palette.border, + borderRadius: 0, + borderWidth: 0, + gap: 2, + paddingHorizontal: 0, + paddingVertical: 8, + }, subagentAction: { justifyContent: "center", minHeight: 44, paddingRight: space.md }, subagentActionLabel: { color: palette.signal, fontSize: 13, fontWeight: "700" }, subagentActions: { flexDirection: "row", flexWrap: "wrap" }, diff --git a/apps/mobile/src/screens/workspace-screen.integration.test.tsx b/apps/mobile/src/screens/workspace-screen.integration.test.tsx index 0d3c60a..8a42908 100644 --- a/apps/mobile/src/screens/workspace-screen.integration.test.tsx +++ b/apps/mobile/src/screens/workspace-screen.integration.test.tsx @@ -52,6 +52,10 @@ jest.mock("@opencode2-mobile/opencode-adapter", () => ({ tokens: { cache: { read: 0, write: 0 }, input: 0, output: 0, reasoning: 0 }, })), getOpenCodeSessionMessage: jest.fn(), + getOpenCodeVcs: jest.fn(async () => ({ + data: { branch: { current: "docs/mobile-workflow-screenshots" } }, + location, + })), interruptOpenCodeSession: jest.fn(), listActiveOpenCodeSessions: jest.fn(async () => ({})), listOpenCodeAgents: jest.fn(async () => ({ data: [], location })), diff --git a/apps/mobile/src/screens/workspace-screen.tsx b/apps/mobile/src/screens/workspace-screen.tsx index 757f181..548650f 100644 --- a/apps/mobile/src/screens/workspace-screen.tsx +++ b/apps/mobile/src/screens/workspace-screen.tsx @@ -2,6 +2,7 @@ import { getDefaultOpenCodeLocation, getOpenCodeLocation, getOpenCodeSession, + getOpenCodeVcs, type LocationRef, listOpenCodeMessages, listOpenCodeProjects, @@ -533,6 +534,16 @@ export function SessionScreen({ navigation, route }: SessionProps) { }, queryKey: openCodeQueryKeys.session(connectionId ?? "unselected", location, sessionID), }); + const session = sessionQuery.data; + const sessionLocation = session?.location ?? location; + const vcsQuery = useQuery({ + enabled: Boolean(client && connectionId === routeConnectionId), + queryFn: ({ signal }) => { + if (!client) throw new Error("CONNECTION_NOT_READY"); + return getOpenCodeVcs(client, sessionLocation, { signal }); + }, + queryKey: openCodeQueryKeys.vcs(connectionId ?? "unselected", sessionLocation), + }); const messagesQuery = useInfiniteQuery({ enabled: Boolean(client && connectionId === routeConnectionId), getNextPageParam: (lastPage: SessionMessagesResponse) => lastPage.cursor.next ?? undefined, @@ -553,7 +564,18 @@ export function SessionScreen({ navigation, route }: SessionProps) { order: "desc", }), }); - const session = sessionQuery.data; + const currentBranch = vcsQuery.data?.data.branch.current; + const branch = currentBranch + ? ({ + name: currentBranch, + stale: vcsQuery.isError || runtime.status !== "connected", + state: "known", + } as const) + : vcsQuery.isError || runtime.status !== "connected" + ? ({ state: "unavailable" } as const) + : vcsQuery.isPending + ? ({ state: "loading" } as const) + : ({ state: "none" } as const); const messages = flattenTranscriptPages(messagesQuery.data?.pages); const draft = useSessionDraft(routeConnectionId, sessionID); const execution = useSessionExecution({ @@ -589,6 +611,13 @@ export function SessionScreen({ navigation, route }: SessionProps) { }, [location, navigation, routeConnectionId], ); + const openDiff = useCallback(() => { + navigation.push("Diff", { + connectionId: routeConnectionId, + location, + mode: "working", + }); + }, [location, navigation, routeConnectionId]); useEffect(() => { workspaceSelection.setLocation(location); @@ -858,6 +887,7 @@ export function SessionScreen({ navigation, route }: SessionProps) { return ( section === "Workspace" ? navigation.popTo("Workspace") : navigation.navigate(section) } @@ -926,6 +956,7 @@ export function SessionScreen({ navigation, route }: SessionProps) { )} diff --git a/apps/mobile/src/state/connection-event-query-bridge.test.ts b/apps/mobile/src/state/connection-event-query-bridge.test.ts index cf78078..ec9b789 100644 --- a/apps/mobile/src/state/connection-event-query-bridge.test.ts +++ b/apps/mobile/src/state/connection-event-query-bridge.test.ts @@ -174,7 +174,7 @@ test("does not refetch connection queries for file-change hints", () => { queryClient.clear(); }); -test("does not refetch connection queries for shell and VCS advisory events", () => { +test("does not refetch connection queries for shell advisory events", () => { const queryClient = new QueryClient(); const invalidate = jest.spyOn(queryClient, "invalidateQueries"); const scheduled: Array<() => void> = []; @@ -182,12 +182,7 @@ test("does not refetch connection queries for shell and VCS advisory events", () scheduled.push(callback); }); - for (const [index, type] of [ - "shell.created", - "shell.exited", - "shell.deleted", - "vcs.branch.updated", - ].entries()) { + for (const [index, type] of ["shell.created", "shell.exited", "shell.deleted"].entries()) { bridge.apply({ created: index + 1, data: {}, @@ -202,6 +197,33 @@ test("does not refetch connection queries for shell and VCS advisory events", () queryClient.clear(); }); +test("invalidates VCS information only for the affected location", () => { + const queryClient = new QueryClient(); + const invalidate = jest.spyOn(queryClient, "invalidateQueries"); + const bridge = new ConnectionEventQueryBridge(queryClient, "connection-1", (callback) => + callback(), + ); + const affectedKey = openCodeQueryKeys.vcs("connection-1", { directory: "/workspace" }); + const otherKey = openCodeQueryKeys.vcs("connection-1", { directory: "/other" }); + queryClient.setQueryData(affectedKey, { data: { branch: { current: "old" } } }); + queryClient.setQueryData(otherKey, { data: { branch: { current: "other" } } }); + + bridge.apply({ + created: 1, + data: { branch: "feature/mobile" }, + id: "event-vcs", + location: { directory: "/workspace" }, + type: "vcs.branch.updated", + }); + + const predicate = invalidate.mock.calls[0]?.[0]?.predicate; + const affected = queryClient.getQueryCache().find({ queryKey: affectedKey }); + const other = queryClient.getQueryCache().find({ queryKey: otherKey }); + expect(affected && predicate?.(affected)).toBe(true); + expect(other && predicate?.(other)).toBe(false); + queryClient.clear(); +}); + test("falls back to connection reconciliation for an unknown session event", () => { const queryClient = new QueryClient(); const invalidate = jest.spyOn(queryClient, "invalidateQueries"); diff --git a/apps/mobile/src/state/connection-event-query-bridge.ts b/apps/mobile/src/state/connection-event-query-bridge.ts index 6bccd4d..bf5c25e 100644 --- a/apps/mobile/src/state/connection-event-query-bridge.ts +++ b/apps/mobile/src/state/connection-event-query-bridge.ts @@ -22,7 +22,8 @@ type InvalidationRoot = | "inbox" | "messages" | "permissions" - | "sessions"; + | "sessions" + | "vcs"; type InvalidationTarget = { location?: LocationRef; root: InvalidationRoot; sessionId?: string }; type PendingTranscript = { eventIds: Set; @@ -264,6 +265,7 @@ export function eventRequiresConnectionSnapshot(event: OpenCodeEvent) { function eventInvalidationRoot(event: OpenCodeEvent): InvalidationRoot | undefined { if (advisoryLocationEventTypes.has(event.type)) return undefined; + if (event.type === "vcs.branch.updated") return "vcs"; if (inboxEventTypes.has(event.type)) return "inbox"; if (event.type === "session.status" || event.type === "session.execution.started") { return undefined; @@ -290,7 +292,6 @@ const advisoryLocationEventTypes = new Set([ "shell.created", "shell.deleted", "shell.exited", - "vcs.branch.updated", ]); const inboxEventTypes = new Set([ diff --git a/apps/mobile/src/state/open-code-query-keys.test.ts b/apps/mobile/src/state/open-code-query-keys.test.ts index 197a1c4..6ec47ad 100644 --- a/apps/mobile/src/state/open-code-query-keys.test.ts +++ b/apps/mobile/src/state/open-code-query-keys.test.ts @@ -34,6 +34,22 @@ test("does not collide across connections or workspace locations", () => { expect(first).not.toEqual(workspace); }); +test("separates working-tree and branch diffs within one location", () => { + const location = { directory: "/workspace", workspaceID: "wrk_test" }; + expect(openCodeQueryKeys.vcsDiff("connection-1", location, "working")).toEqual([ + "opencode", + "connection-1", + "location", + "/workspace", + "wrk_test", + "vcs-diff", + "working", + ]); + expect(openCodeQueryKeys.vcsDiff("connection-1", location, "working")).not.toEqual( + openCodeQueryKeys.vcsDiff("connection-1", location, "branch"), + ); +}); + test("keeps all-parent and root-session list parameters distinct", () => { const location = { directory: "/workspace" }; expect(openCodeQueryKeys.sessions("connection-1", location, {})).not.toEqual( diff --git a/apps/mobile/src/state/open-code-query-keys.ts b/apps/mobile/src/state/open-code-query-keys.ts index 84caf8f..abdebd1 100644 --- a/apps/mobile/src/state/open-code-query-keys.ts +++ b/apps/mobile/src/state/open-code-query-keys.ts @@ -140,6 +140,12 @@ export const openCodeQueryKeys = { sessionRoot(connectionId: string, location: LocationRef) { return sessionRootKey(connectionId, location); }, + vcs(connectionId: string, location: LocationRef) { + return [...locationKey(connectionId, location), "vcs"] as const; + }, + vcsDiff(connectionId: string, location: LocationRef, mode: "branch" | "working") { + return [...locationKey(connectionId, location), "vcs-diff", mode] as const; + }, sessions(connectionId: string, location: LocationRef, parameters: SessionListKeyParameters) { return [ ...sessionRootKey(connectionId, location), diff --git a/packages/opencode-adapter/src/index.test.ts b/packages/opencode-adapter/src/index.test.ts index 3f95098..6b95018 100644 --- a/packages/opencode-adapter/src/index.test.ts +++ b/packages/opencode-adapter/src/index.test.ts @@ -19,6 +19,8 @@ import { getOpenCodeLocation, getOpenCodeSession, getOpenCodeSessionMessage, + getOpenCodeVcs, + getOpenCodeVcsDiff, interruptOpenCodeSession, listActiveOpenCodeSessions, listOpenCodeAgents, @@ -339,6 +341,67 @@ it("validates default and explicit resolved locations", async () => { expect(locationGet).toHaveBeenNthCalledWith(1, undefined, undefined); }); +it("validates and forwards location-scoped VCS information", async () => { + const api = createFakeOpenCodeApi({ vcs: { branch: { current: "feature/mobile" } } }); + const client = createOpenCodeClient({ baseUrl: "https://fake.invalid", fetch: api.fetch }); + + await expect( + getOpenCodeVcs(client, { directory: "/workspace", workspaceID: "wrk_test" }), + ).resolves.toMatchObject({ data: { branch: { current: "feature/mobile" } } }); + expect(api.requests.at(-1)).toMatchObject({ + path: "/api/vcs", + query: { + "location[directory]": ["/workspace"], + "location[workspace]": ["wrk_test"], + }, + }); + + const malformedApi = createFakeOpenCodeApi({ vcs: { branch: { current: null } } }); + const malformedClient = createOpenCodeClient({ + baseUrl: "https://fake.invalid", + fetch: malformedApi.fetch, + }); + await expect(getOpenCodeVcs(malformedClient, { directory: "/workspace" })).rejects.toThrow( + "MALFORMED_VCS_INFO", + ); +}); + +it("validates and forwards location-scoped working-tree diffs", async () => { + const diff = { + additions: 2, + deletions: 1, + file: "src/app.ts", + patch: "@@ -1 +1 @@\n-old\n+new", + status: "modified", + }; + const api = createFakeOpenCodeApi({ vcsDiff: [diff] }); + const client = createOpenCodeClient({ baseUrl: "https://fake.invalid", fetch: api.fetch }); + + await expect( + getOpenCodeVcsDiff(client, { directory: "/workspace", workspaceID: "wrk_test" }, "working", { + context: 5, + }), + ).resolves.toMatchObject({ data: [diff] }); + expect(api.requests.at(-1)).toMatchObject({ + path: "/api/vcs/diff", + query: { + context: ["5"], + "location[directory]": ["/workspace"], + "location[workspace]": ["wrk_test"], + mode: ["working"], + }, + }); + + const malformedApi = createFakeOpenCodeApi({ vcsDiff: [{ ...diff, additions: -1 }] }); + const malformedClient = createOpenCodeClient({ + baseUrl: "https://fake.invalid", + fetch: malformedApi.fetch, + }); + await expect( + getOpenCodeVcsDiff(malformedClient, { directory: "/workspace" }, "working"), + ).rejects.toThrow("MALFORMED_VCS_DIFF"); +}); + it("validates and forwards location-scoped agent and model choices", async () => { const location = { directory: "/workspace", diff --git a/packages/opencode-adapter/src/index.ts b/packages/opencode-adapter/src/index.ts index 8e9fcff..180c83a 100644 --- a/packages/opencode-adapter/src/index.ts +++ b/packages/opencode-adapter/src/index.ts @@ -1,4 +1,5 @@ import { + type FileDiffInfo, type FormAnswer, type FormState, type LocationGetOutput, @@ -167,6 +168,45 @@ export async function getOpenCodeLocation( ); } +export async function getOpenCodeVcs( + client: OpenCodeClient, + location: LocationRef, + options?: OpenCodeRequestOptions, +) { + const response = await client.vcs.get({ location: locationInput(location) }, options); + validateResolvedLocation(response.location); + if ( + !isRecord(response.data) || + !isRecord(response.data.branch) || + !isOptionalString(response.data.branch.current) || + !isOptionalString(response.data.branch.default) + ) { + throw new Error("MALFORMED_VCS_INFO"); + } + return response; +} + +export async function getOpenCodeVcsDiff( + client: OpenCodeClient, + location: LocationRef, + mode: "branch" | "working", + options?: OpenCodeRequestOptions & { context?: number }, +) { + const response = await client.vcs.diff( + { + location: locationInput(location), + mode, + ...(options?.context === undefined ? {} : { context: options.context }), + }, + options?.signal ? { signal: options.signal } : undefined, + ); + validateResolvedLocation(response.location); + if (!Array.isArray(response.data) || !response.data.every(isValidFileDiff)) { + throw new Error("MALFORMED_VCS_DIFF"); + } + return response; +} + export async function listOpenCodeProjects( client: OpenCodeClient, options?: OpenCodeRequestOptions, @@ -1132,6 +1172,25 @@ function isOptionalNullableString(value: unknown) { return value === undefined || value === null || typeof value === "string"; } +function isOptionalString(value: unknown) { + return value === undefined || typeof value === "string"; +} + +function isValidFileDiff(value: unknown): value is FileDiffInfo { + return ( + isRecord(value) && + typeof value.file === "string" && + typeof value.patch === "string" && + typeof value.additions === "number" && + Number.isInteger(value.additions) && + value.additions >= 0 && + typeof value.deletions === "number" && + Number.isInteger(value.deletions) && + value.deletions >= 0 && + (value.status === "added" || value.status === "deleted" || value.status === "modified") + ); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -1635,6 +1694,7 @@ export type OpenCodeClient = ReturnType; export type { AgentInfo, AgentListOutput, + FileDiffInfo, FormAnswer, FormField, FormInfo, diff --git a/packages/test-fixtures/src/index.ts b/packages/test-fixtures/src/index.ts index 75372c9..bce94b3 100644 --- a/packages/test-fixtures/src/index.ts +++ b/packages/test-fixtures/src/index.ts @@ -16,6 +16,8 @@ export type FakeOpenCodeApiOptions = { permissions?: unknown[]; projects?: unknown[]; sessions?: FakeSession[]; + vcs?: unknown; + vcsDiff?: unknown[]; }; export type FakeOpenCodeRequest = { @@ -97,6 +99,15 @@ export function createFakeOpenCodeApi(options: FakeOpenCodeApiOptions = {}) { ...(requestedWorkspace ? { workspaceID: requestedWorkspace } : {}), }); } + if (url.pathname === "/api/vcs") { + return json({ + data: options.vcs ?? { branch: {} }, + location: resolvedLocation(options, url), + }); + } + if (url.pathname === "/api/vcs/diff") { + return json({ data: options.vcsDiff ?? [], location: resolvedLocation(options, url) }); + } if (url.pathname === "/api/agent") { return json({ location: resolvedLocation(options, url), data: options.agents ?? [] }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44e671c..5210420 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: expo-camera: specifier: ~17.0.10 version: 17.0.10(expo@54.0.37)(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0) + expo-clipboard: + specifier: ~8.0.8 + version: 8.0.8(expo@54.0.37)(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0) expo-constants: specifier: ~18.0.14 version: 18.0.14(expo@54.0.37)(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(supports-color@8.1.1) @@ -2634,6 +2637,13 @@ packages: react-native-web: optional: true + expo-clipboard@8.0.8: + resolution: {integrity: sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-constants@18.0.14: resolution: {integrity: sha512-BUZm9mkl/TX7zNaN0N4C83ws7mEnesCFiKhqK+rMZwTD2+Q4wWB2kBltqnC/LrCRaMyRi40XxhCRVwuKnJkxXQ==} peerDependencies: @@ -7744,6 +7754,12 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1) + expo-clipboard@8.0.8(expo@54.0.37)(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0): + dependencies: + expo: 54.0.37(@babel/core@7.29.7(supports-color@8.1.1))(@expo/dom-webview@57.0.1)(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(react@19.1.0)(supports-color@8.1.1)(typescript@5.9.3) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1) + expo-constants@18.0.14(expo@54.0.37)(react-native@0.81.5(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.1.17)(react@19.1.0)(supports-color@8.1.1))(supports-color@8.1.1): dependencies: '@expo/config': 12.0.14(supports-color@8.1.1)