authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-11 14:19:53-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 14:19:53-07:00
logd07a07f33c0a1b317dea6234636976231c3688b5
treeb3a135cb2156e40d73b386bdb42ca4223b86c2b3
parent5cbe82a87006a7ee472f0f9c9fce5d5f24caa795
signature Signed by PGP key B5690EEEBB952194

feat(mobile): keep pull requests available offline (#18351)


48 files changed, 1711 insertions(+), 564 deletions(-)

apps/mobile/src/connection/pull-request-cache-store.ts created+22
......@@ -0,0 +1,22 @@
1import { EnvironmentQueryStore } from "@t3tools/client-runtime/state/query-persistence";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4
5import { MobileDatabase } from "../persistence/mobile-database";
6
7export const layer = Layer.effect(
8 EnvironmentQueryStore,
9 Effect.gen(function* () {
10 const database = yield* MobileDatabase;
11 return {
12 load: (environmentId, key) =>
13 database
14 .loadCache(environmentId, "pull-request", `v1:${key}`)
15 .pipe(Effect.catch(() => Effect.succeedNone)),
16 save: (environmentId, key, payload) =>
17 database
18 .saveCache(environmentId, "pull-request", `v1:${key}`, 1, payload)
19 .pipe(Effect.catch(() => Effect.logWarning("Offline query database save failed"))),
20 } satisfies NonNullable<typeof EnvironmentQueryStore.Service>;
21 }),
22);
apps/mobile/src/features/pull-requests/PullRequestControls.tsx+5-4
......@@ -123,7 +123,8 @@ export function PullRequestActionToolbar(props: {
123123 const agent = usePullRequestAgentActions(props.params, props.pr, props.filePath);
124124 const canReview = permissions.verdicts.length > 0;
125125 const pendingReview = canReview && draft.comments.length > 0;
126 const canOpenThread = agent.threads.length > 0 || agent.canAsk;
126 const showAgentControls = agent.canAsk || !permissions.connected;
127 const canOpenThread = agent.threads.length > 0 || showAgentControls;
127128 const threadLabel = agent.threads.length ? "Continue thread" : "Work on PR";
128129 const primaryLabel = pendingReview ? `Submit review · ${draft.comments.length}` : threadLabel;
129130 const chooseThread = agent.threads.length > 1;
......@@ -142,7 +143,7 @@ export function PullRequestActionToolbar(props: {
142143 // Reserve the native outer margins, custom-button padding, and item gaps.
143144 // UIKit needs this space to keep every action out of its overflow menu.
144145 // Duo's side rail uses an intrinsic icon item instead.
145 const sideControls = Number(agent.canAsk) + 1;
146 const sideControls = Number(showAgentControls) + 1;
146147 const primaryWidth = verticalControls
147148 ? undefined
148149 : Math.max(
......@@ -166,7 +167,7 @@ export function PullRequestActionToolbar(props: {
166167 className="flex-row items-center justify-between gap-3 border-t border-border-subtle bg-screen px-4 pt-2"
167168 style={{ paddingBottom: Math.max(insets.bottom, 8) }}
168169 >
169 {agent.canAsk ? (
170 {showAgentControls ? (
170171 <ControlPillMenu
171172 title="AI helpers"
172173 actions={agentActions.map((action) => agent.menuAction(action.task))}
......@@ -259,7 +260,7 @@ export function PullRequestActionToolbar(props: {
259260 />
260261 )
261262 ) : null;
262 const agentControl = agent.canAsk ? (
263 const agentControl = showAgentControls ? (
263264 <NativeHeaderToolbar.Menu
264265 identifier="pull-request-agent"
265266 width={controlSize}
apps/mobile/src/features/pull-requests/PullRequestDescription.tsx+50-57
......@@ -1,13 +1,20 @@
1import { useMemo, useState } from "react";
2import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from "react-native";
1import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown";
2import {
3 PullRequestImageAttachment,
4 PullRequestMedia,
5 PullRequestMediaContext,
6} from "./PullRequestMedia";
7import { PullRequestSectionHeading } from "./PullRequestSectionHeading";
8import { useContext, useMemo, useState } from "react";
9import { Pressable, ScrollView, StyleSheet, View } from "react-native";
310import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
4import { Image } from "expo-image";
11import { pullRequestImageSource } from "./pullRequestMediaSource";
512import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";
613import { nativeMarkdownWithHtml } from "@t3tools/mobile-markdown-text/markdown";
714import { SymbolView } from "../../components/AppSymbol";
815import { AppText as Text } from "../../components/AppText";
916import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
10import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal";
17import type { FilePreviewItem } from "../../components/FilePreviewModal";
1118import { PullRequestMarkdown } from "./PullRequestMarkdown";
1219import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent";
1320
......@@ -21,31 +28,33 @@ export function PullRequestDescription(props: {
2128}) {
2229 const { expanded, onExpandedChange } = props;
2330 const [bodyHeight, setBodyHeight] = useState(0);
24 const [preview, setPreview] = useState<FilePreviewSource | null>(null);
25 const openingUri = preview && "uri" in preview ? preview.uri : null;
31 const mediaContext = useContext(PullRequestMediaContext);
2632 const { themeVariables: colors } = useAppearancePreferences();
2733 const content = useMemo(() => {
2834 const document = nativeMarkdownWithHtml(
2935 parseMarkdownWithOptions(props.markdown, { gfm: true, html: true, math: false }),
3036 (markdown) => parseMarkdownWithOptions(markdown, { gfm: true, html: false, math: false }),
3137 );
32 return pullRequestDescriptionContent(document);
38 return pullRequestDescriptionContent(document, splitPullRequestBody(props.markdown));
3339 }, [props.markdown]);
34 const gallery = useMemo(
40 const gallery = useMemo<FilePreviewItem[]>(
3541 () =>
36 content.attachments.map((attachment) => ({
37 kind: "image" as const,
38 uri: attachment.uri,
39 name: new URL(attachment.uri).pathname.split("/").pop() || "Image",
40 })),
41 [content],
42 content.attachments
43 .filter((attachment) => attachment.kind === "image")
44 .map((attachment) => {
45 return {
46 ...pullRequestImageSource(attachment.uri, mediaContext),
47 name: new URL(attachment.uri).pathname.split("/").pop() || "Image",
48 };
49 }),
50 [content, mediaContext],
4251 );
4352 const canCollapse = bodyHeight > COLLAPSED_HEIGHT;
4453 const collapsed = canCollapse && !expanded;
4554 return (
4655 <View className="gap-5">
4756 <View className="gap-3">
48 <Text className="text-base font-t3-semibold">Description</Text>
57 <PullRequestSectionHeading>Description</PullRequestSectionHeading>
4958 {props.markdown.trim() ? (
5059 <View className="overflow-hidden">
5160 <View
......@@ -130,7 +139,7 @@ export function PullRequestDescription(props: {
130139 </View>
131140 {content.attachments.length ? (
132141 <View className="gap-3">
133 <Text className="text-base font-t3-semibold">Attachments</Text>
142 <PullRequestSectionHeading>Attachments</PullRequestSectionHeading>
134143 <ScrollView
135144 horizontal
136145 keyboardShouldPersistTaps="always"
......@@ -138,50 +147,34 @@ export function PullRequestDescription(props: {
138147 showsHorizontalScrollIndicator={false}
139148 contentContainerStyle={{ gap: 12 }}
140149 >
141 {content.attachments.map((attachment) => (
142 <Pressable
143 key={attachment.uri}
144 accessibilityRole="imagebutton"
145 accessibilityLabel={`View attachment: ${attachment.caption}`}
146 accessibilityState={{ busy: openingUri === attachment.uri }}
147 disabled={preview !== null}
148 onPress={() =>
149 setPreview({
150 kind: "image",
151 uri: attachment.uri,
152 name: gallery.find((item) => item.uri === attachment.uri)?.name ?? "Image",
153 gallery,
154 })
155 }
156 className="gap-2 active:opacity-70"
157 style={{ width: 176 }}
158 >
159 <View className="overflow-hidden rounded-2xl border border-border-subtle bg-grouped-card">
160 <Image
161 source={{ uri: attachment.uri }}
162 style={{ width: 174, height: 112 }}
163 contentFit="contain"
164 cachePolicy="memory-disk"
165 />
166 {openingUri === attachment.uri ? (
167 <View
168 pointerEvents="none"
169 style={StyleSheet.absoluteFill}
170 className="items-center justify-center bg-black/30"
171 >
172 <ActivityIndicator color="white" />
173 </View>
174 ) : null}
175 </View>
176 <Text numberOfLines={1} className="text-xs text-foreground-muted">
177 {attachment.caption}
178 </Text>
179 </Pressable>
180 ))}
150 {content.attachments.map((attachment) =>
151 attachment.kind !== "image" ? (
152 <PullRequestMedia
153 key={attachment.uri}
154 url={attachment.uri}
155 kind={attachment.kind}
156 caption={attachment.caption}
157 compact
158 />
159 ) : (
160 <PullRequestImageAttachment
161 key={attachment.uri}
162 url={attachment.uri}
163 caption={attachment.caption}
164 source={
165 gallery[
166 content.attachments
167 .filter((item) => item.kind === "image")
168 .indexOf(attachment)
169 ]!
170 }
171 gallery={gallery}
172 />
173 ),
174 )}
181175 </ScrollView>
182176 </View>
183177 ) : null}
184 <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} />
185178 </View>
186179 );
187180}
apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx+1
......@@ -118,6 +118,7 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss
118118 const resolveLabel = thread?.isResolved ? "Unresolve" : "Resolve";
119119 return (
120120 <PullRequestMarkdownRepository
121 params={params}
121122 value={detail.data?.provider === "github" ? detail.data.url : null}
122123 >
123124 <KeyboardAvoidingView automaticOffset behavior="height" className="flex-1 bg-screen">
apps/mobile/src/features/pull-requests/PullRequestFiles.tsx+12-11
......@@ -1,3 +1,4 @@
1import { PullRequestSectionHeading } from "./PullRequestSectionHeading";
12import { AnimatedLegendList } from "@legendapp/list/reanimated";
23import Animated from "react-native-reanimated";
34import { PR_ROW_LAYOUT, PR_ROW_ENTER, PR_ROW_EXIT } from "./pullRequestAnimations";
......@@ -167,15 +168,15 @@ export function usePullRequestFilesContent(props: {
167168 accessibilityState={{ busy: true }}
168169 className="min-h-17 flex-row items-center gap-2 py-3"
169170 >
170 <View className="min-w-0 flex-1 gap-2">
171 <Text className="text-base font-t3-semibold">
172 {fileCount === undefined
173 ? "Changed files"
174 : `${fileCount} changed ${fileCount === 1 ? "file" : "files"}`}
175 </Text>
176 {filter.hideTestFiles ? (
171 <View className="min-w-0 flex-1 gap-1">
172 <PullRequestSectionHeading>Changed files</PullRequestSectionHeading>
173 {fileCount !== undefined ? (
174 <Text className="text-2xs text-foreground-muted">
175 {fileCount} {fileCount === 1 ? "file" : "files"}
176 </Text>
177 ) : (
177178 <View className="h-2.5 w-28 rounded bg-subtle-strong" />
178 ) : null}
179 )}
179180 </View>
180181 {summary?.additions !== undefined && summary.deletions !== undefined ? (
181182 <PullRequestDiffStats
......@@ -196,14 +197,14 @@ export function usePullRequestFilesContent(props: {
196197 <>
197198 <View className="min-h-17 flex-row items-center gap-2 py-3">
198199 <View className="min-w-0 flex-1 gap-1">
199 <Text className="text-base font-t3-semibold">
200 <PullRequestSectionHeading>Changed files</PullRequestSectionHeading>
201 <Text className="text-2xs text-foreground-muted">
200202 {parsed.fileCount} {filter.hiddenCount ? "visible" : "changed"}{" "}
201203 {parsed.fileCount === 1 ? "file" : "files"}
202204 {viewed.supported
203205 ? ` · ${parsed.files.filter((file) => viewed.isViewed(file.path)).length} viewed`
204206 : ""}
205 </Text>
206 <Text className="text-2xs text-foreground-muted">
207 {" · "}
207208 {filter.hideTestFiles
208209 ? `${filter.hiddenCount} test ${filter.hiddenCount === 1 ? "file" : "files"} hidden`
209210 : "Test files included"}
apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx+90-15
......@@ -1,3 +1,13 @@
1import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown";
2import type { ReactNode } from "react";
3import { View } from "react-native";
4import { useProject } from "../../state/entities";
5import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
6import {
7 PullRequestMedia,
8 PullRequestMediaContext,
9 usePullRequestMediaUrl,
10} from "./PullRequestMedia";
111import { videoMimeType } from "@t3tools/shared/video";
212import { createContext, useContext, useCallback, useMemo, useState } from "react";
313import { githubMarkdownLinks } from "@t3tools/shared/githubMarkdownLinks";
......@@ -16,7 +26,8 @@ import {
1626} from "../../native/SelectableMarkdownText";
1727import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview";
1828import { markdownImageSources } from "../../lib/markdownImageSources";
19import { FilePreviewGallery } from "../../components/FilePreviewGallery";
29import { FilePreviewGallery, useFilePreviewGallery } from "../../components/FilePreviewGallery";
30import { pullRequestImageSource } from "./pullRequestMediaSource";
2031import {
2132 FilePreviewModal,
2233 type FilePreviewSource,
......@@ -25,7 +36,78 @@ import {
2536import { ThreadMarkdownImageView } from "../threads/ThreadMarkdownImage";
2637import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText";
2738
28export const PullRequestMarkdownRepository = createContext<string | null>(null);
39const RepositoryContext = createContext<string | null>(null);
40
41export function PullRequestMarkdownRepository(props: {
42 readonly value: string | null;
43 readonly params: PullRequestParams;
44 readonly children: ReactNode;
45}) {
46 const target = pullRequestTarget(props.params);
47 const project = useProject({
48 environmentId: target.environmentId,
49 projectId: target.input.projectId,
50 });
51 const cwd = project?.workspaceRoot;
52 const media = useMemo(
53 () => (cwd ? { environmentId: target.environmentId, cwd } : null),
54 [target.environmentId, cwd],
55 );
56 return (
57 <RepositoryContext value={props.value}>
58 <PullRequestMediaContext value={media}>{props.children}</PullRequestMediaContext>
59 </RepositoryContext>
60 );
61}
62
63function MarkdownImage(props: {
64 readonly href: string;
65 readonly alt: string | null;
66 readonly onPreview: (source: FilePreviewSource) => void;
67}) {
68 const media = usePullRequestMediaUrl(props.href);
69 const context = useContext(PullRequestMediaContext);
70 const withGallery = useFilePreviewGallery();
71 return (
72 <ThreadMarkdownImageView
73 uri={media.uri}
74 sourceKey={props.href}
75 unavailable={media.unavailable || !/^https?:\/\//i.test(props.href)}
76 alt={props.alt}
77 onPressPreview={(source) =>
78 props.onPreview(
79 withGallery({
80 ...pullRequestImageSource(props.href, context),
81 name: source.name,
82 sourceIdentifier: source.sourceIdentifier,
83 }),
84 )
85 }
86 />
87 );
88}
89
90export function PullRequestMarkdown(props: {
91 readonly markdown: string;
92 readonly imageGroupMarkdown?: string;
93}) {
94 const segments = useMemo(() => splitPullRequestBody(props.markdown), [props.markdown]);
95 return (
96 <View style={{ gap: 12 }}>
97 {segments.map((segment) =>
98 segment.kind === "markdown" ? (
99 <PullRequestMarkdownContent
100 key={segment.id}
101 markdown={segment.text}
102 imageGroupMarkdown={props.imageGroupMarkdown ?? props.markdown}
103 />
104 ) : (
105 <PullRequestMedia key={segment.id} url={segment.url} kind={segment.media} />
106 ),
107 )}
108 </View>
109 );
110}
29111
30112function renderLink(
31113 { href, children }: CustomRendererProps,
......@@ -61,11 +143,12 @@ function renderHtmlBlock(
61143 ) : null;
62144}
63145
64export function PullRequestMarkdown(props: {
146function PullRequestMarkdownContent(props: {
65147 readonly markdown: string;
66148 readonly imageGroupMarkdown?: string;
67149}) {
68 const repositoryUrl = useContext(PullRequestMarkdownRepository);
150 const repositoryUrl = useContext(RepositoryContext);
151 const mediaContext = useContext(PullRequestMediaContext);
69152 const markdown = useMemo(
70153 () => githubMarkdownLinks(props.markdown, repositoryUrl),
71154 [props.markdown, repositoryUrl],
......@@ -85,20 +168,12 @@ export function PullRequestMarkdown(props: {
85168 /^https?:\/\//i.test(uri) &&
86169 videoMimeType({ name: uri.split(/[?#]/)[0] ?? uri, mimeType: "" }) === null,
87170 )
88 .map((uri) => ({ kind: "image", uri })),
89 [props.markdown, props.imageGroupMarkdown],
171 .map((uri) => pullRequestImageSource(uri, mediaContext)),
172 [props.markdown, props.imageGroupMarkdown, mediaContext],
90173 );
91174 const [preview, setPreview] = useState<FilePreviewSource | null>(null);
92175 const renderImage = useCallback<MarkdownImageRenderer>(
93 (image) => (
94 <ThreadMarkdownImageView
95 uri={/^https?:\/\//i.test(image.href) ? image.href : null}
96 sourceKey={image.href}
97 unavailable={!/^https?:\/\//i.test(image.href)}
98 alt={image.alt}
99 onPressPreview={setPreview}
100 />
101 ),
176 (image) => <MarkdownImage href={image.href} alt={image.alt} onPreview={setPreview} />,
102177 [],
103178 );
104179 const styles = useMarkdownPreviewStyles(renderImage);
apps/mobile/src/features/pull-requests/PullRequestMedia.tsx created+142
......@@ -0,0 +1,142 @@
1import { createContext, useContext, useMemo, useState } from "react";
2import { pullRequestMediaSource, pullRequestImageSource } from "./pullRequestMediaSource";
3import { useFilePreviewGallery } from "../../components/FilePreviewGallery";
4import type { AssetResource, EnvironmentId } from "@t3tools/contracts";
5import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia";
6import { videoMimeType } from "@t3tools/shared/video";
7import { Pressable, View } from "react-native";
8import { Image } from "expo-image";
9import { AppText } from "../../components/AppText";
10import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets";
11import { MediaVideoPlayer } from "../../components/MediaVideoPlayer";
12import { VideoAttachmentTile } from "../../components/VideoAttachmentTile";
13import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
14import { ThreadMarkdownImageView } from "../threads/ThreadMarkdownImage";
15import {
16 FilePreviewModal,
17 type FilePreviewSource,
18 type FilePreviewItem,
19} from "../../components/FilePreviewModal";
20
21export const PullRequestMediaContext = createContext<{
22 readonly environmentId: EnvironmentId;
23 readonly cwd: string;
24} | null>(null);
25
26/** Only GitHub-hosted media needs the environment's authenticated asset path. */
27export function usePullRequestMediaUrl(url: string) {
28 const context = useContext(PullRequestMediaContext);
29 const fetchUrl = githubMediaFetchUrl(url);
30 const resource = useMemo<AssetResource | null>(
31 () => (context && fetchUrl ? { _tag: "github-media", cwd: context.cwd, url: fetchUrl } : null),
32 [context, fetchUrl],
33 );
34 const asset = useAssetUrlState(context?.environmentId ?? null, resource);
35 const refresh = useRefreshAssetUrl(context?.environmentId ?? null, resource);
36 return {
37 uri: asset._tag === "Success" ? asset.url : resource ? null : url,
38 unavailable: resource !== null && asset._tag === "Failure",
39 source: pullRequestMediaSource(url, context),
40 resolvePlaybackUri: resource ? async () => (await refresh()) ?? null : undefined,
41 };
42}
43
44export function PullRequestMedia(props: {
45 readonly url: string;
46 readonly kind: "image" | "video" | "unknown";
47 readonly caption?: string;
48 readonly compact?: boolean;
49}) {
50 const media = usePullRequestMediaUrl(props.url);
51 const context = useContext(PullRequestMediaContext);
52 const withGallery = useFilePreviewGallery();
53 const [image, setImage] = useState<FilePreviewSource | null>(null);
54 const [video, setVideo] = useState<VideoPreviewSource | null>(null);
55 const name = props.caption || new URL(props.url).pathname.split("/").pop() || "Attachment";
56 if (props.kind === "image")
57 return (
58 <>
59 <ThreadMarkdownImageView
60 uri={media.uri}
61 sourceKey={props.url}
62 unavailable={media.unavailable}
63 alt={name}
64 onPressPreview={(source) =>
65 setImage(
66 withGallery({
67 ...pullRequestImageSource(props.url, context),
68 name,
69 sourceIdentifier: source.sourceIdentifier,
70 }),
71 )
72 }
73 />
74 <FilePreviewModal source={image} onRequestClose={() => setImage(null)} />
75 </>
76 );
77 return (
78 <View style={props.compact ? { width: 176 } : undefined}>
79 {props.compact ? (
80 <VideoAttachmentTile
81 name={name}
82 sourceIdentifier={props.url}
83 thumbnailSource={media.uri}
84 style={{ width: 176, height: 112, borderRadius: 16 }}
85 onPress={() =>
86 setVideo({
87 type: "media",
88 ...media.source,
89 name,
90 mimeType:
91 videoMimeType({ name: props.url.split(/[?#]/)[0] ?? props.url, mimeType: "" }) ??
92 "video/mp4",
93 })
94 }
95 />
96 ) : (
97 <MediaVideoPlayer
98 uri={media.uri}
99 unavailable={media.unavailable}
100 name={name}
101 thumbnailKey={props.url}
102 resolvePlaybackUri={media.resolvePlaybackUri}
103 />
104 )}
105 <VideoPreviewModal source={video} onRequestClose={() => setVideo(null)} />
106 </View>
107 );
108}
109
110export function PullRequestImageAttachment(props: {
111 readonly url: string;
112 readonly caption: string;
113 readonly source: FilePreviewItem;
114 readonly gallery: readonly FilePreviewItem[];
115}) {
116 const media = usePullRequestMediaUrl(props.url);
117 const [preview, setPreview] = useState<FilePreviewSource | null>(null);
118 return (
119 <>
120 <Pressable
121 accessibilityRole="imagebutton"
122 accessibilityLabel={`View attachment: ${props.caption}`}
123 onPress={() => setPreview({ ...props.source, gallery: props.gallery })}
124 className="gap-2 active:opacity-70"
125 style={{ width: 176 }}
126 >
127 <View className="overflow-hidden rounded-2xl border border-border-subtle bg-grouped-card">
128 <Image
129 source={media.uri ? { uri: media.uri } : null}
130 style={{ width: 174, height: 112 }}
131 contentFit="contain"
132 cachePolicy="memory-disk"
133 />
134 </View>
135 <AppText numberOfLines={1} className="text-xs text-foreground-muted">
136 {props.caption}
137 </AppText>
138 </Pressable>
139 <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} />
140 </>
141 );
142}
apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx+8-4
......@@ -54,9 +54,12 @@ export function PullRequestMergeControls(props: {
5454 hasStack: stack.stack !== null,
5555 isFresh: stack.isFresh,
5656 });
57 const checkingStack = stack.supportsStackActions && !stack.isFresh && !stack.error;
57 const checkingStack =
58 permissions.connected && stack.supportsStackActions && !stack.isFresh && !stack.error;
5859 const reason =
59 mergeUnavailableReason(pr, permissions.canWrite) ??
60 (!permissions.connected
61 ? "Reconnect to merge."
62 : mergeUnavailableReason(pr, permissions.canWrite)) ??
6063 (stack.stack
6164 ? !stack.supportsStackActions
6265 ? "This environment can display stacks but cannot merge them."
......@@ -185,7 +188,7 @@ export function PullRequestMergeControls(props: {
185188 <Text
186189 className={`shrink text-center text-sm font-t3-semibold ${canMerge ? "text-screen" : "text-foreground-muted"}`}
187190 >
188 {busy ? "Working…" : label}
191 {busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label}
189192 </Text>
190193 </Pressable>
191194 {methods.length > 0 || canAutoMerge ? (
......@@ -230,7 +233,8 @@ export function PullRequestMergeControls(props: {
230233 </ControlPillMenu>
231234 ) : null}
232235 </View>
233 {reason &&
236 {permissions.connected &&
237 reason &&
234238 reason !== MERGE_CONFLICT_REASON &&
235239 reason !== "Checking stack membership…" &&
236240 !(checkingStack && reason === "Refresh the stack before merging.") ? (
apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx+2-1
......@@ -1,3 +1,4 @@
1import { PullRequestSectionHeading } from "./PullRequestSectionHeading";
12import type { PullRequestListEntry } from "@t3tools/contracts";
23import { View } from "react-native";
34import { AppText as Text } from "../../components/AppText";
......@@ -97,7 +98,7 @@ export function PullRequestOverviewLoading({
9798 </View>
9899 ) : null}
99100 <View className="gap-3">
100 <Text className="text-base font-t3-semibold">Description</Text>
101 <PullRequestSectionHeading>Description</PullRequestSectionHeading>
101102 <View className="gap-3 py-1">
102103 <View className="h-4 w-full rounded bg-subtle-strong" />
103104 <View className="h-4 w-full rounded bg-subtle-strong" />
apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx+3
......@@ -1,3 +1,4 @@
1import { usePullRequestConnected } from "./usePullRequestConnected";
12import { useAtomValue } from "@effect/atom-react";
23import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
34import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
......@@ -60,7 +61,9 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps<PullRequ
6061 const canWrite = useAtomValue(
6162 composerPullRequests.requestReviewers.permissionAtom(target.environmentId),
6263 );
64 const connected = usePullRequestConnected(route.params.environmentId);
6365 const allowed =
66 connected &&
6467 canWrite &&
6568 pr?.state === "open" &&
6669 pr.capabilities.reviewers.request &&
apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx+10-3
......@@ -62,6 +62,7 @@ import { SegmentedControl } from "../../components/SegmentedControl";
6262import { SymbolView } from "../../components/AppSymbol";
6363import { relativeTime } from "../../lib/time";
6464import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
65import { usePullRequestConnected } from "./usePullRequestConnected";
6566import { composerPullRequests } from "../../state/pull-requests";
6667import { useEnvironmentQuery } from "../../state/query";
6768import { usePullToRefresh } from "../../state/use-pull-to-refresh";
......@@ -272,6 +273,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
272273 const navigation = useNavigation();
273274 const insets = useSafeAreaInsets();
274275 const params = route.params;
276 const connected = usePullRequestConnected(params.environmentId);
275277 const [section, setSection] = useState<"overview" | "activity" | "guide">("overview");
276278 // Keep each tab's native list and measured rows alive when it is hidden.
277279 const requestedSection = useRef(section);
......@@ -350,18 +352,23 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
350352 }
351353 };
352354 return (
353 <PullRequestMarkdownRepository value={pr?.provider === "github" ? pr.url : null}>
355 <PullRequestMarkdownRepository
356 params={params}
357 value={pr?.provider === "github" ? pr.url : null}
358 >
354359 <View className="flex-1 bg-screen">
355360 <NativeStackScreenOptions
356361 options={{
357362 title: `#${params.number}`,
358363 headerTitle: `#${params.number}`,
359 unstable_headerSubtitle: params.repository,
364 unstable_headerSubtitle: connected
365 ? params.repository
366 : `${params.repository} · Offline`,
360367 }}
361368 />
362369 {Platform.OS === "android" ? (
363370 <AndroidScreenHeader
364 title={`#${params.number}`}
371 title={`#${params.number}${connected ? "" : " · Offline"}`}
365372 onBack={() => navigation.goBack()}
366373 actions={[
367374 ...(permissions.verdicts.length
apps/mobile/src/features/pull-requests/PullRequestSectionHeading.tsx created+6
......@@ -0,0 +1,6 @@
1import type { ReactNode } from "react";
2import { AppText } from "../../components/AppText";
3
4export function PullRequestSectionHeading({ children }: { readonly children: ReactNode }) {
5 return <AppText className="text-base font-t3-semibold">{children}</AppText>;
6}
apps/mobile/src/features/pull-requests/PullRequestStatusRows.tsx+3
......@@ -1,3 +1,4 @@
1import { usePullRequestConnected } from "./usePullRequestConnected";
12import { useAtomValue } from "@effect/atom-react";
23import { useNavigation } from "@react-navigation/native";
34import type { PullRequestAction, PullRequestDetail } from "@t3tools/contracts";
......@@ -237,7 +238,9 @@ export function PullRequestReviewersRow(props: RowProps) {
237238 pullRequestTarget(props.params).environmentId,
238239 ),
239240 );
241 const connected = usePullRequestConnected(props.params.environmentId);
240242 const canRequest =
243 connected &&
241244 canWrite &&
242245 props.pr.state === "open" &&
243246 props.pr.capabilities.reviewers.request &&
apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx+21-11
......@@ -99,7 +99,6 @@ export function PullRequestsScreen() {
9999 environments
100100 .filter(
101101 (environment) =>
102 environment.connectionState === "connected" &&
103102 configs.get(environment.environmentId)?.environment.capabilities.pullRequests ===
104103 true &&
105104 (environmentId === null || environment.environmentId === environmentId) &&
......@@ -121,8 +120,15 @@ export function PullRequestsScreen() {
121120 [environments, environmentId, configs, controls, query],
122121 );
123122 const result = usePullRequestList(targets);
123 const connectedTargets = targets.filter((target) =>
124 environments.some(
125 (environment) =>
126 environment.environmentId === target.environmentId &&
127 environment.connectionState === "connected",
128 ),
129 );
124130 const { actions, select, busyKey } = usePullRequestListActions(
125 targets.map((target) => target.environmentId),
131 connectedTargets.map((target) => target.environmentId),
126132 result.updateEntry,
127133 );
128134 const selectedKey = primaryColumn?.selectedPullRequestKey;
......@@ -309,7 +315,7 @@ export function PullRequestsScreen() {
309315 ) : undefined
310316 }
311317 ListEmptyComponent={
312 result.isPending ? (
318 result.isPending && connectedTargets.length > 0 ? (
313319 <View className="items-center py-12">
314320 <ActivityIndicator colorClassName="accent-icon" />
315321 </View>
......@@ -319,17 +325,21 @@ export function PullRequestsScreen() {
319325 title={
320326 result.error
321327 ? "Couldn't load pull requests"
322 : targets.length
323 ? query
324 ? "No results"
325 : "No matching pull requests"
326 : "Connect an environment"
328 : targets.length && connectedTargets.length === 0
329 ? "Environment offline"
330 : targets.length
331 ? query
332 ? "No results"
333 : "No matching pull requests"
334 : "Connect an environment"
327335 }
328336 detail={
329337 result.error ??
330 (targets.length
331 ? "Pull requests from the selected environments' projects appear here."
332 : "Add an environment in Settings to browse its pull requests.")
338 (targets.length && connectedTargets.length === 0
339 ? "Reconnect to load pull requests that aren't saved on this device."
340 : targets.length
341 ? "Pull requests from the selected environments' projects appear here."
342 : "Add an environment in Settings to browse its pull requests.")
333343 }
334344 variant="plain"
335345 />
apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx created+85
......@@ -0,0 +1,85 @@
1import { Pressable, View } from "react-native";
2import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated";
3import { AppText as Text } from "../../../components/AppText";
4import { SymbolView } from "../../../components/AppSymbol";
5import { PR_ROW_EXIT, PR_ROW_LAYOUT } from "../pullRequestAnimations";
6import { guideNoticeState } from "./reviewGuideState";
7import type { useReviewGuide } from "./useReviewGuide";
8
9const NOTICE_ENTER = FadeIn.delay(180).duration(120).reduceMotion(ReduceMotion.System);
10
11/** Keep the action in one place as loading, writing, and retry states change. */
12export function GuideNotice({ state }: { readonly state: ReturnType<typeof useReviewGuide> }) {
13 const notice = guideNoticeState(state);
14 if (notice.kind === "ready") return null;
15 const compact = state.data?.guide != null;
16 const failed = notice.kind === "failed" || notice.kind === "unavailable";
17 const busy = notice.kind === "loading" || notice.kind === "writing";
18 return (
19 <Animated.View
20 layout={PR_ROW_LAYOUT}
21 entering={compact ? NOTICE_ENTER : undefined}
22 exiting={PR_ROW_EXIT}
23 className={
24 compact ? "mb-5 rounded-2xl bg-grouped-card p-4" : "rounded-2xl bg-grouped-card p-5"
25 }
26 accessibilityLiveRegion="polite"
27 accessibilityState={{ busy }}
28 >
29 <View className="flex-row items-center gap-3">
30 <SymbolView
31 name={failed ? "exclamationmark.circle" : busy ? "clock" : "list.number"}
32 size={19}
33 tintColorClassName={failed ? "accent-danger-foreground" : "accent-icon-muted"}
34 />
35 <Text className="text-base font-t3-semibold">
36 {compact ? notice.title : "Review guide"}
37 </Text>
38 </View>
39 <View style={compact ? undefined : { minHeight: 100 }} className="pt-3 pb-4">
40 <Text className="text-sm leading-6 text-foreground-muted">{notice.description}</Text>
41 </View>
42 <View className="min-h-12 flex-row items-center justify-between gap-3">
43 {state.canWrite || busy || notice.kind === "unavailable" ? (
44 <Pressable
45 accessibilityRole="button"
46 accessibilityState={{ disabled: busy, busy }}
47 disabled={busy}
48 style={{ width: 152 }}
49 className={
50 compact
51 ? "min-h-12 items-center justify-center rounded-full bg-secondary px-4 active:opacity-70"
52 : "min-h-12 items-center justify-center rounded-full bg-foreground px-4 active:opacity-70"
53 }
54 onPress={() => (notice.kind === "unavailable" ? state.refresh() : void state.write())}
55 >
56 <Text
57 className={
58 compact
59 ? "text-center font-t3-medium text-secondary-foreground"
60 : "text-center font-t3-medium text-screen"
61 }
62 >
63 {notice.action}
64 </Text>
65 </Pressable>
66 ) : (
67 <Text className="text-xs text-foreground-muted">
68 {notice.kind === "failed"
69 ? "Reconnect or ask someone with write access to retry."
70 : "Writing a guide requires a connection and write access."}
71 </Text>
72 )}
73 {notice.kind === "writing" && state.data?.run?._tag === "Running" ? (
74 <Text className="shrink text-xs text-foreground-muted">
75 Started{" "}
76 {new Date(state.data.run.startedAt).toLocaleTimeString([], {
77 hour: "2-digit",
78 minute: "2-digit",
79 })}
80 </Text>
81 ) : null}
82 </View>
83 </Animated.View>
84 );
85}
apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx+32-93
......@@ -13,7 +13,6 @@ import {
1313import { SymbolView } from "../../../components/AppSymbol";
1414import { GuideOutline } from "./GuideOutline";
1515import { AppText as Text } from "../../../components/AppText";
16import { MaterialButton } from "../../../components/MaterialButton";
1716import { ScreenScrollView } from "../../../components/ScreenScrollView";
1817import { ControlPillMenu } from "../../../components/ControlPillMenu";
1918import { MaterialIconButton } from "../../../components/MaterialIconButton";
......@@ -24,6 +23,9 @@ import { PullRequestMarkdown, PullRequestMarkdownRepository } from "../PullReque
2423import { usePullRequestDiff, type PullRequestParams } from "../usePullRequestDiff";
2524import { ReviewGuideExcerpt } from "./ReviewGuideExcerpt";
2625import { reviewGuideState } from "./reviewGuideState";
26import { GuideNotice } from "./GuideNotice";
27import Animated from "react-native-reanimated";
28import { PR_ROW_ENTER, PR_ROW_EXIT, PR_ROW_LAYOUT } from "../pullRequestAnimations";
2729import { useEnvironmentQuery } from "../../../state/query";
2830import { composerPullRequests } from "../../../state/pull-requests";
2931import { usePullRequestPermissions } from "../usePullRequestPermissions";
......@@ -44,111 +46,47 @@ const attentionLabels = {
4446 accessibility: "An accessibility call",
4547};
4648
47function GuideNotice({ state }: { readonly state: ReturnType<typeof useReviewGuide> }) {
48 const { guide, stale, failure } = reviewGuideState(state.data);
49 const error = state.startError ?? failure ?? state.error;
50 const label = state.running
51 ? guide
52 ? "Rewriting review guide"
53 : "Writing review guide"
54 : error
55 ? "Could not write guide"
56 : stale
57 ? "Guide is out of date"
58 : "No review guide yet";
59 if (guide && !stale && !error && !state.running) return null;
60 return (
61 <View
62 className={guide ? "gap-3 border-l-2 border-border pl-4 py-1" : "gap-4 py-4"}
63 accessibilityLiveRegion="polite"
64 >
65 <View className="flex-row items-center gap-2">
66 <SymbolView
67 name={
68 state.running
69 ? "clock"
70 : error
71 ? "exclamationmark.circle"
72 : stale
73 ? "arrow.clockwise"
74 : "list.number"
75 }
76 size={20}
77 tintColorClassName={
78 error
79 ? "accent-danger-foreground"
80 : stale
81 ? "accent-warning-foreground"
82 : "accent-icon-muted"
83 }
84 />
85 <Text className={guide ? "text-base font-t3-semibold" : "text-xl font-t3-semibold"}>
86 {label}
87 </Text>
88 </View>
89 <Text className="text-sm leading-6 text-foreground-muted">
90 {error ??
91 (state.running
92 ? "An agent is reading the change. You can leave this screen and return when it’s ready."
93 : stale
94 ? "There are new changes since this guide was written. Rewrite it to see the latest code."
95 : "Walk through the important code in order, with notes on what changed and where to look closely.")}
96 </Text>
97 {state.running ? (
98 <Text className="text-xs text-foreground-muted">
99 {state.data?.run?._tag === "Running"
100 ? `Started at ${new Date(state.data.run.startedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`
101 : "Starting the guide…"}
102 </Text>
103 ) : state.canWrite ? (
104 <MaterialButton
105 label={error ? "Try again" : guide ? "Rewrite guide" : "Write guide"}
106 tone={guide ? "secondary" : "contrast"}
107 onPress={() => void state.write()}
108 />
109 ) : null}
110 </View>
111 );
112}
113
11449export function PullRequestGuideContent({ params }: { readonly params: PullRequestParams }) {
11550 const navigation = useNavigation();
11651 const state = useReviewGuide(params);
11752 const { guide, stale } = reviewGuideState(state.data);
11853 return (
119 <View className="w-full max-w-[760px] self-center gap-5">
54 <Animated.View layout={PR_ROW_LAYOUT} className="w-full max-w-[760px] self-center">
12055 {!state.enabled ? (
12156 <Text>Review guides are disabled for this server.</Text>
12257 ) : (
12358 <>
124 {state.isPending && !state.data ? (
125 <Text className="text-foreground-muted">Loading review guide…</Text>
126 ) : (
127 <GuideNotice state={state} />
128 )}
59 <GuideNotice state={state} />
12960 {guide ? (
130 <GuideOutline
131 guide={guide}
132 onOpen={(position) =>
133 navigation.dispatch(
134 StackActions.push("PullRequestGuideStop", {
135 ...params,
136 position,
137 guideHeadSha: guide.headSha,
138 guideGeneratedAt: guide.generatedAt,
139 }),
140 )
141 }
142 onRewrite={
143 state.canWrite && !state.running && !stale && !state.startError && !state.error
144 ? () => void state.write()
145 : undefined
146 }
147 />
61 <Animated.View
62 key={`outline:${guide.headSha}:${guide.generatedAt}`}
63 layout={PR_ROW_LAYOUT}
64 entering={PR_ROW_ENTER}
65 exiting={PR_ROW_EXIT}
66 >
67 <GuideOutline
68 guide={guide}
69 onOpen={(position) =>
70 navigation.dispatch(
71 StackActions.push("PullRequestGuideStop", {
72 ...params,
73 position,
74 guideHeadSha: guide.headSha,
75 guideGeneratedAt: guide.generatedAt,
76 }),
77 )
78 }
79 onRewrite={
80 state.canWrite && !state.running && !stale && !state.startError && !state.error
81 ? () => void state.write()
82 : undefined
83 }
84 />
85 </Animated.View>
14886 ) : null}
14987 </>
15088 )}
151 </View>
89 </Animated.View>
15290 );
15391}
15492
......@@ -240,6 +178,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps<GuideSto
240178 </NativeHeaderToolbar.Menu>
241179 </NativeHeaderToolbar>
242180 <PullRequestMarkdownRepository
181 params={route.params}
243182 value={`https://${route.params.host ?? "github.com"}/${route.params.repository}`}
244183 >
245184 <ScreenScrollView
apps/mobile/src/features/pull-requests/guide/reviewGuideState.test.ts+56-1
......@@ -4,7 +4,7 @@ import {
44 ProviderInstanceId,
55 type PullRequestReviewGuideResult,
66} from "@t3tools/contracts";
7import { reviewGuideState } from "./reviewGuideState";
7import { guideNoticeState, reviewGuideState } from "./reviewGuideState";
88
99const guide: NonNullable<PullRequestReviewGuideResult["guide"]> = {
1010 headSha: "old-head",
......@@ -46,3 +46,58 @@ describe("mobile review guide state", () => {
4646 });
4747 });
4848});
49
50const view = {
51 data: null,
52 isPending: false,
53 running: false,
54 startError: null,
55 error: null,
56};
57
58describe("guide notice transitions", () => {
59 it("does not flash an empty guide before the first response", () => {
60 expect(guideNoticeState({ ...view, isPending: true }).kind).toBe("loading");
61 expect(
62 guideNoticeState({ ...view, data: { guide: null, run: null, currentHeadSha: "head" } }).kind,
63 ).toBe("empty");
64 });
65
66 it("keeps cached guides visible while refreshing", () => {
67 expect(
68 guideNoticeState({
69 ...view,
70 isPending: true,
71 data: { guide, run: null, currentHeadSha: guide.headSha },
72 }).kind,
73 ).toBe("ready");
74 });
75
76 it("hides the previous failure as soon as a retry starts", () => {
77 const data = {
78 guide: null,
79 currentHeadSha: "head",
80 run: {
81 _tag: "Failed" as const,
82 headSha: "head",
83 failedAt: guide.generatedAt,
84 detail: "Writer disconnected",
85 },
86 };
87 expect(guideNoticeState({ ...view, data }).kind).toBe("failed");
88 expect(guideNoticeState({ ...view, data, running: true }).kind).toBe("writing");
89 });
90
91 it("keeps a stale guide readable through rewriting and completion", () => {
92 const data = { guide, currentHeadSha: "head", run: null };
93 expect(guideNoticeState({ ...view, data }).kind).toBe("stale");
94 expect(guideNoticeState({ ...view, data, running: true }).kind).toBe("writing");
95 expect(
96 guideNoticeState({ ...view, data: { ...data, guide: { ...guide, headSha: "head" } } }).kind,
97 ).toBe("ready");
98 });
99});
100
101it("distinguishes a failed read from a failed writer", () => {
102 expect(guideNoticeState({ ...view, error: "Connection lost" }).kind).toBe("unavailable");
103});
apps/mobile/src/features/pull-requests/guide/reviewGuideState.ts+61
......@@ -13,3 +13,64 @@ export function reviewGuideState(data: PullRequestReviewGuideResult | null) {
1313 : null,
1414 };
1515}
16
17/** A pending refresh must not hide cached content, or a new run reveal an old failure. */
18export function guideNoticeState(state: {
19 readonly data: PullRequestReviewGuideResult | null;
20 readonly isPending: boolean;
21 readonly running: boolean;
22 readonly startError: string | null;
23 readonly error: string | null;
24}) {
25 const { guide, stale, failure } = reviewGuideState(state.data);
26 if (state.running) {
27 return {
28 kind: "writing" as const,
29 title: "Rewriting guide",
30 description: guide
31 ? "You can keep reading this guide while the agent writes the update."
32 : "An agent is reading the change and writing the tour. You can leave and come back when it’s ready.",
33 action: guide ? "Rewriting…" : "Writing guide…",
34 };
35 }
36 const error = state.startError ?? failure;
37 if (error) {
38 return {
39 kind: "failed" as const,
40 title: "Could not write guide",
41 description: error,
42 action: "Try again",
43 };
44 }
45 if (state.error) {
46 return {
47 kind: "unavailable" as const,
48 title: "Could not refresh guide",
49 description: state.error,
50 action: "Try again",
51 };
52 }
53 if (!state.data) {
54 return {
55 kind: "loading" as const,
56 title: "Review guide",
57 description: "Checking for a saved guide…",
58 action: "Loading guide…",
59 };
60 }
61 if (stale) {
62 return {
63 kind: "stale" as const,
64 title: "New changes since this guide",
65 description: "You can read the previous guide, or rewrite it for the latest changes.",
66 action: "Rewrite guide",
67 };
68 }
69 return {
70 kind: guide ? ("ready" as const) : ("empty" as const),
71 title: "Review guide",
72 description:
73 "Get a tour of the change, with code excerpts and notes on what needs a closer look.",
74 action: "Write guide",
75 };
76}
apps/mobile/src/features/pull-requests/guide/useReviewGuide.ts+4-3
......@@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
22import { useFocusEffect } from "@react-navigation/native";
33import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
44import { useCallback, useRef, useState } from "react";
5import { usePullRequestConnected } from "../usePullRequestConnected";
56import { composerPullRequests } from "../../../state/pull-requests";
67import { useEnvironmentQuery } from "../../../state/query";
78import { serverEnvironment } from "../../../state/server";
......@@ -19,9 +20,11 @@ export function useReviewGuide(params: PullRequestParams) {
1920 const target = pullRequestTarget(params);
2021 const enabled = useReviewGuidesEnabled(params);
2122 const query = useEnvironmentQuery(enabled ? composerPullRequests.reviewGuide(target) : null);
22 const canWrite = useAtomValue(
23 const connected = usePullRequestConnected(target.environmentId);
24 const permitted = useAtomValue(
2325 composerPullRequests.generateReviewGuide.permissionAtom(target.environmentId),
2426 );
27 const canWrite = connected && permitted;
2528 const generate = useAtomCommand(composerPullRequests.generateReviewGuide, {
2629 reportFailure: false,
2730 });
......@@ -49,8 +52,6 @@ export function useReviewGuide(params: PullRequestParams) {
4952 if (result._tag === "Failure") {
5053 const cause = squashAtomCommandFailure(result);
5154 setError(cause instanceof Error ? cause.message : "Could not start the review guide.");
52 } else {
53 refresh();
5455 }
5556 } finally {
5657 pending.current = false;
apps/mobile/src/features/pull-requests/pullRequestDescription.test.ts+2-2
......@@ -15,8 +15,8 @@ describe("PR description overview", () => {
1515 ],
1616 });
1717 expect(result.attachments).toEqual([
18 { uri: "https://example.com/before.png", caption: "Before" },
19 { uri: "https://example.com/after.png", caption: "After" },
18 { uri: "https://example.com/before.png", caption: "Before", kind: "image" },
19 { uri: "https://example.com/after.png", caption: "After", kind: "image" },
2020 ]);
2121 expect(result.preview).toContain("Fixes reconnects.");
2222 expect(result.preview).not.toContain("Duplicate");
apps/mobile/src/features/pull-requests/pullRequestDescriptionContent.test.ts created+38
......@@ -0,0 +1,38 @@
1import { describe, expect, it } from "vite-plus/test";
2import type { MarkdownNode } from "react-native-nitro-markdown/headless";
3import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown";
4import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent";
5
6describe("PR attachments", () => {
7 it("combines embedded images and detected media without duplicate URLs", () => {
8 const uri = "https://drop.example.com/shot.png";
9 const document = {
10 type: "document",
11 children: [
12 { type: "image", href: uri, alt: "Screenshot" },
13 { type: "image", href: uri, alt: "Duplicate" },
14 ],
15 } as MarkdownNode;
16 const media = splitPullRequestBody(
17 `${uri}\n\nhttps://drop.example.com/demo.mp4\n\nhttps://github.com/user-attachments/assets/123-abc`,
18 );
19 expect(pullRequestDescriptionContent(document, media).attachments).toEqual([
20 { uri, caption: "Screenshot", kind: "image" },
21 { uri: "https://drop.example.com/demo.mp4", caption: "Video", kind: "video" },
22 {
23 uri: "https://github.com/user-attachments/assets/123-abc",
24 caption: "Video",
25 kind: "video",
26 },
27 ]);
28 });
29
30 it("does not collect ordinary links or media examples in code", () => {
31 const media = splitPullRequestBody(
32 "https://example.com/docs\n\n```\nhttps://drop.example.com/demo.mp4\n```",
33 );
34 expect(
35 pullRequestDescriptionContent({ type: "document" } as MarkdownNode, media).attachments,
36 ).toEqual([]);
37 });
38});
apps/mobile/src/features/pull-requests/pullRequestDescriptionContent.ts+24-3
......@@ -1,13 +1,24 @@
1import type { PullRequestBodySegment } from "@t3tools/client-runtime/pull-request-markdown";
12import type { MarkdownNode } from "react-native-nitro-markdown/headless";
23
34/** Read media from the parsed document so code examples never become attachments. */
4export function pullRequestDescriptionContent(document: MarkdownNode) {
5 const attachments = new Map<string, { readonly uri: string; readonly caption: string }>();
5export function pullRequestDescriptionContent(
6 document: MarkdownNode,
7 media: readonly PullRequestBodySegment[] = [],
8) {
9 const attachments = new Map<
10 string,
11 { readonly uri: string; readonly caption: string; readonly kind: "image" | "video" | "unknown" }
12 >();
613 const text: string[] = [];
714 const visit = (node: MarkdownNode) => {
815 if (node.type === "image") {
916 if (node.href && /^https?:\/\//i.test(node.href) && !attachments.has(node.href)) {
10 attachments.set(node.href, { uri: node.href, caption: node.alt || node.title || "Image" });
17 attachments.set(node.href, {
18 uri: node.href,
19 caption: node.alt || node.title || "Image",
20 kind: "image",
21 });
1122 }
1223 return;
1324 }
......@@ -18,5 +29,15 @@ export function pullRequestDescriptionContent(document: MarkdownNode) {
1829 if (node.type === "soft_break" || node.type === "line_break") text.push(" ");
1930 };
2031 visit(document);
32 for (const segment of media) {
33 if (segment.kind === "attachment" && !attachments.has(segment.url)) {
34 const name = new URL(segment.url).pathname.split("/").pop();
35 attachments.set(segment.url, {
36 uri: segment.url,
37 caption: segment.media === "video" ? "Video" : name || "Image",
38 kind: segment.media,
39 });
40 }
41 }
2142 return { preview: text.join("").trim(), attachments: [...attachments.values()] };
2243}
apps/mobile/src/features/pull-requests/pullRequestMediaSource.test.ts created+37
......@@ -0,0 +1,37 @@
1import { describe, expect, it } from "vite-plus/test";
2import { EnvironmentId } from "@t3tools/contracts";
3import { filePreviewGallery } from "../../lib/filePreviewGallery";
4import { mediaVideoPreviewUri, mediaVideoThumbnailKey } from "../../lib/videoPreviewSource";
5import { pullRequestImageSource, pullRequestMediaSource } from "./pullRequestMediaSource";
6
7const location = { environmentId: EnvironmentId.make("test"), cwd: "/repo" };
8const url = "https://github.com/user-attachments/assets/abc-123";
9
10describe("PR media previews", () => {
11 it("keeps a video resource pending until resolved and accepts refreshed capabilities", () => {
12 const source = {
13 type: "media" as const,
14 name: "Video",
15 mimeType: "video/mp4",
16 ...pullRequestMediaSource(url, location),
17 };
18 expect(mediaVideoPreviewUri(source, null)).toBeNull();
19 expect(mediaVideoPreviewUri(source, "https://signed/first")).toBe("https://signed/first");
20 expect(mediaVideoPreviewUri(source, "https://signed/renewed")).toBe("https://signed/renewed");
21 expect(mediaVideoThumbnailKey(source)).toContain("github-media");
22 });
23
24 it("keeps every gallery image resolvable and the tapped image appears only once", () => {
25 const first = pullRequestImageSource(url, location);
26 const second = pullRequestImageSource(`${url}-second`, location);
27 const selected = { ...second, sourceIdentifier: "tapped" };
28 const gallery = filePreviewGallery(selected, [first, second]);
29 expect(gallery).toEqual([first, selected]);
30 expect(gallery.every((item) => "resource" in item)).toBe(true);
31 });
32
33 it("leaves custom-domain media as direct URLs", () => {
34 const external = "https://drop.example/file.mp4";
35 expect(pullRequestMediaSource(external, location)).toEqual({ uri: external });
36 });
37});
apps/mobile/src/features/pull-requests/pullRequestMediaSource.ts created+26
......@@ -0,0 +1,26 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia";
3import type { FilePreviewItem } from "../../components/FilePreviewModal.types";
4
5export interface PullRequestMediaLocation {
6 readonly environmentId: EnvironmentId;
7 readonly cwd: string;
8}
9
10/** Keep the original resource identity so previewers can resolve and renew its capability. */
11export function pullRequestMediaSource(url: string, location: PullRequestMediaLocation | null) {
12 const fetchUrl = githubMediaFetchUrl(url);
13 return location && fetchUrl
14 ? {
15 environmentId: location.environmentId,
16 resource: { _tag: "github-media" as const, cwd: location.cwd, url: fetchUrl },
17 }
18 : { uri: url };
19}
20
21export function pullRequestImageSource(
22 url: string,
23 location: PullRequestMediaLocation | null,
24): FilePreviewItem {
25 return { kind: "image", ...pullRequestMediaSource(url, location) };
26}
apps/mobile/src/features/pull-requests/usePullRequestAgentActions.test.ts created+55
......@@ -0,0 +1,55 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import type { PullRequestDetail } from "@t3tools/contracts";
3import { usePullRequestAgentActions } from "./usePullRequestAgentActions";
4
5const state = vi.hoisted(() => ({
6 connected: false,
7 canAsk: true,
8 ask: vi.fn(),
9}));
10vi.mock("./usePullRequestConnected", () => ({
11 usePullRequestConnected: () => state.connected,
12}));
13vi.mock("./usePullRequestThreads", () => ({
14 usePullRequestThreads: () => ({
15 threads: [],
16 canAsk: state.canAsk,
17 canWatch: true,
18 busy: false,
19 isPending: true,
20 ask: state.ask,
21 }),
22}));
23const params = {
24 environmentId: "environment",
25 projectId: "project",
26 repository: "owner/repo",
27 number: 1,
28};
29const pr = { state: "open", mergeability: "conflicting" } as PullRequestDetail;
30
31beforeEach(() => {
32 state.connected = false;
33 state.canAsk = true;
34 state.ask.mockClear();
35});
36
37describe("PR agent drafts", () => {
38 it("opens offline drafts without waiting for linked-thread discovery", () => {
39 const actions = usePullRequestAgentActions(params, pr);
40 expect(actions.disabled).toBe(false);
41 actions.start("ask");
42 actions.start("explain");
43 expect(state.ask.mock.calls).toEqual([
44 [undefined, "ask"],
45 [undefined, "explain"],
46 ]);
47 });
48
49 it("does not open drafts when the connection denies task access", () => {
50 state.connected = true;
51 state.canAsk = false;
52 usePullRequestAgentActions(params, pr).start("explain");
53 expect(state.ask).not.toHaveBeenCalled();
54 });
55});
apps/mobile/src/features/pull-requests/usePullRequestAgentActions.ts+3-1
......@@ -5,6 +5,7 @@ import {
55 type PullRequestAgentTask,
66} from "./pullRequestAgentTasks";
77import type { PullRequestParams } from "./usePullRequestDiff";
8import { usePullRequestConnected } from "./usePullRequestConnected";
89import { usePullRequestThreads } from "./usePullRequestThreads";
910
1011/** Shared destinations for toolbar helpers and actions beside PR readiness rows. */
......@@ -14,8 +15,9 @@ export function usePullRequestAgentActions(
1415 filePath?: string,
1516) {
1617 const links = usePullRequestThreads(params, pr, filePath);
18 const connected = usePullRequestConnected(params.environmentId);
1719 const actions = pr ? pullRequestAgentActions(pr, links.canWatch) : [];
18 const disabled = links.busy || !pr || (links.isPending && !links.threads.length);
20 const disabled = links.busy || !pr || (connected && links.isPending && !links.threads.length);
1921 const start = (task: PullRequestAgentTask, threadId?: string) => {
2022 if (disabled) return;
2123 const thread = pullRequestAgentThread(links.threads, threadId);
apps/mobile/src/features/pull-requests/usePullRequestConnected.ts created+8
......@@ -0,0 +1,8 @@
1import { useWorkspaceEnvironments } from "../../state/workspace";
2
3export function usePullRequestConnected(environmentId: string) {
4 return useWorkspaceEnvironments().some(
5 (environment) =>
6 environment.environmentId === environmentId && environment.connectionState === "connected",
7 );
8}
apps/mobile/src/features/pull-requests/usePullRequestFilesViewed.ts+4-1
......@@ -1,3 +1,4 @@
1import { usePullRequestConnected } from "./usePullRequestConnected";
12import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
23import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
34import type { PullRequestDetail } from "@t3tools/contracts";
......@@ -12,9 +13,11 @@ export function usePullRequestFilesViewed(params: PullRequestParams, pr: PullReq
1213 const target = pullRequestTarget(params);
1314 const scopeKey = pullRequestReviewKey(params);
1415 const supported = Boolean(pr?.capabilities.viewedFiles);
15 const allowed = useAtomValue(
16 const permission = useAtomValue(
1617 composerPullRequests.setFilesViewed.permissionAtom(target.environmentId),
1718 );
19 const connected = usePullRequestConnected(params.environmentId);
20 const allowed = connected && permission;
1821 const query = useEnvironmentQuery(supported ? composerPullRequests.filesViewed(target) : null);
1922 const refresh = useAtomRefresh(composerPullRequests.filesViewed(target));
2023 const write = useAtomCommand(composerPullRequests.setFilesViewed, { reportFailure: false });
apps/mobile/src/features/pull-requests/usePullRequestPermissions.ts+17-5
......@@ -1,10 +1,12 @@
11import { useAtomValue } from "@effect/atom-react";
22import type { PullRequestDetail } from "@t3tools/contracts";
3import { usePullRequestConnected } from "./usePullRequestConnected";
34import { composerPullRequests } from "../../state/pull-requests";
45import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
56
67export function usePullRequestPermissions(params: PullRequestParams, pr: PullRequestDetail | null) {
78 const { environmentId } = pullRequestTarget(params);
9 const connected = usePullRequestConnected(environmentId);
810 const canWrite = useAtomValue(composerPullRequests.runAction.permissionAtom(environmentId));
911 const canComment = useAtomValue(composerPullRequests.comment.permissionAtom(environmentId));
1012 const canReview = useAtomValue(composerPullRequests.submitReview.permissionAtom(environmentId));
......@@ -13,7 +15,9 @@ export function usePullRequestPermissions(params: PullRequestParams, pr: PullReq
1315 composerPullRequests.setThreadResolution.permissionAtom(environmentId),
1416 );
1517 return {
18 connected,
1619 canInlineComment:
20 connected &&
1721 canReview &&
1822 pr?.state === "open" &&
1923 pr.capabilities.review.inlineComment &&
......@@ -21,13 +25,21 @@ export function usePullRequestPermissions(params: PullRequestParams, pr: PullReq
2125 pr.capabilities.review.verdicts.some((verdict) =>
2226 pr.viewerPermissions.verdicts.includes(verdict),
2327 ),
24 canReply: canReply && pr?.capabilities.review.reply === true && pr.viewerPermissions.comment,
28 canReply:
29 connected &&
30 canReply &&
31 pr?.capabilities.review.reply === true &&
32 pr.viewerPermissions.comment,
2533 canResolve:
26 canResolve && pr?.capabilities.review.resolve === true && pr.viewerPermissions.resolve,
27 canWrite,
28 canComment: canComment && pr?.capabilities.comment === true && pr.viewerPermissions.comment,
34 connected &&
35 canResolve &&
36 pr?.capabilities.review.resolve === true &&
37 pr.viewerPermissions.resolve,
38 canWrite: connected && canWrite,
39 canComment:
40 connected && canComment && pr?.capabilities.comment === true && pr.viewerPermissions.comment,
2941 verdicts:
30 canReview && pr?.state === "open"
42 connected && canReview && pr?.state === "open"
3143 ? pr.capabilities.review.verdicts.filter((verdict) =>
3244 pr.viewerPermissions.verdicts.includes(verdict),
3345 )
apps/mobile/src/features/pull-requests/usePullRequestThreads.ts+6-2
......@@ -27,6 +27,7 @@ import {
2727 updateComposerDraftSettings,
2828 waitForComposerDraftsLoaded,
2929} from "../../state/use-composer-drafts";
30import { usePullRequestConnected } from "./usePullRequestConnected";
3031import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
3132import {
3233 appendPullRequestAgentPrompt,
......@@ -63,7 +64,10 @@ export function usePullRequestThreads(
6364 setLastRelations({ key, data: relations.data });
6465 const threads =
6566 (relations.data ?? (lastRelations.key === key ? lastRelations.data : null))?.threads ?? [];
66 const canAsk = useEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope);
67 const connected = usePullRequestConnected(params.environmentId);
68 const canOperate = useEnvironmentScope(target.environmentId, AuthOrchestrationOperateScope);
69 // Composing offline only writes a local draft. The outbox checks grants on delivery.
70 const canAsk = !connected || canOperate;
6771 const canCheckout = useEnvironmentScope(target.environmentId, AuthSourceControlWriteScope);
6872 const navigation = useNavigation();
6973 const pending = useRef(false);
......@@ -76,7 +80,7 @@ export function usePullRequestThreads(
7680 pending.current = true;
7781 setBusy(true);
7882 try {
79 if (!thread && !canCheckout)
83 if (connected && !thread && !canCheckout)
8084 throw new Error("Starting work from this PR requires source control write access.");
8185 await waitForComposerDraftsLoaded();
8286 const draftKey = thread
apps/mobile/src/lib/videoPreviewSource.ts+17-9
......@@ -17,7 +17,7 @@ export type MediaVideoPreviewSource = {
1717 readonly environmentId: EnvironmentId;
1818 readonly resource: Extract<
1919 AssetResource,
20 { readonly _tag: "attachment" | "media-file" | "draft-workspace-file" }
20 { readonly _tag: "attachment" | "media-file" | "draft-workspace-file" | "github-media" }
2121 >;
2222 }
2323);
......@@ -47,14 +47,22 @@ export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string
4747 source.resource.path,
4848 source.srcFragment ?? "",
4949 ]
50 : [
51 "media-video",
52 "draft-workspace-file",
53 source.environmentId,
54 source.resource.cwd,
55 source.resource.path,
56 source.srcFragment ?? "",
57 ],
50 : source.resource._tag === "github-media"
51 ? [
52 "media-video",
53 "github-media",
54 source.environmentId,
55 source.resource.cwd,
56 source.resource.url,
57 ]
58 : [
59 "media-video",
60 "draft-workspace-file",
61 source.environmentId,
62 source.resource.cwd,
63 source.resource.path,
64 source.srcFragment ?? "",
65 ],
5866 );
5967}
6068
apps/mobile/src/persistence/layer.ts+2
......@@ -1,5 +1,6 @@
11import * as Layer from "effect/Layer";
22
3import * as PullRequestCacheStore from "../connection/pull-request-cache-store";
34import * as EnvironmentCacheStore from "../connection/environment-cache-store";
45import * as MobileDatabase from "./mobile-database";
56import * as MobilePreferences from "./mobile-preferences";
......@@ -11,6 +12,7 @@ const layerDependent = Layer.mergeAll(
1112 MobilePreferences.layer,
1213 MobileStorage.layer,
1314 EnvironmentCacheStore.layer,
15 PullRequestCacheStore.layer,
1416).pipe(Layer.provide(layerBase));
1517
1618export const layer = Layer.merge(layerBase, layerDependent);
apps/mobile/src/persistence/mobile-database.test.ts+85
......@@ -1,5 +1,6 @@
11import { describe, expect, it } from "@effect/vitest";
22import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
34import { vi } from "vite-plus/test";
45
56const openDatabaseAsync = vi.hoisted(() => vi.fn());
......@@ -69,3 +70,87 @@ describe("mobile database legacy cache migration", () => {
6970 ).toBeNull();
7071 });
7172});
73
74// Exercise the production SQL against SQLite, including the eviction window functions.
75describe("offline pull request cache", () => {
76 const environmentId = "cached-environment" as import("@t3tools/contracts").EnvironmentId;
77 const otherEnvironment = "other-environment" as import("@t3tools/contracts").EnvironmentId;
78
79 async function sqliteDatabase() {
80 const { DatabaseSync } = await import("node:sqlite");
81 const sqlite = new DatabaseSync(":memory:");
82 sqlite.exec("PRAGMA user_version = 1");
83 const database = {
84 execAsync: async (sql: string) => {
85 sqlite.exec(sql);
86 },
87 runAsync: async (sql: string, ...args: Array<string | number>) =>
88 sqlite.prepare(sql).run(...args),
89 getFirstAsync: async (sql: string, ...args: Array<string | number>) =>
90 sqlite.prepare(sql).get(...args),
91 getAllAsync: async (sql: string, ...args: Array<string | number>) =>
92 sqlite.prepare(sql).all(...args),
93 withExclusiveTransactionAsync: async (run: (transaction: unknown) => Promise<void>) => {
94 await run(database);
95 },
96 closeAsync: async () => {
97 sqlite.close();
98 },
99 };
100 openDatabaseAsync.mockResolvedValueOnce(database);
101 return sqlite;
102 }
103
104 it.effect("restores saved records and clears them with the environment", () =>
105 Effect.scoped(
106 Effect.gen(function* () {
107 yield* Effect.promise(sqliteDatabase);
108 const database = yield* MobileDatabase.make;
109 yield* database.saveCache(environmentId, "pull-request", "v1:list", 1, '{"entries":[]}');
110 yield* database.saveCache(otherEnvironment, "pull-request", "v1:list", 1, "other");
111 expect(yield* database.loadCache(environmentId, "pull-request", "v1:list")).toEqual(
112 Option.some('{"entries":[]}'),
113 );
114 yield* database.clearEnvironmentCache(environmentId);
115 expect((yield* database.loadCache(environmentId, "pull-request", "v1:list"))._tag).toBe(
116 "None",
117 );
118 expect((yield* database.loadCache(otherEnvironment, "pull-request", "v1:list"))._tag).toBe(
119 "Some",
120 );
121 }),
122 ),
123 );
124
125 it.effect(
126 "expires old records and bounds count and payload size without evicting other cache kinds",
127 () =>
128 Effect.scoped(
129 Effect.gen(function* () {
130 const sqlite = yield* Effect.promise(sqliteDatabase);
131 const database = yield* MobileDatabase.make;
132 yield* database.saveCache(environmentId, "thread", "thread", 1, "thread");
133 yield* database.saveCache(environmentId, "pull-request", "old", 1, "old");
134 sqlite.prepare("UPDATE client_cache SET updated_at = 0 WHERE cache_key = 'old'").run();
135 expect((yield* database.loadCache(environmentId, "pull-request", "old"))._tag).toBe(
136 "None",
137 );
138 for (let i = 0; i < 260; i++)
139 yield* database.saveCache(environmentId, "pull-request", `page-${i}`, 1, "page");
140 expect((yield* database.listCache("pull-request")).length).toBe(256);
141 const large = "x".repeat(4 * 1024 * 1024);
142 for (let i = 0; i < 10; i++)
143 yield* database.saveCache(environmentId, "pull-request", `diff-${i}`, 1, large);
144 const summary = yield* database.inspectCaches;
145 const prs = summary.find((row) => row.kind === "pull-request");
146 expect(prs?.payloadBytes).toBeLessThanOrEqual(32 * 1024 * 1024);
147 expect(prs?.recordCount).toBeLessThanOrEqual(256);
148 expect((yield* database.loadCache(environmentId, "thread", "thread"))._tag).toBe("Some");
149 yield* database.saveCache(environmentId, "pull-request", "oversized", 1, large + "x");
150 expect((yield* database.loadCache(environmentId, "pull-request", "oversized"))._tag).toBe(
151 "None",
152 );
153 }),
154 ),
155 );
156});
apps/mobile/src/persistence/mobile-database.ts+49-17
......@@ -22,6 +22,7 @@ export const ClientCacheKind = Schema.Literals([
2222 "server-config",
2323 "vcs-refs",
2424 "project-favicon",
25 "pull-request",
2526]);
2627export type ClientCacheKind = typeof ClientCacheKind.Type;
2728
......@@ -294,10 +295,12 @@ const makeAvailable = Effect.gen(function* () {
294295 database.getFirstAsync<{ readonly payload: string }>(
295296 `SELECT payload
296297 FROM client_cache
297 WHERE environment_id = ? AND kind = ? AND cache_key = ?`,
298 WHERE environment_id = ? AND kind = ? AND cache_key = ?
299 AND (kind != 'pull-request' OR updated_at >= ?)`,
298300 environmentId,
299301 kind,
300302 cacheKey,
303 Date.now() - 30 * 24 * 60 * 60_000,
301304 ),
302305 catch: databaseError("load-cache"),
303306 }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))),
......@@ -315,22 +318,51 @@ const makeAvailable = Effect.gen(function* () {
315318 saveCache: Effect.fn("MobileDatabase.saveCache")(
316319 (environmentId, kind, cacheKey, schemaVersion, payload) =>
317320 Effect.tryPromise({
318 try: () =>
319 database.runAsync(
320 `INSERT INTO client_cache
321 (environment_id, kind, cache_key, schema_version, payload, updated_at)
322 VALUES (?, ?, ?, ?, ?, ?)
323 ON CONFLICT (environment_id, kind, cache_key) DO UPDATE SET
324 schema_version = excluded.schema_version,
325 payload = excluded.payload,
326 updated_at = excluded.updated_at`,
327 environmentId,
328 kind,
329 cacheKey,
330 schemaVersion,
331 payload,
332 Date.now(),
333 ),
321 try: async () => {
322 // Diffs can be large. Keep the offline PR cache within 32 MiB and 256
323 // responses per environment, and don't let one huge diff evict it all.
324 if (
325 kind === "pull-request" &&
326 new TextEncoder().encode(payload).length > 4 * 1024 * 1024
327 )
328 return;
329 const save = async (transaction: SQLiteDatabase) => {
330 await transaction.runAsync(
331 `INSERT INTO client_cache
332 (environment_id, kind, cache_key, schema_version, payload, updated_at)
333 VALUES (?, ?, ?, ?, ?, ?)
334 ON CONFLICT (environment_id, kind, cache_key) DO UPDATE SET
335 schema_version = excluded.schema_version,
336 payload = excluded.payload,
337 updated_at = excluded.updated_at`,
338 environmentId,
339 kind,
340 cacheKey,
341 schemaVersion,
342 payload,
343 Date.now(),
344 );
345 if (kind === "pull-request") {
346 await transaction.runAsync(
347 `DELETE FROM client_cache WHERE environment_id = ? AND kind = 'pull-request'
348 AND cache_key IN (
349 SELECT cache_key FROM (
350 SELECT cache_key, updated_at,
351 ROW_NUMBER() OVER (ORDER BY updated_at DESC, cache_key) AS position,
352 SUM(LENGTH(CAST(payload AS BLOB))) OVER (
353 ORDER BY updated_at DESC, cache_key
354 ) AS total_bytes
355 FROM client_cache WHERE environment_id = ? AND kind = 'pull-request'
356 ) WHERE position > 256 OR total_bytes > 33554432 OR updated_at < ?
357 )`,
358 environmentId,
359 environmentId,
360 Date.now() - 30 * 24 * 60 * 60_000,
361 );
362 }
363 };
364 await save(database);
365 },
334366 catch: databaseError("save-cache"),
335367 }).pipe(Effect.asVoid),
336368 ),
apps/web/src/components/pullRequest/PullRequestMarkdown.tsx+2-2
......@@ -90,11 +90,11 @@ export function PullRequestMarkdown({
9090 data-image-gallery
9191 >
9292 {segments.map((segment) => {
93 if (segment.kind === "markdown") {
93 if (segment.kind === "markdown" || segment.media === "image") {
9494 return (
9595 <ChatMarkdown
9696 key={segment.id}
97 text={segment.text}
97 text={segment.kind === "markdown" ? segment.text : `![](<${segment.url}>)`}
9898 cwd={cwd}
9999 threadRef={resolvedThreadRef}
100100 pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF}
apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts deleted-146
......@@ -1,146 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { splitPullRequestBody } from "./pullRequestMarkdown.logic";
4
5describe("pull request body segmentation", () => {
6 it("keeps a plain body as a single markdown run", () => {
7 expect(splitPullRequestBody("## What changed\n\nSome prose.")).toEqual([
8 { id: "markdown:0", kind: "markdown", text: "## What changed\n\nSome prose." },
9 ]);
10 });
11
12 // GitHub writes a dropped image into the body as an `<img>` tag, so a bare attachment link on
13 // its own line is the shape it uses for a video — every one sampled in the wild was one.
14 it("lifts a dropped video out and keeps the prose around it", () => {
15 expect(
16 splitPullRequestBody(
17 "Before\n\nhttps://github.com/user-attachments/assets/2f8c1a90-1b2c-4d5e-8f90-abcdef123456\n\nAfter",
18 ),
19 ).toEqual([
20 { id: "markdown:0", kind: "markdown", text: "Before" },
21 {
22 id: "attachment:1",
23 kind: "attachment",
24 url: "https://github.com/user-attachments/assets/2f8c1a90-1b2c-4d5e-8f90-abcdef123456",
25 media: "video",
26 },
27 { id: "markdown:2", kind: "markdown", text: "After" },
28 ]);
29 });
30
31 it("names a bare link to a video file a video", () => {
32 expect(splitPullRequestBody("https://example.com/demo.mp4?raw=1")).toEqual([
33 {
34 id: "attachment:0",
35 kind: "attachment",
36 url: "https://example.com/demo.mp4?raw=1",
37 media: "video",
38 },
39 ]);
40 });
41
42 it("reads the source out of a video tag, including a multi-line one", () => {
43 expect(
44 splitPullRequestBody(
45 '<video controls>\n <source src="https://example.com/a.webm">\n</video>',
46 ),
47 ).toEqual([
48 {
49 id: "attachment:0",
50 kind: "attachment",
51 url: "https://example.com/a.webm",
52 media: "video",
53 },
54 ]);
55 });
56
57 it("leaves a dropped image as markdown so it still renders as an image", () => {
58 const body = "![shot](https://github.com/user-attachments/assets/2f8c1a90-1b2c-4d5e-8f90-ab)";
59 expect(splitPullRequestBody(body)).toEqual([
60 { id: "markdown:0", kind: "markdown", text: body },
61 ]);
62 });
63
64 it("leaves the img tag GitHub writes for a dropped image as markdown", () => {
65 const body =
66 '<img width="1414" alt="image" src="https://github.com/user-attachments/assets/7195f963-51a9-4331-be74-3e06be760422" />';
67 expect(splitPullRequestBody(body)).toEqual([
68 { id: "markdown:0", kind: "markdown", text: body },
69 ]);
70 });
71
72 it("leaves an ordinary link alone, whether it is bare or written as markdown", () => {
73 const body = "https://example.com/page\n\n[the docs](https://example.com/docs)";
74 expect(splitPullRequestBody(body)).toEqual([
75 { id: "markdown:0", kind: "markdown", text: body },
76 ]);
77 });
78
79 it("never lifts a link inside fenced code out of it", () => {
80 const body = "```\nhttps://example.com/demo.mp4\n```";
81 expect(splitPullRequestBody(body)).toEqual([
82 { id: "markdown:0", kind: "markdown", text: body },
83 ]);
84 });
85
86 it("does not let a different fence marker close an open fence", () => {
87 const body = "```\n~~~\nhttps://example.com/demo.mp4\n```";
88 expect(splitPullRequestBody(body)).toEqual([
89 { id: "markdown:0", kind: "markdown", text: body },
90 ]);
91 });
92
93 it("keeps a video tag that shares its line with prose as markdown", () => {
94 const body = 'Before <video src="https://example.com/a.mp4"></video> after';
95 expect(splitPullRequestBody(body)).toEqual([
96 { id: "markdown:0", kind: "markdown", text: body },
97 ]);
98 });
99
100 it("keeps the indentation that opens a code block", () => {
101 const body = " const answer = 42;";
102 expect(splitPullRequestBody(body)).toEqual([
103 { id: "markdown:0", kind: "markdown", text: body },
104 ]);
105 });
106
107 it("treats an info-string fence as a nested opener, not a close", () => {
108 const body = "```\n```javascript\nhttps://example.com/demo.mp4\n```";
109 expect(splitPullRequestBody(body)).toEqual([
110 { id: "markdown:0", kind: "markdown", text: body },
111 ]);
112 });
113
114 it("leaves an indented code line alone even when it is only a video link", () => {
115 const body = " https://example.com/demo.mp4";
116 expect(splitPullRequestBody(body)).toEqual([
117 { id: "markdown:0", kind: "markdown", text: body },
118 ]);
119 });
120
121 it("leaves an unclosed video tag as prose without eating the rest of the body", () => {
122 const body = ['<video src="https://example.com/a.mp4">', "still prose", "more prose"].join(
123 "\n",
124 );
125 expect(splitPullRequestBody(body)).toEqual([
126 { id: "markdown:0", kind: "markdown", text: body },
127 ]);
128 });
129
130 it("stays linear when the body is full of unclosed video tags", () => {
131 const body = Array.from(
132 { length: 4_000 },
133 () => '<video src="https://example.com/a.mp4">',
134 ).join("\n");
135 const startedAt = performance.now();
136 expect(splitPullRequestBody(body)).toHaveLength(1);
137 expect(performance.now() - startedAt).toBeLessThan(1_000);
138 });
139
140 it("refuses a non-http source rather than making a link out of it", () => {
141 const body = '<video src="javascript:alert(1)"></video>';
142 expect(splitPullRequestBody(body)).toEqual([
143 { id: "markdown:0", kind: "markdown", text: body },
144 ]);
145 });
146});
apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts+4-143
......@@ -4,149 +4,10 @@ import {
44 type TextMatch,
55} from "~/vendor/mdast-find-and-replace";
66
7/** `id` is positional on purpose: the same attachment can be embedded twice in one body. */
8export type PullRequestBodySegment =
9 | { readonly id: string; readonly kind: "markdown"; readonly text: string }
10 | {
11 readonly id: string;
12 readonly kind: "attachment";
13 readonly url: string;
14 /**
15 * What the reader can be told the upload is. The uuid says nothing on its own — the type
16 * only appears in the signed redirect GitHub answers with — but the shape does: GitHub
17 * writes a dropped image into the body as an `<img>` tag, so a bare attachment link on its
18 * own line is what it does with a video. Anything else keeps the neutral name.
19 */
20 readonly media: "video" | "unknown";
21 };
22
23const FENCE_PATTERN = /^\s{0,3}((?:`{3,})|(?:~{3,}))(.*)$/u;
24/**
25 * How far a `<video>` tag may reach for its closing tag. Real embeds are one to three lines,
26 * and the bound keeps an unclosed tag from making each one rescan the rest of the body.
27 */
28const VIDEO_TAG_MAX_LINES = 8;
29/** Four spaces open an indented code block, so its contents stay verbatim markdown. */
30const INDENTED_CODE_PATTERN = /^(?: {4}|\t)/u;
31const BARE_URL_PATTERN = /^<?(https?:\/\/\S+?)>?$/u;
32const VIDEO_EXTENSION_PATTERN = /\.(?:mp4|webm|mov|m4v|ogv)(?:$|[?#])/iu;
33/** A dropped video becomes a bare asset link; a dropped image becomes an `<img>` tag. */
34const GITHUB_ASSET_PATTERN = /^https:\/\/github\.com\/user-attachments\/assets\/[\w-]+$/iu;
35const VIDEO_TAG_SRC_PATTERN = /<(?:video|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/iu;
36/** Only a tag that owns its line is an embed; inline, it is prose the renderer should keep. */
37const STANDALONE_VIDEO_TAG_PATTERN = /^\s*<video\b/iu;
38const VIDEO_TAG_END_PATTERN = /<\/video>\s*$/iu;
39
40/** Anything else — `javascript:`, `data:`, a relative path — is not an upload to link out to. */
41function isWebUrl(url: string): boolean {
42 try {
43 const parsed = new URL(url);
44 return parsed.protocol === "https:" || parsed.protocol === "http:";
45 } catch {
46 return false;
47 }
48}
49
50function attachmentFromLine(line: string): { url: string; media: "video" | "unknown" } | null {
51 const url = BARE_URL_PATTERN.exec(line.trim())?.[1];
52 if (url === undefined || !isWebUrl(url)) return null;
53 if (VIDEO_EXTENSION_PATTERN.test(url) || GITHUB_ASSET_PATTERN.test(url)) {
54 return { url, media: "video" };
55 }
56 return null;
57}
58
59/**
60 * Splits a pull request body into markdown runs and the uploads embedded in it, which the
61 * markdown renderer drops: `<video>` is not in its sanitizer's schema, and a bare attachment
62 * link arrives as prose. Two shapes are recognised, both of which GitHub produces itself: a
63 * `<video>` (or `<source>`) tag, and a bare link on its own line to a video file or an
64 * uploaded attachment. Fenced code is copied through untouched so a snippet that happens to
65 * contain a link is never lifted out of it.
66 */
67export function splitPullRequestBody(body: string): ReadonlyArray<PullRequestBodySegment> {
68 const segments: PullRequestBodySegment[] = [];
69 const markdown: string[] = [];
70 let openFence: string | null = null;
71
72 // Blank lines around a run are dropped, but never leading spaces: four of them open an
73 // indented code block, so trimming a run would silently turn code into prose.
74 const flushMarkdown = () => {
75 const text = markdown.join("\n").replace(/^\n+/u, "").replace(/\s+$/u, "");
76 markdown.length = 0;
77 if (text.trim().length > 0) {
78 segments.push({ id: `markdown:${segments.length}`, kind: "markdown", text });
79 }
80 };
81
82 const lines = body.split("\n");
83 for (let index = 0; index < lines.length; index += 1) {
84 const line = lines[index]!;
85 const fenceMatch = FENCE_PATTERN.exec(line);
86 if (fenceMatch !== null) {
87 const fence = fenceMatch[1]!;
88 // A fence closes only on the same marker, at least as long as the one that opened it,
89 // and with nothing after it: `~~~` cannot end a ``` block, and ```` ```ts ```` is an
90 // info string opening a nested fence, not a close.
91 const closes =
92 openFence !== null &&
93 fence[0] === openFence[0] &&
94 fence.length >= openFence.length &&
95 fenceMatch[2]!.trim().length === 0;
96 if (openFence === null) {
97 openFence = fence;
98 } else if (closes) {
99 openFence = null;
100 }
101 markdown.push(line);
102 continue;
103 }
104 if (openFence !== null || INDENTED_CODE_PATTERN.test(line)) {
105 markdown.push(line);
106 continue;
107 }
108
109 const bareAttachment = attachmentFromLine(line);
110 if (bareAttachment !== null) {
111 flushMarkdown();
112 segments.push({ id: `attachment:${segments.length}`, kind: "attachment", ...bareAttachment });
113 continue;
114 }
115
116 if (!STANDALONE_VIDEO_TAG_PATTERN.test(line)) {
117 markdown.push(line);
118 continue;
119 }
120 // A tag can span lines, so look ahead for its close — bounded, and without building the
121 // joined text until one is actually found.
122 const lastCandidate = Math.min(index + VIDEO_TAG_MAX_LINES, lines.length) - 1;
123 let cursor = index;
124 while (cursor < lastCandidate && !VIDEO_TAG_END_PATTERN.test(lines[cursor]!)) {
125 cursor += 1;
126 }
127 const source = VIDEO_TAG_END_PATTERN.test(lines[cursor]!)
128 ? VIDEO_TAG_SRC_PATTERN.exec(lines.slice(index, cursor + 1).join("\n"))?.[1]
129 : undefined;
130 if (source !== undefined && isWebUrl(source)) {
131 flushMarkdown();
132 segments.push({
133 id: `attachment:${segments.length}`,
134 kind: "attachment",
135 url: source,
136 // The author wrote the tag, so this one is a video whatever the URL looks like.
137 media: "video",
138 });
139 index = cursor;
140 } else {
141 // Unclosed, or nothing linkable in it: prose. Only this line is consumed, so the
142 // lines that were looked at are still parsed on their own terms.
143 markdown.push(line);
144 }
145 }
146
147 flushMarkdown();
148 return segments;
149}
7export {
8 splitPullRequestBody,
9 type PullRequestBodySegment,
10} from "@t3tools/client-runtime/pull-request-markdown";
15011
15112const AUTOLINK_CANDIDATE_PATTERN = /#[1-9]\d*|[0-9a-f]{40}/giu;
15213const AUTOLINK_WORD_CHARACTER_PATTERN = /[A-Za-z0-9_]/u;
docs/user/source-control.md+4
......@@ -158,6 +158,10 @@ to read its description, activity, commits, and changed files. Line comments sta
158158until you submit the review. **Work on PR** opens a chat with the review attached; sending
159159the first message prepares a worktree from the review's branch.
160160
161Previously loaded lists, review details, guides, and diff pages remain readable on mobile
162without a connection, including after restarting the app. Saved content refreshes when the
163environment reconnects; submitting changes requires a connection.
164
161165Enable **Remove agent credits when merging** in Settings → Source Control to remove recognized
162166agent co-author and generated-by lines from GitHub merge and squash commit messages. Human
163167co-authors stay credited. The setting is off by default and projects can override it. It also
packages/client-runtime/package.json+8
......@@ -402,6 +402,14 @@
402402 "./review-guide-tour": {
403403 "types": "./src/reviewGuideTour.ts",
404404 "default": "./src/reviewGuideTour.ts"
405 },
406 "./state/query-persistence": {
407 "types": "./src/state/queryPersistence.ts",
408 "default": "./src/state/queryPersistence.ts"
409 },
410 "./pull-request-markdown": {
411 "types": "./src/pullRequestMarkdown.ts",
412 "default": "./src/pullRequestMarkdown.ts"
405413 }
406414 },
407415 "scripts": {
packages/client-runtime/src/pullRequestMarkdown.test.ts created+166
......@@ -0,0 +1,166 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { splitPullRequestBody } from "./pullRequestMarkdown.ts";
4
5describe("pull request body segmentation", () => {
6 it("keeps a plain body as a single markdown run", () => {
7 expect(splitPullRequestBody("## What changed\n\nSome prose.")).toEqual([
8 { id: "markdown:0", kind: "markdown", text: "## What changed\n\nSome prose." },
9 ]);
10 });
11
12 // GitHub writes a dropped image into the body as an `<img>` tag, so a bare attachment link on
13 // its own line is the shape it uses for a video — every one sampled in the wild was one.
14 it("lifts a dropped video out and keeps the prose around it", () => {
15 expect(
16 splitPullRequestBody(
17 "Before\n\nhttps://github.com/user-attachments/assets/2f8c1a90-1b2c-4d5e-8f90-abcdef123456\n\nAfter",
18 ),
19 ).toEqual([
20 { id: "markdown:0", kind: "markdown", text: "Before" },
21 {
22 id: "attachment:1",
23 kind: "attachment",
24 url: "https://github.com/user-attachments/assets/2f8c1a90-1b2c-4d5e-8f90-abcdef123456",
25 media: "video",
26 },
27 { id: "markdown:2", kind: "markdown", text: "After" },
28 ]);
29 });
30
31 it("names a bare link to a video file a video", () => {
32 expect(splitPullRequestBody("https://example.com/demo.mp4?raw=1")).toEqual([
33 {
34 id: "attachment:0",
35 kind: "attachment",
36 url: "https://example.com/demo.mp4?raw=1",
37 media: "video",
38 },
39 ]);
40 });
41
42 it("reads the source out of a video tag, including a multi-line one", () => {
43 expect(
44 splitPullRequestBody(
45 '<video controls>\n <source src="https://example.com/a.webm">\n</video>',
46 ),
47 ).toEqual([
48 {
49 id: "attachment:0",
50 kind: "attachment",
51 url: "https://example.com/a.webm",
52 media: "video",
53 },
54 ]);
55 });
56
57 it("leaves a dropped image as markdown so it still renders as an image", () => {
58 const body = "![shot](https://github.com/user-attachments/assets/2f8c1a90-1b2c-4d5e-8f90-ab)";
59 expect(splitPullRequestBody(body)).toEqual([
60 { id: "markdown:0", kind: "markdown", text: body },
61 ]);
62 });
63
64 it("leaves the img tag GitHub writes for a dropped image as markdown", () => {
65 const body =
66 '<img width="1414" alt="image" src="https://github.com/user-attachments/assets/7195f963-51a9-4331-be74-3e06be760422" />';
67 expect(splitPullRequestBody(body)).toEqual([
68 { id: "markdown:0", kind: "markdown", text: body },
69 ]);
70 });
71
72 it("leaves an ordinary link alone, whether it is bare or written as markdown", () => {
73 const body = "https://example.com/page\n\n[the docs](https://example.com/docs)";
74 expect(splitPullRequestBody(body)).toEqual([
75 { id: "markdown:0", kind: "markdown", text: body },
76 ]);
77 });
78
79 it("never lifts a link inside fenced code out of it", () => {
80 const body = "```\nhttps://example.com/demo.mp4\n```";
81 expect(splitPullRequestBody(body)).toEqual([
82 { id: "markdown:0", kind: "markdown", text: body },
83 ]);
84 });
85
86 it("does not let a different fence marker close an open fence", () => {
87 const body = "```\n~~~\nhttps://example.com/demo.mp4\n```";
88 expect(splitPullRequestBody(body)).toEqual([
89 { id: "markdown:0", kind: "markdown", text: body },
90 ]);
91 });
92
93 it("keeps a video tag that shares its line with prose as markdown", () => {
94 const body = 'Before <video src="https://example.com/a.mp4"></video> after';
95 expect(splitPullRequestBody(body)).toEqual([
96 { id: "markdown:0", kind: "markdown", text: body },
97 ]);
98 });
99
100 it("keeps the indentation that opens a code block", () => {
101 const body = " const answer = 42;";
102 expect(splitPullRequestBody(body)).toEqual([
103 { id: "markdown:0", kind: "markdown", text: body },
104 ]);
105 });
106
107 it("treats an info-string fence as a nested opener, not a close", () => {
108 const body = "```\n```javascript\nhttps://example.com/demo.mp4\n```";
109 expect(splitPullRequestBody(body)).toEqual([
110 { id: "markdown:0", kind: "markdown", text: body },
111 ]);
112 });
113
114 it("leaves an indented code line alone even when it is only a video link", () => {
115 const body = " https://example.com/demo.mp4";
116 expect(splitPullRequestBody(body)).toEqual([
117 { id: "markdown:0", kind: "markdown", text: body },
118 ]);
119 });
120
121 it("leaves an unclosed video tag as prose without eating the rest of the body", () => {
122 const body = ['<video src="https://example.com/a.mp4">', "still prose", "more prose"].join(
123 "\n",
124 );
125 expect(splitPullRequestBody(body)).toEqual([
126 { id: "markdown:0", kind: "markdown", text: body },
127 ]);
128 });
129
130 it("stays linear when the body is full of unclosed video tags", () => {
131 const body = Array.from(
132 { length: 4_000 },
133 () => '<video src="https://example.com/a.mp4">',
134 ).join("\n");
135 const startedAt = performance.now();
136 expect(splitPullRequestBody(body)).toHaveLength(1);
137 expect(performance.now() - startedAt).toBeLessThan(1_000);
138 });
139
140 it("refuses a non-http source rather than making a link out of it", () => {
141 const body = '<video src="javascript:alert(1)"></video>';
142 expect(splitPullRequestBody(body)).toEqual([
143 { id: "markdown:0", kind: "markdown", text: body },
144 ]);
145 });
146});
147
148describe("media on custom hosts", () => {
149 it.each([
150 ["https://drop.example.com/capture.PNG?token=abc#preview", "image"],
151 ["[Recording](https://drop.example.com/recording.mov)", "video"],
152 ["<https://drop.example.com/shot.webp>", "image"],
153 ])("recognizes %s without a request", (body, media) => {
154 expect(splitPullRequestBody(body)).toEqual([
155 expect.objectContaining({ kind: "attachment", media }),
156 ]);
157 });
158
159 it("keeps inline links and media examples in code as prose", () => {
160 const body =
161 "See [recording](https://drop.example.com/a.mp4) for context.\n\n```md\nhttps://drop.example.com/a.png\n```";
162 expect(splitPullRequestBody(body)).toEqual([
163 { id: "markdown:0", kind: "markdown", text: body },
164 ]);
165 });
166});
packages/client-runtime/src/pullRequestMarkdown.ts created+146
......@@ -0,0 +1,146 @@
1/** `id` is positional on purpose: the same attachment can be embedded twice in one body. */
2export type PullRequestBodySegment =
3 | { readonly id: string; readonly kind: "markdown"; readonly text: string }
4 | {
5 readonly id: string;
6 readonly kind: "attachment";
7 readonly url: string;
8 /**
9 * What the reader can be told the upload is. The uuid says nothing on its own — the type
10 * only appears in the signed redirect GitHub answers with — but the shape does: GitHub
11 * writes a dropped image into the body as an `<img>` tag, so a bare attachment link on its
12 * own line is what it does with a video. Anything else keeps the neutral name.
13 */
14 readonly media: "video" | "image" | "unknown";
15 };
16
17const FENCE_PATTERN = /^\s{0,3}((?:`{3,})|(?:~{3,}))(.*)$/u;
18/**
19 * How far a `<video>` tag may reach for its closing tag. Real embeds are one to three lines,
20 * and the bound keeps an unclosed tag from making each one rescan the rest of the body.
21 */
22const VIDEO_TAG_MAX_LINES = 8;
23/** Four spaces open an indented code block, so its contents stay verbatim markdown. */
24const INDENTED_CODE_PATTERN = /^(?: {4}|\t)/u;
25const BARE_URL_PATTERN = /^<?(https?:\/\/\S+?)>?$/u;
26const IMAGE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp|avif|heic|heif|svg)(?:$|[?#])/iu;
27const MARKDOWN_LINK_PATTERN = /^\[[^\]]*\]\((https?:\/\/[^\s)]+)\)$/u;
28const VIDEO_EXTENSION_PATTERN = /\.(?:mp4|webm|mov|m4v|ogv)(?:$|[?#])/iu;
29/** A dropped video becomes a bare asset link; a dropped image becomes an `<img>` tag. */
30const GITHUB_ASSET_PATTERN = /^https:\/\/github\.com\/user-attachments\/assets\/[\w-]+$/iu;
31const VIDEO_TAG_SRC_PATTERN = /<(?:video|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/iu;
32/** Only a tag that owns its line is an embed; inline, it is prose the renderer should keep. */
33const STANDALONE_VIDEO_TAG_PATTERN = /^\s*<video\b/iu;
34const VIDEO_TAG_END_PATTERN = /<\/video>\s*$/iu;
35
36/** Anything else — `javascript:`, `data:`, a relative path — is not an upload to link out to. */
37function isWebUrl(url: string): boolean {
38 try {
39 const parsed = new URL(url);
40 return parsed.protocol === "https:" || parsed.protocol === "http:";
41 } catch {
42 return false;
43 }
44}
45
46function attachmentFromLine(
47 line: string,
48): { url: string; media: "video" | "image" | "unknown" } | null {
49 const url =
50 BARE_URL_PATTERN.exec(line.trim())?.[1] ?? MARKDOWN_LINK_PATTERN.exec(line.trim())?.[1];
51 if (url === undefined || !isWebUrl(url)) return null;
52 if (VIDEO_EXTENSION_PATTERN.test(url) || GITHUB_ASSET_PATTERN.test(url)) {
53 return { url, media: "video" };
54 }
55 if (IMAGE_EXTENSION_PATTERN.test(url)) return { url, media: "image" };
56 return null;
57}
58
59/**
60 * Lifts standalone media links and video tags out of prose for both clients.
61 * Ordinary links and code examples remain markdown. Images already embedded
62 * with markdown or HTML keep their original renderer.
63 */
64export function splitPullRequestBody(body: string): ReadonlyArray<PullRequestBodySegment> {
65 const segments: PullRequestBodySegment[] = [];
66 const markdown: string[] = [];
67 let openFence: string | null = null;
68
69 // Blank lines around a run are dropped, but never leading spaces: four of them open an
70 // indented code block, so trimming a run would silently turn code into prose.
71 const flushMarkdown = () => {
72 const text = markdown.join("\n").replace(/^\n+/u, "").replace(/\s+$/u, "");
73 markdown.length = 0;
74 if (text.trim().length > 0) {
75 segments.push({ id: `markdown:${segments.length}`, kind: "markdown", text });
76 }
77 };
78
79 const lines = body.split("\n");
80 for (let index = 0; index < lines.length; index += 1) {
81 const line = lines[index]!;
82 const fenceMatch = FENCE_PATTERN.exec(line);
83 if (fenceMatch !== null) {
84 const fence = fenceMatch[1]!;
85 // A fence closes only on the same marker, at least as long as the one that opened it,
86 // and with nothing after it: `~~~` cannot end a ``` block, and ```` ```ts ```` is an
87 // info string opening a nested fence, not a close.
88 const closes =
89 openFence !== null &&
90 fence[0] === openFence[0] &&
91 fence.length >= openFence.length &&
92 fenceMatch[2]!.trim().length === 0;
93 if (openFence === null) {
94 openFence = fence;
95 } else if (closes) {
96 openFence = null;
97 }
98 markdown.push(line);
99 continue;
100 }
101 if (openFence !== null || INDENTED_CODE_PATTERN.test(line)) {
102 markdown.push(line);
103 continue;
104 }
105
106 const bareAttachment = attachmentFromLine(line);
107 if (bareAttachment !== null) {
108 flushMarkdown();
109 segments.push({ id: `attachment:${segments.length}`, kind: "attachment", ...bareAttachment });
110 continue;
111 }
112
113 if (!STANDALONE_VIDEO_TAG_PATTERN.test(line)) {
114 markdown.push(line);
115 continue;
116 }
117 // A tag can span lines, so look ahead for its close — bounded, and without building the
118 // joined text until one is actually found.
119 const lastCandidate = Math.min(index + VIDEO_TAG_MAX_LINES, lines.length) - 1;
120 let cursor = index;
121 while (cursor < lastCandidate && !VIDEO_TAG_END_PATTERN.test(lines[cursor]!)) {
122 cursor += 1;
123 }
124 const source = VIDEO_TAG_END_PATTERN.test(lines[cursor]!)
125 ? VIDEO_TAG_SRC_PATTERN.exec(lines.slice(index, cursor + 1).join("\n"))?.[1]
126 : undefined;
127 if (source !== undefined && isWebUrl(source)) {
128 flushMarkdown();
129 segments.push({
130 id: `attachment:${segments.length}`,
131 kind: "attachment",
132 url: source,
133 // The author wrote the tag, so this one is a video whatever the URL looks like.
134 media: "video",
135 });
136 index = cursor;
137 } else {
138 // Unclosed, or nothing linkable in it: prose. Only this line is consumed, so the
139 // lines that were looked at are still parsed on their own terms.
140 markdown.push(line);
141 }
142 }
143
144 flushMarkdown();
145 return segments;
146}
packages/client-runtime/src/state/pullRequestStatsPersistence.ts created+43
......@@ -0,0 +1,43 @@
1import {
2 PullRequestDiffStat,
3 PullRequestListStatsInput,
4 type PullRequestListStatsResult,
5 type PullRequestRef,
6} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import * as Option from "effect/Option";
9import * as Schema from "effect/Schema";
10import { persistQuery, type QueryPersistence } from "./queryPersistence.ts";
11
12const requestKey = Schema.fromJsonString(Schema.Tuple([Schema.String, PullRequestListStatsInput]));
13const statKey = (ref: PullRequestRef) =>
14 JSON.stringify(["pull-request-stat", ref.projectId, ref.host ?? "", ref.repository, ref.number]);
15
16/** Visible rows are batched differently after a restart, so store their counts individually. */
17export function persistPullRequestStats(): QueryPersistence<PullRequestListStatsResult> {
18 const row = persistQuery(PullRequestDiffStat);
19 return {
20 load: (environmentId, key) =>
21 Effect.gen(function* () {
22 const [, input] = yield* Schema.decodeEffect(requestKey)(key);
23 const rows = yield* Effect.forEach(input.refs, (ref) =>
24 row.load(environmentId, statKey(ref)),
25 );
26 const stats = rows.flatMap((value) => (Option.isSome(value) ? [value.value] : []));
27 return stats.length ? Option.some({ stats }) : Option.none();
28 }).pipe(Effect.orElseSucceed(() => Option.none())),
29 save: (environmentId, key, result) =>
30 Effect.gen(function* () {
31 const [, input] = yield* Schema.decodeEffect(requestKey)(key);
32 yield* Effect.forEach(result.stats, (stat) => {
33 const ref = input.refs.find(
34 (ref) =>
35 ref.projectId === stat.projectId &&
36 ref.repository === stat.repository &&
37 ref.number === stat.number,
38 );
39 return ref ? row.save(environmentId, statKey(ref), stat) : Effect.void;
40 });
41 }).pipe(Effect.ignore),
42 };
43}
packages/client-runtime/src/state/pullRequests.test.ts+46
......@@ -4,6 +4,7 @@ import { vi } from "vite-plus/test";
44import {
55 EnvironmentId,
66 AuthSourceControlWriteScope,
7 AuthOrchestrationOperateScope,
78 ProjectId,
89 PullRequestOperationError,
910 WS_METHODS,
......@@ -2082,3 +2083,48 @@ it.effect("denies a routed write when only the origin has source-control permiss
20822083 }),
20832084 ),
20842085);
2086
2087it.effect("publishes the accepted guide run before the follow-up read finishes", () =>
2088 Effect.scoped(
2089 Effect.gen(function* () {
2090 const accepted = {
2091 guide: null,
2092 currentHeadSha: "head",
2093 run: { _tag: "Running" as const, headSha: "head", startedAt: "2026-10-11T00:00:00.000Z" },
2094 };
2095 let reads = 0;
2096 const refreshStarted = yield* Latch.make();
2097 const client = {
2098 [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.never,
2099 [WS_METHODS.pullRequestsReviewGuide]: () =>
2100 Effect.gen(function* () {
2101 if (++reads > 1) {
2102 yield* refreshStarted.open;
2103 return yield* Effect.never;
2104 }
2105 return { guide: null, run: null, currentHeadSha: "head" };
2106 }),
2107 [WS_METHODS.pullRequestsGenerateReviewGuide]: () => Effect.succeed(accepted),
2108 } as unknown as WsRpcProtocolClient;
2109 const { atoms, registry } = yield* makeTestRuntime(client);
2110 const target = {
2111 environmentId: TARGET.environmentId,
2112 input: { projectId: ProjectId.make("project-1"), repository: "acme/web", number: 1 },
2113 };
2114 registry.update(
2115 grantedSessions(target.environmentId),
2116 AsyncResult.map((session) => ({
2117 ...session,
2118 scopes: [...(session.scopes ?? []), AuthOrchestrationOperateScope],
2119 })),
2120 );
2121 const query = atoms.reviewGuide(target);
2122 registry.mount(query);
2123 yield* AtomRegistry.getResult(registry, query, { suspendOnWaiting: true });
2124 const result = yield* Effect.promise(() => atoms.generateReviewGuide.run(registry, target));
2125 expect(result._tag).toBe("Success");
2126 yield* refreshStarted.await;
2127 expect(Option.getOrNull(AsyncResult.value(registry.get(query)))).toEqual(accepted);
2128 }),
2129 ),
2130);
packages/client-runtime/src/state/pullRequests.ts+28-1
......@@ -1,5 +1,14 @@
11import {
22 WS_METHODS,
3 PullRequestActivity,
4 PullRequestDetail as PullRequestDetailSchema,
5 PullRequestPreview,
6 PullRequestReviewGuideResult,
7 PullRequestListResult,
8 PullRequestChecks,
9 PullRequestDiffResult,
10 PullRequestFilesViewedResult,
11 PullRequestLinkedThreadsResult,
312 type EnvironmentId,
413 type PullRequestActor,
514 type PullRequestActionInput,
......@@ -13,6 +22,9 @@ import {
1322} from "@t3tools/contracts";
1423import * as Data from "effect/Data";
1524import * as Effect from "effect/Effect";
25import * as Schema from "effect/Schema";
26import { persistPullRequestStats } from "./pullRequestStatsPersistence.ts";
27import { persistQuery } from "./queryPersistence.ts";
1628import * as Option from "effect/Option";
1729import * as SubscriptionRef from "effect/SubscriptionRef";
1830import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
......@@ -172,6 +184,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
172184 const activity = writableQueryFamily(
173185 createEnvironmentRpcQueryAtomFamily(runtime, {
174186 label: "environment-data:pull-requests:activity",
187 persistence: persistQuery(PullRequestActivity),
175188 tag: WS_METHODS.pullRequestsActivity,
176189 execute: (input) => routedRequest(WS_METHODS.pullRequestsActivity, input),
177190 staleTimeMs: 60_000,
......@@ -181,6 +194,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
181194 const detail = writableQueryFamily(
182195 createEnvironmentRpcQueryAtomFamily(runtime, {
183196 label: "environment-data:pull-requests:detail",
197 persistence: persistQuery(PullRequestDetailSchema),
184198 tag: WS_METHODS.pullRequestsDetail,
185199 execute: (input) => routedRequest(WS_METHODS.pullRequestsDetail, input),
186200 staleTimeMs: 60_000,
......@@ -190,6 +204,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
190204 const preview = writableQueryFamily(
191205 createEnvironmentRpcQueryAtomFamily(runtime, {
192206 label: "environment-data:pull-requests:preview",
207 persistence: persistQuery(PullRequestPreview),
193208 tag: WS_METHODS.pullRequestsPreview,
194209 execute: (input) => routedRequest(WS_METHODS.pullRequestsPreview, input),
195210 staleTimeMs: 60_000,
......@@ -208,6 +223,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
208223 const reviewGuide = writableQueryFamily(
209224 createEnvironmentRpcQueryAtomFamily(runtime, {
210225 label: "environment-data:pull-requests:review-guide",
226 persistence: persistQuery(PullRequestReviewGuideResult),
211227 tag: WS_METHODS.pullRequestsReviewGuide,
212228 execute: (input) => routedRequest(WS_METHODS.pullRequestsReviewGuide, input),
213229 staleTimeMs: 60_000,
......@@ -226,6 +242,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
226242 refreshes,
227243 linkedThreads: createEnvironmentRpcQueryAtomFamily(runtime, {
228244 label: "environment-data:pull-requests:linked-threads",
245 persistence: persistQuery(PullRequestLinkedThreadsResult),
229246 tag: WS_METHODS.pullRequestsLinkedThreads,
230247 staleTimeMs: 0,
231248 refreshIntervalMs: 10_000,
......@@ -233,6 +250,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
233250 }),
234251 list: createEnvironmentRpcQueryAtomFamily(runtime, {
235252 label: "environment-data:pull-requests:list",
253 persistence: persistQuery(PullRequestListResult),
236254 tag: WS_METHODS.pullRequestsList,
237255 staleTimeMs: 30_000,
238256 refreshTrigger: ({ environmentId, input }) =>
......@@ -246,6 +264,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
246264 */
247265 listStats: createEnvironmentRpcQueryAtomFamily(runtime, {
248266 label: "environment-data:pull-requests:list-stats",
267 persistence: persistPullRequestStats(),
249268 tag: WS_METHODS.pullRequestsListStats,
250269 staleTimeMs: 60_000,
251270 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
......@@ -254,6 +273,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
254273 preview,
255274 checks: createEnvironmentRpcQueryAtomFamily(runtime, {
256275 label: "environment-data:pull-requests:checks",
276 persistence: persistQuery(Schema.NullOr(PullRequestChecks)),
257277 tag: WS_METHODS.pullRequestsChecks,
258278 execute: (input) => routedRequest(WS_METHODS.pullRequestsChecks, input),
259279 staleTimeMs: 45_000,
......@@ -273,6 +293,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
273293 }),
274294 diff: createEnvironmentQueryAtomFamily(runtime, {
275295 label: "environment-data:pull-requests:diff",
296 persistence: persistQuery(PullRequestDiffResult),
276297 staleTimeMs: 60_000,
277298 execute: (input: PullRequestDiffInput) =>
278299 Effect.gen(function* () {
......@@ -310,6 +331,7 @@ export function createPullRequestEnvironmentAtoms<R, E>(
310331 }),
311332 filesViewed: createEnvironmentRpcQueryAtomFamily(runtime, {
312333 label: "environment-data:pull-requests:files-viewed",
334 persistence: persistQuery(PullRequestFilesViewedResult),
313335 tag: WS_METHODS.pullRequestsFilesViewed,
314336 execute: (input) => routedRequest(WS_METHODS.pullRequestsFilesViewed, input),
315337 staleTimeMs: 15_000,
......@@ -517,7 +539,12 @@ export function createPullRequestEnvironmentAtoms<R, E>(
517539 label: "environment-data:pull-requests:generate-review-guide",
518540 tag: WS_METHODS.pullRequestsGenerateReviewGuide,
519541 execute: (input) => routedRequest(WS_METHODS.pullRequestsGenerateReviewGuide, input),
520 onSuccess: (target, registry) => Effect.sync(() => registry.refresh(reviewGuide(target))),
542 onSuccess: (target, registry, result) =>
543 Effect.sync(() => {
544 // The command returns the accepted run. Publish it before starting ends so readers
545 // never fall back to the empty/failed state while the follow-up query is in flight.
546 updateCached(registry, reviewGuide(target), () => result, true);
547 }),
521548 }),
522549 /** Read when the label menu opens, and kept for a minute, like the reviewer candidates. */
523550 labelCandidates,
packages/client-runtime/src/state/queryPersistence.test.ts created+71
......@@ -0,0 +1,71 @@
1import { describe, expect, it } from "@effect/vitest";
2import { EnvironmentId, ProjectId } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6import { EnvironmentQueryStore, persistQuery } from "./queryPersistence.ts";
7
8import { persistPullRequestStats } from "./pullRequestStatsPersistence.ts";
9
10const env = EnvironmentId.make("one");
11const other = EnvironmentId.make("two");
12const persisted = persistQuery(Schema.Struct({ title: Schema.String }));
13
14function store(records = new Map<string, string>()) {
15 return {
16 load: (environmentId: EnvironmentId, key: string) =>
17 Effect.sync(() => Option.fromUndefinedOr(records.get(`${environmentId}:${key}`))),
18 save: (environmentId: EnvironmentId, key: string, payload: string) =>
19 Effect.sync(() => {
20 records.set(`${environmentId}:${key}`, payload);
21 }),
22 };
23}
24
25describe("query persistence", () => {
26 it.effect("round trips responses without mixing environments or query scopes", () =>
27 Effect.gen(function* () {
28 yield* persisted.save(env, "open", { title: "saved" });
29 expect(yield* persisted.load(env, "open")).toEqual(Option.some({ title: "saved" }));
30 expect(yield* persisted.load(other, "open")).toEqual(Option.none());
31 expect(yield* persisted.load(env, "closed")).toEqual(Option.none());
32 }).pipe(Effect.provideService(EnvironmentQueryStore, store())),
33 );
34
35 it.effect("ignores corrupt or incompatible records", () =>
36 Effect.gen(function* () {
37 expect(yield* persisted.load(env, "broken")).toEqual(Option.none());
38 expect(yield* persisted.load(env, "incompatible")).toEqual(Option.none());
39 }).pipe(
40 Effect.provideService(
41 EnvironmentQueryStore,
42 store(
43 new Map([
44 ["one:broken", "not json"],
45 ["one:incompatible", '{"title":42}'],
46 ]),
47 ),
48 ),
49 ),
50 );
51
52 it.effect("does not persist on platforms without a query store", () =>
53 Effect.gen(function* () {
54 yield* persisted.save(env, "open", { title: "saved" });
55 expect(yield* persisted.load(env, "open")).toEqual(Option.none());
56 }),
57 );
58});
59
60it.effect("restores line counts when visible PR batches change", () =>
61 Effect.gen(function* () {
62 const cache = persistPullRequestStats();
63 const first = { projectId: ProjectId.make("project"), repository: "owner/repo", number: 1 };
64 const second = { ...first, number: 2 };
65 const stat = { ...first, additions: 42, deletions: 3 };
66 yield* cache.save(env, JSON.stringify(["stats", { refs: [first, second] }]), { stats: [stat] });
67 const key = JSON.stringify(["stats", { refs: [first] }]);
68 expect(yield* cache.load(env, key)).toEqual(Option.some({ stats: [stat] }));
69 expect(yield* cache.load(other, key)).toEqual(Option.none());
70 }).pipe(Effect.provideService(EnvironmentQueryStore, store())),
71);
packages/client-runtime/src/state/queryPersistence.ts created+49
......@@ -0,0 +1,49 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6
7/** Platforms opt in to storing selected query responses beside their other offline data. */
8export class EnvironmentQueryStore extends Context.Reference<{
9 readonly load: (
10 environmentId: EnvironmentId,
11 key: string,
12 ) => Effect.Effect<Option.Option<string>>;
13 readonly save: (
14 environmentId: EnvironmentId,
15 key: string,
16 payload: string,
17 ) => Effect.Effect<void>;
18} | null>("@t3tools/client-runtime/EnvironmentQueryStore", {
19 defaultValue: () => null,
20}) {}
21
22export interface QueryPersistence<A> {
23 readonly load: (environmentId: EnvironmentId, key: string) => Effect.Effect<Option.Option<A>>;
24 readonly save: (environmentId: EnvironmentId, key: string, value: A) => Effect.Effect<void>;
25}
26
27export function persistQuery<A, I>(schema: Schema.Codec<A, I>): QueryPersistence<A> {
28 const codec = Schema.fromJsonString(Schema.toCodecJson(schema));
29 return {
30 load: (environmentId, key) =>
31 Effect.gen(function* () {
32 const store = yield* EnvironmentQueryStore;
33 if (store === null) return Option.none<A>();
34 const stored = yield* store.load(environmentId, key);
35 if (Option.isNone(stored)) return Option.none<A>();
36 return yield* Schema.decodeUnknownEffect(codec)(stored.value).pipe(
37 Effect.map(Option.some),
38 Effect.catch(() => Effect.succeedNone),
39 );
40 }),
41 save: (environmentId, key, value) =>
42 Effect.gen(function* () {
43 const store = yield* EnvironmentQueryStore;
44 if (store === null) return;
45 const payload = yield* Schema.encodeEffect(codec)(value);
46 yield* store.save(environmentId, key, payload);
47 }).pipe(Effect.catch(() => Effect.logWarning("Offline query cache save failed"))),
48 };
49}
packages/client-runtime/src/state/runtime.test.ts+96-2
......@@ -26,6 +26,7 @@ import * as EnvironmentRegistry from "../connection/registry.ts";
2626import * as EnvironmentSupervisor from "../connection/supervisor.ts";
2727import { EnvironmentRpcUnavailableError } from "../rpc/client.ts";
2828import type * as RpcSession from "../rpc/session.ts";
29import type { QueryPersistence } from "./queryPersistence.ts";
2930import {
3031 environmentRpcKey,
3132 createAtomCommandScheduler,
......@@ -85,9 +86,16 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness"
8586 {
8687 refreshTrigger,
8788 idleTtlMs,
88 }: { readonly refreshTrigger?: Atom.Atom<unknown>; readonly idleTtlMs?: number } = {},
89 persistence,
90 initialState,
91 }: {
92 readonly refreshTrigger?: Atom.Atom<unknown>;
93 readonly idleTtlMs?: number;
94 readonly persistence?: QueryPersistence<A>;
95 readonly initialState?: SupervisorConnectionState;
96 } = {},
8997) {
90 const supervisorState = yield* SubscriptionRef.make(queryConnectionState());
98 const supervisorState = yield* SubscriptionRef.make(initialState ?? queryConnectionState());
9199 const supervisorSession = yield* SubscriptionRef.make(Option.some(QUERY_RPC_SESSION));
92100 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
93101 target: QUERY_ENVIRONMENT,
......@@ -115,6 +123,7 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness"
115123 const family = createEnvironmentQueryAtomFamily(runtime, {
116124 label: "test.environment-query",
117125 staleTimeMs: 60_000,
126 ...(persistence === undefined ? {} : { persistence }),
118127 execute: () => execute,
119128 ...(refreshTrigger === undefined ? {} : { refreshTrigger: () => refreshTrigger }),
120129 ...(idleTtlMs === undefined ? {} : { idleTtlMs }),
......@@ -1126,3 +1135,88 @@ describe("runtime command runner", () => {
11261135 registry.dispose();
11271136 });
11281137});
1138
1139describe("persisted environment queries", () => {
1140 it.effect(
1141 "restores cached data in a new registry while offline, then refreshes on reconnect",
1142 () =>
1143 Effect.scoped(
1144 Effect.gen(function* () {
1145 let saved = Option.none<string>();
1146 const persistence: QueryPersistence<string> = {
1147 load: () => Effect.sync(() => saved),
1148 save: (_environmentId, _key, value) =>
1149 Effect.sync(() => {
1150 saved = Option.some(value);
1151 }),
1152 };
1153 const first = yield* makeEnvironmentQueryHarness(Effect.succeed("first"), {
1154 persistence,
1155 });
1156 const firstRegistry = yield* mountEnvironmentQuery(first.atom);
1157 expect(yield* readEnvironmentQuery(firstRegistry, first.atom)).toBe("first");
1158 expect(saved).toEqual(Option.some("first"));
1159
1160 const restarted = yield* makeEnvironmentQueryHarness(Effect.succeed("updated"), {
1161 persistence,
1162 initialState: queryConnectionState({ phase: "offline", network: "offline" }),
1163 });
1164 const registry = yield* mountEnvironmentQuery(restarted.atom);
1165 expect(yield* readEnvironmentQuery(registry, restarted.atom)).toBe("first");
1166 yield* SubscriptionRef.set(restarted.supervisorState, queryConnectionState());
1167 yield* Effect.yieldNow;
1168 expect(yield* readEnvironmentQuery(registry, restarted.atom)).toBe("updated");
1169 expect(saved).toEqual(Option.some("updated"));
1170 }),
1171 ),
1172 );
1173
1174 it.effect("keeps cached content available when revalidation fails", () =>
1175 Effect.scoped(
1176 Effect.gen(function* () {
1177 const harness = yield* makeEnvironmentQueryHarness(
1178 Effect.fail(new TestQueryError({ message: "failed" })),
1179 {
1180 persistence: { load: () => Effect.succeedSome("cached"), save: () => Effect.void },
1181 },
1182 );
1183 const registry = yield* mountEnvironmentQuery(harness.atom);
1184 yield* Effect.result(readEnvironmentQuery(registry, harness.atom));
1185 expect(Option.getOrNull(AsyncResult.value(registry.get(harness.atom)))).toBe("cached");
1186 }),
1187 ),
1188 );
1189
1190 it.effect("shows persisted data before a live request finishes", () =>
1191 Effect.scoped(
1192 Effect.gen(function* () {
1193 const response = yield* Deferred.make<string>();
1194 const seeded = yield* Deferred.make<void>();
1195 const refreshed = yield* Deferred.make<void>();
1196 const harness = yield* makeEnvironmentQueryHarness(Deferred.await(response), {
1197 persistence: {
1198 load: () => Effect.succeedSome("cached"),
1199 save: () => Effect.void,
1200 },
1201 });
1202 const registry = yield* mountEnvironmentQuery(harness.atom);
1203 const unsubscribe = registry.subscribe(
1204 harness.atom,
1205 (value) => {
1206 if (Option.getOrNull(AsyncResult.value(value)) === "cached")
1207 Deferred.doneUnsafe(seeded, Exit.void);
1208 if (Option.getOrNull(AsyncResult.value(value)) === "fresh")
1209 Deferred.doneUnsafe(refreshed, Exit.void);
1210 },
1211 { immediate: true },
1212 );
1213 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
1214 yield* Deferred.await(seeded);
1215 expect(Option.getOrNull(AsyncResult.value(registry.get(harness.atom)))).toBe("cached");
1216 yield* Deferred.succeed(response, "fresh");
1217 yield* Deferred.await(refreshed);
1218 expect(yield* readEnvironmentQuery(registry, harness.atom)).toBe("fresh");
1219 }),
1220 ),
1221 );
1222});
packages/client-runtime/src/state/runtime.ts+62-27
......@@ -1,3 +1,4 @@
1import type { QueryPersistence } from "./queryPersistence.ts";
12import { createCommandPermissions } from "./commandPermissions.ts";
23import { followStreamInEnvironment } from "./environmentStreams.ts";
34export { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts";
......@@ -65,6 +66,7 @@ interface EnvironmentQueryAtomOptions<Input, A, E, R> extends Omit<
6566 input: Input,
6667 emit: (value: A) => Effect.Effect<void>,
6768 ) => Effect.Effect<A, E, R>;
69 readonly persistence?: QueryPersistence<A>;
6870 readonly staleTimeMs?: number;
6971 readonly idleTtlMs?: number;
7072 readonly refreshIntervalMs?: number;
......@@ -550,10 +552,6 @@ export function createEnvironmentQueryAtomFamily<R, ER, Input, A, E>(
550552 const connection = Option.getOrNull(
551553 AsyncResult.value(get(connectionAtom(target.environmentId))),
552554 );
553 if (connection === null) {
554 return Effect.never;
555 }
556 const [connectionState, session] = connection;
557555 const emit = (value: A) =>
558556 Effect.sync(() => get.setSelf(AsyncResult.success(value, { waiting: true }))).pipe(
559557 // A value set while the atom builds wins over the build's own
......@@ -562,29 +560,64 @@ export function createEnvironmentQueryAtomFamily<R, ER, Input, A, E>(
562560 // inside the build) so the settled result arrives asynchronously.
563561 Effect.andThen(Effect.promise(() => Promise.resolve())),
564562 );
565 switch (connectionState.phase) {
566 case "connected":
567 return Option.isSome(session)
568 ? runInEnvironment(target.environmentId, options.execute(target.input, emit))
569 : Effect.never;
570 case "connecting":
571 case "backoff":
572 return Effect.never;
573 case "available":
574 case "offline":
575 case "blocked":
576 if (connectionState.lastFailure !== null) {
577 return Effect.fail(connectionState.lastFailure);
578 }
579 return Effect.fail(
580 new EnvironmentRpcUnavailableError({
581 environmentId: target.environmentId,
582 message: `Environment ${target.environmentId} is ${
583 connectionState.phase === "available" ? "not connected" : connectionState.phase
584 }.`,
585 }),
586 );
587 }
563 const execute = (): Effect.Effect<
564 A,
565 | E
566 | ConnectionAttemptError
567 | EnvironmentRegistry.EnvironmentNotRegisteredError
568 | EnvironmentRpcUnavailableError,
569 | EnvironmentRegistry.EnvironmentRegistry
570 | AtomRegistry.AtomRegistry
571 | Exclude<R, EnvironmentSupervisor.EnvironmentSupervisor>
572 > => {
573 if (connection === null) return Effect.never;
574 const [connectionState, session] = connection;
575 switch (connectionState.phase) {
576 case "connected":
577 return Option.isSome(session)
578 ? runInEnvironment(target.environmentId, options.execute(target.input, emit))
579 : Effect.never;
580 case "connecting":
581 case "backoff":
582 return Effect.never;
583 case "available":
584 case "offline":
585 case "blocked":
586 if (connectionState.lastFailure !== null) {
587 return Effect.fail(connectionState.lastFailure);
588 }
589 return Effect.fail(
590 new EnvironmentRpcUnavailableError({
591 environmentId: target.environmentId,
592 message: `Environment ${target.environmentId} is ${
593 connectionState.phase === "available" ? "not connected" : connectionState.phase
594 }.`,
595 }),
596 );
597 }
598 };
599 if (!options.persistence) return execute();
600 const persistence = options.persistence;
601 const cacheKey = JSON.stringify([options.label, target.input]);
602 const previous = Option.flatMap(
603 get.self<AsyncResult.AsyncResult<A, unknown>>(),
604 AsyncResult.value,
605 );
606 return Effect.gen(function* () {
607 const cached = Option.isSome(previous)
608 ? previous
609 : yield* persistence.load(target.environmentId, cacheKey);
610 if (
611 Option.isSome(cached) &&
612 (connection === null || connection[0].phase !== "connected")
613 ) {
614 return cached.value;
615 }
616 if (Option.isNone(previous) && Option.isSome(cached)) yield* emit(cached.value);
617 return yield* execute().pipe(
618 Effect.tap((value) => persistence.save(target.environmentId, cacheKey, value)),
619 );
620 });
588621 })
589622 .pipe(
590623 Atom.swr({
......@@ -675,6 +708,7 @@ export function createEnvironmentRpcQueryAtomFamily<
675708 EnvironmentRpcFailure<TTag> | EnvironmentRpcUnavailableError,
676709 EnvironmentSupervisor.EnvironmentSupervisor | EnvironmentRegistry.EnvironmentRegistry
677710 >;
711 readonly persistence?: QueryPersistence<EnvironmentRpcSuccess<TTag>>;
678712 readonly staleTimeMs?: number;
679713 readonly idleTtlMs?: number;
680714 readonly refreshIntervalMs?: number;
......@@ -686,6 +720,7 @@ export function createEnvironmentRpcQueryAtomFamily<
686720) {
687721 return createEnvironmentQueryAtomFamily(runtime, {
688722 label: options.label,
723 ...(options.persistence === undefined ? {} : { persistence: options.persistence }),
689724 ...(options.staleTimeMs === undefined ? {} : { staleTimeMs: options.staleTimeMs }),
690725 ...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }),
691726 ...(options.refreshIntervalMs === undefined