diff --git a/apps/mobile/src/app.tsx b/apps/mobile/src/app.tsx index fa0ff0f..4c06933 100644 --- a/apps/mobile/src/app.tsx +++ b/apps/mobile/src/app.tsx @@ -17,7 +17,7 @@ import { ConnectionRuntimeProvider } from "./state/connection-runtime-context"; import { FollowedProjectsProvider } from "./state/followed-projects-context"; import { TranscriptPreferencesProvider } from "./state/transcript-preferences"; import { migrateMobileDatabase, mobileDatabaseName } from "./storage/database"; -import { palette } from "./theme"; +import { control, palette, typography } from "./theme"; import { AppUpdateBanner, AppUpdateCard, AppUpdatesProvider } from "./updates/app-updates"; const queryClient = new QueryClient({ @@ -158,10 +158,8 @@ const styles = StyleSheet.create({ marginTop: 12, }, errorEyebrow: { + ...typography.label, color: palette.danger, - fontSize: 12, - fontWeight: "800", - letterSpacing: 1.6, }, errorShell: { backgroundColor: palette.background, @@ -170,25 +168,19 @@ const styles = StyleSheet.create({ padding: 28, }, errorTitle: { + ...typography.title, color: palette.ink, - fontSize: 30, - fontWeight: "700", - letterSpacing: -0.8, - lineHeight: 35, marginTop: 10, }, retryButton: { + ...control, alignSelf: "flex-start", backgroundColor: palette.signal, - borderRadius: 8, marginTop: 24, - paddingHorizontal: 20, - paddingVertical: 12, }, retryLabel: { + ...typography.control, color: palette.background, - fontSize: 15, - fontWeight: "800", }, reportButton: { marginTop: 18, @@ -196,8 +188,7 @@ const styles = StyleSheet.create({ paddingVertical: 12, }, reportLabel: { + ...typography.control, color: palette.dim, - fontSize: 14, - fontWeight: "700", }, }); diff --git a/apps/mobile/src/components/modal-sheet.tsx b/apps/mobile/src/components/modal-sheet.tsx index 0772518..9d832c5 100644 --- a/apps/mobile/src/components/modal-sheet.tsx +++ b/apps/mobile/src/components/modal-sheet.tsx @@ -13,7 +13,7 @@ import { } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import { palette, space, typeRamp, usesLargeTextLayout } from "../theme"; +import { palette, space, typeRamp, typography, usesLargeTextLayout } from "../theme"; export function ModalSheet({ children, @@ -108,7 +108,7 @@ export function ModalSheet({ const styles = StyleSheet.create({ closeButton: { justifyContent: "center", minHeight: 44, paddingHorizontal: space.sm }, - closeLabel: { color: palette.signal, fontSize: 16, fontWeight: "700" }, + closeLabel: { ...typography.control, color: palette.signal }, content: { gap: space.md, padding: space.lg, paddingBottom: space.xl }, fixedContent: { flex: 1, gap: space.md, padding: space.lg, paddingBottom: space.xl }, header: { @@ -125,6 +125,6 @@ const styles = StyleSheet.create({ keyboardView: { flex: 1 }, pressed: { opacity: 0.55 }, safeArea: { backgroundColor: palette.background, flex: 1 }, - subtitle: { color: palette.dim, fontSize: 12, marginTop: 2 }, - title: { color: palette.ink, fontSize: 20, fontWeight: "700" }, + subtitle: { ...typography.caption, color: palette.dim, marginTop: 2 }, + title: { ...typography.sheetTitle, color: palette.ink }, }); diff --git a/apps/mobile/src/components/working-indicator.tsx b/apps/mobile/src/components/working-indicator.tsx index ab67f99..e829994 100644 --- a/apps/mobile/src/components/working-indicator.tsx +++ b/apps/mobile/src/components/working-indicator.tsx @@ -85,8 +85,8 @@ export function WorkingIndicator({ variant = "dots" }: { variant?: "dots" | "blo style={[ styles.dot, { - left: bit < 3 ? 0 : 6, - top: (bit % 3) * 6, + left: bit < 3 ? 0 : 4.5, + top: (bit % 3) * 4.5, opacity: (0x0b & (1 << bit)) !== 0 ? 1 : 0, }, !reducedMotion && { @@ -106,6 +106,6 @@ export function WorkingIndicator({ variant = "dots" }: { variant?: "dots" | "blo const styles = StyleSheet.create({ blocks: { flexDirection: "row", flexShrink: 0, gap: 1 }, block: { width: 5, height: 6, backgroundColor: palette.activity }, - icon: { width: 10, height: 16, flexShrink: 0 }, - dot: { position: "absolute", width: 4, height: 4, backgroundColor: palette.signal }, + icon: { width: 7.5, height: 12, flexShrink: 0 }, + dot: { position: "absolute", width: 3, height: 3, backgroundColor: palette.activity }, }); diff --git a/apps/mobile/src/navigation/root-navigation.tsx b/apps/mobile/src/navigation/root-navigation.tsx index e4cd761..22b0983 100644 --- a/apps/mobile/src/navigation/root-navigation.tsx +++ b/apps/mobile/src/navigation/root-navigation.tsx @@ -269,7 +269,7 @@ const styles = StyleSheet.create({ header: { alignItems: "center", flexDirection: "row", - height: 44, + minHeight: 44, paddingHorizontal: 8, }, headerButtonPressed: { opacity: 0.55 }, diff --git a/apps/mobile/src/navigation/workspace-header-actions.tsx b/apps/mobile/src/navigation/workspace-header-actions.tsx index 0dcd3dc..2655cfe 100644 --- a/apps/mobile/src/navigation/workspace-header-actions.tsx +++ b/apps/mobile/src/navigation/workspace-header-actions.tsx @@ -4,7 +4,7 @@ import { Keyboard, Pressable, StyleSheet, Text, View } from "react-native"; import { ModalSheet } from "../components/modal-sheet"; import { useWorkspaceSelection } from "../state/workspace-selection-context"; -import { palette, radius, space } from "../theme"; +import { palette, radius, space, typography } from "../theme"; type HeaderDestination = "Connections" | "FollowedProjects" | "Pending" | "Settings"; @@ -169,7 +169,7 @@ const styles = StyleSheet.create({ }, menuButtonLast: { borderBottomWidth: 0 }, menuCopy: { flex: 1, minWidth: 0 }, - menuDescription: { color: palette.dim, fontSize: 12, marginTop: 3 }, + menuDescription: { ...typography.caption, color: palette.dim, marginTop: 3 }, menuGroup: { backgroundColor: palette.card, borderColor: palette.border, @@ -177,7 +177,7 @@ const styles = StyleSheet.create({ borderWidth: 1, overflow: "hidden", }, - menuLabel: { color: palette.ink, fontSize: 15, fontWeight: "700" }, + menuLabel: { ...typography.heading, color: palette.ink }, optionsButton: { alignItems: "center", borderRadius: 22, diff --git a/apps/mobile/src/screens/app-shell.test.ts b/apps/mobile/src/screens/app-shell.test.ts index 0c86854..f9da592 100644 --- a/apps/mobile/src/screens/app-shell.test.ts +++ b/apps/mobile/src/screens/app-shell.test.ts @@ -214,52 +214,71 @@ test("allows a permission owned by a background child session from Pending", () view.unmount(); }); -test("reports partial location failures without blaming a live connection", async () => { - const refetch = jest.fn(async () => undefined); - const navigate = jest.fn(); - 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: "incomplete", - failedLocationCount: 1, - failedProjects: [{ label: "Alpha", locationCount: 1, projectID: "project-1" }], - freshness: "stale", - knownLocationCount: 7, - reconciledLocationCount: 6, - }, - followedProjectIds: ["project-1"], - forms: [], - interactionsError: true, - interactionsLoading: false, - pendingCount: 0, - permissions: [], - preferencesLoading: false, - refetch, - } as never); +test.each([0, 1])( + "explains unavailable locations with %i known requests on a live connection", + async (pendingCount) => { + const refetch = jest.fn(async () => undefined); + const navigate = jest.fn(); + 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: "incomplete", + failedLocationCount: 1, + failedLocations: [ + { + location: { directory: "/a/worktrees/old" }, + projectLabel: "Alpha", + source: "session-history", + failedChecks: ["permissions", "forms"], + }, + ], + failedProjects: [{ label: "Alpha", locationCount: 1, projectID: "project-1" }], + freshness: "stale", + knownLocationCount: 7, + reconciledLocationCount: 6, + }, + followedProjectIds: ["project-1"], + forms: [], + interactionsError: true, + interactionsLoading: false, + pendingCount, + permissions: [], + preferencesLoading: false, + refetch, + } as never); - const view = render( - createElement(PendingInteractionsScreen, { - navigation: { navigate, popTo: jest.fn() }, - } as never), - ); + const view = render( + createElement(PendingInteractionsScreen, { + navigation: { navigate, popTo: jest.fn() }, + } as never), + ); - expect(screen.queryByRole("header", { name: "Requests that need you." })).toBeNull(); - expect(screen.getByText("1 location could not be checked.")).toBeOnTheScreen(); - expect( - screen.getByText( - /Affected project: Alpha\. No requests were found at the 6 locations that responded\./, - ), - ).toBeOnTheScreen(); - fireEvent.press(screen.getByRole("button", { name: "Retry" })); - await waitFor(() => expect(refetch).toHaveBeenCalledTimes(1)); - expect(screen.queryByRole("button", { name: "Connections" })).toBeNull(); - expect(navigate).not.toHaveBeenCalled(); - view.unmount(); -}); + expect(screen.queryByRole("header", { name: "Requests that need you." })).toBeNull(); + expect(screen.getByText("1 location could not be checked.")).toBeOnTheScreen(); + expect( + screen.getByText( + pendingCount === 0 + ? /Affected project: Alpha\. No requests were found at the 6 locations that responded\./ + : /Showing requests found at the other locations/, + ), + ).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "Review locations" })); + expect(screen.getByText("/a/worktrees/old")).toBeOnTheScreen(); + expect(screen.getByText("Referenced by session history")).toBeOnTheScreen(); + expect(screen.getByText(/may have been moved or removed/)).toBeOnTheScreen(); + expect(screen.getByText(/Session history is preserved/)).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "Close Unavailable locations" })); + fireEvent.press(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => expect(refetch).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole("button", { name: "Connections" })).toBeNull(); + expect(navigate).not.toHaveBeenCalled(); + view.unmount(); + }, +); diff --git a/apps/mobile/src/screens/app-shell.tsx b/apps/mobile/src/screens/app-shell.tsx index 596510c..08bf387 100644 --- a/apps/mobile/src/screens/app-shell.tsx +++ b/apps/mobile/src/screens/app-shell.tsx @@ -34,10 +34,20 @@ import { useConnectionRuntime } from "../state/connection-runtime-context"; import type { ConnectionTransportStatus } from "../state/connection-transport-coordinator"; import { useTranscriptPreferences } from "../state/transcript-preferences"; import { useWorkspaceSelection } from "../state/workspace-selection-context"; -import { palette, radius, space, typeRamp, usesLargeTextLayout } from "../theme"; +import { + control, + palette, + radius, + space, + switchColors, + typeRamp, + typography, + usesLargeTextLayout, +} from "../theme"; import { AppUpdateCard } from "../updates/app-updates"; import { FormRequestList } from "./form-request-list"; import { PermissionRequestCard } from "./permission-request-card"; +import { sanitizeTranscriptText } from "./session-transcript-model"; type Section = "Pending" | "Settings" | "Workspace"; type SessionBranch = { @@ -53,11 +63,13 @@ type ScreenProps = NativeStackScreen const tabletBreakpoint = 760; export function PendingInteractionsScreen({ navigation }: ScreenProps<"Pending">) { + const [showLocations, setShowLocations] = useState(false); const runtime = useConnectionRuntime(); const selection = useWorkspaceSelection(); const { width } = useWindowDimensions(); const tablet = isTabletShell(width); const failedLocationCount = selection.attentionCoverage.failedLocationCount; + const failedLocations = selection.attentionCoverage.failedLocations ?? []; const failedProjectSummary = (selection.attentionCoverage.failedProjects ?? []) .map((project) => project.label) .join(", "); @@ -118,8 +130,12 @@ export function PendingInteractionsScreen({ navigation }: ScreenProps<"Pending"> : `No requests were found at the ${selection.attentionCoverage.reconciledLocationCount} ${selection.attentionCoverage.reconciledLocationCount === 1 ? "location" : "locations"} that responded.`} + {failedLocations.length > 0 ? ( + setShowLocations(true)} /> + ) : null} 0} onPress={async () => { await selection.refetch(); }} @@ -145,6 +161,9 @@ export function PendingInteractionsScreen({ navigation }: ScreenProps<"Pending"> : "The affected project could not be identified. "} Showing requests found at the other locations. + {failedLocations.length > 0 ? ( + setShowLocations(true)} /> + ) : null} { @@ -190,6 +209,75 @@ export function PendingInteractionsScreen({ navigation }: ScreenProps<"Pending"> )} + setShowLocations(false)} + > + + These checks failed. Pending work at these locations is still unknown. Session history is + preserved. + + {failedLocations.some( + (failure) => failure.source === "session-history" || failure.source === "worktree", + ) ? ( + + A directory referenced by session history or a worktree may have been moved or removed. + A failed check alone does not confirm that it was deleted. + + ) : null} + {failedLocations.map((failure) => ( + + + {sanitizeTranscriptText(failure.projectLabel, 256)} + + + {failure.source === "project-root" + ? "Project root" + : failure.source === "worktree" + ? "Registered worktree" + : failure.source === "session-history" + ? "Referenced by session history" + : "Discovered location"} + + + {sanitizeTranscriptText( + failure.location.directory, + failure.location.directory.length, + )} + + {failure.location.workspaceID ? ( + + Workspace:{" "} + {sanitizeTranscriptText( + failure.location.workspaceID, + failure.location.workspaceID.length, + )} + + ) : null} + + Could not check: {failure.failedChecks.join(" and ")}. + + + ))} + {failedLocations.length === 0 ? ( + No location checks are currently failing. + ) : ( + + Check these directories on the server. Restore a moved or removed worktree if you still + need it, then retry. If a whole project is retired, unfollow it on this device. + Unfollowing does not delete its sessions. + + )} + { + setShowLocations(false); + navigation.navigate("FollowedProjects"); + }} + secondary + /> + ); } @@ -297,6 +385,7 @@ export function SettingsScreen({ navigation }: ScreenProps<"Settings">) { ) { ) { void appLock.setEnabled(enabled)} - thumbColor={appLock.enabled ? palette.signal : palette.dim} - trackColor={{ false: palette.border, true: palette.signalDark }} value={appLock.enabled} /> @@ -367,11 +456,10 @@ export function SettingsScreen({ navigation }: ScreenProps<"Settings">) { /> ) : ( void setNotificationsEnabled(enabled)} - thumbColor={notificationState?.enabled ? palette.signal : palette.dim} - trackColor={{ false: palette.border, true: palette.signalDark }} value={notificationState?.enabled ?? false} /> )} @@ -858,22 +946,29 @@ export function ActionButton({ } const styles = StyleSheet.create({ + locationFailure: { + backgroundColor: palette.card, + borderColor: palette.border, + borderWidth: 1, + borderRadius: radius.lg, + padding: space.md, + gap: space.xs, + }, + locationPath: { ...typography.code, color: palette.ink }, actionButton: { + ...control, alignSelf: "flex-start", backgroundColor: palette.signal, - borderRadius: radius.sm, marginTop: space.md, - minHeight: 44, - paddingHorizontal: space.md, - paddingVertical: 12, + justifyContent: "center", + maxWidth: "100%", }, actionButtonDisabled: { opacity: 0.5 }, actionButtonFullWidth: { alignSelf: "stretch", alignItems: "center" }, actionButtonLabel: { + ...typography.control, color: palette.background, - fontSize: 13, - fontWeight: "700", - letterSpacing: 0.2, + textAlign: "center", }, actionButtonLabelSecondary: { color: palette.ink }, actionButtonPressed: { opacity: 0.72 }, @@ -920,12 +1015,10 @@ const styles = StyleSheet.create({ padding: space.lg, }, cardCopy: { color: palette.dim, fontSize: 15, lineHeight: 22, marginTop: space.xs }, - cardLabel: { color: palette.warm, fontSize: 11, fontWeight: "900", letterSpacing: 1.2 }, + cardLabel: { ...typography.label, color: palette.dim }, cardTitle: { + ...typography.heading, color: palette.ink, - fontSize: 18, - fontWeight: "700", - lineHeight: 23, marginTop: space.xs, }, centeredState: { @@ -985,9 +1078,9 @@ const styles = StyleSheet.create({ padding: space.lg, }, emptyMark: { color: palette.signal, fontSize: 34, fontWeight: "300", letterSpacing: -1.2 }, - errorEyebrow: { color: palette.danger, fontSize: 11, fontWeight: "900", letterSpacing: 1.2 }, + errorEyebrow: { ...typography.label, color: palette.danger }, errorText: { color: palette.danger, fontSize: 14, lineHeight: 20, marginTop: space.sm }, - eyebrow: { color: palette.signal, fontSize: 11, fontWeight: "900", letterSpacing: 1.4 }, + eyebrow: { ...typography.label, color: palette.dim }, failureCard: { backgroundColor: palette.background, borderColor: palette.danger, @@ -1065,7 +1158,7 @@ const styles = StyleSheet.create({ flexDirection: "row", gap: space.md, marginTop: space.lg, - padding: space.lg, + padding: space.md, }, settingText: { flex: 1 }, shell: { flex: 1 }, @@ -1098,11 +1191,8 @@ const styles = StyleSheet.create({ statusDot: { borderRadius: 4, height: 8, marginRight: space.sm, width: 8 }, tabletShell: { flexDirection: "row" }, title: { + ...typography.title, color: palette.ink, - fontSize: 34, - fontWeight: "700", - letterSpacing: -1.1, - lineHeight: 39, marginTop: space.sm, }, }); diff --git a/apps/mobile/src/screens/connection-screen.tsx b/apps/mobile/src/screens/connection-screen.tsx index bf28d1a..b04de37 100644 --- a/apps/mobile/src/screens/connection-screen.tsx +++ b/apps/mobile/src/screens/connection-screen.tsx @@ -36,7 +36,7 @@ import { useConnections } from "../connections/connections-context"; import { boundedOpenCodeFetch, expoOpenCodeFetch } from "../expo-open-code-fetch"; import { useAppLock } from "../security/app-lock-context"; import { useConnectionRuntime } from "../state/connection-runtime-context"; -import { palette, radius, space } from "../theme"; +import { control, palette, radius, space, switchColors, typography } from "../theme"; import { AppUpdateCard } from "../updates/app-updates"; import { type LifecycleTransportPhase, @@ -647,11 +647,10 @@ export function ConnectionScreen({ onDone, onPair }: { onDone?: () => void; onPa {appLock.busy ? : null} @@ -815,10 +814,9 @@ export function ConnectionScreen({ onDone, onPair }: { onDone?: () => void; onPa @@ -1199,10 +1197,8 @@ const styles = StyleSheet.create({ lineHeight: 20, }, diagnosticsText: { + ...typography.code, color: palette.dim, - fontFamily: Platform.select({ android: "monospace", ios: "Menlo" }), - fontSize: 10, - lineHeight: 16, marginTop: space.xs, }, detailHeading: { @@ -1216,10 +1212,10 @@ const styles = StyleSheet.create({ borderBottomColor: palette.border, borderBottomWidth: StyleSheet.hairlineWidth, justifyContent: "center", - minHeight: 40, + minHeight: 44, paddingHorizontal: space.sm, }, - editLabel: { color: palette.signal, fontSize: 10, fontWeight: "900", letterSpacing: 0.7 }, + editLabel: { ...typography.control, color: palette.signal }, field: { gap: space.xs, }, @@ -1245,10 +1241,8 @@ const styles = StyleSheet.create({ lineHeight: 20, }, guidanceTitle: { - color: palette.warm, - fontSize: 11, - fontWeight: "900", - letterSpacing: 1, + ...typography.label, + color: palette.dim, }, header: { alignItems: "center", @@ -1271,10 +1265,8 @@ const styles = StyleSheet.create({ paddingVertical: 12, }, closeButtonLabel: { + ...typography.control, color: palette.ink, - fontSize: 11, - fontWeight: "800", - letterSpacing: 0.9, }, input: { backgroundColor: palette.background, @@ -1282,7 +1274,6 @@ const styles = StyleSheet.create({ borderRadius: radius.sm, borderWidth: 1, color: palette.ink, - fontFamily: Platform.select({ android: "monospace", ios: "Menlo" }), fontSize: 15, minHeight: 48, paddingHorizontal: 14, @@ -1296,10 +1287,8 @@ const styles = StyleSheet.create({ maxWidth: 560, }, label: { + ...typography.label, color: palette.dim, - fontSize: 11, - fontWeight: "800", - letterSpacing: 1.3, }, lifecyclePrompt: { backgroundColor: palette.background, @@ -1313,10 +1302,8 @@ const styles = StyleSheet.create({ minWidth: "45%", }, metricLabel: { + ...typography.label, color: palette.dim, - fontSize: 10, - fontWeight: "700", - letterSpacing: 1.1, marginTop: 3, }, metrics: { @@ -1350,22 +1337,20 @@ const styles = StyleSheet.create({ padding: space.md, }, primaryButton: { + ...control, alignItems: "center", backgroundColor: palette.signal, - borderRadius: radius.sm, flexDirection: "row", gap: space.sm, justifyContent: "center", - minHeight: 52, - paddingHorizontal: space.md, }, primaryButtonDisabled: { opacity: 0.65 }, primaryButtonPressed: { opacity: 0.7 }, primaryLabel: { + ...typography.control, color: palette.background, - fontSize: 14, - fontWeight: "900", - letterSpacing: 0.9, + flexShrink: 1, + textAlign: "center", }, profileCard: { alignItems: "stretch", @@ -1446,10 +1431,8 @@ const styles = StyleSheet.create({ gap: space.sm, }, resultTitle: { + ...typography.label, color: palette.success, - fontSize: 12, - fontWeight: "900", - letterSpacing: 1.2, }, runtimeCopy: { color: palette.dim, @@ -1487,66 +1470,54 @@ const styles = StyleSheet.create({ paddingTop: space.md, }, runtimeTitle: { + ...typography.label, color: palette.ink, - fontSize: 11, - fontWeight: "900", - letterSpacing: 1, }, removeButton: { alignItems: "center", justifyContent: "center", - minHeight: 40, + minHeight: 44, paddingHorizontal: space.sm, }, removeButtonPressed: { backgroundColor: palette.card }, removeLabel: { + ...typography.control, color: palette.danger, - fontSize: 10, - fontWeight: "900", - letterSpacing: 0.7, textAlign: "center", }, saveButton: { + ...control, alignItems: "center", - borderColor: palette.signal, - borderRadius: radius.sm, + borderColor: palette.border, borderWidth: 1, flexDirection: "row", gap: space.sm, justifyContent: "center", - minHeight: 52, - paddingHorizontal: space.md, }, secondaryButton: { + ...control, alignItems: "center", - borderColor: palette.signal, - borderRadius: radius.sm, + borderColor: palette.border, borderWidth: 1, flexDirection: "row", gap: space.sm, justifyContent: "center", marginTop: space.md, - minHeight: 52, - paddingHorizontal: space.md, }, secondaryButtonPressed: { backgroundColor: palette.signalDark }, secondaryLabel: { + ...typography.control, color: palette.signal, - fontSize: 12, - fontWeight: "900", - letterSpacing: 0.7, + flexShrink: 1, + textAlign: "center", }, sectionTitle: { + ...typography.heading, color: palette.ink, - fontSize: 13, - fontWeight: "900", - letterSpacing: 1, }, selectedLabel: { + ...typography.label, color: palette.signal, - fontSize: 9, - fontWeight: "900", - letterSpacing: 0.8, }, smallButton: { alignItems: "center", @@ -1559,10 +1530,8 @@ const styles = StyleSheet.create({ paddingVertical: 8, }, smallButtonLabel: { + ...typography.control, color: palette.signal, - fontSize: 10, - fontWeight: "900", - letterSpacing: 0.7, }, safeArea: { backgroundColor: palette.background, @@ -1588,10 +1557,8 @@ const styles = StyleSheet.create({ flexDirection: "column", }, segmentLabel: { + ...typography.control, color: palette.dim, - fontSize: 11, - fontWeight: "800", - letterSpacing: 0.8, }, segmentLabelSelected: { color: palette.background }, segmentSelected: { backgroundColor: palette.signal }, @@ -1621,19 +1588,14 @@ const styles = StyleSheet.create({ padding: space.lg, }, title: { + ...typography.title, color: palette.ink, - fontSize: 40, - fontWeight: "700", - letterSpacing: -1.5, - lineHeight: 44, - marginTop: 42, + marginTop: space.lg, maxWidth: 600, }, httpLabel: { + ...typography.label, color: palette.warm, - fontSize: 9, - fontWeight: "900", - letterSpacing: 0.7, marginTop: 3, }, warningCopy: { flex: 1 }, @@ -1654,9 +1616,7 @@ const styles = StyleSheet.create({ marginTop: space.xs, }, warningTitle: { + ...typography.label, color: palette.warm, - fontSize: 11, - fontWeight: "900", - letterSpacing: 0.8, }, }); diff --git a/apps/mobile/src/screens/diff-screen.tsx b/apps/mobile/src/screens/diff-screen.tsx index 8c9b534..6d1e9d3 100644 --- a/apps/mobile/src/screens/diff-screen.tsx +++ b/apps/mobile/src/screens/diff-screen.tsx @@ -14,7 +14,7 @@ import { import type { RootStackParamList } from "../navigation/root-navigation"; import { useConnectionRuntime } from "../state/connection-runtime-context"; import { openCodeQueryKeys } from "../state/open-code-query-keys"; -import { diffPalette, palette, space, typeRamp } from "../theme"; +import { diffPalette, palette, space, typeRamp, typography } from "../theme"; import { sanitizeTranscriptText } from "./session-transcript-model"; type Props = NativeStackScreenProps; @@ -258,13 +258,11 @@ const styles = StyleSheet.create({ 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 }, + fileName: { ...typography.code, color: palette.ink, fontWeight: "600" }, + fileStatus: { ...typography.label, color: palette.dim }, line: { + ...typography.code, color: diffPalette.contextText, - fontFamily: "monospace", - fontSize: 12, - lineHeight: 18, paddingHorizontal: space.md, paddingVertical: 1, }, @@ -277,8 +275,8 @@ const styles = StyleSheet.create({ 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" }, + stateTitle: { ...typography.heading, color: palette.ink }, summary: { gap: space.xs, padding: space.md }, - title: { color: palette.ink, fontSize: 18, fontWeight: "800" }, - totals: { fontFamily: "monospace", fontSize: 13 }, + title: { ...typography.heading, color: palette.ink }, + totals: { ...typography.code }, }); diff --git a/apps/mobile/src/screens/followed-projects-screen.test.tsx b/apps/mobile/src/screens/followed-projects-screen.test.tsx index 0217c58..b81e100 100644 --- a/apps/mobile/src/screens/followed-projects-screen.test.tsx +++ b/apps/mobile/src/screens/followed-projects-screen.test.tsx @@ -10,6 +10,33 @@ jest.mock("./app-shell", () => ({ ShellFrame: ({ children }: { children: ReactNode }) => children, })); +test.each(["projectsError", "empty"])("offers recovery for %s", (state) => { + const refetch = jest.fn(async () => undefined); + jest.mocked(useWorkspaceSelection).mockReturnValue({ + followedProjectIds: [], + preferencesLoading: false, + projects: [], + projectsError: state === "projectsError", + projectsLoading: false, + refetch, + unavailableProjectIds: [], + } as never); + render( + , + ); + if (state === "empty") expect(screen.getByText("No server projects found")).toBeOnTheScreen(); + else expect(screen.getByRole("alert")).toBeOnTheScreen(); + fireEvent.press( + screen.getByRole("button", { + name: state === "empty" ? "Refresh projects" : "Retry loading projects", + }), + ); + expect(refetch).toHaveBeenCalledTimes(1); +}); + test("follows, unfollows, and reorders only local project IDs", () => { const setFollowedProjectIds = jest.fn(async () => undefined); jest.mocked(useWorkspaceSelection).mockReturnValue({ diff --git a/apps/mobile/src/screens/followed-projects-screen.tsx b/apps/mobile/src/screens/followed-projects-screen.tsx index 006d4a9..93fc8b7 100644 --- a/apps/mobile/src/screens/followed-projects-screen.tsx +++ b/apps/mobile/src/screens/followed-projects-screen.tsx @@ -4,7 +4,7 @@ import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from import type { RootStackParamList } from "../navigation/root-navigation"; import { useWorkspaceSelection } from "../state/workspace-selection-context"; -import { palette, radius, space, typeRamp } from "../theme"; +import { control, palette, radius, space, typeRamp, typography } from "../theme"; import { ShellFrame } from "./app-shell"; import { sanitizeTranscriptText } from "./session-transcript-model"; @@ -84,11 +84,31 @@ export function FollowedProjectsScreen({ navigation }: Props) { Loading known projects ) : selection.projectsError ? ( - - Projects could not be loaded from this connection. - + + + Projects could not be loaded from this connection. Try again. + + void selection.refetch()} + /> + ) : ( + {projects.length === 0 && selection.unavailableProjectIds.length === 0 ? ( + + No server projects found + Open a project in OpenCode, then refresh this list. + void selection.refetch()} + /> + + ) : null} {projects.map((project) => { const selected = followed.has(project.id); const position = selection.followedProjectIds.indexOf(project.id); @@ -203,22 +223,20 @@ function projectLabel(project: { canonical: string; id: string; name?: string }) const styles = StyleSheet.create({ content: { gap: space.md, padding: space.lg, paddingBottom: space.xl }, copy: { color: palette.dim, fontSize: 14, lineHeight: 21 }, - disabled: { opacity: 0.35 }, + disabled: { opacity: 0.5 }, error: { color: palette.danger, fontSize: 14, lineHeight: 20 }, - eyebrow: { color: palette.signal, fontSize: 11, fontWeight: "900", letterSpacing: 1.2 }, - followState: { color: palette.dim, fontSize: 10, fontWeight: "900", letterSpacing: 0.8 }, + eyebrow: { ...typography.label, color: palette.dim }, + followState: { ...typography.label, color: palette.dim }, followStateSelected: { color: palette.signal }, list: { gap: space.sm }, orderActions: { flexDirection: "row", flexWrap: "wrap", gap: space.sm, padding: space.sm }, orderButton: { + ...control, borderColor: palette.border, - borderRadius: radius.sm, borderWidth: 1, justifyContent: "center", - minHeight: 44, - paddingHorizontal: space.md, }, - orderLabel: { color: palette.ink, fontSize: 12, fontWeight: "800" }, + orderLabel: { ...typography.control, color: palette.ink }, pressed: { opacity: 0.58 }, project: { backgroundColor: palette.card, @@ -229,9 +247,9 @@ const styles = StyleSheet.create({ projectMain: { gap: space.xs, minHeight: 72, padding: space.md }, projectPath: { color: palette.dim, fontSize: 12, lineHeight: 17 }, projectSelected: { borderColor: palette.signal }, - projectTitle: { color: palette.ink, fontSize: 16, fontWeight: "800" }, + projectTitle: { ...typography.heading, color: palette.ink }, state: { alignItems: "center", gap: space.sm, paddingVertical: space.xl }, - title: { color: palette.ink, fontSize: 28, fontWeight: "800", lineHeight: 34 }, + title: { ...typography.title, color: palette.ink }, unavailableProject: { backgroundColor: palette.card, borderColor: palette.warm, @@ -248,5 +266,5 @@ const styles = StyleSheet.create({ justifyContent: "center", minHeight: 44, }, - unfollowLabel: { color: palette.danger, fontSize: 13, fontWeight: "800" }, + unfollowLabel: { ...typography.control, color: palette.danger }, }); diff --git a/apps/mobile/src/screens/form-request-card.tsx b/apps/mobile/src/screens/form-request-card.tsx index dfa521f..01b2cf7 100644 --- a/apps/mobile/src/screens/form-request-card.tsx +++ b/apps/mobile/src/screens/form-request-card.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { Alert, Linking, Pressable, StyleSheet, Text, TextInput, View } from "react-native"; import { applicationName } from "../application-name"; -import { palette, radius, space, typeRamp } from "../theme"; +import { control, palette, radius, space, typeRamp, typography } from "../theme"; import { createFormDraft, type FormDraft, @@ -77,7 +77,7 @@ export function FormRequestCard({ return ( - FORM REQUIRED + Input required {sanitizeTranscriptText(form.title, 512)} @@ -101,7 +101,12 @@ export function FormRequestCard({ ) : null} - + @@ -197,6 +202,7 @@ function FormControl({ {field.custom ? ( [ styles.button, + primary && styles.buttonPrimary, danger && styles.buttonDanger, disabled && styles.disabled, pressed && styles.pressed, ]} > - {label} + + {label} + ); } @@ -401,24 +419,25 @@ const styles = StyleSheet.create({ actions: { flexDirection: "row", flexWrap: "wrap", gap: space.sm }, booleanOptions: { flexDirection: "row", + flexWrap: "wrap", gap: space.sm, }, button: { + ...control, alignItems: "center", borderColor: palette.border, - borderRadius: radius.sm, borderWidth: 1, justifyContent: "center", - minHeight: 44, - paddingHorizontal: space.md, }, - buttonDanger: { borderColor: palette.danger }, + buttonPrimary: { backgroundColor: palette.signal }, + buttonPrimaryLabel: { color: palette.background }, + buttonDanger: { borderColor: palette.border }, buttonDangerLabel: { color: palette.danger }, - buttonLabel: { color: palette.ink, fontSize: 13, fontWeight: "800" }, + buttonLabel: { ...typography.control, color: palette.ink, textAlign: "center" }, card: { - backgroundColor: palette.background, - borderColor: palette.warm, - borderRadius: radius.md, + backgroundColor: palette.card, + borderColor: palette.border, + borderRadius: radius.lg, borderWidth: 1, gap: space.md, padding: space.md, @@ -434,11 +453,11 @@ const styles = StyleSheet.create({ choiceLabel: { color: palette.ink, fontSize: 14, fontWeight: "700" }, choiceLabelSelected: { color: palette.signal }, choiceSelected: { backgroundColor: palette.signalDark, borderColor: palette.signal }, - constraint: { color: palette.dim, fontSize: 11 }, - description: { color: palette.dim, fontSize: 12, lineHeight: 17 }, + constraint: { ...typography.caption, color: palette.dim }, + description: { ...typography.caption, color: palette.dim }, disabled: { opacity: 0.5 }, - error: { color: palette.danger, fontSize: 12, lineHeight: 17 }, - eyebrow: { color: palette.warm, fontSize: 10, fontWeight: "900", letterSpacing: 1 }, + error: { ...typography.body, color: palette.danger }, + eyebrow: { ...typography.heading, color: palette.warm }, field: { gap: space.xs }, fieldHeading: { gap: 2 }, input: { @@ -452,9 +471,9 @@ const styles = StyleSheet.create({ paddingHorizontal: space.sm, paddingVertical: space.sm, }, - label: { color: palette.ink, fontSize: 14, fontWeight: "800" }, + label: { ...typography.control, color: palette.ink }, multilineInput: { minHeight: 88, textAlignVertical: "top" }, options: { gap: space.xs }, pressed: { opacity: 0.62 }, - title: { color: palette.ink, fontSize: 17, fontWeight: "800" }, + title: { ...typography.heading, color: palette.ink }, }); diff --git a/apps/mobile/src/screens/new-session-screen.tsx b/apps/mobile/src/screens/new-session-screen.tsx index 92fdd96..85b30bb 100644 --- a/apps/mobile/src/screens/new-session-screen.tsx +++ b/apps/mobile/src/screens/new-session-screen.tsx @@ -19,6 +19,7 @@ import { StyleSheet, Text, TextInput, + useWindowDimensions, View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; @@ -27,7 +28,15 @@ import type { RootStackParamList } from "../navigation/root-navigation"; import { useConnectionRuntime } from "../state/connection-runtime-context"; import { openCodeQueryKeys } from "../state/open-code-query-keys"; import { useWorkspaceSelection } from "../state/workspace-selection-context"; -import { palette, radius, space, typeRamp } from "../theme"; +import { + control, + palette, + radius, + space, + typeRamp, + typography, + usesLargeTextLayout, +} from "../theme"; import { sanitizeTranscriptText } from "./session-transcript-model"; type Props = NativeStackScreenProps; @@ -36,6 +45,8 @@ type LocationChoice = { key: string; label: string; location: LocationRef }; type ProjectRow = { id: string; project?: Project; title?: string; type: "project" | "section" }; export function NewSessionScreen({ navigation }: Props) { + const { fontScale } = useWindowDimensions(); + const largeText = usesLargeTextLayout(fontScale); const runtime = useConnectionRuntime(); const selection = useWorkspaceSelection(); const queryClient = useQueryClient(); @@ -183,8 +194,8 @@ export function NewSessionScreen({ navigation }: Props) { behavior={Platform.OS === "ios" ? "padding" : undefined} style={styles.keyboardView} > - - + + void }) { const db = useSQLiteContext(); @@ -301,9 +301,14 @@ export function NotificationPairingScreen({ onDone }: { onDone: () => void }) { ) : ( void openScanner()} - style={({ pressed }) => [styles.scanButton, pressed && styles.pressed]} + style={({ pressed }) => [ + styles.scanButton, + busy && styles.disabled, + pressed && styles.pressed, + ]} > SCAN QR CODE @@ -315,6 +320,8 @@ export function NotificationPairingScreen({ onDone }: { onDone: () => void }) { void }) { /> inspect()} - style={({ pressed }) => [styles.inspectButton, pressed && styles.pressed]} + style={({ pressed }) => [ + styles.inspectButton, + (busy || manualCode.trim().length === 0) && styles.disabled, + pressed && styles.pressed, + ]} > CHECK CODE @@ -356,9 +368,19 @@ export function NotificationPairingScreen({ onDone }: { onDone: () => void }) { void pair()} - style={({ pressed }) => [styles.pairButton, pressed && styles.pressed]} + style={({ pressed }) => [ + styles.pairButton, + busy && styles.disabled, + pressed && styles.pressed, + ]} > {busy ? ( @@ -373,9 +395,14 @@ export function NotificationPairingScreen({ onDone }: { onDone: () => void }) { {preview.kind === "opencode" ? ( void pairWithoutNotifications()} - style={({ pressed }) => [styles.inspectButton, pressed && styles.pressed]} + style={({ pressed }) => [ + styles.inspectButton, + busy && styles.disabled, + pressed && styles.pressed, + ]} > {preview.prepared.allowDevelopmentHttp @@ -502,15 +529,14 @@ const styles = StyleSheet.create({ }, cameraHint: { color: palette.dim, fontSize: 13, padding: space.md, textAlign: "center" }, closeButton: { justifyContent: "center", minHeight: 44, paddingLeft: space.md }, - closeLabel: { color: palette.dim, fontSize: 12, fontWeight: "800", letterSpacing: 1 }, + closeLabel: { ...typography.control, color: palette.signal }, codeInput: { + ...typography.code, backgroundColor: palette.card, borderColor: palette.border, borderRadius: radius.md, borderWidth: 1, color: palette.ink, - fontFamily: Platform.select({ android: "monospace", ios: "Menlo" }), - fontSize: 12, minHeight: 116, padding: space.md, textAlignVertical: "top", @@ -518,7 +544,7 @@ const styles = StyleSheet.create({ content: { padding: space.lg, paddingBottom: 48 }, copy: { color: palette.dim, fontSize: 16, lineHeight: 24, marginTop: space.md }, divider: { backgroundColor: palette.border, flex: 1, height: 1 }, - dividerLabel: { color: palette.dim, fontSize: 11, fontWeight: "800", letterSpacing: 1.2 }, + dividerLabel: { ...typography.label, color: palette.dim }, dividerRow: { alignItems: "center", flexDirection: "row", @@ -526,51 +552,49 @@ const styles = StyleSheet.create({ marginVertical: space.lg, }, error: { color: palette.danger, fontSize: 14, lineHeight: 20, marginTop: space.md }, - eyebrow: { color: palette.signal, fontSize: 12, fontWeight: "800", letterSpacing: 1.5 }, + eyebrow: { ...typography.label, color: palette.dim }, flex: { flex: 1 }, headerCopy: { flex: 1 }, headerRow: { alignItems: "flex-start", flexDirection: "row" }, inspectButton: { + ...control, alignItems: "center", borderColor: palette.border, - borderRadius: radius.sm, borderWidth: 1, marginTop: space.sm, - minHeight: 48, justifyContent: "center", }, - inspectLabel: { color: palette.ink, fontSize: 13, fontWeight: "800", letterSpacing: 1 }, + inspectLabel: { ...typography.control, color: palette.ink, textAlign: "center" }, pairButton: { + ...control, alignItems: "center", backgroundColor: palette.signal, - borderRadius: radius.sm, justifyContent: "center", marginTop: space.lg, - minHeight: 52, }, - pairLabel: { color: palette.background, fontSize: 14, fontWeight: "900", letterSpacing: 1 }, + pairLabel: { ...typography.control, color: palette.background, textAlign: "center" }, + disabled: { opacity: 0.5 }, pressed: { opacity: 0.65 }, previewCard: { - backgroundColor: palette.signalDark, - borderColor: palette.signal, + backgroundColor: palette.card, + borderColor: palette.border, borderRadius: radius.lg, borderWidth: 1, marginTop: space.lg, padding: space.lg, }, - previewEyebrow: { color: palette.signal, fontSize: 11, fontWeight: "800", letterSpacing: 1.3 }, + previewEyebrow: { ...typography.label, color: palette.success }, previewMeta: { color: palette.dim, fontSize: 13, lineHeight: 19, marginTop: space.sm }, - previewName: { color: palette.ink, fontSize: 23, fontWeight: "800", marginTop: space.xs }, + previewName: { ...typography.heading, color: palette.ink, marginTop: space.xs }, previewOrigin: { color: palette.ink, fontSize: 14, marginTop: space.xs }, safeArea: { backgroundColor: palette.background, flex: 1 }, scanButton: { + ...control, alignItems: "center", backgroundColor: palette.signal, - borderRadius: radius.md, justifyContent: "center", marginTop: space.lg, - minHeight: 56, }, - scanLabel: { color: palette.background, fontSize: 14, fontWeight: "900", letterSpacing: 1.2 }, - title: { color: palette.ink, fontSize: 32, fontWeight: "800", letterSpacing: -0.8, marginTop: 5 }, + scanLabel: { ...typography.control, color: palette.background, textAlign: "center" }, + title: { ...typography.title, color: palette.ink, marginTop: space.xs }, }); diff --git a/apps/mobile/src/screens/permission-request-card.tsx b/apps/mobile/src/screens/permission-request-card.tsx index 29d7378..3440e13 100644 --- a/apps/mobile/src/screens/permission-request-card.tsx +++ b/apps/mobile/src/screens/permission-request-card.tsx @@ -1,8 +1,16 @@ import type { PermissionReply, PermissionRequest } from "@opencode2-mobile/opencode-adapter"; import { useState } from "react"; -import { Platform, Pressable, StyleSheet, Text, useWindowDimensions, View } from "react-native"; +import { Pressable, StyleSheet, Text, useWindowDimensions, View } from "react-native"; import { ModalSheet } from "../components/modal-sheet"; -import { markdownPalette, palette, radius, space, usesLargeTextLayout } from "../theme"; +import { + control, + markdownPalette, + palette, + radius, + space, + typography, + usesLargeTextLayout, +} from "../theme"; import { permissionActionExplanation } from "./permission-presentation"; import { sanitizeTranscriptText } from "./session-transcript-model"; @@ -105,7 +113,7 @@ export function PermissionRequestCard({ accessibilityLabel="Details" accessibilityHint="Opens the full request, saved permission patterns, and explanation" onPress={() => setDetails(true)} - style={styles.detailsButton} + style={({ pressed }) => [styles.detailsButton, pressed && styles.disabled]} > Details › @@ -130,7 +138,12 @@ export function PermissionRequestCard({ Always allow saves the displayed patterns, which can cover more than this request. ) : null} - {explanation ? {explanation} : null} + {explanation ? ( + + OpenCode Mobile explanation + {explanation} + + ) : null} Reject may also reject other pending permission requests in this session. @@ -148,40 +161,34 @@ const styles = StyleSheet.create({ padding: 12, gap: 8, }, - title: { color: palette.warm, fontSize: 16, fontWeight: "600" }, - action: { color: palette.ink, fontSize: 15, lineHeight: 22 }, + title: { ...typography.heading, color: palette.warm }, + action: { ...typography.body, color: palette.ink }, commandBox: { backgroundColor: palette.background, borderRadius: radius.sm }, command: { + ...typography.code, color: markdownPalette.code, - fontFamily: Platform.select({ ios: "Menlo", android: "monospace" }), - fontSize: 13, - lineHeight: 19, padding: 10, }, scope: { gap: 4 }, pattern: { + ...typography.code, color: palette.ink, - fontFamily: Platform.select({ ios: "Menlo", android: "monospace" }), - fontSize: 13, - lineHeight: 19, }, - caption: { color: palette.dim, fontSize: 12, lineHeight: 18 }, - copy: { color: palette.dim, fontSize: 15, lineHeight: 22, marginVertical: space.sm }, + caption: { ...typography.caption, color: palette.dim }, + copy: { ...typography.body, color: palette.dim, marginVertical: space.sm }, actions: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, actionsLarge: { flexDirection: "column" }, button: { - minHeight: 48, + ...control, flexGrow: 1, justifyContent: "center", alignItems: "center", borderColor: palette.border, borderWidth: 1, - borderRadius: radius.md, - padding: 10, }, primary: { backgroundColor: palette.signal }, primaryLabel: { color: palette.background }, - buttonLabel: { color: palette.ink, fontSize: 14, fontWeight: "600" }, + buttonLabel: { ...typography.control, color: palette.ink, textAlign: "center" }, reject: { color: palette.danger }, disabled: { opacity: 0.5 }, detailsButton: { minHeight: 44, justifyContent: "center" }, diff --git a/apps/mobile/src/screens/session-composer.tsx b/apps/mobile/src/screens/session-composer.tsx index 1330140..e8449a4 100644 --- a/apps/mobile/src/screens/session-composer.tsx +++ b/apps/mobile/src/screens/session-composer.tsx @@ -23,7 +23,7 @@ import { } from "react-native"; import { ModalSheet } from "../components/modal-sheet"; -import { palette, radius, space, typeRamp } from "../theme"; +import { palette, radius, space, typeRamp, typography } from "../theme"; import type { PromptDelivery } from "./prompt-admission-model"; import { applyMentionCompletion, @@ -213,6 +213,7 @@ export function SessionComposer({ style={[styles.editorRow, expanded && styles.editorRowExpanded]} > ; +}; + export type AttentionCoverage = { completeness: "complete" | "incomplete"; failedLocationCount: number; + failedLocations: AttentionLocationFailure[]; failedProjects: Array<{ label: string; locationCount: number; projectID?: string }>; freshness: "current" | "reconciling" | "stale"; knownLocationCount: number; @@ -401,12 +410,75 @@ export function FollowedProjectsProvider({ children }: { children: ReactNode }) discoveredLocationsRef.current.scopeKey === followedScopeKey ? discoveredLocationsRef.current.locations : []; - const knownLocations = uniqueLocations([...previousLocations, ...discoveredLocations]); - discoveredLocationsRef.current = { scopeKey: followedScopeKey, locations: knownLocations }; + const candidateLocations = uniqueLocations([...previousLocations, ...discoveredLocations]); + discoveredLocationsRef.current = { scopeKey: followedScopeKey, locations: candidateLocations }; + const protectedLocationKeys = new Set( + [ + ...projectLocations, + ...runtime.attentionLocations, + ...runtime.eventLocations, + ...Object.values(activeAncestryQuery.data?.sessions ?? {}).map((session) => session.location), + ...(location ? [location] : []), + ].map(locationKey), + ); + const historicalCandidates = candidateLocations.flatMap((candidate) => { + if (protectedLocationKeys.has(locationKey(candidate)) || candidate.workspaceID) return []; + const session = rootSessions.find( + (item) => locationKey(item.location) === locationKey(candidate), + ); + const project = session ? projectByID.get(session.projectID) : undefined; + if (!project || !candidate.directory.startsWith(`${project.canonical.replace(/\/$/, "")}/`)) + return []; + // Previously observed blocked work takes precedence over retirement. Active + // session lists alone do not cover permission- or form-blocked sessions. + const permissionSnapshot = queryClient.getQueryData<{ data: PermissionRequest[] }>( + openCodeQueryKeys.permissions(connectionID ?? unresolvedConnectionID, candidate), + ); + const formSnapshot = queryClient.getQueryData<{ data: FormInfo[] }>( + openCodeQueryKeys.forms(connectionID ?? unresolvedConnectionID, candidate), + ); + if (permissionSnapshot?.data.length || formSnapshot?.data.length) return []; + return [{ location: candidate, root: { directory: project.canonical } }]; + }); + const directoryQueries = useQueries({ + queries: historicalCandidates.map((candidate) => ({ + enabled: Boolean(client && connectionID), + queryKey: openCodeQueryKeys.historicalDirectory( + connectionID ?? unresolvedConnectionID, + candidate.root, + candidate.location.directory, + revision, + ), + queryFn: ({ signal }: { signal: AbortSignal }) => { + if (!client) throw new Error("CONNECTION_NOT_READY"); + return openCodeDirectoryExists(client, candidate.root, candidate.location.directory, { + signal, + }); + }, + })), + }); + const directoryQueryByLocation = new Map( + historicalCandidates.map((candidate, index) => [ + locationKey(candidate.location), + directoryQueries[index], + ]), + ); + const activeLocationsResolved = + activeSessionsQuery.isSuccess && + (activeSessionIDs.length === 0 || + (activeAncestryQuery.isSuccess && activeAncestryQuery.data.failures.length === 0)); + const knownLocations = candidateLocations.filter((candidate) => { + const query = directoryQueryByLocation.get(locationKey(candidate)); + return !activeLocationsResolved || !query?.isSuccess || query.data !== false; + }); const interactionQueries = useQueries({ queries: knownLocations.flatMap((knownLocation) => [ { - enabled: Boolean(client && connectionID), + enabled: Boolean( + client && + connectionID && + !directoryQueryByLocation.get(locationKey(knownLocation))?.isPending, + ), queryFn: ({ signal }: { signal: AbortSignal }) => { if (!client) throw new Error("CONNECTION_NOT_READY"); return listOpenCodePermissionRequests(client, knownLocation, { signal }); @@ -417,7 +489,11 @@ export function FollowedProjectsProvider({ children }: { children: ReactNode }) ), }, { - enabled: Boolean(client && connectionID), + enabled: Boolean( + client && + connectionID && + !directoryQueryByLocation.get(locationKey(knownLocation))?.isPending, + ), queryFn: ({ signal }: { signal: AbortSignal }) => { if (!client) throw new Error("CONNECTION_NOT_READY"); return listOpenCodeFormRequests(client, knownLocation, { signal }); @@ -583,6 +659,41 @@ export function FollowedProjectsProvider({ children }: { children: ReactNode }) ); } const failedProjectCounts = new Map(); + // UI-only location details stay in memory. Never include paths or raw query + // errors in the redacted support report or device persistence. + const sessionLocationKeys = new Set( + [...rootSessions, ...Object.values(ancestrySessions)].map((session) => + locationKey(session.location), + ), + ); + const failedLocationDetails: AttentionLocationFailure[] = [ + ...knownLocations, + ...supplementalLocations, + ].flatMap((failedLocation, index) => { + const failedChecks: AttentionLocationFailure["failedChecks"] = []; + if (allInteractionQueries[index * 2]?.isError) failedChecks.push("permissions"); + if (allInteractionQueries[index * 2 + 1]?.isError) failedChecks.push("forms"); + if (failedChecks.length === 0) return []; + const projectID = + projectIDByLocation.get(locationKey(failedLocation)) ?? + inferLocationProjectID(failedLocation, projects); + const project = projectID ? projectByID.get(projectID) : undefined; + return [ + { + location: failedLocation, + projectLabel: project ? labelProject(project) : "Unknown project", + source: + project?.canonical === failedLocation.directory + ? "project-root" + : project?.sandboxes.includes(failedLocation.directory) + ? "worktree" + : sessionLocationKeys.has(locationKey(failedLocation)) + ? "session-history" + : "discovered", + failedChecks, + }, + ]; + }); for (const failedLocation of failedLocations) { const projectID = projectIDByLocation.get(locationKey(failedLocation)) ?? @@ -602,6 +713,7 @@ export function FollowedProjectsProvider({ children }: { children: ReactNode }) .sort((first, second) => first.label.localeCompare(second.label)); const sessionProjectFailures = failedFollowedSessionProjects(sessionsQuery.data?.pages); const reconciling = + directoryQueries.some((query) => query.isPending || query.isFetching) || !preferenceReady || projectsQuery.isPending || defaultLocationQuery.isPending || @@ -636,6 +748,7 @@ export function FollowedProjectsProvider({ children }: { children: ReactNode }) const attentionCoverage: AttentionCoverage = { completeness: reconciling || coverageReasons.length > 0 ? "incomplete" : "complete", failedLocationCount, + failedLocations: failedLocationDetails, failedProjects, freshness: runtime.status !== "connected" ? "stale" : reconciling ? "reconciling" : "current", knownLocationCount: knownLocations.length + supplementalLocations.length, @@ -747,6 +860,7 @@ export function FollowedProjectsProvider({ children }: { children: ReactNode }) projectsLoading: projectsQuery.isPending, refetch: () => Promise.allSettled([ + ...directoryQueries.map((query) => query.refetch()), projectsQuery.refetch(), defaultLocationQuery.refetch(), activeSessionsQuery.refetch(), diff --git a/apps/mobile/src/state/open-code-query-keys.ts b/apps/mobile/src/state/open-code-query-keys.ts index 342d500..b09c2da 100644 --- a/apps/mobile/src/state/open-code-query-keys.ts +++ b/apps/mobile/src/state/open-code-query-keys.ts @@ -31,6 +31,19 @@ export const openCodeQueryKeys = { forms(connectionId: string, location: LocationRef) { return [...locationKey(connectionId, location), "forms"] as const; }, + historicalDirectory( + connectionId: string, + root: LocationRef, + directory: string, + revision: number, + ) { + return [ + ...locationKey(connectionId, root), + "historical-directory", + directory, + revision, + ] as const; + }, fileFind(connectionId: string, location: LocationRef, query: string, limit: number) { return [...locationKey(connectionId, location), "file-find", query, limit] as const; }, diff --git a/apps/mobile/src/state/workspace-selection-context.test.tsx b/apps/mobile/src/state/workspace-selection-context.test.tsx index 66087db..2bb355d 100644 --- a/apps/mobile/src/state/workspace-selection-context.test.tsx +++ b/apps/mobile/src/state/workspace-selection-context.test.tsx @@ -10,6 +10,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react-nativ import { Pressable, Text } from "react-native"; import { FollowedProjectsProvider, useFollowedProjects } from "./followed-projects-context"; +import { openCodeQueryKeys } from "./open-code-query-keys"; type InteractionOutput = { data: T[]; location: ReturnType }; type ListFormsCall = ( @@ -30,6 +31,14 @@ type ReplyPermissionCall = ( ) => Promise; const mockListForms = jest.fn(); +const mockDirectoryExists = jest.fn( + async ( + _client: unknown, + _root: LocationRef, + _directory: string, + _options?: { signal?: AbortSignal }, + ) => true, +); const mockListPermissions = jest.fn(); const mockGetOpenCodeLocation = jest.fn(async (_client: unknown, location: { directory: string }) => mockResolvedLocation( @@ -74,6 +83,8 @@ const mockDb = { jest.mock("expo-sqlite", () => ({ useSQLiteContext: () => mockDb })); jest.mock("@opencode2-mobile/opencode-adapter", () => ({ + openCodeDirectoryExists: (...args: Parameters) => + mockDirectoryExists(...args), getDefaultOpenCodeLocation: jest.fn(async () => mockResolvedLocation("/a", "project-a")), getOpenCodeLocation: (...args: Parameters) => mockGetOpenCodeLocation(...args), @@ -121,6 +132,8 @@ jest.mock("./connection-runtime-context", () => ({ })); beforeEach(() => { + mockDirectoryExists.mockReset(); + mockDirectoryExists.mockResolvedValue(true); jest.useFakeTimers(); mockEventLocations = []; mockAttentionLocations = []; @@ -523,6 +536,138 @@ test("refreshes the displayed project search instead of the unfiltered feed", as queryClient.clear(); }); +test.each([ + ["/b/sub", "session-history"], + ["/b", "project-root"], +])("keeps failed %s locations explicit until checks recover", async (directory, source) => { + const original = mockListPermissions.getMockImplementation(); + if (!original) throw new Error("Missing permission fixture"); + mockListPermissions.mockImplementation(async (client, location, options) => { + if (location.directory === directory) throw new Error("HTTP 500 private server details"); + return original(client, location, options); + }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + const view = render( + + + + + , + ); + await waitFor(() => expect(screen.getByText("locations:1:2")).toBeOnTheScreen()); + await waitFor(() => expect(screen.getByText("freshness:current")).toBeOnTheScreen()); + expect(screen.getByText(`unavailable:Beta:${directory}:${source}:permissions`)).toBeOnTheScreen(); + expect(screen.getByText("incomplete:2:2:0")).toBeOnTheScreen(); + expect(screen.getByText("Beta:ses_beta:0:1")).toBeOnTheScreen(); + mockListPermissions.mockImplementation(original); + fireEvent.press(screen.getByRole("button", { name: "Refresh sessions" })); + await waitFor(() => expect(screen.getByText("complete:2:2:0")).toBeOnTheScreen()); + expect(screen.queryByText(/^unavailable:/)).toBeNull(); + view.unmount(); + queryClient.clear(); +}); + +test("retires missing historical directories and checks them again after restoration", async () => { + const directory = "/b/.worktree/retired"; + mockListProjectSessions.mockImplementation(async (_client, projectID) => ({ + cursor: {}, + data: [ + projectID === "project-b" + ? mockSession("ses_retired", projectID, directory, 2) + : mockSession("ses_alpha", projectID, "/a", 3), + ], + })); + mockDirectoryExists.mockImplementation( + async (_client, _root, candidate) => candidate !== directory, + ); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + const view = render( + + + + + , + ); + await waitFor(() => expect(screen.getByText("complete:1:1:0")).toBeOnTheScreen()); + expect(mockDirectoryExists).toHaveBeenCalledWith( + expect.anything(), + { directory: "/b" }, + directory, + expect.anything(), + ); + expect(mockListPermissions.mock.calls.some((call) => call[1].directory === directory)).toBe( + false, + ); + expect(mockListForms.mock.calls.some((call) => call[1].directory === directory)).toBe(false); + expect(screen.getByText("recent:ses_retired")).toBeOnTheScreen(); + mockDirectoryExists.mockResolvedValue(true); + fireEvent.press(screen.getByRole("button", { name: "Refresh sessions" })); + await waitFor(() => + expect(mockListPermissions.mock.calls.some((call) => call[1].directory === directory)).toBe( + true, + ), + ); + view.unmount(); + queryClient.clear(); +}); + +test.each(["notification", "event", "cached-form"])( + "keeps %s attention at a missing historical directory", + async (source) => { + const location = { directory: "/b/sub" }; + mockDirectoryExists.mockResolvedValue(false); + if (source === "notification") mockAttentionLocations = [location]; + if (source === "event") mockEventLocations = [location]; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + if (source === "cached-form") { + queryClient.setQueryData(openCodeQueryKeys.forms("connection-1", location), { + data: [{ fields: [], id: "form_beta", sessionID: "ses_beta", title: "Input" }], + location: mockResolvedLocation(location.directory, "project-b"), + }); + } + const view = render( + + + + + , + ); + await waitFor(() => expect(screen.getByText("complete:2:2:0")).toBeOnTheScreen()); + expect(mockDirectoryExists.mock.calls.some((call) => call[2] === location.directory)).toBe( + false, + ); + expect(mockListForms.mock.calls.some((call) => call[1].directory === location.directory)).toBe( + true, + ); + 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({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + const view = render( + + + + + , + ); + await waitFor(() => expect(screen.getByText("complete:2:2:0")).toBeOnTheScreen()); + expect(mockListForms.mock.calls.some((call) => call[1].directory === "/b/sub")).toBe(true); + view.unmount(); + queryClient.clear(); +}); + function Capture() { const state = useFollowedProjects(); return ( @@ -534,6 +679,15 @@ function Capture() { freshness:{state.attentionCoverage.freshness} followed:{state.followedProjectIds.join(",")} preferences:{state.preferencesError ? "error" : "ok"} + {state.inbox.recent.map((row) => ( + recent:{row.session.id} + ))} + {state.attentionCoverage.failedLocations?.map((failure) => ( + + unavailable:{failure.projectLabel}:{failure.location.directory}:{failure.source}: + {failure.failedChecks.join(",")} + + ))} locations:{state.attentionCoverage.failedLocationCount}: {state.attentionCoverage.reconciledLocationCount} diff --git a/apps/mobile/src/theme.ts b/apps/mobile/src/theme.ts index fd607d2..d1b25e8 100644 --- a/apps/mobile/src/theme.ts +++ b/apps/mobile/src/theme.ts @@ -1,8 +1,10 @@ -// Neutral dark surfaces inspired by the supplied ChatGPT and Gemini iOS references. +import { Platform } from "react-native"; + +// PR #33's charcoal cards and restrained content accents are the mobile baseline. // Keep color for links and feedback; navigation and primary controls are monochrome. export const palette = { accent: "#8ab4f8", - activity: "#e3e3e3", + activity: "#56b6c2", background: "#000000", border: "#2c2c2c", card: "#141414", @@ -62,3 +64,32 @@ export const typeRamp = { heading: "title1", subheading: "subheadline", } as const; + +// Native system fonts for UI; platform monospace only for code and technical data. +export const typography = { + title: { fontSize: 28, lineHeight: 34, fontWeight: "700" }, + sheetTitle: { fontSize: 20, lineHeight: 26, fontWeight: "600" }, + heading: { fontSize: 16, lineHeight: 22, fontWeight: "600" }, + body: { fontSize: 15, lineHeight: 22 }, + control: { fontSize: 14, lineHeight: 20, fontWeight: "600" }, + caption: { fontSize: 12, lineHeight: 18 }, + label: { fontSize: 12, lineHeight: 18, fontWeight: "600" }, + code: { + fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }), + fontSize: 13, + lineHeight: 20, + }, +} as const; + +export const control = { + minHeight: 48, + borderRadius: radius.md, + paddingHorizontal: space.md, + paddingVertical: space.sm, +} as const; + +export const switchColors = { + thumbColor: palette.ink, + trackColor: { false: palette.border, true: "#626262" }, + ios_backgroundColor: palette.border, +} as const; diff --git a/apps/mobile/src/updates/app-updates.tsx b/apps/mobile/src/updates/app-updates.tsx index 46c3ff5..c5e6a94 100644 --- a/apps/mobile/src/updates/app-updates.tsx +++ b/apps/mobile/src/updates/app-updates.tsx @@ -18,7 +18,7 @@ import { } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { palette, radius, space } from "../theme"; +import { palette, radius, space, typography } from "../theme"; import { AppUpdateController } from "./app-update-controller"; import { prepareAppReload } from "./prepare-app-reload"; @@ -153,12 +153,14 @@ const styles = StyleSheet.create({ }, card: { backgroundColor: palette.card, - borderRadius: radius.md, + borderColor: palette.border, + borderWidth: 1, + borderRadius: radius.lg, padding: space.md, gap: space.sm, }, - title: { color: palette.ink, fontSize: 17, fontWeight: "700" }, - copy: { color: palette.dim, fontSize: 14, lineHeight: 20 }, + title: { ...typography.heading, color: palette.ink }, + copy: { ...typography.body, color: palette.dim }, error: { color: palette.danger, fontSize: 14, lineHeight: 20 }, button: { minHeight: 44, @@ -166,6 +168,6 @@ const styles = StyleSheet.create({ alignSelf: "flex-start", paddingVertical: space.sm, }, - label: { color: palette.signal, fontSize: 15, fontWeight: "700" }, + label: { ...typography.control, color: palette.signal }, dimmed: { opacity: 0.5 }, }); diff --git a/docs/UX-REVIEW.md b/docs/UX-REVIEW.md new file mode 100644 index 0000000..6d60e57 --- /dev/null +++ b/docs/UX-REVIEW.md @@ -0,0 +1,132 @@ +# Mobile UX consistency review + +Reviewed September 28, 2026 against merged PR #33, `feat(mobile): improve +transcript hierarchy and approval cards`. + +## Scope and evidence + +This is a source-level review of every implemented mobile route, shared sheet, +and supporting startup state. It covers typography, colors, hierarchy, spacing, +controls, loading and recovery, and large-text layout constraints. The intended +user is someone managing OpenCode sessions from a phone, with quick access to +blocked work and a readable transcript. + +PR #33 supplies the visual baseline: black page backgrounds, charcoal cards, +white primary actions, muted supporting text, green code, violet reasoning, and +amber permission headings. The changes keep the foundation dark theme specified +in `SPEC.md`. + +No native device or emulator was available for this review. Source inspection, +component tests, calculated color contrast, and Hermes exports do not establish +actual VoiceOver, TalkBack, keyboard, or large-text rendering on devices. + +## Findings and changes + +| Area | Finding | Change | +| --- | --- | --- | +| Typography | Management screens used 9–11 point labels with 800–900 weights and wide tracking, unlike PR #33's readable semibold controls. | Added shared title, sheet title, heading, body, control, caption, label, and code styles in `apps/mobile/src/theme.ts`. Applied them across the screens below. | +| Code | Permission details used Menlo on iOS while transcript code and diffs requested generic monospace. | Shared platform code style for permission resources, transcript commands and output, fenced code, diffs, pairing codes, and connection diagnostics. Inline code inherits the surrounding text size. | +| Color meaning | Forms and admission cards used prominent amber borders and black backgrounds; ordinary Settings labels also used amber. | Neutral charcoal cards and borders, amber attention headings, muted informational labels. Preserved PR #33's code and reasoning accents. | +| Switches | Settings mixed native default colors with two custom treatments. Connections used another treatment. | Shared neutral track and thumb colors for transcript, reasoning, app lock, notifications, and HTTP settings. | +| Buttons | Primary and secondary controls differed in height, corner radius, padding, and label weight across management screens. | Shared 48 point minimum, 12 point radius, padded semibold controls. Forms now have a white Submit action and a secondary destructive action. | +| Touch targets | Connection Edit/Remove and transcript text actions had 40 point minimums. Queue/steer controls also used 40. | Raised compact actions to at least 44 points and queue/steer to 48. | +| Large text | New-session heading and Cancel competed in a row; boolean form choices could overflow; the custom iOS header had a fixed height. | New-session header stacks at the existing large-text threshold. Boolean choices wrap. Custom header can grow vertically. | +| Pairing feedback | Disabled scan/check controls retained their enabled appearance; the pairing input lacked an explicit accessible label. | Added disabled styling and accessibility state, a named input, and a stable busy-state pairing label that preserves HTTP approval wording. | +| Recovery | Followed projects showed only an error message on load failure and no explanation for an empty project list. | Added Retry and a descriptive empty state with Refresh. Component tests exercise both actions. | +| Keyboard appearance | Search, form, and composer inputs did not all request the same iOS keyboard appearance. | All implemented text inputs now request the dark keyboard. | +| Permission details | PR #33's explanatory copy did not identify its client authorship. | Restored an explicit OpenCode Mobile explanation label in Details. | + +## Screen coverage + +| Screen or shared UI | Review result | +| --- | --- | +| Sessions inbox, search, project filters, child rows | Kept the lightweight feed hierarchy, project identity, status text, and attention ordering. Standardized badges, section labels, and sheet row headings. | +| Session transcript | Kept 17 point reading text and existing live-follow behavior. Unified code fonts and reduced heavy activity, reasoning, user, and subagent labels. | +| Composer, agent/model pickers, completion list | Kept PR #33's collapsed composer alignment and native multiline sizing. Standardized picker/completion headings, increased queue/steer targets, and aligned keyboard appearance. | +| Execution, admission, and inbox panels | Neutral admission cards, shared action typography and sizing, wrapping inbox headings. State labels still distinguish delivery, execution, and attention. | +| Permission cards and Details | Kept PR #33's shared card in both session and Needs you. Adopted shared font/control tokens and added Details press feedback and explanation attribution. Full resources and saved patterns remain selectable in Details. | +| Forms, including external, numeric, boolean, and multiselect fields | Matched permission card colors and radius, clearer primary Submit action, readable field labels and errors, wrapping boolean choices. | +| Needs you | Uses the same permission and form components as session detail. Shared shell headings and buttons now follow the same hierarchy. | +| New session and location/worktree choice | Shared sheet title, readable project headings and section labels, stacked large-text header, consistent secondary button. | +| Followed projects | Shared title and project-card typography, consistent ordering controls, explicit empty and retry states. | +| Connections, profile editing, onboarding, and transport diagnostics | Reduced oversized title and heavy small labels. Human-entered text uses the system UI font; diagnostics remain monospace. Aligned primary/secondary controls and switches. | +| QR/manual pairing and pairing preview | Shared heading/control typography, neutral preview card, visible disabled states, accessible input and busy action. | +| Settings and support export | Shared card headings, quieter category labels, consistent switches and action buttons, 16 point settings-card padding. | +| Current changes/diff | Shared code font and readable status/headings. Kept added/removed colors, backgrounds, and textual diff prefixes. | +| Navigation menu and shared modal sheets | Shared menu headings, sheet titles, caption line heights, and Done control typography. Existing large-text modal stacking remains in place. | +| App updates and restart overlay | Update card uses the same charcoal border/radius treatment, shared heading/body styles, and control labels. | +| App lock and local render/storage states | Shared title, label, and action typography. Preserved explicit error and recovery messages. | + +## Measured text contrast + +Ratios use WCAG relative luminance from the opaque theme colors. These are text +color checks, not certification of the complete interface or disabled states. + +| Text | Black page | Charcoal card | Raised panel | +| --- | --- | --- | --- | +| Main text | 18.10:1 | 15.88:1 | 13.38:1 | +| Muted text | 8.03:1 | 7.04:1 | 5.94:1 | +| Error | 7.67:1 | 6.73:1 | 5.67:1 | +| Attention | 13.35:1 | 11.71:1 | 9.86:1 | +| Success | 12.06:1 | 10.58:1 | 8.92:1 | +| Link | 9.96:1 | 8.74:1 | 7.37:1 | +| Code | 12.89:1 | 11.31:1 | 9.53:1 | +| Reasoning | 11.38:1 | 9.98:1 | 8.41:1 | + +## Automated verification + +- `fnm exec --using=26.7.0 pnpm check` passed lint, typecheck, tests, and build. +- All 343 mobile tests passed across 55 suites, including the two new + followed-project recovery cases. The transcript inline-code assertion now + expects Menlo on the test runner's iOS platform. +- Both iOS and Android Hermes bundles exported successfully. +- `fnm exec --using=26.7.0 pnpm native:doctor` passed all 18 checks. +- The test run still emits non-failing React `act` warnings for asynchronous + icon loading and WorkingIndicator updates. + +## Follow-up: unavailable historical locations + +Needs you now offers **Review locations** alongside Retry. The details sheet +shows each failed directory and optional workspace, its project, which checks +failed, and whether the location came from a project root, a registered worktree, +session history, or discovery. Paths are selectable, remain in memory, and are +excluded from the support report. + +The server can return a generic HTTP 500 for a removed directory. The app does +not treat that response alone as proof of deletion. It explains the possible +removed-worktree case and offers project management without deleting sessions +or silently marking failed locations reconciled. Successful checks still display +their requests. Retrying clears the failure details when those checks recover. + +Regression tests exercise historical and project-root failures, partial success +between forms and permissions, recovery, and opening the details sheet with or +without known pending requests. + +Historical directories beneath a live project root now have a presence check +before attention requests. A successful parent-directory listing that omits the +directory lets the app exclude it from attention coverage. Current project roots +and registered worktrees remain checked, as do locations with active sessions, +event or notification activity, a current selection, or cached pending requests. +Session history remains available. Refresh and reconciliation check presence +again, so restored directories return to coverage. Failed or malformed listings +do not count as proof of removal. Locations outside the project root remain +checked because this probe cannot establish their absence. + +## Device follow-up + +Before calling the visual review device-verified, check both iOS and Android: + +- Small phone width and maximum supported accessibility text size, especially + Settings switches, connection profile actions, pairing previews, and startup + recovery screens. +- Form and permission actions with long content, visible keyboard, and multiple + pending requests. Confirm scrolling reaches every action. +- VoiceOver and TalkBack focus order, selected controls, busy announcements, + and focus return after closing Details or a picker. +- Keyboard appearance and safe-area spacing in search, pairing, forms, and the + expanded composer. Android keyboard colors remain keyboard-app controlled. +- Native switch rendering, reduced-motion behavior, and the growing custom iOS + header on supported OS versions. + +Record actual device observations in `COMPATIBILITY.md`. The existing open +accessibility and device-validation tasks in `TODO.md` still apply. diff --git a/packages/opencode-adapter/src/index.test.ts b/packages/opencode-adapter/src/index.test.ts index ea99f29..95f4ee0 100644 --- a/packages/opencode-adapter/src/index.test.ts +++ b/packages/opencode-adapter/src/index.test.ts @@ -35,6 +35,7 @@ import { listOpenCodeSessions, listOpenCodeSkills, normalizeOpenCodeBaseUrl, + openCodeDirectoryExists, openEventStreamGeneration, probeEventStream, probePtyTransport, @@ -53,6 +54,69 @@ import { waitForOpenCodeSession, } from "./index"; +describe("historical directory presence", () => { + it("checks the live parent rather than initializing a missing worktree", async () => { + const fixture = createFakeOpenCodeApi({ + directoryEntries: [ + { path: ".worktree/live/", type: "directory" }, + { path: ".worktree/link", type: "file" }, + ], + }); + const client = createOpenCodeClient({ baseUrl: "http://fake.invalid", fetch: fixture.fetch }); + await expect( + openCodeDirectoryExists(client, { directory: "/workspace" }, "/workspace/.worktree/retired"), + ).resolves.toBe(false); + await expect( + openCodeDirectoryExists(client, { directory: "/workspace" }, "/workspace/.worktree/live"), + ).resolves.toBe(true); + await expect( + openCodeDirectoryExists(client, { directory: "/workspace" }, "/workspace/.worktree/link"), + ).resolves.toBe(true); + expect( + fixture.requests.every( + (request) => request.path === "/api/fs/list" && request.query.path?.[0] === ".worktree", + ), + ).toBe(true); + }); + + it.each([401, 404, 500])("does not interpret HTTP %i as directory deletion", async (status) => { + const fixture = createFakeOpenCodeApi({ + failures: { "/api/fs/list": { status, body: { message: "unavailable" } } }, + }); + const client = createOpenCodeClient({ baseUrl: "http://fake.invalid", fetch: fixture.fetch }); + await expect( + openCodeDirectoryExists(client, { directory: "/workspace" }, "/workspace/.worktree/retired"), + ).rejects.toBeDefined(); + }); + + it.each(["live", "/workspace/.worktree/live", ".worktree/nested/live"])( + "rejects ambiguous directory entry %s", + async (path) => { + const fixture = createFakeOpenCodeApi({ directoryEntries: [{ path, type: "directory" }] }); + const client = createOpenCodeClient({ baseUrl: "http://fake.invalid", fetch: fixture.fetch }); + await expect( + openCodeDirectoryExists( + client, + { directory: "/workspace" }, + "/workspace/.worktree/retired", + ), + ).rejects.toThrow("MALFORMED_DIRECTORY_LIST"); + }, + ); + + it.each(["/elsewhere/retired", "/workspace/../retired", "/workspace/.worktree//retired"])( + "rejects unscoped candidate %s", + async (directory) => { + const fixture = createFakeOpenCodeApi(); + const client = createOpenCodeClient({ baseUrl: "http://fake.invalid", fetch: fixture.fetch }); + await expect( + openCodeDirectoryExists(client, { directory: "/workspace" }, directory), + ).rejects.toThrow(); + expect(fixture.requests).toHaveLength(0); + }, + ); +}); + describe("normalizeOpenCodeBaseUrl", () => { it("normalizes an origin", () => { expect(normalizeOpenCodeBaseUrl(" https://open.tailnet.ts.net:4096/ ")).toBe( diff --git a/packages/opencode-adapter/src/index.ts b/packages/opencode-adapter/src/index.ts index cf1d754..32cbdf7 100644 --- a/packages/opencode-adapter/src/index.ts +++ b/packages/opencode-adapter/src/index.ts @@ -336,6 +336,56 @@ export async function listOpenCodeSkills( return output; } +// Query the parent through a live project root, not through the possibly deleted +// location. A successful, complete directory listing is evidence of absence; +// an HTTP error, including a generic 404/500, is not. +export async function openCodeDirectoryExists( + client: OpenCodeClient, + root: LocationRef, + directory: string, + options?: OpenCodeRequestOptions, +) { + const base = root.directory.replace(/\\/g, "/").replace(/\/$/, ""); + const target = directory.replace(/\\/g, "/"); + if (!target.startsWith(`${base}/`) || root.workspaceID) + throw new Error("DIRECTORY_OUTSIDE_PROJECT"); + const relative = target.slice(base.length + 1); + if (relative.split("/").some((part) => !part || part === "." || part === "..")) { + throw new Error("INVALID_DIRECTORY_PATH"); + } + const parent = relative.includes("/") ? relative.slice(0, relative.lastIndexOf("/")) : "."; + const output = await client.file.list({ location: locationInput(root), path: parent }, options); + validateResolvedLocation(output.location); + if ( + output.location.directory !== root.directory || + !Array.isArray(output.data) || + !output.data.every((entry) => + isRecord(entry) && typeof entry.path === "string" + ? isValidFileSystemEntry({ ...entry, path: entry.path.replace(/\/$/, "") }) + : false, + ) + ) { + throw new Error("MALFORMED_DIRECTORY_LIST"); + } + // 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(/\/$/, "")); + if ( + paths.some( + (path) => + !path.startsWith(prefix) || + !path.slice(prefix.length) || + path.slice(prefix.length).includes("/") || + path.includes("\\"), + ) + ) { + throw new Error("MALFORMED_DIRECTORY_LIST"); + } + // A symlink may be reported as a file. Only absence is proof of retirement. + return paths.includes(relative); +} + export async function findOpenCodeFiles( client: OpenCodeClient, location: LocationRef, diff --git a/packages/test-fixtures/src/index.ts b/packages/test-fixtures/src/index.ts index 4a26d1f..ca28a89 100644 --- a/packages/test-fixtures/src/index.ts +++ b/packages/test-fixtures/src/index.ts @@ -5,6 +5,7 @@ export type FakeOpenCodeApiOptions = { eventFrame?: string; failures?: Record; files?: unknown[]; + directoryEntries?: unknown[]; forms?: unknown[]; location?: { directory: string; @@ -138,6 +139,12 @@ export function createFakeOpenCodeApi(options: FakeOpenCodeApiOptions = {}) { if (url.pathname === "/api/fs/find") { return json({ location: resolvedLocation(options, url), data: options.files ?? [] }); } + if (url.pathname === "/api/fs/list") { + return json({ + location: resolvedLocation(options, url), + data: options.directoryEntries ?? [], + }); + } const formMatch = url.pathname.match( /^\/api\/session\/([^/]+)\/form\/([^/]+)(?:\/(reply|cancel))?$/, );