| author | |
| committer | |
| log | d07a07f33c0a1b317dea6234636976231c3688b5 |
| tree | b3a135cb2156e40d73b386bdb42ca4223b86c2b3 |
| parent | 5cbe82a87006a7ee472f0f9c9fce5d5f24caa795 |
| signature | Signed by PGP key B5690EEEBB952194 |
48 files changed, 1711 insertions(+), 564 deletions(-)
apps/mobile/src/connection/pull-request-cache-store.ts created+22| ... | ... | @@ -0,0 +1,22 @@ |
| 1 | import { EnvironmentQueryStore } from "@t3tools/client-runtime/state/query-persistence"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | ||
| 5 | import { MobileDatabase } from "../persistence/mobile-database"; | |
| 6 | ||
| 7 | export 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: { |
| 123 | 123 | const agent = usePullRequestAgentActions(props.params, props.pr, props.filePath); |
| 124 | 124 | const canReview = permissions.verdicts.length > 0; |
| 125 | 125 | 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; | |
| 127 | 128 | const threadLabel = agent.threads.length ? "Continue thread" : "Work on PR"; |
| 128 | 129 | const primaryLabel = pendingReview ? `Submit review · ${draft.comments.length}` : threadLabel; |
| 129 | 130 | const chooseThread = agent.threads.length > 1; |
| ... | ... | @@ -142,7 +143,7 @@ export function PullRequestActionToolbar(props: { |
| 142 | 143 | // Reserve the native outer margins, custom-button padding, and item gaps. |
| 143 | 144 | // UIKit needs this space to keep every action out of its overflow menu. |
| 144 | 145 | // Duo's side rail uses an intrinsic icon item instead. |
| 145 | const sideControls = Number(agent.canAsk) + 1; | |
| 146 | const sideControls = Number(showAgentControls) + 1; | |
| 146 | 147 | const primaryWidth = verticalControls |
| 147 | 148 | ? undefined |
| 148 | 149 | : Math.max( |
| ... | ... | @@ -166,7 +167,7 @@ export function PullRequestActionToolbar(props: { |
| 166 | 167 | className="flex-row items-center justify-between gap-3 border-t border-border-subtle bg-screen px-4 pt-2" |
| 167 | 168 | style={{ paddingBottom: Math.max(insets.bottom, 8) }} |
| 168 | 169 | > |
| 169 | {agent.canAsk ? ( | |
| 170 | {showAgentControls ? ( | |
| 170 | 171 | <ControlPillMenu |
| 171 | 172 | title="AI helpers" |
| 172 | 173 | actions={agentActions.map((action) => agent.menuAction(action.task))} |
| ... | ... | @@ -259,7 +260,7 @@ export function PullRequestActionToolbar(props: { |
| 259 | 260 | /> |
| 260 | 261 | ) |
| 261 | 262 | ) : null; |
| 262 | const agentControl = agent.canAsk ? ( | |
| 263 | const agentControl = showAgentControls ? ( | |
| 263 | 264 | <NativeHeaderToolbar.Menu |
| 264 | 265 | identifier="pull-request-agent" |
| 265 | 266 | width={controlSize} |
apps/mobile/src/features/pull-requests/PullRequestDescription.tsx+50-57| ... | ... | @@ -1,13 +1,20 @@ |
| 1 | import { useMemo, useState } from "react"; | |
| 2 | import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from "react-native"; | |
| 1 | import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown"; | |
| 2 | import { | |
| 3 | PullRequestImageAttachment, | |
| 4 | PullRequestMedia, | |
| 5 | PullRequestMediaContext, | |
| 6 | } from "./PullRequestMedia"; | |
| 7 | import { PullRequestSectionHeading } from "./PullRequestSectionHeading"; | |
| 8 | import { useContext, useMemo, useState } from "react"; | |
| 9 | import { Pressable, ScrollView, StyleSheet, View } from "react-native"; | |
| 3 | 10 | import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; |
| 4 | import { Image } from "expo-image"; | |
| 11 | import { pullRequestImageSource } from "./pullRequestMediaSource"; | |
| 5 | 12 | import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; |
| 6 | 13 | import { nativeMarkdownWithHtml } from "@t3tools/mobile-markdown-text/markdown"; |
| 7 | 14 | import { SymbolView } from "../../components/AppSymbol"; |
| 8 | 15 | import { AppText as Text } from "../../components/AppText"; |
| 9 | 16 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 10 | import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; | |
| 17 | import type { FilePreviewItem } from "../../components/FilePreviewModal"; | |
| 11 | 18 | import { PullRequestMarkdown } from "./PullRequestMarkdown"; |
| 12 | 19 | import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent"; |
| 13 | 20 | |
| ... | ... | @@ -21,31 +28,33 @@ export function PullRequestDescription(props: { |
| 21 | 28 | }) { |
| 22 | 29 | const { expanded, onExpandedChange } = props; |
| 23 | 30 | 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); | |
| 26 | 32 | const { themeVariables: colors } = useAppearancePreferences(); |
| 27 | 33 | const content = useMemo(() => { |
| 28 | 34 | const document = nativeMarkdownWithHtml( |
| 29 | 35 | parseMarkdownWithOptions(props.markdown, { gfm: true, html: true, math: false }), |
| 30 | 36 | (markdown) => parseMarkdownWithOptions(markdown, { gfm: true, html: false, math: false }), |
| 31 | 37 | ); |
| 32 | return pullRequestDescriptionContent(document); | |
| 38 | return pullRequestDescriptionContent(document, splitPullRequestBody(props.markdown)); | |
| 33 | 39 | }, [props.markdown]); |
| 34 | const gallery = useMemo( | |
| 40 | const gallery = useMemo<FilePreviewItem[]>( | |
| 35 | 41 | () => |
| 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], | |
| 42 | 51 | ); |
| 43 | 52 | const canCollapse = bodyHeight > COLLAPSED_HEIGHT; |
| 44 | 53 | const collapsed = canCollapse && !expanded; |
| 45 | 54 | return ( |
| 46 | 55 | <View className="gap-5"> |
| 47 | 56 | <View className="gap-3"> |
| 48 | <Text className="text-base font-t3-semibold">Description</Text> | |
| 57 | <PullRequestSectionHeading>Description</PullRequestSectionHeading> | |
| 49 | 58 | {props.markdown.trim() ? ( |
| 50 | 59 | <View className="overflow-hidden"> |
| 51 | 60 | <View |
| ... | ... | @@ -130,7 +139,7 @@ export function PullRequestDescription(props: { |
| 130 | 139 | </View> |
| 131 | 140 | {content.attachments.length ? ( |
| 132 | 141 | <View className="gap-3"> |
| 133 | <Text className="text-base font-t3-semibold">Attachments</Text> | |
| 142 | <PullRequestSectionHeading>Attachments</PullRequestSectionHeading> | |
| 134 | 143 | <ScrollView |
| 135 | 144 | horizontal |
| 136 | 145 | keyboardShouldPersistTaps="always" |
| ... | ... | @@ -138,50 +147,34 @@ export function PullRequestDescription(props: { |
| 138 | 147 | showsHorizontalScrollIndicator={false} |
| 139 | 148 | contentContainerStyle={{ gap: 12 }} |
| 140 | 149 | > |
| 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 | )} | |
| 181 | 175 | </ScrollView> |
| 182 | 176 | </View> |
| 183 | 177 | ) : null} |
| 184 | <FilePreviewModal source={preview} onRequestClose={() => setPreview(null)} /> | |
| 185 | 178 | </View> |
| 186 | 179 | ); |
| 187 | 180 | } |
apps/mobile/src/features/pull-requests/PullRequestDiscussionScreen.tsx+1| ... | ... | @@ -118,6 +118,7 @@ function Discussion({ params }: { params: PullRequestParams & { readonly discuss |
| 118 | 118 | const resolveLabel = thread?.isResolved ? "Unresolve" : "Resolve"; |
| 119 | 119 | return ( |
| 120 | 120 | <PullRequestMarkdownRepository |
| 121 | params={params} | |
| 121 | 122 | value={detail.data?.provider === "github" ? detail.data.url : null} |
| 122 | 123 | > |
| 123 | 124 | <KeyboardAvoidingView automaticOffset behavior="height" className="flex-1 bg-screen"> |
apps/mobile/src/features/pull-requests/PullRequestFiles.tsx+12-11| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { PullRequestSectionHeading } from "./PullRequestSectionHeading"; | |
| 1 | 2 | import { AnimatedLegendList } from "@legendapp/list/reanimated"; |
| 2 | 3 | import Animated from "react-native-reanimated"; |
| 3 | 4 | import { PR_ROW_LAYOUT, PR_ROW_ENTER, PR_ROW_EXIT } from "./pullRequestAnimations"; |
| ... | ... | @@ -167,15 +168,15 @@ export function usePullRequestFilesContent(props: { |
| 167 | 168 | accessibilityState={{ busy: true }} |
| 168 | 169 | className="min-h-17 flex-row items-center gap-2 py-3" |
| 169 | 170 | > |
| 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 | ) : ( | |
| 177 | 178 | <View className="h-2.5 w-28 rounded bg-subtle-strong" /> |
| 178 | ) : null} | |
| 179 | )} | |
| 179 | 180 | </View> |
| 180 | 181 | {summary?.additions !== undefined && summary.deletions !== undefined ? ( |
| 181 | 182 | <PullRequestDiffStats |
| ... | ... | @@ -196,14 +197,14 @@ export function usePullRequestFilesContent(props: { |
| 196 | 197 | <> |
| 197 | 198 | <View className="min-h-17 flex-row items-center gap-2 py-3"> |
| 198 | 199 | <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"> | |
| 200 | 202 | {parsed.fileCount} {filter.hiddenCount ? "visible" : "changed"}{" "} |
| 201 | 203 | {parsed.fileCount === 1 ? "file" : "files"} |
| 202 | 204 | {viewed.supported |
| 203 | 205 | ? ` · ${parsed.files.filter((file) => viewed.isViewed(file.path)).length} viewed` |
| 204 | 206 | : ""} |
| 205 | </Text> | |
| 206 | <Text className="text-2xs text-foreground-muted"> | |
| 207 | {" · "} | |
| 207 | 208 | {filter.hideTestFiles |
| 208 | 209 | ? `${filter.hiddenCount} test ${filter.hiddenCount === 1 ? "file" : "files"} hidden` |
| 209 | 210 | : "Test files included"} |
apps/mobile/src/features/pull-requests/PullRequestMarkdown.tsx+90-15| ... | ... | @@ -1,3 +1,13 @@ |
| 1 | import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown"; | |
| 2 | import type { ReactNode } from "react"; | |
| 3 | import { View } from "react-native"; | |
| 4 | import { useProject } from "../../state/entities"; | |
| 5 | import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; | |
| 6 | import { | |
| 7 | PullRequestMedia, | |
| 8 | PullRequestMediaContext, | |
| 9 | usePullRequestMediaUrl, | |
| 10 | } from "./PullRequestMedia"; | |
| 1 | 11 | import { videoMimeType } from "@t3tools/shared/video"; |
| 2 | 12 | import { createContext, useContext, useCallback, useMemo, useState } from "react"; |
| 3 | 13 | import { githubMarkdownLinks } from "@t3tools/shared/githubMarkdownLinks"; |
| ... | ... | @@ -16,7 +26,8 @@ import { |
| 16 | 26 | } from "../../native/SelectableMarkdownText"; |
| 17 | 27 | import { useMarkdownPreviewStyles } from "../files/FileMarkdownPreview"; |
| 18 | 28 | import { markdownImageSources } from "../../lib/markdownImageSources"; |
| 19 | import { FilePreviewGallery } from "../../components/FilePreviewGallery"; | |
| 29 | import { FilePreviewGallery, useFilePreviewGallery } from "../../components/FilePreviewGallery"; | |
| 30 | import { pullRequestImageSource } from "./pullRequestMediaSource"; | |
| 20 | 31 | import { |
| 21 | 32 | FilePreviewModal, |
| 22 | 33 | type FilePreviewSource, |
| ... | ... | @@ -25,7 +36,78 @@ import { |
| 25 | 36 | import { ThreadMarkdownImageView } from "../threads/ThreadMarkdownImage"; |
| 26 | 37 | import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText"; |
| 27 | 38 | |
| 28 | export const PullRequestMarkdownRepository = createContext<string | null>(null); | |
| 39 | const RepositoryContext = createContext<string | null>(null); | |
| 40 | ||
| 41 | export 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 | ||
| 63 | function 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 | ||
| 90 | export 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 | } | |
| 29 | 111 | |
| 30 | 112 | function renderLink( |
| 31 | 113 | { href, children }: CustomRendererProps, |
| ... | ... | @@ -61,11 +143,12 @@ function renderHtmlBlock( |
| 61 | 143 | ) : null; |
| 62 | 144 | } |
| 63 | 145 | |
| 64 | export function PullRequestMarkdown(props: { | |
| 146 | function PullRequestMarkdownContent(props: { | |
| 65 | 147 | readonly markdown: string; |
| 66 | 148 | readonly imageGroupMarkdown?: string; |
| 67 | 149 | }) { |
| 68 | const repositoryUrl = useContext(PullRequestMarkdownRepository); | |
| 150 | const repositoryUrl = useContext(RepositoryContext); | |
| 151 | const mediaContext = useContext(PullRequestMediaContext); | |
| 69 | 152 | const markdown = useMemo( |
| 70 | 153 | () => githubMarkdownLinks(props.markdown, repositoryUrl), |
| 71 | 154 | [props.markdown, repositoryUrl], |
| ... | ... | @@ -85,20 +168,12 @@ export function PullRequestMarkdown(props: { |
| 85 | 168 | /^https?:\/\//i.test(uri) && |
| 86 | 169 | videoMimeType({ name: uri.split(/[?#]/)[0] ?? uri, mimeType: "" }) === null, |
| 87 | 170 | ) |
| 88 | .map((uri) => ({ kind: "image", uri })), | |
| 89 | [props.markdown, props.imageGroupMarkdown], | |
| 171 | .map((uri) => pullRequestImageSource(uri, mediaContext)), | |
| 172 | [props.markdown, props.imageGroupMarkdown, mediaContext], | |
| 90 | 173 | ); |
| 91 | 174 | const [preview, setPreview] = useState<FilePreviewSource | null>(null); |
| 92 | 175 | 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} />, | |
| 102 | 177 | [], |
| 103 | 178 | ); |
| 104 | 179 | const styles = useMarkdownPreviewStyles(renderImage); |
apps/mobile/src/features/pull-requests/PullRequestMedia.tsx created+142| ... | ... | @@ -0,0 +1,142 @@ |
| 1 | import { createContext, useContext, useMemo, useState } from "react"; | |
| 2 | import { pullRequestMediaSource, pullRequestImageSource } from "./pullRequestMediaSource"; | |
| 3 | import { useFilePreviewGallery } from "../../components/FilePreviewGallery"; | |
| 4 | import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; | |
| 5 | import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; | |
| 6 | import { videoMimeType } from "@t3tools/shared/video"; | |
| 7 | import { Pressable, View } from "react-native"; | |
| 8 | import { Image } from "expo-image"; | |
| 9 | import { AppText } from "../../components/AppText"; | |
| 10 | import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; | |
| 11 | import { MediaVideoPlayer } from "../../components/MediaVideoPlayer"; | |
| 12 | import { VideoAttachmentTile } from "../../components/VideoAttachmentTile"; | |
| 13 | import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal"; | |
| 14 | import { ThreadMarkdownImageView } from "../threads/ThreadMarkdownImage"; | |
| 15 | import { | |
| 16 | FilePreviewModal, | |
| 17 | type FilePreviewSource, | |
| 18 | type FilePreviewItem, | |
| 19 | } from "../../components/FilePreviewModal"; | |
| 20 | ||
| 21 | export 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. */ | |
| 27 | export 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 | ||
| 44 | export 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 | ||
| 110 | export 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: { |
| 54 | 54 | hasStack: stack.stack !== null, |
| 55 | 55 | isFresh: stack.isFresh, |
| 56 | 56 | }); |
| 57 | const checkingStack = stack.supportsStackActions && !stack.isFresh && !stack.error; | |
| 57 | const checkingStack = | |
| 58 | permissions.connected && stack.supportsStackActions && !stack.isFresh && !stack.error; | |
| 58 | 59 | const reason = |
| 59 | mergeUnavailableReason(pr, permissions.canWrite) ?? | |
| 60 | (!permissions.connected | |
| 61 | ? "Reconnect to merge." | |
| 62 | : mergeUnavailableReason(pr, permissions.canWrite)) ?? | |
| 60 | 63 | (stack.stack |
| 61 | 64 | ? !stack.supportsStackActions |
| 62 | 65 | ? "This environment can display stacks but cannot merge them." |
| ... | ... | @@ -185,7 +188,7 @@ export function PullRequestMergeControls(props: { |
| 185 | 188 | <Text |
| 186 | 189 | className={`shrink text-center text-sm font-t3-semibold ${canMerge ? "text-screen" : "text-foreground-muted"}`} |
| 187 | 190 | > |
| 188 | {busy ? "Working…" : label} | |
| 191 | {busy ? "Working…" : !permissions.connected ? "Reconnect to merge" : label} | |
| 189 | 192 | </Text> |
| 190 | 193 | </Pressable> |
| 191 | 194 | {methods.length > 0 || canAutoMerge ? ( |
| ... | ... | @@ -230,7 +233,8 @@ export function PullRequestMergeControls(props: { |
| 230 | 233 | </ControlPillMenu> |
| 231 | 234 | ) : null} |
| 232 | 235 | </View> |
| 233 | {reason && | |
| 236 | {permissions.connected && | |
| 237 | reason && | |
| 234 | 238 | reason !== MERGE_CONFLICT_REASON && |
| 235 | 239 | reason !== "Checking stack membership…" && |
| 236 | 240 | !(checkingStack && reason === "Refresh the stack before merging.") ? ( |
apps/mobile/src/features/pull-requests/PullRequestOverviewLoading.tsx+2-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { PullRequestSectionHeading } from "./PullRequestSectionHeading"; | |
| 1 | 2 | import type { PullRequestListEntry } from "@t3tools/contracts"; |
| 2 | 3 | import { View } from "react-native"; |
| 3 | 4 | import { AppText as Text } from "../../components/AppText"; |
| ... | ... | @@ -97,7 +98,7 @@ export function PullRequestOverviewLoading({ |
| 97 | 98 | </View> |
| 98 | 99 | ) : null} |
| 99 | 100 | <View className="gap-3"> |
| 100 | <Text className="text-base font-t3-semibold">Description</Text> | |
| 101 | <PullRequestSectionHeading>Description</PullRequestSectionHeading> | |
| 101 | 102 | <View className="gap-3 py-1"> |
| 102 | 103 | <View className="h-4 w-full rounded bg-subtle-strong" /> |
| 103 | 104 | <View className="h-4 w-full rounded bg-subtle-strong" /> |
apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx+3| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { usePullRequestConnected } from "./usePullRequestConnected"; | |
| 1 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 3 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 3 | 4 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| ... | ... | @@ -60,7 +61,9 @@ export function PullRequestReviewersScreen({ route }: StaticScreenProps<PullRequ |
| 60 | 61 | const canWrite = useAtomValue( |
| 61 | 62 | composerPullRequests.requestReviewers.permissionAtom(target.environmentId), |
| 62 | 63 | ); |
| 64 | const connected = usePullRequestConnected(route.params.environmentId); | |
| 63 | 65 | const allowed = |
| 66 | connected && | |
| 64 | 67 | canWrite && |
| 65 | 68 | pr?.state === "open" && |
| 66 | 69 | pr.capabilities.reviewers.request && |
apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx+10-3| ... | ... | @@ -62,6 +62,7 @@ import { SegmentedControl } from "../../components/SegmentedControl"; |
| 62 | 62 | import { SymbolView } from "../../components/AppSymbol"; |
| 63 | 63 | import { relativeTime } from "../../lib/time"; |
| 64 | 64 | import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; |
| 65 | import { usePullRequestConnected } from "./usePullRequestConnected"; | |
| 65 | 66 | import { composerPullRequests } from "../../state/pull-requests"; |
| 66 | 67 | import { useEnvironmentQuery } from "../../state/query"; |
| 67 | 68 | import { usePullToRefresh } from "../../state/use-pull-to-refresh"; |
| ... | ... | @@ -272,6 +273,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 272 | 273 | const navigation = useNavigation(); |
| 273 | 274 | const insets = useSafeAreaInsets(); |
| 274 | 275 | const params = route.params; |
| 276 | const connected = usePullRequestConnected(params.environmentId); | |
| 275 | 277 | const [section, setSection] = useState<"overview" | "activity" | "guide">("overview"); |
| 276 | 278 | // Keep each tab's native list and measured rows alive when it is hidden. |
| 277 | 279 | const requestedSection = useRef(section); |
| ... | ... | @@ -350,18 +352,23 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP |
| 350 | 352 | } |
| 351 | 353 | }; |
| 352 | 354 | return ( |
| 353 | <PullRequestMarkdownRepository value={pr?.provider === "github" ? pr.url : null}> | |
| 355 | <PullRequestMarkdownRepository | |
| 356 | params={params} | |
| 357 | value={pr?.provider === "github" ? pr.url : null} | |
| 358 | > | |
| 354 | 359 | <View className="flex-1 bg-screen"> |
| 355 | 360 | <NativeStackScreenOptions |
| 356 | 361 | options={{ |
| 357 | 362 | title: `#${params.number}`, |
| 358 | 363 | headerTitle: `#${params.number}`, |
| 359 | unstable_headerSubtitle: params.repository, | |
| 364 | unstable_headerSubtitle: connected | |
| 365 | ? params.repository | |
| 366 | : `${params.repository} · Offline`, | |
| 360 | 367 | }} |
| 361 | 368 | /> |
| 362 | 369 | {Platform.OS === "android" ? ( |
| 363 | 370 | <AndroidScreenHeader |
| 364 | title={`#${params.number}`} | |
| 371 | title={`#${params.number}${connected ? "" : " · Offline"}`} | |
| 365 | 372 | onBack={() => navigation.goBack()} |
| 366 | 373 | actions={[ |
| 367 | 374 | ...(permissions.verdicts.length |
apps/mobile/src/features/pull-requests/PullRequestSectionHeading.tsx created+6| ... | ... | @@ -0,0 +1,6 @@ |
| 1 | import type { ReactNode } from "react"; | |
| 2 | import { AppText } from "../../components/AppText"; | |
| 3 | ||
| 4 | export 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 @@ |
| 1 | import { usePullRequestConnected } from "./usePullRequestConnected"; | |
| 1 | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 3 | import { useNavigation } from "@react-navigation/native"; |
| 3 | 4 | import type { PullRequestAction, PullRequestDetail } from "@t3tools/contracts"; |
| ... | ... | @@ -237,7 +238,9 @@ export function PullRequestReviewersRow(props: RowProps) { |
| 237 | 238 | pullRequestTarget(props.params).environmentId, |
| 238 | 239 | ), |
| 239 | 240 | ); |
| 241 | const connected = usePullRequestConnected(props.params.environmentId); | |
| 240 | 242 | const canRequest = |
| 243 | connected && | |
| 241 | 244 | canWrite && |
| 242 | 245 | props.pr.state === "open" && |
| 243 | 246 | props.pr.capabilities.reviewers.request && |
apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx+21-11| ... | ... | @@ -99,7 +99,6 @@ export function PullRequestsScreen() { |
| 99 | 99 | environments |
| 100 | 100 | .filter( |
| 101 | 101 | (environment) => |
| 102 | environment.connectionState === "connected" && | |
| 103 | 102 | configs.get(environment.environmentId)?.environment.capabilities.pullRequests === |
| 104 | 103 | true && |
| 105 | 104 | (environmentId === null || environment.environmentId === environmentId) && |
| ... | ... | @@ -121,8 +120,15 @@ export function PullRequestsScreen() { |
| 121 | 120 | [environments, environmentId, configs, controls, query], |
| 122 | 121 | ); |
| 123 | 122 | 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 | ); | |
| 124 | 130 | const { actions, select, busyKey } = usePullRequestListActions( |
| 125 | targets.map((target) => target.environmentId), | |
| 131 | connectedTargets.map((target) => target.environmentId), | |
| 126 | 132 | result.updateEntry, |
| 127 | 133 | ); |
| 128 | 134 | const selectedKey = primaryColumn?.selectedPullRequestKey; |
| ... | ... | @@ -309,7 +315,7 @@ export function PullRequestsScreen() { |
| 309 | 315 | ) : undefined |
| 310 | 316 | } |
| 311 | 317 | ListEmptyComponent={ |
| 312 | result.isPending ? ( | |
| 318 | result.isPending && connectedTargets.length > 0 ? ( | |
| 313 | 319 | <View className="items-center py-12"> |
| 314 | 320 | <ActivityIndicator colorClassName="accent-icon" /> |
| 315 | 321 | </View> |
| ... | ... | @@ -319,17 +325,21 @@ export function PullRequestsScreen() { |
| 319 | 325 | title={ |
| 320 | 326 | result.error |
| 321 | 327 | ? "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" | |
| 327 | 335 | } |
| 328 | 336 | detail={ |
| 329 | 337 | 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.") | |
| 333 | 343 | } |
| 334 | 344 | variant="plain" |
| 335 | 345 | /> |
apps/mobile/src/features/pull-requests/guide/GuideNotice.tsx created+85| ... | ... | @@ -0,0 +1,85 @@ |
| 1 | import { Pressable, View } from "react-native"; | |
| 2 | import Animated, { FadeIn, ReduceMotion } from "react-native-reanimated"; | |
| 3 | import { AppText as Text } from "../../../components/AppText"; | |
| 4 | import { SymbolView } from "../../../components/AppSymbol"; | |
| 5 | import { PR_ROW_EXIT, PR_ROW_LAYOUT } from "../pullRequestAnimations"; | |
| 6 | import { guideNoticeState } from "./reviewGuideState"; | |
| 7 | import type { useReviewGuide } from "./useReviewGuide"; | |
| 8 | ||
| 9 | const 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. */ | |
| 12 | export 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 { |
| 13 | 13 | import { SymbolView } from "../../../components/AppSymbol"; |
| 14 | 14 | import { GuideOutline } from "./GuideOutline"; |
| 15 | 15 | import { AppText as Text } from "../../../components/AppText"; |
| 16 | import { MaterialButton } from "../../../components/MaterialButton"; | |
| 17 | 16 | import { ScreenScrollView } from "../../../components/ScreenScrollView"; |
| 18 | 17 | import { ControlPillMenu } from "../../../components/ControlPillMenu"; |
| 19 | 18 | import { MaterialIconButton } from "../../../components/MaterialIconButton"; |
| ... | ... | @@ -24,6 +23,9 @@ import { PullRequestMarkdown, PullRequestMarkdownRepository } from "../PullReque |
| 24 | 23 | import { usePullRequestDiff, type PullRequestParams } from "../usePullRequestDiff"; |
| 25 | 24 | import { ReviewGuideExcerpt } from "./ReviewGuideExcerpt"; |
| 26 | 25 | import { reviewGuideState } from "./reviewGuideState"; |
| 26 | import { GuideNotice } from "./GuideNotice"; | |
| 27 | import Animated from "react-native-reanimated"; | |
| 28 | import { PR_ROW_ENTER, PR_ROW_EXIT, PR_ROW_LAYOUT } from "../pullRequestAnimations"; | |
| 27 | 29 | import { useEnvironmentQuery } from "../../../state/query"; |
| 28 | 30 | import { composerPullRequests } from "../../../state/pull-requests"; |
| 29 | 31 | import { usePullRequestPermissions } from "../usePullRequestPermissions"; |
| ... | ... | @@ -44,111 +46,47 @@ const attentionLabels = { |
| 44 | 46 | accessibility: "An accessibility call", |
| 45 | 47 | }; |
| 46 | 48 | |
| 47 | function 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 | ||
| 114 | 49 | export function PullRequestGuideContent({ params }: { readonly params: PullRequestParams }) { |
| 115 | 50 | const navigation = useNavigation(); |
| 116 | 51 | const state = useReviewGuide(params); |
| 117 | 52 | const { guide, stale } = reviewGuideState(state.data); |
| 118 | 53 | 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"> | |
| 120 | 55 | {!state.enabled ? ( |
| 121 | 56 | <Text>Review guides are disabled for this server.</Text> |
| 122 | 57 | ) : ( |
| 123 | 58 | <> |
| 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} /> | |
| 129 | 60 | {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> | |
| 148 | 86 | ) : null} |
| 149 | 87 | </> |
| 150 | 88 | )} |
| 151 | </View> | |
| 89 | </Animated.View> | |
| 152 | 90 | ); |
| 153 | 91 | } |
| 154 | 92 | |
| ... | ... | @@ -240,6 +178,7 @@ export function PullRequestGuideStopScreen({ route }: StaticScreenProps<GuideSto |
| 240 | 178 | </NativeHeaderToolbar.Menu> |
| 241 | 179 | </NativeHeaderToolbar> |
| 242 | 180 | <PullRequestMarkdownRepository |
| 181 | params={route.params} | |
| 243 | 182 | value={`https://${route.params.host ?? "github.com"}/${route.params.repository}`} |
| 244 | 183 | > |
| 245 | 184 | <ScreenScrollView |
apps/mobile/src/features/pull-requests/guide/reviewGuideState.test.ts+56-1| ... | ... | @@ -4,7 +4,7 @@ import { |
| 4 | 4 | ProviderInstanceId, |
| 5 | 5 | type PullRequestReviewGuideResult, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | import { reviewGuideState } from "./reviewGuideState"; | |
| 7 | import { guideNoticeState, reviewGuideState } from "./reviewGuideState"; | |
| 8 | 8 | |
| 9 | 9 | const guide: NonNullable<PullRequestReviewGuideResult["guide"]> = { |
| 10 | 10 | headSha: "old-head", |
| ... | ... | @@ -46,3 +46,58 @@ describe("mobile review guide state", () => { |
| 46 | 46 | }); |
| 47 | 47 | }); |
| 48 | 48 | }); |
| 49 | ||
| 50 | const view = { | |
| 51 | data: null, | |
| 52 | isPending: false, | |
| 53 | running: false, | |
| 54 | startError: null, | |
| 55 | error: null, | |
| 56 | }; | |
| 57 | ||
| 58 | describe("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 | ||
| 101 | it("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) { |
| 13 | 13 | : null, |
| 14 | 14 | }; |
| 15 | 15 | } |
| 16 | ||
| 17 | /** A pending refresh must not hide cached content, or a new run reveal an old failure. */ | |
| 18 | export 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"; |
| 2 | 2 | import { useFocusEffect } from "@react-navigation/native"; |
| 3 | 3 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 4 | 4 | import { useCallback, useRef, useState } from "react"; |
| 5 | import { usePullRequestConnected } from "../usePullRequestConnected"; | |
| 5 | 6 | import { composerPullRequests } from "../../../state/pull-requests"; |
| 6 | 7 | import { useEnvironmentQuery } from "../../../state/query"; |
| 7 | 8 | import { serverEnvironment } from "../../../state/server"; |
| ... | ... | @@ -19,9 +20,11 @@ export function useReviewGuide(params: PullRequestParams) { |
| 19 | 20 | const target = pullRequestTarget(params); |
| 20 | 21 | const enabled = useReviewGuidesEnabled(params); |
| 21 | 22 | const query = useEnvironmentQuery(enabled ? composerPullRequests.reviewGuide(target) : null); |
| 22 | const canWrite = useAtomValue( | |
| 23 | const connected = usePullRequestConnected(target.environmentId); | |
| 24 | const permitted = useAtomValue( | |
| 23 | 25 | composerPullRequests.generateReviewGuide.permissionAtom(target.environmentId), |
| 24 | 26 | ); |
| 27 | const canWrite = connected && permitted; | |
| 25 | 28 | const generate = useAtomCommand(composerPullRequests.generateReviewGuide, { |
| 26 | 29 | reportFailure: false, |
| 27 | 30 | }); |
| ... | ... | @@ -49,8 +52,6 @@ export function useReviewGuide(params: PullRequestParams) { |
| 49 | 52 | if (result._tag === "Failure") { |
| 50 | 53 | const cause = squashAtomCommandFailure(result); |
| 51 | 54 | setError(cause instanceof Error ? cause.message : "Could not start the review guide."); |
| 52 | } else { | |
| 53 | refresh(); | |
| 54 | 55 | } |
| 55 | 56 | } finally { |
| 56 | 57 | pending.current = false; |
apps/mobile/src/features/pull-requests/pullRequestDescription.test.ts+2-2| ... | ... | @@ -15,8 +15,8 @@ describe("PR description overview", () => { |
| 15 | 15 | ], |
| 16 | 16 | }); |
| 17 | 17 | 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" }, | |
| 20 | 20 | ]); |
| 21 | 21 | expect(result.preview).toContain("Fixes reconnects."); |
| 22 | 22 | expect(result.preview).not.toContain("Duplicate"); |
apps/mobile/src/features/pull-requests/pullRequestDescriptionContent.test.ts created+38| ... | ... | @@ -0,0 +1,38 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | |
| 3 | import { splitPullRequestBody } from "@t3tools/client-runtime/pull-request-markdown"; | |
| 4 | import { pullRequestDescriptionContent } from "./pullRequestDescriptionContent"; | |
| 5 | ||
| 6 | describe("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 @@ |
| 1 | import type { PullRequestBodySegment } from "@t3tools/client-runtime/pull-request-markdown"; | |
| 1 | 2 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; |
| 2 | 3 | |
| 3 | 4 | /** Read media from the parsed document so code examples never become attachments. */ |
| 4 | export function pullRequestDescriptionContent(document: MarkdownNode) { | |
| 5 | const attachments = new Map<string, { readonly uri: string; readonly caption: string }>(); | |
| 5 | export 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 | >(); | |
| 6 | 13 | const text: string[] = []; |
| 7 | 14 | const visit = (node: MarkdownNode) => { |
| 8 | 15 | if (node.type === "image") { |
| 9 | 16 | 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 | }); | |
| 11 | 22 | } |
| 12 | 23 | return; |
| 13 | 24 | } |
| ... | ... | @@ -18,5 +29,15 @@ export function pullRequestDescriptionContent(document: MarkdownNode) { |
| 18 | 29 | if (node.type === "soft_break" || node.type === "line_break") text.push(" "); |
| 19 | 30 | }; |
| 20 | 31 | 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 | } | |
| 21 | 42 | return { preview: text.join("").trim(), attachments: [...attachments.values()] }; |
| 22 | 43 | } |
apps/mobile/src/features/pull-requests/pullRequestMediaSource.test.ts created+37| ... | ... | @@ -0,0 +1,37 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 3 | import { filePreviewGallery } from "../../lib/filePreviewGallery"; | |
| 4 | import { mediaVideoPreviewUri, mediaVideoThumbnailKey } from "../../lib/videoPreviewSource"; | |
| 5 | import { pullRequestImageSource, pullRequestMediaSource } from "./pullRequestMediaSource"; | |
| 6 | ||
| 7 | const location = { environmentId: EnvironmentId.make("test"), cwd: "/repo" }; | |
| 8 | const url = "https://github.com/user-attachments/assets/abc-123"; | |
| 9 | ||
| 10 | describe("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 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import { githubMediaFetchUrl } from "@t3tools/shared/githubMedia"; | |
| 3 | import type { FilePreviewItem } from "../../components/FilePreviewModal.types"; | |
| 4 | ||
| 5 | export 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. */ | |
| 11 | export 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 | ||
| 21 | export 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 @@ |
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | import type { PullRequestDetail } from "@t3tools/contracts"; | |
| 3 | import { usePullRequestAgentActions } from "./usePullRequestAgentActions"; | |
| 4 | ||
| 5 | const state = vi.hoisted(() => ({ | |
| 6 | connected: false, | |
| 7 | canAsk: true, | |
| 8 | ask: vi.fn(), | |
| 9 | })); | |
| 10 | vi.mock("./usePullRequestConnected", () => ({ | |
| 11 | usePullRequestConnected: () => state.connected, | |
| 12 | })); | |
| 13 | vi.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 | })); | |
| 23 | const params = { | |
| 24 | environmentId: "environment", | |
| 25 | projectId: "project", | |
| 26 | repository: "owner/repo", | |
| 27 | number: 1, | |
| 28 | }; | |
| 29 | const pr = { state: "open", mergeability: "conflicting" } as PullRequestDetail; | |
| 30 | ||
| 31 | beforeEach(() => { | |
| 32 | state.connected = false; | |
| 33 | state.canAsk = true; | |
| 34 | state.ask.mockClear(); | |
| 35 | }); | |
| 36 | ||
| 37 | describe("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 { |
| 5 | 5 | type PullRequestAgentTask, |
| 6 | 6 | } from "./pullRequestAgentTasks"; |
| 7 | 7 | import type { PullRequestParams } from "./usePullRequestDiff"; |
| 8 | import { usePullRequestConnected } from "./usePullRequestConnected"; | |
| 8 | 9 | import { usePullRequestThreads } from "./usePullRequestThreads"; |
| 9 | 10 | |
| 10 | 11 | /** Shared destinations for toolbar helpers and actions beside PR readiness rows. */ |
| ... | ... | @@ -14,8 +15,9 @@ export function usePullRequestAgentActions( |
| 14 | 15 | filePath?: string, |
| 15 | 16 | ) { |
| 16 | 17 | const links = usePullRequestThreads(params, pr, filePath); |
| 18 | const connected = usePullRequestConnected(params.environmentId); | |
| 17 | 19 | 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); | |
| 19 | 21 | const start = (task: PullRequestAgentTask, threadId?: string) => { |
| 20 | 22 | if (disabled) return; |
| 21 | 23 | const thread = pullRequestAgentThread(links.threads, threadId); |
apps/mobile/src/features/pull-requests/usePullRequestConnected.ts created+8| ... | ... | @@ -0,0 +1,8 @@ |
| 1 | import { useWorkspaceEnvironments } from "../../state/workspace"; | |
| 2 | ||
| 3 | export 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 @@ |
| 1 | import { usePullRequestConnected } from "./usePullRequestConnected"; | |
| 1 | 2 | import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; |
| 2 | 3 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 3 | 4 | import type { PullRequestDetail } from "@t3tools/contracts"; |
| ... | ... | @@ -12,9 +13,11 @@ export function usePullRequestFilesViewed(params: PullRequestParams, pr: PullReq |
| 12 | 13 | const target = pullRequestTarget(params); |
| 13 | 14 | const scopeKey = pullRequestReviewKey(params); |
| 14 | 15 | const supported = Boolean(pr?.capabilities.viewedFiles); |
| 15 | const allowed = useAtomValue( | |
| 16 | const permission = useAtomValue( | |
| 16 | 17 | composerPullRequests.setFilesViewed.permissionAtom(target.environmentId), |
| 17 | 18 | ); |
| 19 | const connected = usePullRequestConnected(params.environmentId); | |
| 20 | const allowed = connected && permission; | |
| 18 | 21 | const query = useEnvironmentQuery(supported ? composerPullRequests.filesViewed(target) : null); |
| 19 | 22 | const refresh = useAtomRefresh(composerPullRequests.filesViewed(target)); |
| 20 | 23 | const write = useAtomCommand(composerPullRequests.setFilesViewed, { reportFailure: false }); |
apps/mobile/src/features/pull-requests/usePullRequestPermissions.ts+17-5| ... | ... | @@ -1,10 +1,12 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import type { PullRequestDetail } from "@t3tools/contracts"; |
| 3 | import { usePullRequestConnected } from "./usePullRequestConnected"; | |
| 3 | 4 | import { composerPullRequests } from "../../state/pull-requests"; |
| 4 | 5 | import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; |
| 5 | 6 | |
| 6 | 7 | export function usePullRequestPermissions(params: PullRequestParams, pr: PullRequestDetail | null) { |
| 7 | 8 | const { environmentId } = pullRequestTarget(params); |
| 9 | const connected = usePullRequestConnected(environmentId); | |
| 8 | 10 | const canWrite = useAtomValue(composerPullRequests.runAction.permissionAtom(environmentId)); |
| 9 | 11 | const canComment = useAtomValue(composerPullRequests.comment.permissionAtom(environmentId)); |
| 10 | 12 | const canReview = useAtomValue(composerPullRequests.submitReview.permissionAtom(environmentId)); |
| ... | ... | @@ -13,7 +15,9 @@ export function usePullRequestPermissions(params: PullRequestParams, pr: PullReq |
| 13 | 15 | composerPullRequests.setThreadResolution.permissionAtom(environmentId), |
| 14 | 16 | ); |
| 15 | 17 | return { |
| 18 | connected, | |
| 16 | 19 | canInlineComment: |
| 20 | connected && | |
| 17 | 21 | canReview && |
| 18 | 22 | pr?.state === "open" && |
| 19 | 23 | pr.capabilities.review.inlineComment && |
| ... | ... | @@ -21,13 +25,21 @@ export function usePullRequestPermissions(params: PullRequestParams, pr: PullReq |
| 21 | 25 | pr.capabilities.review.verdicts.some((verdict) => |
| 22 | 26 | pr.viewerPermissions.verdicts.includes(verdict), |
| 23 | 27 | ), |
| 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, | |
| 25 | 33 | 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, | |
| 29 | 41 | verdicts: |
| 30 | canReview && pr?.state === "open" | |
| 42 | connected && canReview && pr?.state === "open" | |
| 31 | 43 | ? pr.capabilities.review.verdicts.filter((verdict) => |
| 32 | 44 | pr.viewerPermissions.verdicts.includes(verdict), |
| 33 | 45 | ) |
apps/mobile/src/features/pull-requests/usePullRequestThreads.ts+6-2| ... | ... | @@ -27,6 +27,7 @@ import { |
| 27 | 27 | updateComposerDraftSettings, |
| 28 | 28 | waitForComposerDraftsLoaded, |
| 29 | 29 | } from "../../state/use-composer-drafts"; |
| 30 | import { usePullRequestConnected } from "./usePullRequestConnected"; | |
| 30 | 31 | import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; |
| 31 | 32 | import { |
| 32 | 33 | appendPullRequestAgentPrompt, |
| ... | ... | @@ -63,7 +64,10 @@ export function usePullRequestThreads( |
| 63 | 64 | setLastRelations({ key, data: relations.data }); |
| 64 | 65 | const threads = |
| 65 | 66 | (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; | |
| 67 | 71 | const canCheckout = useEnvironmentScope(target.environmentId, AuthSourceControlWriteScope); |
| 68 | 72 | const navigation = useNavigation(); |
| 69 | 73 | const pending = useRef(false); |
| ... | ... | @@ -76,7 +80,7 @@ export function usePullRequestThreads( |
| 76 | 80 | pending.current = true; |
| 77 | 81 | setBusy(true); |
| 78 | 82 | try { |
| 79 | if (!thread && !canCheckout) | |
| 83 | if (connected && !thread && !canCheckout) | |
| 80 | 84 | throw new Error("Starting work from this PR requires source control write access."); |
| 81 | 85 | await waitForComposerDraftsLoaded(); |
| 82 | 86 | const draftKey = thread |
apps/mobile/src/lib/videoPreviewSource.ts+17-9| ... | ... | @@ -17,7 +17,7 @@ export type MediaVideoPreviewSource = { |
| 17 | 17 | readonly environmentId: EnvironmentId; |
| 18 | 18 | readonly resource: Extract< |
| 19 | 19 | AssetResource, |
| 20 | { readonly _tag: "attachment" | "media-file" | "draft-workspace-file" } | |
| 20 | { readonly _tag: "attachment" | "media-file" | "draft-workspace-file" | "github-media" } | |
| 21 | 21 | >; |
| 22 | 22 | } |
| 23 | 23 | ); |
| ... | ... | @@ -47,14 +47,22 @@ export function mediaVideoThumbnailKey(source: MediaVideoPreviewSource): string |
| 47 | 47 | source.resource.path, |
| 48 | 48 | source.srcFragment ?? "", |
| 49 | 49 | ] |
| 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 | ], | |
| 58 | 66 | ); |
| 59 | 67 | } |
| 60 | 68 |
apps/mobile/src/persistence/layer.ts+2| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import * as Layer from "effect/Layer"; |
| 2 | 2 | |
| 3 | import * as PullRequestCacheStore from "../connection/pull-request-cache-store"; | |
| 3 | 4 | import * as EnvironmentCacheStore from "../connection/environment-cache-store"; |
| 4 | 5 | import * as MobileDatabase from "./mobile-database"; |
| 5 | 6 | import * as MobilePreferences from "./mobile-preferences"; |
| ... | ... | @@ -11,6 +12,7 @@ const layerDependent = Layer.mergeAll( |
| 11 | 12 | MobilePreferences.layer, |
| 12 | 13 | MobileStorage.layer, |
| 13 | 14 | EnvironmentCacheStore.layer, |
| 15 | PullRequestCacheStore.layer, | |
| 14 | 16 | ).pipe(Layer.provide(layerBase)); |
| 15 | 17 | |
| 16 | 18 | export const layer = Layer.merge(layerBase, layerDependent); |
apps/mobile/src/persistence/mobile-database.test.ts+85| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Option from "effect/Option"; | |
| 3 | 4 | import { vi } from "vite-plus/test"; |
| 4 | 5 | |
| 5 | 6 | const openDatabaseAsync = vi.hoisted(() => vi.fn()); |
| ... | ... | @@ -69,3 +70,87 @@ describe("mobile database legacy cache migration", () => { |
| 69 | 70 | ).toBeNull(); |
| 70 | 71 | }); |
| 71 | 72 | }); |
| 73 | ||
| 74 | // Exercise the production SQL against SQLite, including the eviction window functions. | |
| 75 | describe("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([ |
| 22 | 22 | "server-config", |
| 23 | 23 | "vcs-refs", |
| 24 | 24 | "project-favicon", |
| 25 | "pull-request", | |
| 25 | 26 | ]); |
| 26 | 27 | export type ClientCacheKind = typeof ClientCacheKind.Type; |
| 27 | 28 | |
| ... | ... | @@ -294,10 +295,12 @@ const makeAvailable = Effect.gen(function* () { |
| 294 | 295 | database.getFirstAsync<{ readonly payload: string }>( |
| 295 | 296 | `SELECT payload |
| 296 | 297 | 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 >= ?)`, | |
| 298 | 300 | environmentId, |
| 299 | 301 | kind, |
| 300 | 302 | cacheKey, |
| 303 | Date.now() - 30 * 24 * 60 * 60_000, | |
| 301 | 304 | ), |
| 302 | 305 | catch: databaseError("load-cache"), |
| 303 | 306 | }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))), |
| ... | ... | @@ -315,22 +318,51 @@ const makeAvailable = Effect.gen(function* () { |
| 315 | 318 | saveCache: Effect.fn("MobileDatabase.saveCache")( |
| 316 | 319 | (environmentId, kind, cacheKey, schemaVersion, payload) => |
| 317 | 320 | 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 | }, | |
| 334 | 366 | catch: databaseError("save-cache"), |
| 335 | 367 | }).pipe(Effect.asVoid), |
| 336 | 368 | ), |
apps/web/src/components/pullRequest/PullRequestMarkdown.tsx+2-2| ... | ... | @@ -90,11 +90,11 @@ export function PullRequestMarkdown({ |
| 90 | 90 | data-image-gallery |
| 91 | 91 | > |
| 92 | 92 | {segments.map((segment) => { |
| 93 | if (segment.kind === "markdown") { | |
| 93 | if (segment.kind === "markdown" || segment.media === "image") { | |
| 94 | 94 | return ( |
| 95 | 95 | <ChatMarkdown |
| 96 | 96 | key={segment.id} |
| 97 | text={segment.text} | |
| 97 | text={segment.kind === "markdown" ? segment.text : ``} | |
| 98 | 98 | cwd={cwd} |
| 99 | 99 | threadRef={resolvedThreadRef} |
| 100 | 100 | pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF} |
apps/web/src/components/pullRequest/pullRequestMarkdown.logic.test.ts deleted-146| ... | ... | @@ -1,146 +0,0 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { splitPullRequestBody } from "./pullRequestMarkdown.logic"; | |
| 4 | ||
| 5 | describe("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 = ""; | |
| 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 { |
| 4 | 4 | type TextMatch, |
| 5 | 5 | } from "~/vendor/mdast-find-and-replace"; |
| 6 | 6 | |
| 7 | /** `id` is positional on purpose: the same attachment can be embedded twice in one body. */ | |
| 8 | export 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 | ||
| 23 | const 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 | */ | |
| 28 | const VIDEO_TAG_MAX_LINES = 8; | |
| 29 | /** Four spaces open an indented code block, so its contents stay verbatim markdown. */ | |
| 30 | const INDENTED_CODE_PATTERN = /^(?: {4}|\t)/u; | |
| 31 | const BARE_URL_PATTERN = /^<?(https?:\/\/\S+?)>?$/u; | |
| 32 | const 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. */ | |
| 34 | const GITHUB_ASSET_PATTERN = /^https:\/\/github\.com\/user-attachments\/assets\/[\w-]+$/iu; | |
| 35 | const 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. */ | |
| 37 | const STANDALONE_VIDEO_TAG_PATTERN = /^\s*<video\b/iu; | |
| 38 | const VIDEO_TAG_END_PATTERN = /<\/video>\s*$/iu; | |
| 39 | ||
| 40 | /** Anything else — `javascript:`, `data:`, a relative path — is not an upload to link out to. */ | |
| 41 | function 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 | ||
| 50 | function 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 | */ | |
| 67 | export 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 | } | |
| 7 | export { | |
| 8 | splitPullRequestBody, | |
| 9 | type PullRequestBodySegment, | |
| 10 | } from "@t3tools/client-runtime/pull-request-markdown"; | |
| 150 | 11 | |
| 151 | 12 | const AUTOLINK_CANDIDATE_PATTERN = /#[1-9]\d*|[0-9a-f]{40}/giu; |
| 152 | 13 | const 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 |
| 158 | 158 | until you submit the review. **Work on PR** opens a chat with the review attached; sending |
| 159 | 159 | the first message prepares a worktree from the review's branch. |
| 160 | 160 | |
| 161 | Previously loaded lists, review details, guides, and diff pages remain readable on mobile | |
| 162 | without a connection, including after restarting the app. Saved content refreshes when the | |
| 163 | environment reconnects; submitting changes requires a connection. | |
| 164 | ||
| 161 | 165 | Enable **Remove agent credits when merging** in Settings → Source Control to remove recognized |
| 162 | 166 | agent co-author and generated-by lines from GitHub merge and squash commit messages. Human |
| 163 | 167 | co-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 @@ |
| 402 | 402 | "./review-guide-tour": { |
| 403 | 403 | "types": "./src/reviewGuideTour.ts", |
| 404 | 404 | "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" | |
| 405 | 413 | } |
| 406 | 414 | }, |
| 407 | 415 | "scripts": { |
packages/client-runtime/src/pullRequestMarkdown.test.ts created+166| ... | ... | @@ -0,0 +1,166 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { splitPullRequestBody } from "./pullRequestMarkdown.ts"; | |
| 4 | ||
| 5 | describe("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 = ""; | |
| 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 | ||
| 148 | describe("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. */ | |
| 2 | export 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 | ||
| 17 | const 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 | */ | |
| 22 | const VIDEO_TAG_MAX_LINES = 8; | |
| 23 | /** Four spaces open an indented code block, so its contents stay verbatim markdown. */ | |
| 24 | const INDENTED_CODE_PATTERN = /^(?: {4}|\t)/u; | |
| 25 | const BARE_URL_PATTERN = /^<?(https?:\/\/\S+?)>?$/u; | |
| 26 | const IMAGE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp|avif|heic|heif|svg)(?:$|[?#])/iu; | |
| 27 | const MARKDOWN_LINK_PATTERN = /^\[[^\]]*\]\((https?:\/\/[^\s)]+)\)$/u; | |
| 28 | const 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. */ | |
| 30 | const GITHUB_ASSET_PATTERN = /^https:\/\/github\.com\/user-attachments\/assets\/[\w-]+$/iu; | |
| 31 | const 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. */ | |
| 33 | const STANDALONE_VIDEO_TAG_PATTERN = /^\s*<video\b/iu; | |
| 34 | const VIDEO_TAG_END_PATTERN = /<\/video>\s*$/iu; | |
| 35 | ||
| 36 | /** Anything else — `javascript:`, `data:`, a relative path — is not an upload to link out to. */ | |
| 37 | function 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 | ||
| 46 | function 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 | */ | |
| 64 | export 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 @@ |
| 1 | import { | |
| 2 | PullRequestDiffStat, | |
| 3 | PullRequestListStatsInput, | |
| 4 | type PullRequestListStatsResult, | |
| 5 | type PullRequestRef, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as Option from "effect/Option"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | import { persistQuery, type QueryPersistence } from "./queryPersistence.ts"; | |
| 11 | ||
| 12 | const requestKey = Schema.fromJsonString(Schema.Tuple([Schema.String, PullRequestListStatsInput])); | |
| 13 | const 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. */ | |
| 17 | export 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"; |
| 4 | 4 | import { |
| 5 | 5 | EnvironmentId, |
| 6 | 6 | AuthSourceControlWriteScope, |
| 7 | AuthOrchestrationOperateScope, | |
| 7 | 8 | ProjectId, |
| 8 | 9 | PullRequestOperationError, |
| 9 | 10 | WS_METHODS, |
| ... | ... | @@ -2082,3 +2083,48 @@ it.effect("denies a routed write when only the origin has source-control permiss |
| 2082 | 2083 | }), |
| 2083 | 2084 | ), |
| 2084 | 2085 | ); |
| 2086 | ||
| 2087 | it.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 @@ |
| 1 | 1 | import { |
| 2 | 2 | WS_METHODS, |
| 3 | PullRequestActivity, | |
| 4 | PullRequestDetail as PullRequestDetailSchema, | |
| 5 | PullRequestPreview, | |
| 6 | PullRequestReviewGuideResult, | |
| 7 | PullRequestListResult, | |
| 8 | PullRequestChecks, | |
| 9 | PullRequestDiffResult, | |
| 10 | PullRequestFilesViewedResult, | |
| 11 | PullRequestLinkedThreadsResult, | |
| 3 | 12 | type EnvironmentId, |
| 4 | 13 | type PullRequestActor, |
| 5 | 14 | type PullRequestActionInput, |
| ... | ... | @@ -13,6 +22,9 @@ import { |
| 13 | 22 | } from "@t3tools/contracts"; |
| 14 | 23 | import * as Data from "effect/Data"; |
| 15 | 24 | import * as Effect from "effect/Effect"; |
| 25 | import * as Schema from "effect/Schema"; | |
| 26 | import { persistPullRequestStats } from "./pullRequestStatsPersistence.ts"; | |
| 27 | import { persistQuery } from "./queryPersistence.ts"; | |
| 16 | 28 | import * as Option from "effect/Option"; |
| 17 | 29 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 18 | 30 | import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; |
| ... | ... | @@ -172,6 +184,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 172 | 184 | const activity = writableQueryFamily( |
| 173 | 185 | createEnvironmentRpcQueryAtomFamily(runtime, { |
| 174 | 186 | label: "environment-data:pull-requests:activity", |
| 187 | persistence: persistQuery(PullRequestActivity), | |
| 175 | 188 | tag: WS_METHODS.pullRequestsActivity, |
| 176 | 189 | execute: (input) => routedRequest(WS_METHODS.pullRequestsActivity, input), |
| 177 | 190 | staleTimeMs: 60_000, |
| ... | ... | @@ -181,6 +194,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 181 | 194 | const detail = writableQueryFamily( |
| 182 | 195 | createEnvironmentRpcQueryAtomFamily(runtime, { |
| 183 | 196 | label: "environment-data:pull-requests:detail", |
| 197 | persistence: persistQuery(PullRequestDetailSchema), | |
| 184 | 198 | tag: WS_METHODS.pullRequestsDetail, |
| 185 | 199 | execute: (input) => routedRequest(WS_METHODS.pullRequestsDetail, input), |
| 186 | 200 | staleTimeMs: 60_000, |
| ... | ... | @@ -190,6 +204,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 190 | 204 | const preview = writableQueryFamily( |
| 191 | 205 | createEnvironmentRpcQueryAtomFamily(runtime, { |
| 192 | 206 | label: "environment-data:pull-requests:preview", |
| 207 | persistence: persistQuery(PullRequestPreview), | |
| 193 | 208 | tag: WS_METHODS.pullRequestsPreview, |
| 194 | 209 | execute: (input) => routedRequest(WS_METHODS.pullRequestsPreview, input), |
| 195 | 210 | staleTimeMs: 60_000, |
| ... | ... | @@ -208,6 +223,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 208 | 223 | const reviewGuide = writableQueryFamily( |
| 209 | 224 | createEnvironmentRpcQueryAtomFamily(runtime, { |
| 210 | 225 | label: "environment-data:pull-requests:review-guide", |
| 226 | persistence: persistQuery(PullRequestReviewGuideResult), | |
| 211 | 227 | tag: WS_METHODS.pullRequestsReviewGuide, |
| 212 | 228 | execute: (input) => routedRequest(WS_METHODS.pullRequestsReviewGuide, input), |
| 213 | 229 | staleTimeMs: 60_000, |
| ... | ... | @@ -226,6 +242,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 226 | 242 | refreshes, |
| 227 | 243 | linkedThreads: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 228 | 244 | label: "environment-data:pull-requests:linked-threads", |
| 245 | persistence: persistQuery(PullRequestLinkedThreadsResult), | |
| 229 | 246 | tag: WS_METHODS.pullRequestsLinkedThreads, |
| 230 | 247 | staleTimeMs: 0, |
| 231 | 248 | refreshIntervalMs: 10_000, |
| ... | ... | @@ -233,6 +250,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 233 | 250 | }), |
| 234 | 251 | list: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 235 | 252 | label: "environment-data:pull-requests:list", |
| 253 | persistence: persistQuery(PullRequestListResult), | |
| 236 | 254 | tag: WS_METHODS.pullRequestsList, |
| 237 | 255 | staleTimeMs: 30_000, |
| 238 | 256 | refreshTrigger: ({ environmentId, input }) => |
| ... | ... | @@ -246,6 +264,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 246 | 264 | */ |
| 247 | 265 | listStats: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 248 | 266 | label: "environment-data:pull-requests:list-stats", |
| 267 | persistence: persistPullRequestStats(), | |
| 249 | 268 | tag: WS_METHODS.pullRequestsListStats, |
| 250 | 269 | staleTimeMs: 60_000, |
| 251 | 270 | refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), |
| ... | ... | @@ -254,6 +273,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 254 | 273 | preview, |
| 255 | 274 | checks: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 256 | 275 | label: "environment-data:pull-requests:checks", |
| 276 | persistence: persistQuery(Schema.NullOr(PullRequestChecks)), | |
| 257 | 277 | tag: WS_METHODS.pullRequestsChecks, |
| 258 | 278 | execute: (input) => routedRequest(WS_METHODS.pullRequestsChecks, input), |
| 259 | 279 | staleTimeMs: 45_000, |
| ... | ... | @@ -273,6 +293,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 273 | 293 | }), |
| 274 | 294 | diff: createEnvironmentQueryAtomFamily(runtime, { |
| 275 | 295 | label: "environment-data:pull-requests:diff", |
| 296 | persistence: persistQuery(PullRequestDiffResult), | |
| 276 | 297 | staleTimeMs: 60_000, |
| 277 | 298 | execute: (input: PullRequestDiffInput) => |
| 278 | 299 | Effect.gen(function* () { |
| ... | ... | @@ -310,6 +331,7 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 310 | 331 | }), |
| 311 | 332 | filesViewed: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 312 | 333 | label: "environment-data:pull-requests:files-viewed", |
| 334 | persistence: persistQuery(PullRequestFilesViewedResult), | |
| 313 | 335 | tag: WS_METHODS.pullRequestsFilesViewed, |
| 314 | 336 | execute: (input) => routedRequest(WS_METHODS.pullRequestsFilesViewed, input), |
| 315 | 337 | staleTimeMs: 15_000, |
| ... | ... | @@ -517,7 +539,12 @@ export function createPullRequestEnvironmentAtoms<R, E>( |
| 517 | 539 | label: "environment-data:pull-requests:generate-review-guide", |
| 518 | 540 | tag: WS_METHODS.pullRequestsGenerateReviewGuide, |
| 519 | 541 | 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 | }), | |
| 521 | 548 | }), |
| 522 | 549 | /** Read when the label menu opens, and kept for a minute, like the reviewer candidates. */ |
| 523 | 550 | labelCandidates, |
packages/client-runtime/src/state/queryPersistence.test.ts created+71| ... | ... | @@ -0,0 +1,71 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import { EnvironmentId, ProjectId } from "@t3tools/contracts"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | import * as Schema from "effect/Schema"; | |
| 6 | import { EnvironmentQueryStore, persistQuery } from "./queryPersistence.ts"; | |
| 7 | ||
| 8 | import { persistPullRequestStats } from "./pullRequestStatsPersistence.ts"; | |
| 9 | ||
| 10 | const env = EnvironmentId.make("one"); | |
| 11 | const other = EnvironmentId.make("two"); | |
| 12 | const persisted = persistQuery(Schema.Struct({ title: Schema.String })); | |
| 13 | ||
| 14 | function 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 | ||
| 25 | describe("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 | ||
| 60 | it.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 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | import * as Schema from "effect/Schema"; | |
| 6 | ||
| 7 | /** Platforms opt in to storing selected query responses beside their other offline data. */ | |
| 8 | export 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 | ||
| 22 | export 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 | ||
| 27 | export 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"; |
| 26 | 26 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 27 | 27 | import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; |
| 28 | 28 | import type * as RpcSession from "../rpc/session.ts"; |
| 29 | import type { QueryPersistence } from "./queryPersistence.ts"; | |
| 29 | 30 | import { |
| 30 | 31 | environmentRpcKey, |
| 31 | 32 | createAtomCommandScheduler, |
| ... | ... | @@ -85,9 +86,16 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness" |
| 85 | 86 | { |
| 86 | 87 | refreshTrigger, |
| 87 | 88 | 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 | } = {}, | |
| 89 | 97 | ) { |
| 90 | const supervisorState = yield* SubscriptionRef.make(queryConnectionState()); | |
| 98 | const supervisorState = yield* SubscriptionRef.make(initialState ?? queryConnectionState()); | |
| 91 | 99 | const supervisorSession = yield* SubscriptionRef.make(Option.some(QUERY_RPC_SESSION)); |
| 92 | 100 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ |
| 93 | 101 | target: QUERY_ENVIRONMENT, |
| ... | ... | @@ -115,6 +123,7 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness" |
| 115 | 123 | const family = createEnvironmentQueryAtomFamily(runtime, { |
| 116 | 124 | label: "test.environment-query", |
| 117 | 125 | staleTimeMs: 60_000, |
| 126 | ...(persistence === undefined ? {} : { persistence }), | |
| 118 | 127 | execute: () => execute, |
| 119 | 128 | ...(refreshTrigger === undefined ? {} : { refreshTrigger: () => refreshTrigger }), |
| 120 | 129 | ...(idleTtlMs === undefined ? {} : { idleTtlMs }), |
| ... | ... | @@ -1126,3 +1135,88 @@ describe("runtime command runner", () => { |
| 1126 | 1135 | registry.dispose(); |
| 1127 | 1136 | }); |
| 1128 | 1137 | }); |
| 1138 | ||
| 1139 | describe("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 @@ |
| 1 | import type { QueryPersistence } from "./queryPersistence.ts"; | |
| 1 | 2 | import { createCommandPermissions } from "./commandPermissions.ts"; |
| 2 | 3 | import { followStreamInEnvironment } from "./environmentStreams.ts"; |
| 3 | 4 | export { runStreamInEnvironment, followStreamInEnvironment } from "./environmentStreams.ts"; |
| ... | ... | @@ -65,6 +66,7 @@ interface EnvironmentQueryAtomOptions<Input, A, E, R> extends Omit< |
| 65 | 66 | input: Input, |
| 66 | 67 | emit: (value: A) => Effect.Effect<void>, |
| 67 | 68 | ) => Effect.Effect<A, E, R>; |
| 69 | readonly persistence?: QueryPersistence<A>; | |
| 68 | 70 | readonly staleTimeMs?: number; |
| 69 | 71 | readonly idleTtlMs?: number; |
| 70 | 72 | readonly refreshIntervalMs?: number; |
| ... | ... | @@ -550,10 +552,6 @@ export function createEnvironmentQueryAtomFamily<R, ER, Input, A, E>( |
| 550 | 552 | const connection = Option.getOrNull( |
| 551 | 553 | AsyncResult.value(get(connectionAtom(target.environmentId))), |
| 552 | 554 | ); |
| 553 | if (connection === null) { | |
| 554 | return Effect.never; | |
| 555 | } | |
| 556 | const [connectionState, session] = connection; | |
| 557 | 555 | const emit = (value: A) => |
| 558 | 556 | Effect.sync(() => get.setSelf(AsyncResult.success(value, { waiting: true }))).pipe( |
| 559 | 557 | // 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>( |
| 562 | 560 | // inside the build) so the settled result arrives asynchronously. |
| 563 | 561 | Effect.andThen(Effect.promise(() => Promise.resolve())), |
| 564 | 562 | ); |
| 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 | }); | |
| 588 | 621 | }) |
| 589 | 622 | .pipe( |
| 590 | 623 | Atom.swr({ |
| ... | ... | @@ -675,6 +708,7 @@ export function createEnvironmentRpcQueryAtomFamily< |
| 675 | 708 | EnvironmentRpcFailure<TTag> | EnvironmentRpcUnavailableError, |
| 676 | 709 | EnvironmentSupervisor.EnvironmentSupervisor | EnvironmentRegistry.EnvironmentRegistry |
| 677 | 710 | >; |
| 711 | readonly persistence?: QueryPersistence<EnvironmentRpcSuccess<TTag>>; | |
| 678 | 712 | readonly staleTimeMs?: number; |
| 679 | 713 | readonly idleTtlMs?: number; |
| 680 | 714 | readonly refreshIntervalMs?: number; |
| ... | ... | @@ -686,6 +720,7 @@ export function createEnvironmentRpcQueryAtomFamily< |
| 686 | 720 | ) { |
| 687 | 721 | return createEnvironmentQueryAtomFamily(runtime, { |
| 688 | 722 | label: options.label, |
| 723 | ...(options.persistence === undefined ? {} : { persistence: options.persistence }), | |
| 689 | 724 | ...(options.staleTimeMs === undefined ? {} : { staleTimeMs: options.staleTimeMs }), |
| 690 | 725 | ...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }), |
| 691 | 726 | ...(options.refreshIntervalMs === undefined |