From 84289f87206c79a0dcab1055f2980408acadb5c0 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Tue, 10 Nov 2026 09:58:20 +0100 Subject: [PATCH 1/3] Show transcript images as tiles with a swipeable viewer Images form justified rows that keep each image's shape. Rows aim for 170 points and stop at 200, so a lone portrait screenshot stays a small tile instead of filling the screen. A small linear partition balances the rows, so seven screenshots read as three and four. The full-screen viewer now pages between the group's images and shows a position counter. iOS keeps a zooming scroll view per page. Android locks paging while zoomed, and its pan activates only when zoomed so an unzoomed swipe reaches the pager. --- .../src/screens/transcript-images.test.tsx | 65 +++- apps/mobile/src/screens/transcript-images.tsx | 322 +++++++++++++----- docs/COMPATIBILITY.md | 18 + 3 files changed, 325 insertions(+), 80 deletions(-) diff --git a/apps/mobile/src/screens/transcript-images.test.tsx b/apps/mobile/src/screens/transcript-images.test.tsx index d5515b0..74e7a56 100644 --- a/apps/mobile/src/screens/transcript-images.test.tsx +++ b/apps/mobile/src/screens/transcript-images.test.tsx @@ -2,12 +2,13 @@ import "react-native-gesture-handler/jestSetup"; import { afterEach, beforeEach, expect, jest, test } from "@jest/globals"; import type { SessionMessageInfo } from "@opencode2-mobile/opencode-adapter"; import { act, cleanup, fireEvent, render, screen } from "@testing-library/react-native"; -import { Platform } from "react-native"; +import { Dimensions, Platform } from "react-native"; import { SessionTranscriptRow, TranscriptActivityGroup } from "./session-transcript"; import { attachmentImages, inlineImageUri, + justifiedImageRows, maxTranscriptImageLength, maxTranscriptImages, TranscriptImages, @@ -95,6 +96,68 @@ test("tool and attachment images are collected with stable keys and a cap", () = ).toEqual([{ key: "attachment:0", label: "photo.png", uri: png }]); }); +const tiles = { gap: 6, maxHeight: 200, targetHeight: 170 }; + +test("justified rows balance a batch of screenshots and cap their height", () => { + const [short, full] = justifiedImageRows(Array(7).fill(0.46), 345, tiles); + expect([short?.start, short?.end, full?.start, full?.end]).toEqual([0, 3, 3, 7]); + // Three screenshots would need about 241 points to fill the width, so the cap holds them. + expect(short?.height).toBe(200); + expect((full?.height ?? 0) * 0.46 * 4 + tiles.gap * 3).toBeCloseTo(345); + expect(justifiedImageRows([1, 1, 1, 1], 345, tiles)).toEqual([ + { start: 0, end: 2, height: 169.5 }, + { start: 2, end: 4, height: 169.5 }, + ]); +}); + +test("a lone image stays a capped tile and wide images fill the width", () => { + expect(justifiedImageRows([0.46], 345, tiles)).toEqual([{ start: 0, end: 1, height: 200 }]); + const [wide] = justifiedImageRows([16 / 9], 345, tiles); + expect(wide?.height).toBeCloseTo(345 / (16 / 9)); + expect(justifiedImageRows([], 345, tiles)).toEqual([]); + expect(justifiedImageRows([1], 0, tiles)).toEqual([]); +}); + +test("several images render as tiles that each open the viewer", () => { + render( + ({ + key: name, + label: `${name}.png`, + uri: png, + }))} + />, + ); + expect(screen.getAllByRole("imagebutton")).toHaveLength(3); + fireEvent.press(screen.getByRole("imagebutton", { name: "two.png" })); + expect(screen.getAllByLabelText("two.png")).toHaveLength(2); + expect(screen.getByText("2 of 3")).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "Close image" })); + expect(screen.queryByRole("button", { name: "Close image" })).toBeNull(); +}); + +test("swiping the viewer moves between images and a lone image has no counter", () => { + const images = ["one", "two", "three"].map((name) => ({ + key: name, + label: `${name}.png`, + uri: png, + })); + const view = render(); + fireEvent.press(screen.getByRole("imagebutton", { name: "one.png" })); + expect(screen.getByText("1 of 3")).toBeOnTheScreen(); + const pager = screen.getByTestId("image-pager"); + expect(pager.props.scrollEnabled).toBe(true); + const { width } = Dimensions.get("window"); + fireEvent(pager, "momentumScrollEnd", { nativeEvent: { contentOffset: { x: width * 2, y: 0 } } }); + expect(screen.getByText("3 of 3")).toBeOnTheScreen(); + view.unmount(); + + render(); + fireEvent.press(screen.getByRole("imagebutton", { name: "one.png" })); + expect(screen.queryByText(/ of /)).toBeNull(); + expect(screen.getByTestId("image-pager").props.scrollEnabled).toBe(false); +}); + test("tapping an image opens a full-screen viewer that closes", () => { render(); expect(screen.queryByRole("button", { name: "Close image" })).toBeNull(); diff --git a/apps/mobile/src/screens/transcript-images.tsx b/apps/mobile/src/screens/transcript-images.tsx index aa5407d..aa74a81 100644 --- a/apps/mobile/src/screens/transcript-images.tsx +++ b/apps/mobile/src/screens/transcript-images.tsx @@ -1,6 +1,6 @@ import Feather from "@expo/vector-icons/Feather"; import type { SessionMessageInfo } from "@opencode2-mobile/opencode-adapter"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Image, Modal, @@ -9,10 +9,16 @@ import { ScrollView, StyleSheet, Text, + useWindowDimensions, View, } from "react-native"; import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; -import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated"; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { @@ -41,7 +47,6 @@ const dataUriPattern = /^data:(image\/[a-z]+);base64,/i; export const maxTranscriptImages = 8; // The message list is already capped at 16 MiB; this keeps one image from using most of it. export const maxTranscriptImageLength = 12 * 1024 * 1024; -const minimumAspectRatio = 0.75; const maximumZoom = 4; export function inlineImageUri(uri: string, mime: string) { @@ -100,62 +105,136 @@ function imageLabel(name: string | null | undefined) { return trimmed ? sanitizeTranscriptText(trimmed.split("/").at(-1) ?? trimmed, 256) : "Image"; } -// One image shows at its own shape. Several form a grid of square crops, two columns for two -// or four and three otherwise, so a batch of screenshots stays scannable. -export function TranscriptImages({ images }: { images: readonly TranscriptImage[] }) { - const [viewing, setViewing] = useState(); - if (images.length === 0) return null; - const columns = images.length === 2 || images.length === 4 ? 2 : 3; - const rows: TranscriptImage[][] = []; - if (images.length > 1) { - for (let index = 0; index < images.length; index += columns) { - rows.push(images.slice(index, index + columns)); +export type JustifiedImageRow = { start: number; end: number; height: number }; + +// Rows of tiles that keep each image's shape and share one height per row. The row count comes +// from the target height, and a small linear partition balances the rows so a batch of seven +// screenshots reads as three and four instead of a full row and a straggler. No row grows past +// the cap, so a lone portrait screenshot stays a tile instead of filling the screen. +export function justifiedImageRows( + aspects: readonly number[], + width: number, + options: { gap: number; maxHeight: number; targetHeight: number }, +): JustifiedImageRow[] { + const count = aspects.length; + if (count === 0 || width <= 0) return []; + const total = aspects.reduce((sum, aspect) => sum + aspect, 0); + const rowCount = Math.min(count, Math.max(1, Math.round((total * options.targetHeight) / width))); + const ideal = total / rowCount; + const prefix = [0]; + for (const aspect of aspects) prefix.push((prefix.at(-1) ?? 0) + aspect); + const span = (start: number, end: number) => (prefix[end] ?? 0) - (prefix[start] ?? 0); + + // cost[rows][end] is the best squared deviation for the first `end` images in `rows` rows. + const cost = Array.from({ length: rowCount + 1 }, () => Array(count + 1).fill(Infinity)); + const split = Array.from({ length: rowCount + 1 }, () => Array(count + 1).fill(0)); + const first = cost[0]; + if (first) first[0] = 0; + for (let rows = 1; rows <= rowCount; rows += 1) { + for (let end = rows; end <= count; end += 1) { + for (let start = rows - 1; start < end; start += 1) { + const value = (cost[rows - 1]?.[start] ?? Infinity) + (span(start, end) - ideal) ** 2; + const row = cost[rows]; + if (row && value < (row[end] ?? Infinity) - 1e-9) { + row[end] = value; + const splitRow = split[rows]; + if (splitRow) splitRow[end] = start; + } + } } } + + const result: JustifiedImageRow[] = []; + let end = count; + for (let rows = rowCount; rows > 0; rows -= 1) { + const start = split[rows]?.[end] ?? 0; + const available = width - options.gap * (end - start - 1); + result.unshift({ + start, + end, + height: Math.min(options.maxHeight, available / span(start, end)), + }); + end = start; + } + return result; +} + +const tileGap = space.xs; +const tileTargetHeight = 170; +// Below the height a row of three phone screenshots needs to fill the width, so such a row +// stays short and ends early instead of growing. +const tileMaxHeight = 200; +// Very tall or wide images crop to these bounds in a tile; the viewer always shows all of it. +const tileAspectBounds = { min: 0.4, max: 2.5 } as const; +// Lays out before the first measurement, so rows have a size in tests and on the first frame. +const fallbackWidth = 320; + +// Several images form justified rows of small tiles. Tapping one opens the full-screen viewer. +export function TranscriptImages({ images }: { images: readonly TranscriptImage[] }) { + const [viewing, setViewing] = useState(); + const [width, setWidth] = useState(0); + const [aspects, setAspects] = useState>>({}); + if (images.length === 0) return null; + const rows = justifiedImageRows( + images.map((image) => aspects[image.key] ?? 1), + width || fallbackWidth, + { gap: tileGap, maxHeight: tileMaxHeight, targetHeight: tileTargetHeight }, + ); + const setAspect = (key: string, aspect: number) => { + const bounded = Math.min(tileAspectBounds.max, Math.max(tileAspectBounds.min, aspect)); + setAspects((current) => (current[key] === bounded ? current : { ...current, [key]: bounded })); + }; return ( - - {images.length === 1 && images[0] ? ( - - ) : ( - rows.map((row) => ( - image.key).join("|")} style={styles.gridRow}> - {row.map((image) => ( - - - - ))} - {/* One spacer fills the empty columns so a short last row keeps cell widths. */} - {row.length < columns ? ( - { + if (Math.abs(layout.width - width) >= 1) setWidth(layout.width); + }} + style={styles.images} + > + {rows.map((row) => { + const rowImages = images.slice(row.start, row.end); + return ( + image.key).join("|")} style={styles.tileRow}> + {rowImages.map((image) => ( + setViewing(images.indexOf(opened))} + width={row.height * (aspects[image.key] ?? 1)} /> - ) : null} + ))} - )) - )} - setViewing(undefined)} /> + ); + })} + setViewing(undefined)} + /> ); } -function TranscriptImageThumbnail({ - grid = false, +function TranscriptImageTile({ + height, image, + onAspect, onOpen, + width, }: { - grid?: boolean; + height: number; image: TranscriptImage; + onAspect: (key: string, aspect: number) => void; onOpen: (image: TranscriptImage) => void; + width: number; }) { - const [aspectRatio, setAspectRatio] = useState(4 / 3); const [failed, setFailed] = useState(false); if (failed) { return ( - - + + {image.label} could not be shown @@ -167,21 +246,17 @@ function TranscriptImageThumbnail({ accessibilityLabel={image.label} accessibilityRole="imagebutton" onPress={() => onOpen(image)} - style={({ pressed }) => [ - styles.thumbnail, - { aspectRatio: grid ? 1 : aspectRatio }, - pressed && styles.pressed, - ]} + style={({ pressed }) => [styles.tile, { height, width }, pressed && styles.pressed]} > setFailed(true)} onLoad={({ nativeEvent: { source } }) => { if (source.width > 0 && source.height > 0) { - setAspectRatio(Math.max(minimumAspectRatio, source.width / source.height)); + onAspect(image.key, source.width / source.height); } }} - resizeMode={grid ? "cover" : "contain"} + resizeMode="cover" source={{ uri: image.uri }} style={styles.fill} /> @@ -190,13 +265,16 @@ function TranscriptImageThumbnail({ } function TranscriptImageViewer({ - image, + images, + index, onClose, }: { - image: TranscriptImage | undefined; + images: readonly TranscriptImage[]; + index: number | undefined; onClose: () => void; }) { const insets = useSafeAreaInsets(); + const open = index !== undefined && index >= 0 && index < images.length; return ( {/* A Modal is a separate native root, so Android gestures need their own handler root. */} - {image && Platform.OS === "android" ? ( - - ) : image ? ( - - - - ) : null} + {open ? : null} (null); + const [page, setPage] = useState(initialIndex); + const [zoomed, setZoomed] = useState(false); + const pageRef = useRef(page); + pageRef.current = page; + + // Keep the current image in place after the first layout and after a rotation. + useEffect(() => { + pager.current?.scrollTo({ animated: false, x: pageRef.current * width }); + }, [width]); + + return ( + <> + { + const next = Math.round(nativeEvent.contentOffset.x / width); + setPage(Math.min(images.length - 1, Math.max(0, next))); + }} + pagingEnabled + scrollEnabled={images.length > 1 && !zoomed} + showsHorizontalScrollIndicator={false} + testID="image-pager" + > + {images.map((image) => ( + + {Platform.OS === "android" ? ( + + ) : ( + + + + )} + + ))} + + {images.length > 1 ? ( + + + + {page + 1} of {images.length} + + + + ) : null} + + ); +} + // Android's ScrollView has no zoom, so pinch, pan and double tap drive a transform instead. -function ZoomableImage({ image }: { image: TranscriptImage }) { +function ZoomableImage({ + image, + onZoomChange, +}: { + image: TranscriptImage; + onZoomChange: (zoomed: boolean) => void; +}) { const scale = useSharedValue(1); const startScale = useSharedValue(1); const offsetX = useSharedValue(0); @@ -264,6 +409,7 @@ function ZoomableImage({ image }: { image: TranscriptImage }) { startScale.value = 1; offsetX.value = withTiming(0); offsetY.value = withTiming(0); + runOnJS(onZoomChange)(false); }; const clampOffset = (value: number, size: number, zoom: number) => { "worklet"; @@ -273,6 +419,7 @@ function ZoomableImage({ image }: { image: TranscriptImage }) { const pinch = Gesture.Pinch() .onStart(() => { startScale.value = scale.value; + runOnJS(onZoomChange)(true); }) .onUpdate((event) => { scale.value = Math.min(maximumZoom, Math.max(1, startScale.value * event.scale)); @@ -282,8 +429,14 @@ function ZoomableImage({ image }: { image: TranscriptImage }) { .onEnd(() => { if (scale.value <= 1.01) reset(); }); + // Pan claims the touch only while zoomed, so an unzoomed swipe reaches the pager. const pan = Gesture.Pan() .averageTouches(true) + .manualActivation(true) + .onTouchesMove((_event, state) => { + if (scale.value > 1) state.activate(); + else state.fail(); + }) .onStart(() => { startX.value = offsetX.value; startY.value = offsetY.value; @@ -297,7 +450,10 @@ function ZoomableImage({ image }: { image: TranscriptImage }) { .numberOfTaps(2) .onEnd(() => { if (scale.value > 1) reset(); - else scale.value = withTiming(2); + else { + scale.value = withTiming(2); + runOnJS(onZoomChange)(true); + } }); const transform = useAnimatedStyle(() => ({ transform: [ @@ -342,21 +498,29 @@ const styles = StyleSheet.create({ failed: { backgroundColor: palette.raised, borderRadius: radius.md, + justifyContent: "center", + overflow: "hidden", padding: space.sm, }, failedText: { ...typography.caption, color: palette.dim }, fill: { height: "100%", width: "100%" }, - images: { gap: space.xs, marginVertical: space.xs }, - gridCell: { flex: 1, minWidth: 0 }, - gridFailed: { aspectRatio: 1, justifyContent: "center" }, - gridRow: { flexDirection: "row", gap: space.xs }, + images: { gap: tileGap, marginVertical: space.xs }, pressed: { opacity: pressedOpacity }, - thumbnail: { + tile: { backgroundColor: palette.raised, borderRadius: radius.md, overflow: "hidden", - width: "100%", }, + tileRow: { flexDirection: "row", gap: tileGap }, + counter: { alignItems: "center", left: 0, position: "absolute", right: 0 }, + counterPill: { + backgroundColor: "rgba(40, 40, 40, 0.85)", + borderRadius: radius.pill, + paddingHorizontal: space.sm, + paddingVertical: space.xs / 2, + }, + counterText: { ...typography.caption, color: palette.ink }, + page: { height: "100%" }, viewer: { backgroundColor: "#000000", flex: 1 }, viewerContent: { flexGrow: 1 }, }); diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 92b9f76..ceabe95 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -12,6 +12,24 @@ the fail-closed database backup-exclusion startup guard. Statements marked pending in older dated entries describe the status at the time of that probe; later entries supersede them. +## 2026-11-10: transcript image tiles and swipeable viewer, pending device verification + +Transcript images now form justified rows of tiles that keep each image's shape. +Rows aim for 170 points and stop at 200, so a lone portrait screenshot stays a +small tile. Tapping a tile opens the full-screen viewer, which now pages +horizontally between the group's images and shows a position counter. iOS keeps +a zooming scroll view inside each page. Android locks paging while an image is +zoomed, and its pan gesture activates only when zoomed so an unzoomed swipe +reaches the pager. Hermes behavior of a pinch that starts with sideways motion +on Android, and of nested zoom inside the pager on iOS, still needs checking on a +device. + +The full lint, typecheck, test, and build sequence passed, including 1532 mobile +tests and both Hermes exports. Both native fingerprints and runtime matched the +installed 0.1.6 baseline under the preview EAS environment. Local EAS Update +publication then succeeded for iOS and Android on preview. Publication does not +establish installation or device behavior. + ## 2026-11-01: code block syntax highlighting, pending device verification Fenced code blocks are now colored like the OpenCode iOS app. Keywords and From ec9ce2c5c9b2f952cb13670bd21a259101215c42 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Tue, 10 Nov 2026 10:08:29 +0100 Subject: [PATCH 2/3] Keep transcript image functions under the CRAP threshold Rows now come from a midpoint rule instead of a linear partition, so the layout has no uncovered nullable branches. Pan no longer rechecks zoom in its update, since it activates only while zoomed. --- .../src/screens/transcript-images.test.tsx | 5 ++ apps/mobile/src/screens/transcript-images.tsx | 66 +++++++------------ 2 files changed, 29 insertions(+), 42 deletions(-) diff --git a/apps/mobile/src/screens/transcript-images.test.tsx b/apps/mobile/src/screens/transcript-images.test.tsx index 74e7a56..43c5ba2 100644 --- a/apps/mobile/src/screens/transcript-images.test.tsx +++ b/apps/mobile/src/screens/transcript-images.test.tsx @@ -114,6 +114,11 @@ test("a lone image stays a capped tile and wide images fill the width", () => { expect(justifiedImageRows([0.46], 345, tiles)).toEqual([{ start: 0, end: 1, height: 200 }]); const [wide] = justifiedImageRows([16 / 9], 345, tiles); expect(wide?.height).toBeCloseTo(345 / (16 / 9)); + // A panorama takes more than one share of the width; its empty share makes no row. + expect(justifiedImageRows([5, 0.4, 0.4], 345, tiles)).toEqual([ + { start: 0, end: 1, height: 69 }, + { start: 1, end: 3, height: 200 }, + ]); expect(justifiedImageRows([], 345, tiles)).toEqual([]); expect(justifiedImageRows([1], 0, tiles)).toEqual([]); }); diff --git a/apps/mobile/src/screens/transcript-images.tsx b/apps/mobile/src/screens/transcript-images.tsx index aa74a81..7be1f8c 100644 --- a/apps/mobile/src/screens/transcript-images.tsx +++ b/apps/mobile/src/screens/transcript-images.tsx @@ -108,55 +108,38 @@ function imageLabel(name: string | null | undefined) { export type JustifiedImageRow = { start: number; end: number; height: number }; // Rows of tiles that keep each image's shape and share one height per row. The row count comes -// from the target height, and a small linear partition balances the rows so a batch of seven -// screenshots reads as three and four instead of a full row and a straggler. No row grows past -// the cap, so a lone portrait screenshot stays a tile instead of filling the screen. +// from the target height. Each image joins the row its midpoint falls in when the total width +// is cut into equal shares, so a batch of seven screenshots reads as three and four instead of +// a full row and a straggler. No row grows past the cap, so a lone portrait screenshot stays a +// tile instead of filling the screen. export function justifiedImageRows( aspects: readonly number[], width: number, options: { gap: number; maxHeight: number; targetHeight: number }, ): JustifiedImageRow[] { - const count = aspects.length; - if (count === 0 || width <= 0) return []; + if (aspects.length === 0 || width <= 0) return []; const total = aspects.reduce((sum, aspect) => sum + aspect, 0); - const rowCount = Math.min(count, Math.max(1, Math.round((total * options.targetHeight) / width))); - const ideal = total / rowCount; - const prefix = [0]; - for (const aspect of aspects) prefix.push((prefix.at(-1) ?? 0) + aspect); - const span = (start: number, end: number) => (prefix[end] ?? 0) - (prefix[start] ?? 0); - - // cost[rows][end] is the best squared deviation for the first `end` images in `rows` rows. - const cost = Array.from({ length: rowCount + 1 }, () => Array(count + 1).fill(Infinity)); - const split = Array.from({ length: rowCount + 1 }, () => Array(count + 1).fill(0)); - const first = cost[0]; - if (first) first[0] = 0; - for (let rows = 1; rows <= rowCount; rows += 1) { - for (let end = rows; end <= count; end += 1) { - for (let start = rows - 1; start < end; start += 1) { - const value = (cost[rows - 1]?.[start] ?? Infinity) + (span(start, end) - ideal) ** 2; - const row = cost[rows]; - if (row && value < (row[end] ?? Infinity) - 1e-9) { - row[end] = value; - const splitRow = split[rows]; - if (splitRow) splitRow[end] = start; - } - } - } + const rowCount = Math.min( + aspects.length, + Math.max(1, Math.round((total * options.targetHeight) / width)), + ); + const share = total / rowCount; + const starts: number[] = []; + let before = 0; + let current = -1; + for (const [index, aspect] of aspects.entries()) { + // The bias sends an exact tie to the later row, so the shorter row leads. + const row = Math.min(rowCount - 1, Math.floor((before + aspect / 2) / share + 1e-9)); + if (row !== current) starts.push(index); + current = row; + before += aspect; } - - const result: JustifiedImageRow[] = []; - let end = count; - for (let rows = rowCount; rows > 0; rows -= 1) { - const start = split[rows]?.[end] ?? 0; + return starts.map((start, index) => { + const end = starts[index + 1] ?? aspects.length; + const span = aspects.slice(start, end).reduce((sum, aspect) => sum + aspect, 0); const available = width - options.gap * (end - start - 1); - result.unshift({ - start, - end, - height: Math.min(options.maxHeight, available / span(start, end)), - }); - end = start; - } - return result; + return { start, end, height: Math.min(options.maxHeight, available / span) }; + }); } const tileGap = space.xs; @@ -442,7 +425,6 @@ function ZoomableImage({ startY.value = offsetY.value; }) .onUpdate((event) => { - if (scale.value <= 1) return; offsetX.value = clampOffset(startX.value + event.translationX, width.value, scale.value); offsetY.value = clampOffset(startY.value + event.translationY, height.value, scale.value); }); From 804b64f9b50184abc6e97120f616cee475dd5606 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Tue, 10 Nov 2026 10:14:54 +0100 Subject: [PATCH 3/3] Clear the warning for removed sibling worktrees The removed-directory check lists a worktree's parent through the live project root. When that parent is an ancestor of the root, the server lists the entry leading back to the root as "." or "..", and the check rejected the whole listing as malformed, so the location never retired. It now ignores only that single entry; any other unexpected entry still rejects the listing. The selected location was also exempt from the check, and it outlives the session screen that set it, so one visit kept the warning until the app restarted. Selection no longer protects a location, and a failed lookup of a selected location the check proved removed no longer marks attention coverage incomplete. --- .../src/state/followed-projects-context.tsx | 6 +- .../src/state/followed-projects-model.test.ts | 9 +++ .../src/state/followed-projects-model.ts | 9 ++- .../workspace-selection-context.test.tsx | 60 ++++++++++++++++++- docs/COMPATIBILITY.md | 49 +++++++++++++++ packages/opencode-adapter/src/index.test.ts | 47 +++++++++++++++ packages/opencode-adapter/src/index.ts | 14 ++++- 7 files changed, 189 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/state/followed-projects-context.tsx b/apps/mobile/src/state/followed-projects-context.tsx index 44c3702..8682ef6 100644 --- a/apps/mobile/src/state/followed-projects-context.tsx +++ b/apps/mobile/src/state/followed-projects-context.tsx @@ -169,7 +169,6 @@ export function FollowedProjectsProvider({ children }: { children: ReactNode }) protectedLocationKeys: protectedLocationKeys({ activeAncestrySessions: active.ancestry.sessions, attentionLocations: runtime.attentionLocations, - location, projectByID, projectIDs: availableFollowedProjectIDs, }), @@ -299,6 +298,11 @@ export function FollowedProjectsProvider({ children }: { children: ReactNode }) preferenceLoadFailed: preferences.preferencesError, projectIDs: availableFollowedProjectIDs, selectedLocationQuery, + selectedLocationRetired: Boolean( + location && + candidateLocations.some((candidate) => locationKey(candidate) === locationKey(location)) && + !knownLocations.some((candidate) => locationKey(candidate) === locationKey(location)), + ), sessionProjectFailureCount: sessions.sessionProjectFailureCount, unavailableProjectCount: unavailableProjectIds.length, unmatchedSessionCount: inbox.unmatchedSessionIDs.length, diff --git a/apps/mobile/src/state/followed-projects-model.test.ts b/apps/mobile/src/state/followed-projects-model.test.ts index 3fc7ea3..90d6b1a 100644 --- a/apps/mobile/src/state/followed-projects-model.test.ts +++ b/apps/mobile/src/state/followed-projects-model.test.ts @@ -145,6 +145,7 @@ test("reports each incomplete coverage reason in a stable order", () => { preferenceLoadFailed: false, projectIDs: ["project-a"], selectedLocationQuery: { data: undefined, isError: true }, + selectedLocationRetired: false, sessionProjectFailureCount: 0, unavailableProjectCount: 0, unmatchedSessionCount: 0, @@ -160,6 +161,7 @@ test("reports each incomplete coverage reason in a stable order", () => { preferenceLoadFailed: true, projectIDs: ["project-a"], selectedLocationQuery: { data: undefined, isError: true }, + selectedLocationRetired: false, sessionProjectFailureCount: 1, unavailableProjectCount: 1, unmatchedSessionCount: 1, @@ -186,4 +188,11 @@ test("reports each incomplete coverage reason in a stable order", () => { selectedLocationQuery: { data: selectedLocation, isError: true }, }), ).toEqual([]); + expect( + attentionCoverageReasons({ + ...complete, + location: { directory: "/work/alpha/removed" }, + selectedLocationRetired: true, + }), + ).toEqual([]); }); diff --git a/apps/mobile/src/state/followed-projects-model.ts b/apps/mobile/src/state/followed-projects-model.ts index 95bd112..a120836 100644 --- a/apps/mobile/src/state/followed-projects-model.ts +++ b/apps/mobile/src/state/followed-projects-model.ts @@ -182,10 +182,13 @@ export function activeLocationStatus( }; } +// The selected location is not protected. It outlives the session screen that set +// it, so opening an archived session in a removed worktree would otherwise keep +// that directory unavailable until the app restarts. Retirement still requires a +// successful listing that proves the directory is absent. export function protectedLocationKeys(input: { activeAncestrySessions: Record; attentionLocations: readonly LocationRef[]; - location: LocationRef | undefined; projectByID: ReadonlyMap; projectIDs: readonly string[]; }) { @@ -197,7 +200,6 @@ export function protectedLocationKeys(input: { }), ...input.attentionLocations, ...Object.values(input.activeAncestrySessions).map((session) => session.location), - ...(input.location ? [input.location] : []), ].map(locationKey), ); } @@ -541,12 +543,15 @@ export function attentionCoverageReasons(input: { preferenceLoadFailed: boolean | undefined; projectIDs: readonly string[]; selectedLocationQuery: { data: LocationGetOutput | undefined; isError: boolean }; + // A removed directory cannot resolve, so its lookup failure is expected. + selectedLocationRetired: boolean; sessionProjectFailureCount: number; unavailableProjectCount: number; unmatchedSessionCount: number; }) { const selectedLocationFailed = Boolean( input.location && + !input.selectedLocationRetired && input.selectedLocationQuery.isError && (!input.selectedLocationQuery.data || input.projectIDs.includes(input.selectedLocationQuery.data.project.id)), diff --git a/apps/mobile/src/state/workspace-selection-context.test.tsx b/apps/mobile/src/state/workspace-selection-context.test.tsx index bbcb3cf..a92b02c 100644 --- a/apps/mobile/src/state/workspace-selection-context.test.tsx +++ b/apps/mobile/src/state/workspace-selection-context.test.tsx @@ -6,7 +6,7 @@ import type { PermissionRequest, } from "@opencode2-mobile/opencode-adapter"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react-native"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react-native"; import { Pressable, Text } from "react-native"; import type { SessionInboxPreferences } from "../storage/session-inbox-repository"; import { ConnectionEventQueryBridge } from "./connection-event-query-bridge"; @@ -918,6 +918,64 @@ test("cached attention prevents retirement of an external historical location", } }); +test("a selected session location does not keep a removed external worktree unavailable", async () => { + const location = { directory: "/external/worktree/project-b/retired" }; + mockListProjectSessions.mockImplementation(async (_client, projectID) => ({ + cursor: {}, + data: [ + projectID === "project-b" + ? mockSession("ses_beta", projectID, location.directory, 2) + : mockSession("ses_alpha", projectID, "/a", 3), + ], + })); + mockDirectoryExists.mockImplementation( + async (_client, _root, directory) => directory !== location.directory, + ); + const originalPermissions = mockListPermissions.getMockImplementation(); + mockListPermissions.mockImplementation(async (client, candidate, options) => { + if (candidate.directory === location.directory) throw new Error("HTTP 404"); + if (!originalPermissions) throw new Error("Missing permission fixture"); + return originalPermissions(client, candidate, options); + }); + mockGetOpenCodeLocation.mockImplementation(async (_client, candidate) => { + if (candidate.directory === location.directory) throw new Error("HTTP 404"); + return mockResolvedLocation( + candidate.directory, + candidate.directory.startsWith("/b") ? "project-b" : "project-a", + ); + }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + let selectLocation: ((location: LocationRef) => void) | undefined; + function SelectRetired() { + selectLocation = useFollowedProjects().setLocation; + return null; + } + const view = render( + + + + + + , + ); + try { + await waitFor(() => expect(screen.getByText("freshness:current")).toBeOnTheScreen()); + act(() => selectLocation?.(location)); + await waitFor(() => + expect(mockDirectoryExists.mock.calls.some((call) => call[2] === location.directory)).toBe( + true, + ), + ); + await waitFor(() => expect(screen.getByText(/^complete:/)).toBeOnTheScreen()); + expect(screen.queryByText(/^unavailable:/)).toBeNull(); + } finally { + view.unmount(); + queryClient.clear(); + } +}); + test("an unavailable directory probe does not silently retire a valid session location", async () => { mockDirectoryExists.mockRejectedValue(new Error("HTTP 500")); const queryClient = new QueryClient({ diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index ceabe95..013af5f 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -204,6 +204,53 @@ fingerprints and runtime matched the installed baseline under the preview EAS environment, and local EAS Update publication succeeded for iOS and Android on preview, runtime 0.1.5. Physical-device verification remains pending. +## 2026-11-10: removed sibling worktree, pending device verification + +An iPhone screenshot showed a permanent unavailable-location warning for an +external worktree that only archived sessions referenced. Read-only checks +against the local server found the directory absent. Interaction lookups at that +location returned `LocationNotFoundError` (404). A root session list without the +`archived` parameter included archived roots. + +### Root entry in a parent listing + +The first published fix below did not clear the warning on the device. The +worktree was a sibling of its project root, so the removed-directory check lists +the shared parent through the live root. The server listed every sibling as +`../name/`, but listed the project root itself as `./`. One level higher, it +listed the root's parent as `../` among `../../name/` entries. The check rejected +the whole listing as malformed. A rejected listing never proves absence, so the +location stayed unavailable. The 2026-10-25 fix covered worktrees whose parent +does not contain the project root. + +The check now ignores only the single entry that leads back to the root when the +listed parent is an ancestor of the root. Any other unexpected entry still +rejects the listing. Adapter regression tests for a sibling and a higher ancestor +failed before the change and passed after it. Run against the local server, the +fixed check reported the removed sibling absent and a present sibling present. +The full check sequence passed, including 371 adapter and 1528 mobile tests. +Both native fingerprints and runtime matched the installed 0.1.6 baseline, and +local EAS Update publication to preview succeeded for iOS and Android. +Physical-device verification remains pending. + +### Selected location + +Opening a session sets the selected location, and it stays set after the user +leaves the session. The selected location was exempt from the removed-directory +check, so one visit kept the warning until the app restarted. Selection no +longer blocks that check, and a failed lookup of a selected location that the +check proved removed no longer marks coverage incomplete. Active locations, +notification attention, and cached pending requests keep their protections. + +A regression test failed before the change and passed after it. The full lint, +typecheck, test, and build sequence passed, including 1528 mobile tests and both +Hermes exports. Physical-device verification remains pending. + +Both native fingerprints and runtime matched the installed baseline under the +preview EAS environment. Local EAS Update publication then succeeded for iOS and +Android on preview, runtime 0.1.6. Publication does not establish installation +or device behavior. + ## 2026-10-25: removed external worktrees, pending device verification An iPhone screenshot showed failed permission and form checks for historical @@ -215,6 +262,8 @@ published V2 filesystem contract. No missing location was initialized. The existing retirement check only considered descendants of the project root. It now also probes external historical worktrees through the live project root. +This covered parents that do not contain the project root; see 2026-11-10 for +sibling worktrees. Only a successful, validated listing can establish absence. Active locations, selected locations, notification attention, and cached pending requests retain their existing protections. Session history remains visible, and refresh checks diff --git a/packages/opencode-adapter/src/index.test.ts b/packages/opencode-adapter/src/index.test.ts index 343cd24..b4b0974 100644 --- a/packages/opencode-adapter/src/index.test.ts +++ b/packages/opencode-adapter/src/index.test.ts @@ -411,6 +411,53 @@ describe("historical directory presence", () => { ).toBe(true); }); + // A real V2 server lists the project root as "./" and its ancestors as "../", + // "../../", and so on, instead of under the requested parent's prefix. + it("ignores the project root in a listing of its parent", async () => { + const fixture = createFakeOpenCodeApi({ + directoryEntries: [ + { path: "../live/", type: "directory" }, + { path: "./", type: "directory" }, + { path: "../notes.html", type: "file" }, + ], + }); + const client = createOpenCodeClient({ baseUrl: "http://fake.invalid", fetch: fixture.fetch }); + await expect( + openCodeDirectoryExists(client, { directory: "/projects/app" }, "/projects/retired"), + ).resolves.toBe(false); + await expect( + openCodeDirectoryExists(client, { directory: "/projects/app" }, "/projects/live"), + ).resolves.toBe(true); + }); + + it("ignores an ancestor of the project root in a higher listing", async () => { + const fixture = createFakeOpenCodeApi({ + directoryEntries: [ + { path: "../", type: "directory" }, + { path: "../../live/", type: "directory" }, + ], + }); + const client = createOpenCodeClient({ baseUrl: "http://fake.invalid", fetch: fixture.fetch }); + await expect( + openCodeDirectoryExists(client, { directory: "/home/projects/app" }, "/home/retired"), + ).resolves.toBe(false); + await expect( + openCodeDirectoryExists(client, { directory: "/home/projects/app" }, "/home/live"), + ).resolves.toBe(true); + }); + + it.each([ + ["../", "/projects/retired"], + ["./", "/projects/other/retired"], + ["../../", "/home/retired"], + ])("rejects the shortcut entry %s when it is not the root's own path", async (path, target) => { + const fixture = createFakeOpenCodeApi({ directoryEntries: [{ path, type: "directory" }] }); + const client = createOpenCodeClient({ baseUrl: "http://fake.invalid", fetch: fixture.fetch }); + await expect( + openCodeDirectoryExists(client, { directory: "/home/projects/app" }, target), + ).rejects.toThrow("MALFORMED_DIRECTORY_LIST"); + }); + it("preserves same-drive Windows directory checks", async () => { const fixture = createFakeOpenCodeApi({ directoryEntries: [{ path: "../worktree/live/", type: "directory" }], diff --git a/packages/opencode-adapter/src/index.ts b/packages/opencode-adapter/src/index.ts index 09a33ae..b7471fe 100644 --- a/packages/opencode-adapter/src/index.ts +++ b/packages/opencode-adapter/src/index.ts @@ -644,7 +644,13 @@ export async function openCodeDirectoryExists( // V2 returns project-relative paths, including the requested parent's prefix. // Fail closed if a server instead returns absolute, basename-only, or nested paths. const prefix = parent === "." ? "" : `${parent}/`; - const paths = output.data.map((entry) => entry.path.replace(/\/$/, "")); + // When the parent is an ancestor of the root, V2 lists the child that leads + // back to the root by its short relative path: "." for the root itself, ".." + // for its parent. Ignore only that one entry; it can never be the target. + const rootEntry = rootListingEntry(parent); + const paths = output.data + .map((entry) => entry.path.replace(/\/$/, "")) + .filter((path) => path !== rootEntry); if (!paths.every((path) => isDirectoryListChildPath(path, prefix))) { throw new Error("MALFORMED_DIRECTORY_LIST"); } @@ -652,6 +658,12 @@ export async function openCodeDirectoryExists( return paths.includes(relative); } +function rootListingEntry(parent: string) { + const segments = parent.split("/"); + if (!segments.every((segment) => segment === "..")) return undefined; + return segments.length === 1 ? "." : segments.slice(1).join("/"); +} + // Worktrees can live outside the repository. Keep the live root as the // request location, but list the target's parent via a relative path. function relativeDirectoryPath(root: LocationRef, directory: string) {