diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index caaa39d88c0d..8ba8082f0e57 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -88,6 +88,39 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens Zed's ssh deep link", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.all([ + electronShell.openExternal("zed://ssh/example.com/home/user/project"), + electronShell.openExternal("zed://ssh/example.com/"), + ]); + + assert.deepEqual(results, [true, true]); + assert.deepEqual(openExternalMock.mock.calls, [ + ["zed://ssh/example.com/home/user/project"], + ["zed://ssh/example.com/"], + ]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("does not open editor URLs that mix up link shapes", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.all([ + electronShell.openExternal("zed://extension/attacker"), + electronShell.openExternal("vscode://ssh/example.com/home/user/project"), + ]); + + assert.deepEqual(results, [false, false]); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open remote editor URLs with userinfo", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -100,9 +133,10 @@ describe("ElectronShell", () => { electronShell.openExternal( "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project", ), + electronShell.openExternal("zed://ssh/user@example.com/home/user/project"), ]); - assert.deepEqual(results, [false, false]); + assert.deepEqual(results, [false, false, false]); assert.equal(openExternalMock.mock.calls.length, 0); }).pipe(Effect.provide(ElectronShell.layer)), ); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index cda9c2567b37..aa97c018bd21 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -24,8 +24,9 @@ const SYSTEM_SETTINGS_URLS: Record = { "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", }; -// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) -// must reach the OS handler; every other non-web scheme stays blocked. +// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`, +// `zed://ssh//`) must reach the OS handler; every other non-web +// scheme stays blocked. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); const REMOTE_EDITOR_PROTOCOLS = new Set( REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { @@ -34,13 +35,18 @@ const REMOTE_EDITOR_PROTOCOLS = new Set( }), ); +// Zed's host sits in the first path segment, so it needs its own userinfo ban. +const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.*$/; + const isRemoteEditorUrl = (url: URL) => REMOTE_EDITOR_PROTOCOLS.has(url.protocol) && url.username.length === 0 && url.password.length === 0 && - url.host === "vscode-remote" && - url.pathname.startsWith("/ssh-remote+") && - url.pathname.length > "/ssh-remote+".length; + (url.protocol === "zed:" + ? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname) + : url.host === "vscode-remote" && + url.pathname.startsWith("/ssh-remote+") && + url.pathname.length > "/ssh-remote+".length); export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { diff --git a/apps/marketing/src/lib/site.ts b/apps/marketing/src/lib/site.ts index 4d0c6da86c43..dff2241f50c1 100644 --- a/apps/marketing/src/lib/site.ts +++ b/apps/marketing/src/lib/site.ts @@ -7,6 +7,6 @@ export const ANDROID_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=com.t3tools.t3code"; export const MARKETING_STATS = { - githubStars: "21k+", - users: "200,000", + githubStars: "22k+", + users: "300,000", } as const; diff --git a/apps/marketing/vercel.ts b/apps/marketing/vercel.ts index fe11ddd4c069..e37be215f1a0 100644 --- a/apps/marketing/vercel.ts +++ b/apps/marketing/vercel.ts @@ -7,4 +7,11 @@ export const config: VercelConfig = { installCommand: "npm install -g vite-plus && vp install --filter '@t3tools/marketing...'", buildCommand: "vp run --filter @t3tools/marketing build", outputDirectory: "dist", + redirects: [ + { + source: "/app", + destination: "https://app.t3.codes", + permanent: true, + }, + ], }; diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt index af8675831f2d..8e9fa3894aa9 100644 --- a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt +++ b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt @@ -3,6 +3,8 @@ package expo.modules.t3markdowntext import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.text.Spannable +import android.text.SpannableStringBuilder import android.text.Spanned import android.text.style.ReplacementSpan import android.view.ActionMode @@ -18,6 +20,14 @@ import kotlin.math.min private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC" +// Match React Native's measurement buffer. Android orders tied line-height +// spans differently in SpannableString, shifting inline images once RN's +// span priorities are exhausted. +private object MarkdownSpannableFactory : Spannable.Factory() { + override fun newSpannable(source: CharSequence): Spannable = + SpannableStringBuilder(source) +} + private fun copyTextWithoutInlineImages( text: CharSequence, start: Int, @@ -85,6 +95,7 @@ class T3MarkdownTextSelectionModule : Module() { if (currentCallback is SanitizingSelectionActionModeCallback) { return@runOnUiQueueThread } + textView.setSpannableFactory(MarkdownSpannableFactory) textView.customSelectionActionModeCallback = SanitizingSelectionActionModeCallback(textView, currentCallback) } diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json index 8922c8868c44..376befbeb152 100644 --- a/apps/mobile/modules/t3-markdown-text/package.json +++ b/apps/mobile/modules/t3-markdown-text/package.json @@ -25,7 +25,7 @@ "./links": "./src/markdownLinks.ts", "./markdown": "./src/nativeMarkdownText.ts", "./primitive": "./src/MarkdownTextPrimitive.tsx", - "./renderer": "./src/SelectableMarkdownText.ios.tsx", + "./renderer": "./src/SelectableMarkdownText.tsx", "./types": "./src/SelectableMarkdownText.types.ts" }, "peerDependencies": { diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx similarity index 99% rename from apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx rename to apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx index 348a3c489a2c..ab75eaf32a2c 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx @@ -5,7 +5,7 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import { CopyTextButton } from "./CopyTextButton"; import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText"; -import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; +import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText"; import type { MarkdownCodeHighlighter, MarkdownHighlightedToken, diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx similarity index 100% rename from apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx rename to apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx deleted file mode 100644 index 2a231c603584..000000000000 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useMemo } from "react"; -import { View } from "react-native"; -import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; - -import { - nativeMarkdownChunkSpacing, - nativeMarkdownDocumentChunks, - nativeMarkdownDocumentRuns, - nativeMarkdownWithPreservedSoftBreaks, -} from "./nativeMarkdownText"; -import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; -import { - MarkdownFileContextMenuContext, - NativeMarkdownSelectableText, - type MarkdownFileContextMenuHandlers, -} from "./NativeMarkdownSelectableText.ios"; -import type { - SelectableMarkdownSkill, - SelectableMarkdownTextProps, -} from "./SelectableMarkdownText.types"; - -const EMPTY_SKILLS: ReadonlyArray = []; - -export type { - MarkdownCodeHighlighter, - MarkdownHighlightedToken, - MarkdownImageRenderer, - MarkdownImageRequest, - NativeMarkdownTextStyle, - SelectableMarkdownSkill, - SelectableMarkdownTextProps, -} from "./SelectableMarkdownText.types"; - -export function hasNativeSelectableMarkdownText(): boolean { - return true; -} - -export function SelectableMarkdownText({ - markdown, - skills = EMPTY_SKILLS, - textStyle, - highlightCode, - preserveSoftBreaks = false, - onLinkPress, - fileContextMenu, - onFileContextMenuAction, - renderImage, - marginTop = 0, - marginBottom = 0, -}: SelectableMarkdownTextProps) { - const chunks = useMemo(() => { - const parsedDocument = parseMarkdownWithOptions(markdown, { - gfm: true, - html: true, - math: false, - }); - const document = preserveSoftBreaks - ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument) - : parsedDocument; - return nativeMarkdownDocumentChunks(document).map((chunk) => - chunk.kind === "selectable" - ? { - ...chunk, - runs: nativeMarkdownDocumentRuns(chunk.node, skills), - } - : chunk, - ); - }, [markdown, preserveSoftBreaks, skills]); - - const fileContextMenuHandlers = useMemo( - () => - fileContextMenu && onFileContextMenuAction - ? { fileContextMenu, onFileContextMenuAction } - : null, - [fileContextMenu, onFileContextMenuAction], - ); - - return ( - - - {/* A percentage width here creates a cyclic intrinsic measurement inside - shrink-to-fit containers such as user-message bubbles. Yoga then gives - the native text node an unbounded second pass and the parent only clips - the resulting single-line width instead of reflowing it. */} - - {chunks.map((chunk, index) => { - const content = - chunk.kind === "rich" ? ( - - ) : ( - - ); - - return ( - - {content} - - ); - })} - - - - ); -} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx index 006d33e7259d..188a93b30e12 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx @@ -1,4 +1,25 @@ -import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; +import { useMemo } from "react"; +import { View } from "react-native"; +import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; + +import { + nativeMarkdownChunkSpacing, + nativeMarkdownDocumentChunks, + nativeMarkdownDocumentRuns, + nativeMarkdownWithPreservedSoftBreaks, +} from "./nativeMarkdownText"; +import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock"; +import { + MarkdownFileContextMenuContext, + NativeMarkdownSelectableText, + type MarkdownFileContextMenuHandlers, +} from "./NativeMarkdownSelectableText"; +import type { + SelectableMarkdownSkill, + SelectableMarkdownTextProps, +} from "./SelectableMarkdownText.types"; + +const EMPTY_SKILLS: ReadonlyArray = []; export type { MarkdownCodeHighlighter, @@ -10,6 +31,87 @@ export type { SelectableMarkdownTextProps, } from "./SelectableMarkdownText.types"; -export function SelectableMarkdownText(_props: SelectableMarkdownTextProps) { - return null; +export function hasNativeSelectableMarkdownText(): boolean { + return true; +} + +export function SelectableMarkdownText({ + markdown, + skills = EMPTY_SKILLS, + textStyle, + highlightCode, + preserveSoftBreaks = false, + onLinkPress, + fileContextMenu, + onFileContextMenuAction, + renderImage, + marginTop = 0, + marginBottom = 0, +}: SelectableMarkdownTextProps) { + const chunks = useMemo(() => { + const parsedDocument = parseMarkdownWithOptions(markdown, { + gfm: true, + html: true, + math: false, + }); + const document = preserveSoftBreaks + ? nativeMarkdownWithPreservedSoftBreaks(parsedDocument) + : parsedDocument; + return nativeMarkdownDocumentChunks(document).map((chunk) => + chunk.kind === "selectable" + ? { + ...chunk, + runs: nativeMarkdownDocumentRuns(chunk.node, skills), + } + : chunk, + ); + }, [markdown, preserveSoftBreaks, skills]); + + const fileContextMenuHandlers = useMemo( + () => + fileContextMenu && onFileContextMenuAction + ? { fileContextMenu, onFileContextMenuAction } + : null, + [fileContextMenu, onFileContextMenuAction], + ); + + return ( + + + {/* A percentage width here creates a cyclic intrinsic measurement inside + shrink-to-fit containers such as user-message bubbles. Yoga then gives + the native text node an unbounded second pass and the parent only clips + the resulting single-line width instead of reflowing it. */} + + {chunks.map((chunk, index) => { + const content = + chunk.kind === "rich" ? ( + + ) : ( + + ); + + return ( + + {content} + + ); + })} + + + + ); } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index d5285f263fd7..a5d2f40b4a93 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -147,6 +147,7 @@ "buildFromSource": [ "expo-notifications", "react-native-screens", + "react-native-reanimated", "@react-native-menu/menu", "expo-audio" ] diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index b4e545fade71..1a4f11b8c7e0 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -1,5 +1,4 @@ import type { MenuAction, MenuComponentProps } from "@react-native-menu/menu"; -import { BlurView } from "expo-blur"; import type { ReactNode } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import type { StyleProp, ViewStyle } from "react-native"; @@ -8,11 +7,11 @@ import { useKeyboardState } from "react-native-keyboard-controller"; import Animated, { FadeIn } from "react-native-reanimated"; import { appBlurTargetRef } from "../lib/appBlurTarget"; -import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { cn } from "../lib/cn"; import { type AppSymbolName, SymbolView } from "./AppSymbol"; import { AppText as Text } from "./AppText"; import { OverlayPortal } from "./OverlayPortal"; +import { GlassBackdrop } from "./GlassBackdrop"; const MENU_WIDTH = 250; const SCREEN_MARGIN = 12; @@ -79,8 +78,6 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const anchorRef = useRef(null); const overlayRef = useRef(null); - const { themeAppearance } = useAppearancePreferences(); - const isDarkMode = themeAppearance === "dark"; const keyboardVisible = useKeyboardState((state) => state.isVisible); const keyboardHeight = useKeyboardState((state) => state.height); const close = useCallback(() => { @@ -227,16 +224,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { : { bottom: (rootHeight ?? 0) - local.y + ANCHOR_GAP }), }} > - {/* Frosted backdrop: blur of the app content behind the menu, - washed with the translucent card tone so rows keep contrast. */} - - + {/* keyboardShouldPersistTaps: the menu often opens over an active editor; the first item tap must act, not just dismiss the keyboard. */} diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 7d32c70234d1..4da3a97c2bf6 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -78,6 +78,7 @@ import IconSearch from "@tabler/icons-react-native/IconSearch"; import IconServer from "@tabler/icons-react-native/IconServer"; import IconSettings from "@tabler/icons-react-native/IconSettings"; import IconSparkles from "@tabler/icons-react-native/IconSparkles"; +import IconStack2 from "@tabler/icons-react-native/IconStack2"; import IconSun from "@tabler/icons-react-native/IconSun"; import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2"; import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease"; @@ -100,6 +101,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "arrow.right.circle": IconArrowRightCircle, "arrow.triangle.branch": IconGitBranch, "arrow.triangle.pull": IconGitPullRequest, + "square.3.layers.3d": IconStack2, "arrow.turn.left.up": IconArrowBackUp, "arrow.up": IconArrowUp, "arrow.up.circle": IconArrowUpCircle, diff --git a/apps/mobile/src/components/ComposerAttachmentButton.tsx b/apps/mobile/src/components/ComposerAttachmentButton.tsx index 1af72d8883d7..8ea70a45c020 100644 --- a/apps/mobile/src/components/ComposerAttachmentButton.tsx +++ b/apps/mobile/src/components/ComposerAttachmentButton.tsx @@ -40,6 +40,9 @@ export function ComposerAttachmentButton(props: { return ( { if (nativeEvent.event === "photos") { diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index 8cc7f8523e9c..50fbcd253a47 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -32,7 +32,7 @@ export function ComposerInlineControl(props: { readonly icon?: ComponentProps["name"]; readonly iconNode?: ReactNode; readonly label: string; - readonly maxWidth?: number; + readonly maxWidth?: ViewStyle["maxWidth"]; readonly onPress?: () => void; readonly selected?: boolean; readonly static?: boolean; diff --git a/apps/mobile/src/components/GlassBackdrop.tsx b/apps/mobile/src/components/GlassBackdrop.tsx new file mode 100644 index 000000000000..f18ee7908db6 --- /dev/null +++ b/apps/mobile/src/components/GlassBackdrop.tsx @@ -0,0 +1,53 @@ +import { BlurView } from "expo-blur"; +import { useContext, type RefObject } from "react"; +import { Platform, StyleSheet, View, type ColorValue } from "react-native"; + +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { GlassBlurTargetContext } from "../lib/glassBlurTarget"; +import { themeColorWithAlpha } from "../lib/mobileTheme"; + +/** Frosted backdrop for containers that clip their children to their shape. */ +export function GlassBackdrop(props: { + readonly fallbackColor?: ColorValue; + readonly blurTarget?: RefObject; +}) { + const { themeAppearance } = useAppearancePreferences(); + const inheritedBlurTarget = useContext(GlassBlurTargetContext); + const target = props.blurTarget ?? inheritedBlurTarget; + const supportsBlur = + Platform.OS === "ios" || + (Platform.OS === "android" && Platform.Version >= 31 && target !== undefined); + const colorStyle = + props.fallbackColor === undefined + ? undefined + : { backgroundColor: themeColorWithAlpha(String(props.fallbackColor), 1) }; + + return ( + <> + {/* Android samples a separate target. An opaque backing prevents any + transparent pixels in that sample from exposing the unblurred feed. + iOS samples its actual backdrop, so a backing there would hide it. */} + {Platform.OS === "android" ? ( + + ) : null} + {supportsBlur ? ( + + ) : null} + + + ); +} diff --git a/apps/mobile/src/components/GlassSurface.tsx b/apps/mobile/src/components/GlassSurface.tsx index 577c43aa890a..014a760588ab 100644 --- a/apps/mobile/src/components/GlassSurface.tsx +++ b/apps/mobile/src/components/GlassSurface.tsx @@ -1,17 +1,17 @@ import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect"; -import type { ReactNode, Ref } from "react"; +import type { ReactNode, Ref, RefObject } from "react"; import { Platform, useColorScheme, View, type ColorValue, - type StyleProp, type ViewProps, type ViewStyle, } from "react-native"; import { withUniwind } from "uniwind"; import { cn } from "../lib/cn"; +import { GlassBackdrop } from "./GlassBackdrop"; // Explicit mappings keep the native glassEffectStyle enum out of style-array conversion. const ThemedGlassView = withUniwind(GlassView, { @@ -26,8 +26,9 @@ interface GlassSurfaceProps extends ViewProps { readonly tintColor?: ColorValue; readonly tintColorClassName?: string; readonly chrome?: "default" | "none"; - /** Styling used only when native Liquid Glass is unavailable. */ - readonly fallbackStyle?: StyleProp; + /** Base color for the frosted tint, or solid fill when blur is unavailable. */ + readonly fallbackColor?: ColorValue; + readonly blurTarget?: RefObject; /** Uniwind styling used only when native Liquid Glass is unavailable. */ readonly fallbackClassName?: string; } @@ -39,7 +40,8 @@ export function GlassSurface({ chrome = "default", tintColor, tintColorClassName, - fallbackStyle, + fallbackColor, + blurTarget, fallbackClassName, className, style, @@ -95,14 +97,13 @@ export function GlassSurface({ {...props} ref={ref} className={cn( - chrome === "none" - ? "border-0 border-transparent bg-transparent" - : "border border-border bg-glass-surface", + chrome === "none" ? "border-0 border-transparent" : "border border-border", fallbackClassName, className, )} - style={[surfaceStyle, fallbackStyle, style]} + style={[surfaceStyle, style]} > + {children} ); diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index 0e3d8416affa..d06b8bcca04c 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -19,6 +19,7 @@ function LoadedMediaVideo(props: { }) { const focused = useIsFocused(); const active = useRef(focused && AppState.currentState === "active"); + const fullscreen = useRef(false); const [attempt, setAttempt] = useState(0); // Expo's Android player also reports completed playback as idle. const [loadState, setLoadState] = useState<"pending" | "complete" | "error">("pending"); @@ -38,10 +39,12 @@ function LoadedMediaVideo(props: { useEffect(() => { active.current = focused && !props.paused && AppState.currentState === "active"; - if (!active.current) player.pause(); + if (!focused || props.paused || (!active.current && !fullscreen.current)) player.pause(); + // Native background handling distinguishes Android's fullscreen activity + // from leaving the app; React Native reports both as background. const subscription = AppState.addEventListener("change", (state) => { active.current = focused && !props.paused && state === "active"; - if (!active.current) player.pause(); + if (state === "inactive" || (state === "background" && !fullscreen.current)) player.pause(); }); return () => subscription.remove(); }, [focused, player, props.paused]); @@ -70,6 +73,12 @@ function LoadedMediaVideo(props: { nativeControls contentFit="contain" fullscreenOptions={{ enable: true }} + onFullscreenEnter={() => { + fullscreen.current = true; + }} + onFullscreenExit={() => { + fullscreen.current = false; + }} allowsPictureInPicture={false} /> {loadState === "error" || (loadState === "complete" && status === "error") ? ( diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index 697d13e7c472..474bd4481ca8 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -31,6 +31,7 @@ function makeThread( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index 33b99dd8e8ce..50c92ed1f590 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -18,6 +18,7 @@ import { useEnvironmentQuery } from "../../state/query"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { FileTreeBrowser } from "./FileTreeBrowser"; import { preloadWorkspaceFileContents } from "./preload-workspace-file"; +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; export function ThreadFileNavigatorPane(props: { readonly cwd: string; @@ -28,6 +29,7 @@ export function ThreadFileNavigatorPane(props: { readonly onSelectFile: (path: string) => void; }) { const [searchQuery, setSearchQuery] = useState(""); + const { toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); const { themeAppearance: highlightTheme } = useAppearancePreferences(); const theme = useUniwindTheme(); const foregroundColor = theme["--color-foreground"]; @@ -64,8 +66,18 @@ export function ThreadFileNavigatorPane(props: { type: "button" as const, width: 44, }, + { + accessibilityLabel: "Close files", + icon: { name: "xmark", type: "sfSymbol" as const }, + identifier: "thread-file-navigator-close", + onPress: toggleAuxiliaryPane, + sharesBackground: false, + tintColor: foregroundColor, + type: "button" as const, + width: 44, + }, ] as ComponentProps["headerRightBarButtonItems"], - [entriesQuery.refresh, foregroundColor], + [entriesQuery.refresh, foregroundColor, toggleAuxiliaryPane], ); const fileTree = ( @@ -159,6 +171,15 @@ export function ThreadFileNavigatorPane(props: { type="monochrome" /> + + + thread.title.toLocaleLowerCase().includes(query) || + threadPullRequestSearchTerms(thread).some((term) => + term.toLocaleLowerCase().includes(query), + ) || input.matchedThreadKeys?.has( threadSearchMatchKey({ environmentId: thread.environmentId, diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index ad29e0f32a1a..585f55a1265c 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -44,6 +44,7 @@ export function HardwareKeyboardCommandProvider({ ? null : resolveThreadReferenceCopyTarget({ threadId: activeThread?.id ?? activeThreadRef.threadId, + pullRequests: activeThread?.pullRequests, linkedPullRequestUrl: (activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null, }), diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 4d9968cca097..c030e49c2b77 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -22,7 +22,12 @@ import { type ReactNode, } from "react"; import { useWindowDimensions, View } from "react-native"; -import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated"; +import Animated, { + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withTiming, +} from "react-native-reanimated"; import { AsyncResult } from "effect/unstable/reactivity"; import { @@ -51,6 +56,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; +import { WorkspaceContentWidthContext } from "./workspace-content-width"; interface AdaptiveWorkspaceContextValue { readonly layout: Layout; @@ -505,6 +511,10 @@ function AdaptiveWorkspaceLayoutContent( const contentSettledWidth = layout.usesSplitView ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth) : null; + const renderedInspectorWidth = useSharedValue(inspectorColumnTargetWidth); + const renderedContentWidth = useDerivedValue(() => + Math.max(0, width - renderedSidebarWidth.value - renderedInspectorWidth.value), + ); const handleSelectThread = useCallback( (thread: EnvironmentThreadShell) => { @@ -583,10 +593,15 @@ function AdaptiveWorkspaceLayoutContent( contentSettledWidth !== null ? { flex: 1, width: contentSettledWidth } : { flex: 1 } } > - {props.children} + + {props.children} + | null>(null); + +export function useWorkspaceContentWidth() { + return use(WorkspaceContentWidthContext); +} diff --git a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx index 9825ed7fdf5b..7c6574e22e6f 100644 --- a/apps/mobile/src/features/layout/workspace-inspector-pane.tsx +++ b/apps/mobile/src/features/layout/workspace-inspector-pane.tsx @@ -4,6 +4,7 @@ import Animated, { useAnimatedStyle, useSharedValue, withTiming, + type SharedValue, } from "react-native-reanimated"; import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout"; @@ -22,6 +23,7 @@ import { WorkspacePaneDivider } from "./workspace-pane-divider"; * module stays import-cycle-free with AdaptiveWorkspaceLayout. */ export function WorkspaceInspectorPane(props: { + readonly renderedInspectorWidth: SharedValue; /** * When false the pane animates closed but keeps its content mounted for the * exit transition (a route that lost focus). `onClosed` fires once the @@ -45,7 +47,7 @@ export function WorkspaceInspectorPane(props: { // inspector at its final position so route replacement never replays an // entering transition. Only visibility and explicit resizing change it. const inspectorProgress = useSharedValue(inspectorVisible ? 1 : 0); - const renderedInspectorWidth = useSharedValue(inspectorVisible ? (inspectorWidth ?? 0) : 0); + const { renderedInspectorWidth } = props; // The content keeps its own width so the reveal (outer width) clips a // fully-laid-out pane instead of reflowing text every frame. When the OPEN // pane's target width changes (e.g. the sidebar toggles and reserves diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index bca54694bd7b..6829a88cef74 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -1,3 +1,4 @@ +import { BlurTargetView } from "expo-blur"; import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; import type { MenuAction } from "@react-native-menu/menu"; @@ -154,6 +155,7 @@ type ThreadTerminalRouteScreenProps = StaticScreenProps<{ }>; export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) { + const terminalBlurTarget = useRef(null); const navigation = useNavigation(); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const resizeTerminal = useAtomCommand(terminalEnvironment.resize, "terminal resize"); @@ -848,6 +850,19 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) [navigation, selectedThread, terminalId], ); + const handleCloseTerminal = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack(); + return; + } + navigation.dispatch( + StackActions.replace("Thread", { + environmentId: params.environmentId, + threadId: params.threadId, + }), + ); + }, [navigation, params.environmentId, params.threadId]); + const navigateAwayAfterExit = useCallback(() => { // With other shells still live, fall through to the previous one instead // of dropping the user back on the thread. @@ -1141,7 +1156,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) navigation.goBack() : undefined} + onBack={handleCloseTerminal} trailing={ <> {layout.usesSplitView ? ( @@ -1177,6 +1192,12 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) {layout.usesSplitView ? ( + ) : ( <> - + + - + {isAccessoryVisible ? ( - - - } - label={flow.selectedModelOption?.label ?? "Choose model"} - maxWidth={152} - onPress={settingsSheetPresentation.open} - /> + + + + } + label={flow.selectedModelOption?.label ?? "Choose model"} + maxWidth="100%" + onPress={settingsSheetPresentation.open} + /> + {flow.planModeEnabled ? ( ) : null} - + )} {[ ...new Set([ + ...Object.keys(props.answer.questionTextById ?? {}), ...Object.keys(props.answer.answers), ...Object.keys(props.answer.attachmentsByQuestionId), ]), @@ -51,12 +53,11 @@ export function QuestionAnswerHistory(props: { {props.answer.questionTextById[questionId]} ) : null} - - {[props.answer.answers[questionId]] - .flat() - .filter((value): value is string => typeof value === "string") - .join(", ")} - + {getQuestionAnswerText(props.answer.answers[questionId]) ? ( + + {getQuestionAnswerText(props.answer.answers[questionId])} + + ) : null} {(props.answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => ( { + "worklet"; + const timing = { + duration: COMPOSER_TRANSITION_DURATION_MS, + reduceMotion: ReduceMotion.System, + }; + return { + initialValues: { + originX: values.targetOriginX, + originY: values.currentOriginY, + width: values.targetWidth, + height: values.currentHeight, + }, + animations: { + originX: values.targetOriginX, + originY: withTiming(values.targetOriginY, timing), + width: values.targetWidth, + height: withTiming(values.targetHeight, timing), + }, + }; +}; export const COMPOSER_LAYOUT_TRANSITION = - Platform.OS === "android" - ? undefined - : LinearTransition.duration(COMPOSER_TRANSITION_DURATION_MS).reduceMotion(ReduceMotion.System); + Platform.OS === "android" ? undefined : composerHeightTransition; const COMPOSER_ATTACHMENT_ENTERING = Platform.OS === "android" @@ -172,6 +194,7 @@ export function ComposerSurface(props: { readonly animateLayout?: boolean; }) { const { materialYouStyleLayoutActive } = useAppearancePreferences(); + const colors = useUniwindTheme(); const targetBorderRadius = typeof props.style.borderRadius === "number" ? props.style.borderRadius : 0; const animatedBorderRadius = useSharedValue(targetBorderRadius); @@ -210,10 +233,11 @@ export function ComposerSurface(props: { > { setIsFocused(false); - if (!settingsSheetPresentation.isActive) { + if (!settingsSheetPresentation.keepsComposerExpanded) { onExpandedChange?.(false); } onEditorFocusChange?.(false); - }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]); + }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.keepsComposerExpanded]); const handleSend = useCallback(async () => { // Typed out in full rather than picked from the menu. Attachments mean the // user is sending a prompt, so those go through as usual. @@ -778,7 +802,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onPickMedia={props.onPickDraftMedia} onPickFiles={props.onPickDraftFiles} /> - + } label={currentModelOption?.label ?? currentModelSelection.model} - maxWidth={152} + maxWidth="100%" onPress={openSettings} /> diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 3310edc2b3e5..55cd9e9a6c83 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -26,6 +26,8 @@ import type { UserInputQuestion, } from "@t3tools/contracts"; import * as Haptics from "expo-haptics"; +import { BlurTargetView } from "expo-blur"; +import { GlassBlurTargetContext } from "../../lib/glassBlurTarget"; import { memo, useCallback, @@ -56,10 +58,12 @@ import Animated, { FadeOut, ReduceMotion, useAnimatedReaction, + useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useWorkspaceContentWidth } from "../layout/workspace-content-width"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; @@ -645,6 +649,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined; + const workspaceContentWidth = useWorkspaceContentWidth(); + const composerWidthStyle = useAnimatedStyle(() => + isSplitLayout && workspaceContentWidth !== null + ? { width: workspaceContentWidth.value, right: undefined } + : { width: undefined, right: 0 }, + ); const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); const selectedProviderSkills = useMemo(() => { @@ -807,7 +817,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread }, [freeze, scrollMessageToEnd]); const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; - const { themeAppearance } = useAppearancePreferences(); + const { themeAppearance, materialYouStyleLayoutActive } = useAppearancePreferences(); const isDarkMode = themeAppearance === "dark"; const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { @@ -839,17 +849,27 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const handleFeedTouchCancel = useCallback(() => { feedTouchStartRef.current = null; }, []); + const feedBlurTarget = useRef(null); return ( {showContent ? ( - + - + ) : ( )} @@ -903,7 +923,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {/* No paddingTop here: the overlay's measured height becomes the list's bottom inset, so any padding above the pill/composer @@ -1003,41 +1023,43 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread : undefined } > - + + + diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index c4f8eea9275a..8874f77bdc28 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2866,13 +2866,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { entry.type === "message" ? `message:${entry.message.role}` : entry.type } getFixedItemSize={getFixedItemSize} - // LegendList swaps its position and size component types when this - // becomes undefined, remounting the feed and replaying row entrances. - // Keep those containers mounted while ordinary updates stay immediate. + // Android can retain stale native row positions when layout transitions + // race the measurements arriving during sync, even with duration 0. + // Keep its rows on LegendList's non-animated positioning path. On iOS, + // keep a transition installed between disclosures so containers don't remount. itemLayoutAnimation={ - disclosureToggleSettling - ? THREAD_FEED_LAYOUT_TRANSITION - : THREAD_FEED_IMMEDIATE_TRANSITION + Platform.OS === "android" + ? undefined + : disclosureToggleSettling + ? THREAD_FEED_LAYOUT_TRANSITION + : THREAD_FEED_IMMEDIATE_TRANSITION } onItemSizeChanged={handleItemSizeChanged} // Measure rows well before they scroll into view so estimate→actual diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 5aefccb4baff..881f461f29c6 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -4,6 +4,10 @@ import { getGitActionDisabledReason, requiresDefaultBranchConfirmation, } from "@t3tools/client-runtime/state/vcs"; +import { + resolveThreadPullRequestChains, + threadPullRequestKeyOf, +} from "@t3tools/shared/threadPullRequests"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { CommonActions, @@ -49,8 +53,17 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { const isInspector = presentation === "inspector"; const environmentId = EnvironmentId.make(props.route.params.environmentId); const threadId = ThreadId.make(props.route.params.threadId); - const { selectedThread } = useThreadSelection(); + const { selectedThread, selectedEnvironmentRuntime } = useThreadSelection(); const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); + const supportsLinkedPrSnapshots = + selectedEnvironmentRuntime?.serverConfig?.environment.capabilities.threadPullRequests === true; + const linkedPrChains = useMemo( + () => + resolveThreadPullRequestChains( + supportsLinkedPrSnapshots ? (selectedThread?.pullRequests ?? []) : [], + ), + [selectedThread?.pullRequests, supportsLinkedPrSnapshots], + ); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const theme = useUniwindTheme(); @@ -285,6 +298,50 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { /> + {linkedPrChains.length > 0 ? ( + + + Linked pull requests + + {linkedPrChains.map((chain) => ( + + {chain.layers.length > 1 ? ( + + + + {chain.kind === "native" ? "Stack" : "Branch stack"} · {chain.layers.length} PRs + · bottom to top + + + ) : null} + {chain.layers.map((link, index) => ( + + {index > 0 ? : null} + { + void tryOpenExternalUrl(link.url, "pull-request").then((opened) => { + if (!opened) + Alert.alert("Unable to open PR", "The pull request could not be opened."); + }); + }} + /> + + ))} + + ))} + + ) : null} + {currentWorktreePath ? : null} ); diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 1e3094bd071f..cad6181e9623 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -44,7 +44,7 @@ export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; const SIDEBAR_ROW_RADIUS = 12; function pullRequestTintColor( - pr: Pick, + pr: Pick, colorScheme: "light" | "dark", ) { const dark = colorScheme === "dark"; @@ -57,6 +57,11 @@ function pullRequestTintColor( case "merged": return dark ? "#a78bfa" : "#7c3aed"; case "closed": + if (pr.kind === "stack" || pr.others > 0) { + return dark ? "#fb7185" : "#e11d48"; + } + return dark ? "#a1a1aa" : "#71717a"; + case null: return dark ? "#a1a1aa" : "#71717a"; } } @@ -618,15 +623,30 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ) : null} {pr !== null ? ( - - + + {pr.kind === "stack" ? ( + + ) : ( + + )} )} {pr ? ( - - #{pr.label} - + + {pr.kind === "stack" || pr.others > 0 ? ( + + ) : null} + + {pr.kind === "stack" || pr.others > 0 ? pr.label : `#${pr.label}`} + + ) : null} {props.providerInstance ? ( {displayText} + {answerPreview ? ( + {` ${answerPreview}`} + ) : null} )} @@ -893,7 +920,7 @@ export function ThreadWorkGroupToggle(props: { readonly iconSubtleColor: import("react-native").ColorValue; readonly summary: string; readonly summaryKind: ToolGroupSummaryKind; - readonly summaryToolIcon?: "browser" | "t3-code"; + readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request"; readonly themeAppearance: "light" | "dark"; readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface; readonly toolIcon?: ToolActivityIcon; @@ -1231,12 +1258,19 @@ function ToolActivityImage(props: { function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { switch (kind) { + case "pull-request": + case "link-pr": + case "unlink-pr": + case "list-prs": + return "arrow.triangle.pull"; case "read": return { ios: "eye", android: "visibility" }; case "edit": return { ios: "square.and.pencil", android: "edit" }; case "command": return { ios: "terminal", android: "terminal" }; + case "device": + return { ios: "iphone", android: "smartphone" }; case "browser": case "search": return { ios: "globe", android: "public" }; diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 0c7fa5a3688f..3d9aefb4c4e1 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -47,6 +47,7 @@ function makeThread( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 3629e63df462..e1cb8e9ece7f 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -1,3 +1,4 @@ +import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import { effectiveSnoozed, hasQueuedTurnStart, @@ -400,6 +401,9 @@ export function buildThreadListV2Items(input: { if ( query.length > 0 && !thread.title.toLocaleLowerCase().includes(query) && + !threadPullRequestSearchTerms(thread).some((term) => + term.toLocaleLowerCase().includes(query), + ) && input.matchedThreadKeys?.has( threadSearchMatchKey({ environmentId: thread.environmentId, diff --git a/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts b/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts index b5b4914ad11e..805b9acf95dc 100644 --- a/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts +++ b/apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts @@ -3,7 +3,7 @@ import { KeyboardController } from "react-native-keyboard-controller"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; -type PresentationPhase = "closed" | "opening" | "visible"; +type PresentationPhase = "closed" | "opening" | "visible" | "restoring"; /** * The navigator-level UIKit completion event added by the repo's @@ -91,7 +91,8 @@ export function useThreadSettingsSheetPresentation(input: { focusRestoreIdRef.current += 1; clearDismissRestoreTimer(); restorePendingRef.current = false; - restoreFocusAfterDismissRef.current = input.isEditorFocused || KeyboardController.isVisible(); + restoreFocusAfterDismissRef.current = + phase === "restoring" || input.isEditorFocused || KeyboardController.isVisible(); setPhase("opening"); const openingId = openingIdRef.current + 1; @@ -109,7 +110,7 @@ export function useThreadSettingsSheetPresentation(input: { } setPhase("visible"); }); - }, [clearDismissRestoreTimer, input.editorRef, input.isEditorFocused]); + }, [clearDismissRestoreTimer, input.editorRef, input.isEditorFocused, phase]); const restoreEditorFocus = useCallback(() => { const focusRestoreId = focusRestoreIdRef.current + 1; @@ -120,12 +121,11 @@ export function useThreadSettingsSheetPresentation(input: { // normally succeeds; the retries are insurance against UIKit briefly // refusing first-responder status right at the transition boundary. const restoreFocus = () => { - if ( - !isMountedRef.current || - focusRestoreIdRef.current !== focusRestoreId || - isEditorFocusedRef.current || - attemptsRemaining <= 0 - ) { + if (!isMountedRef.current || focusRestoreIdRef.current !== focusRestoreId) { + return; + } + if (isEditorFocusedRef.current || attemptsRemaining <= 0) { + setPhase("closed"); return; } @@ -157,11 +157,15 @@ export function useThreadSettingsSheetPresentation(input: { */ const onDismissed = useCallback(() => { isActiveRef.current = false; - setPhase("closed"); if (!restoreFocusAfterDismissRef.current) { + setPhase("closed"); return; } + // Keep the card expanded across the handoff back to its editor. With a + // hardware keyboard there is no software-keyboard travel to hide a collapse + // while the sheet dismissal and focus restoration finish. + setPhase("restoring"); restoreFocusAfterDismissRef.current = false; restorePendingRef.current = true; clearDismissRestoreTimer(); @@ -185,7 +189,8 @@ export function useThreadSettingsSheetPresentation(input: { }, [runPendingDismissalRestore]); return { - isActive: phase !== "closed", + isActive: phase === "opening" || phase === "visible", + keepsComposerExpanded: phase !== "closed", isVisible: phase === "visible", open, onDismissed, diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 3e5cd0fc9e3d..ad54d6e323ee 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -2,6 +2,7 @@ import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { useNavigation } from "@react-navigation/native"; import { isCompatibleUsageContractVersion, + isModelCostUnknown, type DailyTotals, type MergedUsage, } from "@t3tools/shared/usageMerge"; @@ -607,10 +608,14 @@ function ModelsSection(props: { readonly merged: MergedUsage }) { {model.model} - {formatPercent(model.costShare)} of cost · {formatTokens(model.totalTokens)} tokens + {isModelCostUnknown(model) + ? `no known rates · ${formatTokens(model.totalTokens)} tokens` + : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} - {formatUsd(model.costUsd)} + + {isModelCostUnknown(model) ? "Unpriced" : formatUsd(model.costUsd)} + ))} diff --git a/apps/mobile/src/lib/glassBlurTarget.ts b/apps/mobile/src/lib/glassBlurTarget.ts new file mode 100644 index 000000000000..4e6f7d3e3e88 --- /dev/null +++ b/apps/mobile/src/lib/glassBlurTarget.ts @@ -0,0 +1,6 @@ +import { createContext, type RefObject } from "react"; +import type { View } from "react-native"; + +// Android cannot sample a target that contains the BlurView itself. Keep the +// feed in a separate target, shared by the composer and its popovers. +export const GlassBlurTargetContext = createContext | undefined>(undefined); diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 38ce75f24b08..46a79b70acd2 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -258,6 +258,7 @@ function makeThread( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 0ab9986b2f40..b7d5018edd98 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,4 +1,5 @@ import * as Option from "effect/Option"; +import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input"; import * as Schema from "effect/Schema"; import { requestKindFromRequestType, @@ -167,7 +168,7 @@ export type ThreadFeedEntry = readonly summaryKind: ToolGroupSummaryKind; readonly toolSurface?: WorkLogEntry["toolSurface"]; readonly toolIcon?: WorkLogEntry["toolIcon"]; - readonly summaryToolIcon?: "browser" | "t3-code"; + readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request"; readonly hasFailure: boolean; readonly live: boolean; readonly shimmer: boolean; @@ -405,7 +406,7 @@ function deriveWorkLogEntries( ): DerivedWorkLogEntry[] { const ordered = Arr.sort(activities, activityOrder); const entries: DerivedWorkLogEntry[] = []; - for (const activity of ordered) { + for (const activity of foldUserInputActivities(ordered)) { if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; if (activity.kind === "tool.started") continue; // Like web: an agent's task.started row anchors its batch. It has a fixed @@ -936,6 +937,7 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { if (entry.agentSpawn) return "agent"; if ( + entry.questionAnswer || entry.sourceActivityKind === "user-input.requested" || entry.sourceActivityKind === "user-input.resolved" ) { @@ -2184,21 +2186,30 @@ export function buildThreadFeed( : loadedMessages; const oldestLoadedMessageCreatedAt = options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; - const activityEntries = getThreadFeedActivityEntries(thread.activities); + const activityEntries = getThreadFeedActivityEntries(thread.activities).filter( + (entry) => + oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt, + ); + const foldedAnswerMessageIds = new Set( + activityEntries.flatMap((entry) => + entry.activity.workEntry.questionAnswer + ? [`async-answer:${entry.activity.workEntry.questionAnswer.requestId}`] + : [], + ), + ); const entries = Arr.sortWith( [ - ...messages.map((message) => { - let entry = messageEntriesCache.get(message); - if (!entry) { - entry = { type: "message", id: message.id, createdAt: message.createdAt, message }; - messageEntriesCache.set(message, entry); - } - return entry; - }), - ...activityEntries.filter( - (entry) => - oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt, - ), + ...messages + .filter((message) => message.role !== "user" || !foldedAnswerMessageIds.has(message.id)) + .map((message) => { + let entry = messageEntriesCache.get(message); + if (!entry) { + entry = { type: "message", id: message.id, createdAt: message.createdAt, message }; + messageEntriesCache.set(message, entry); + } + return entry; + }), + ...activityEntries, ], (s) => new Date(s.createdAt), Order.Date, diff --git a/apps/mobile/src/state/pending-thread-creation.ts b/apps/mobile/src/state/pending-thread-creation.ts index 100391cbfe10..5532d6e2c4fb 100644 --- a/apps/mobile/src/state/pending-thread-creation.ts +++ b/apps/mobile/src/state/pending-thread-creation.ts @@ -144,6 +144,7 @@ export function pendingThreadCreationShell( runtimeMode: message.runtimeMode ?? DEFAULT_RUNTIME_MODE, interactionMode: message.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, branch: creation.branch, + pullRequests: [], worktreePath: creation.workspaceMode === "worktree" ? null : creation.worktreePath, linkedPullRequest: null, latestTurn: null, diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index fd7a171810ee..21234b7ff150 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -1,16 +1,28 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import type { + ExecutionEnvironmentCapabilities, + ThreadPullRequestLink, + VcsStatusResult, +} from "@t3tools/contracts"; import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl"; +import { + resolveThreadCurrentPullRequestLink, + resolveThreadPullRequestBadge, +} from "@t3tools/shared/threadPullRequests"; + export type ThreadPr = NonNullable; export interface ThreadPrPresentation { readonly number: number; - readonly state: ThreadPr["state"]; + readonly state: ThreadPr["state"] | null; + readonly kind: "pull-request" | "stack"; + readonly others: number; readonly isDraft: boolean; /** Provider-side last activity, bounding when a terminal state landed. */ readonly updatedAt: string | null; readonly url: string; - /** Compact pull request number label, e.g. "3774". */ + /** Compact pull request number or linked count, e.g. "3774" or "+2". */ readonly label: string; /** Full, provider-aware label for assistive technologies. */ readonly accessibilityLabel: string; @@ -30,6 +42,8 @@ export function presentThreadPr( const presentation = resolveChangeRequestPresentation(provider); const isDraft = pr.state === "open" && pr.isDraft === true; return { + kind: "pull-request", + others: 0, number: pr.number, state: pr.state, isDraft, @@ -40,3 +54,71 @@ export function presentThreadPr( textClassName: isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[pr.state], }; } + +/** Persisted links render immediately, including links awaiting their first host sync. */ +export function presentThreadLinkedPullRequests( + links: ReadonlyArray, +): ThreadPrPresentation | null { + const link = resolveThreadCurrentPullRequestLink(links); + const badge = resolveThreadPullRequestBadge(links); + if (link === null || badge === null) return null; + const snapshot = link.snapshot; + const linkedCount = badge.kind === "pull-request" && badge.others > 0 ? badge.others + 1 : null; + const isMultiple = badge.kind === "stack" || linkedCount !== null; + const state = isMultiple + ? badge.state === "draft" + ? "open" + : badge.state + : (snapshot?.state ?? null); + const isDraft = isMultiple + ? badge.state === "draft" + : snapshot?.isDraft === true && state === "open"; + const label = + badge.kind === "stack" + ? String(badge.layers) + : linkedCount !== null + ? `+${linkedCount}` + : String(link.number); + return { + kind: badge.kind, + others: badge.kind === "pull-request" ? badge.others : 0, + number: link.number, + state, + isDraft, + updatedAt: snapshot?.updatedAt ?? null, + url: link.url, + label, + accessibilityLabel: + badge.kind === "stack" + ? `${badge.layers} pull requests in stack, ${isDraft ? "draft" : (state ?? "status pending")}` + : linkedCount !== null + ? `${linkedCount} linked pull requests, overall ${badge.state}` + : `#${link.number} pull request ${state === null ? "status pending" : isDraft ? "draft" : state}`, + textClassName: + state === null || isDraft + ? "text-foreground-muted" + : isMultiple && state === "closed" + ? "text-adaptive-rose-600-400" + : PR_STATE_TEXT_CLASS[state], + }; +} + +/** Only the array capability replaces legacy references with persisted snapshots. */ +export function resolveThreadPrSource( + thread: Pick, + capabilities: + | Pick + | undefined, +) { + const supportsSnapshots = capabilities?.threadPullRequests === true; + const linkedPresentation = supportsSnapshots + ? presentThreadLinkedPullRequests(thread.pullRequests) + : null; + const pullRequestRef = + linkedPresentation !== null + ? null + : ((supportsSnapshots + ? thread.branchPullRequest + : (thread.linkedPullRequest ?? thread.branchPullRequest)) ?? null); + return { linkedPresentation, pullRequestRef }; +} diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index f320e9da710d..e66f690428e8 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -330,6 +330,8 @@ export function useSelectedThreadGitActions() { ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}), ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), + // A pull request the action opens is linked to the thread it ran beside. + threadId: thread.id, }); if (AsyncResult.isFailure(result)) { return result; diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index f6fddfdc5578..75ec6743ac59 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -1,7 +1,11 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; +import { ProjectId, type ThreadPullRequestLink, type VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { presentThreadPr } from "./thread-pr-presentation"; +import { + presentThreadLinkedPullRequests, + presentThreadPr, + resolveThreadPrSource, +} from "./thread-pr-presentation"; const pullRequest: NonNullable = { number: 3774, @@ -43,3 +47,176 @@ describe("presentThreadPr", () => { }); }); }); + +function linkedPr( + number: number, + overrides: Partial = {}, +): ThreadPullRequestLink { + return { + host: "github.com", + repository: "t3tools/t3code", + number, + url: `https://github.com/t3tools/t3code/pull/${number}`, + source: "manual", + linkedAt: "2026-09-08T00:00:00.000Z", + stack: null, + snapshot: { + state: "open", + title: `Change ${number}`, + headBranch: `change-${number}`, + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-09-08T00:00:00.000Z", + }, + ...overrides, + }; +} + +describe("presentThreadLinkedPullRequests", () => { + it("renders unsynced links with neutral pending status", () => { + expect(presentThreadLinkedPullRequests([linkedPr(1, { snapshot: null })])).toMatchObject({ + number: 1, + label: "1", + state: null, + textClassName: "text-foreground-muted", + accessibilityLabel: "#1 pull request status pending", + }); + }); + + it("counts unrelated links without labelling them a stack", () => { + expect(presentThreadLinkedPullRequests([linkedPr(1), linkedPr(2)])).toMatchObject({ + kind: "pull-request", + label: "+2", + others: 1, + state: "open", + isDraft: false, + textClassName: "text-adaptive-emerald-600-400", + }); + }); + + it.each([ + ["closed", false, "closed", false, "closed", false, "text-adaptive-rose-600-400"], + ["open", true, "open", true, "open", true, "text-foreground-muted"], + ["open", true, "open", false, "open", false, "text-adaptive-emerald-600-400"], + ["closed", false, "open", false, "open", false, "text-adaptive-emerald-600-400"], + ["merged", false, "merged", false, "merged", false, "text-adaptive-violet-600-400"], + ["closed", false, "merged", false, "closed", false, "text-adaptive-rose-600-400"], + ] as const)( + "colors linked %s (draft %s) and %s (draft %s) by their aggregate state", + (firstState, firstDraft, secondState, secondDraft, state, isDraft, textClassName) => { + const first = linkedPr(1); + const second = linkedPr(2); + expect( + presentThreadLinkedPullRequests([ + { ...first, snapshot: { ...first.snapshot!, state: firstState, isDraft: firstDraft } }, + { + ...second, + snapshot: { ...second.snapshot!, state: secondState, isDraft: secondDraft }, + }, + ]), + ).toMatchObject({ + label: "+2", + state, + isDraft, + textClassName, + accessibilityLabel: `2 linked pull requests, overall ${isDraft ? "draft" : state}`, + }); + }, + ); + + it("uses the top of a derived stack even when its bottom was linked later", () => { + const bottom = linkedPr(1, { linkedAt: "2026-09-09T00:00:00.000Z" }); + const top = linkedPr(2); + expect( + presentThreadLinkedPullRequests([ + bottom, + { + ...top, + snapshot: { ...top.snapshot!, baseBranch: "change-1" }, + }, + ]), + ).toMatchObject({ kind: "stack", label: "2", number: 2, url: top.url }); + }); + + it("hides dismissed stack members", () => { + expect( + presentThreadLinkedPullRequests([linkedPr(1, { source: "stack-dismissed" })]), + ).toBeNull(); + }); + + it("retains merged state from the persisted snapshot", () => { + const link = linkedPr(1); + expect( + presentThreadLinkedPullRequests([ + { ...link, snapshot: { ...link.snapshot!, state: "merged" } }, + ]), + ).toMatchObject({ state: "merged", textClassName: "text-adaptive-violet-600-400" }); + }); +}); + +describe("resolveThreadPrSource compatibility", () => { + const legacyRef = { + projectId: ProjectId.make("project"), + repository: "t3tools/t3code", + number: 1, + url: "https://github.com/t3tools/t3code/pull/1", + }; + const branchRef = { ...legacyRef, number: 2, url: "https://github.com/t3tools/t3code/pull/2" }; + + it("polls the legacy reference when only the older linking capability exists", () => { + expect( + resolveThreadPrSource( + { pullRequests: [], linkedPullRequest: legacyRef, branchPullRequest: branchRef }, + { threadPullRequestLinking: true }, + ), + ).toEqual({ linkedPresentation: null, pullRequestRef: legacyRef }); + }); + + it("keeps the legacy reference for a server without either capability", () => { + expect(resolveThreadPrSource({ pullRequests: [], linkedPullRequest: legacyRef }, {})).toEqual({ + linkedPresentation: null, + pullRequestRef: legacyRef, + }); + }); + + it("ignores stale snapshots after reconnecting to a server without array support", () => { + expect( + resolveThreadPrSource( + { pullRequests: [linkedPr(3)], linkedPullRequest: legacyRef }, + { threadPullRequestLinking: true, threadPullRequests: false }, + ), + ).toEqual({ linkedPresentation: null, pullRequestRef: legacyRef }); + }); + + it("uses snapshots without polling when both capabilities are advertised", () => { + expect( + resolveThreadPrSource( + { pullRequests: [linkedPr(3)], linkedPullRequest: legacyRef, branchPullRequest: branchRef }, + { threadPullRequestLinking: true, threadPullRequests: true }, + ), + ).toMatchObject({ linkedPresentation: { number: 3 }, pullRequestRef: null }); + }); + + it("does not revive a removed modern link from the compatibility reference", () => { + expect( + resolveThreadPrSource( + { pullRequests: [], linkedPullRequest: legacyRef, branchPullRequest: branchRef }, + { threadPullRequests: true }, + ), + ).toEqual({ linkedPresentation: null, pullRequestRef: branchRef }); + }); + + it("does not poll when a modern link is waiting for its first snapshot", () => { + expect( + resolveThreadPrSource( + { + pullRequests: [linkedPr(3, { snapshot: null })], + linkedPullRequest: legacyRef, + branchPullRequest: branchRef, + }, + { threadPullRequests: true }, + ), + ).toMatchObject({ linkedPresentation: { number: 3, state: null }, pullRequestRef: null }); + }); +}); diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index e824ce56217b..c903abbb07f2 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -10,8 +10,13 @@ import { useCallback, useEffect, useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "./atom-registry"; +import { serverEnvironment } from "./server"; import { useEnvironmentQuery } from "./query"; -import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; +import { + resolveThreadPrSource, + presentThreadPr, + type ThreadPrPresentation, +} from "./thread-pr-presentation"; const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); const MAX_THREAD_PR_SNAPSHOTS = 500; @@ -35,11 +40,26 @@ export { } from "./thread-pr-presentation"; /** - * Live status for a thread's server-provided PR. Visible rows share a summary - * request for the same PR in the same environment. + * Linked PRs use server snapshots. Branch fallback and legacy references share + * a live summary request across visible rows in the same environment. */ export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null { - const pullRequestRef = thread.linkedPullRequest ?? thread.branchPullRequest ?? null; + const supportsLinks = useAtomValue( + serverEnvironment.configValueAtom(thread.environmentId), + (config) => config?.environment.capabilities.threadPullRequests === true, + ); + const { linkedPresentation, pullRequestRef } = useMemo( + () => + resolveThreadPrSource( + { + pullRequests: thread.pullRequests, + linkedPullRequest: thread.linkedPullRequest, + branchPullRequest: thread.branchPullRequest, + }, + { threadPullRequests: supportsLinks }, + ), + [thread.pullRequests, thread.linkedPullRequest, thread.branchPullRequest, supportsLinks], + ); const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); const snapshotIdentity = JSON.stringify(pullRequestRef); // Select this row's entry so writes for other rows do not re-render it. @@ -101,5 +121,5 @@ export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentatio }); }, [live, snapshotIdentity, threadKey]); - return live === undefined ? snapshot : live; + return linkedPresentation ?? (live === undefined ? snapshot : live); } diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index 85f7fb3c3c92..d922eb7f6d5c 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -64,6 +64,7 @@ function threadDetailToShell( branch: thread.branch, worktreePath: thread.worktreePath, linkedPullRequest: thread.linkedPullRequest ?? null, + pullRequests: thread.pullRequests, branchPullRequest: thread.branchPullRequest ?? null, latestTurn: thread.latestTurn, createdAt: thread.createdAt, diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index ae217a3aabe7..4d8a384ee997 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -67,6 +67,7 @@ import { } from "../src/orchestration/Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts"; +import * as PullRequestSyncReactor from "../src/orchestration/PullRequestSyncReactor.ts"; import * as ThreadPullRequestReactor from "../src/orchestration/ThreadPullRequestReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -404,6 +405,13 @@ export const makeOrchestrationIntegrationHarness = ( drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(PullRequestSyncReactor.PullRequestSyncReactor, { + start: () => Effect.void, + drain: Effect.void, + requestSync: () => Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, diff --git a/apps/server/package.json b/apps/server/package.json index 7d27d39d2eb3..84a8c6a5988b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -40,6 +40,7 @@ "@effect/vitest": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", + "@t3tools/ssh": "workspace:*", "@t3tools/tailscale": "workspace:*", "@t3tools/web": "workspace:*", "@types/bun": "1.3.14", diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index a069322aa8bf..d7d1be455fa1 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -68,6 +68,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, @@ -142,6 +144,15 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.previewAutomationFocusHost]: AuthOrchestrationOperateScope, [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope, [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope, + [WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope, + [WS_METHODS.deviceTestHost]: AuthOrchestrationOperateScope, + [WS_METHODS.deviceList]: AuthOrchestrationReadScope, + [WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope, + [WS_METHODS.deviceClose]: AuthOrchestrationOperateScope, + [WS_METHODS.deviceShutdown]: AuthOrchestrationOperateScope, + [WS_METHODS.deviceDetail]: AuthOrchestrationReadScope, + [WS_METHODS.deviceAction]: AuthOrchestrationOperateScope, + [WS_METHODS.subscribeDeviceState]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope, [WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope, diff --git a/apps/server/src/device/AgentDeviceShim.ts b/apps/server/src/device/AgentDeviceShim.ts new file mode 100644 index 000000000000..e3f92de492f3 --- /dev/null +++ b/apps/server/src/device/AgentDeviceShim.ts @@ -0,0 +1,59 @@ +// @effect-diagnostics preferSchemaOverJson:off - JSON string literals embed paths safely into generated JavaScript. +/** + * A directory holding an `agent-device` launcher that runs the pinned install + * with the server's Node. Prepended to provider subprocess PATHs so the agent + * types `agent-device …` and gets the version the injected instructions were + * written for, regardless of what is or is not globally installed. + */ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +const SHIM_DIR = "device/bin"; + +export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(function* (input: { + readonly entryPath: string; + readonly stateDir: string; +}) { + const { entryPath } = input; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const shimDir = path.join(input.stateDir, SHIM_DIR); + yield* fs.makeDirectory(shimDir, { recursive: true }); + const node = process.execPath; + const launcherPath = path.join(shimDir, "agent-device-launcher.mjs"); + yield* fs.writeFileString( + launcherPath, + `import { spawn } from "node:child_process"; +const args = process.argv.slice(2); +const informational = args.length === 1 && ["help", "--help", "-h", "--version", "version"].includes(args[0]); +const hasValue = flag => { const index = args.indexOf(flag); return index >= 0 && !!args[index + 1] && !args[index + 1].startsWith("--"); }; +if (!informational && !(hasValue("--config") && hasValue("--session"))) { + console.error("Call device_open first and include its --config and --session flags."); + process.exit(1); +} +const env = { ...process.env }; +delete env.AGENT_DEVICE_DAEMON_BASE_URL; +delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; +delete env.AGENT_DEVICE_CONFIG; +const child = spawn(${JSON.stringify(node)}, [${JSON.stringify(entryPath)}, ...args], { stdio: "inherit", env }); +child.on("error", error => { console.error(error.message); process.exitCode = 1; }); +child.on("exit", code => { process.exitCode = code ?? 1; }); +`, + ); + if (platform === "win32") { + const script = `@echo off\r\n"${node}" "${launcherPath}" %*\r\n`; + yield* fs.writeFileString(path.join(shimDir, "agent-device.cmd"), script); + } else { + const command = [node, launcherPath] + .map((value) => "'" + value.replaceAll("'", "'\"'\"'") + "'") + .join(" "); + const script = `#!/bin/sh\nexec ${command} "$@"\n`; + const shimPath = path.join(shimDir, "agent-device"); + yield* fs.writeFileString(shimPath, script); + yield* fs.chmod(shimPath, 0o755); + } + return shimDir; +}); diff --git a/apps/server/src/device/AgentDeviceTarget.test.ts b/apps/server/src/device/AgentDeviceTarget.test.ts new file mode 100644 index 000000000000..3552beaf3cde --- /dev/null +++ b/apps/server/src/device/AgentDeviceTarget.test.ts @@ -0,0 +1,89 @@ +// @effect-diagnostics nodeBuiltinImport:off - exercises concurrent real CLI subprocesses. +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { describe, expect, it } from "@effect/vitest"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeUtil from "node:util"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { ensureAgentDeviceShim } from "./AgentDeviceShim.ts"; +import { + agentDeviceConfigPath, + agentDeviceSession, + writeAgentDeviceConfig, +} from "./AgentDeviceTarget.ts"; + +const exec = NodeUtil.promisify(NodeChildProcess.execFile); + +describe("host-bound agent commands", () => { + it.effect("runs two hosts concurrently and only updates the reconnected host", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temp = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-target-" }); + const platform = yield* HostProcessPlatform; + const dir = path.join( + temp, + platform === "win32" ? "paths with spaces" : "quotes '\" $HOME `literal`", + ); + yield* fs.makeDirectory(dir); + const entryPath = path.join(dir, "cli.mjs"); + yield* fs.writeFileString( + entryPath, + `import { readFileSync } from 'node:fs'; +const args = process.argv.slice(2); +console.log(readFileSync(args[args.indexOf('--config') + 1], 'utf8')); +if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`, + ); + const shim = yield* ensureAgentDeviceShim({ entryPath, stateDir: dir }); + const files = ["mini", "android"].map((host) => agentDeviceConfigPath(dir, host, path)); + for (const [index, file] of files.entries()) + yield* writeAgentDeviceConfig(file, { + baseUrl: `http://127.0.0.1:${1000 + index}`, + token: `token-${index}`, + entryPath, + }); + const invoke = (file: string) => + exec( + platform === "win32" ? process.execPath : path.join(shim, "agent-device"), + [ + ...(platform === "win32" ? [path.join(shim, "agent-device-launcher.mjs")] : []), + "snapshot", + "--config", + file, + "--session", + "test-session", + ], + { env: { ...process.env, AGENT_DEVICE_DAEMON_BASE_URL: "http://wrong-host" } }, + ).then((result) => JSON.parse(result.stdout)); + expect(yield* Effect.promise(() => Promise.all(files.map(invoke)))).toEqual([ + { daemonBaseUrl: "http://127.0.0.1:1000", daemonAuthToken: "token-0" }, + { daemonBaseUrl: "http://127.0.0.1:1001", daemonAuthToken: "token-1" }, + ]); + const second = yield* fs.readFileString(files[1]!); + yield* writeAgentDeviceConfig(files[0]!, { + baseUrl: "http://127.0.0.1:2000", + token: "new", + entryPath, + }); + expect((yield* Effect.promise(() => invoke(files[0]!))).daemonAuthToken).toBe("new"); + expect(yield* fs.readFileString(files[1]!)).toBe(second); + expect(agentDeviceSession("thread", "mini", "same-id")).not.toBe( + agentDeviceSession("thread", "android", "same-id"), + ); + for (const args of [ + ["snapshot"], + ["snapshot", "--config", files[0]!], + ["snapshot", "--config", "help"], + ["snapshot", "--config", files[0]!, "--session"], + ]) { + yield* Effect.promise(() => + expect( + exec(process.execPath, [path.join(shim, "agent-device-launcher.mjs"), ...args]), + ).rejects.toThrow("Call device_open first"), + ); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/device/AgentDeviceTarget.ts b/apps/server/src/device/AgentDeviceTarget.ts new file mode 100644 index 000000000000..379649b1257c --- /dev/null +++ b/apps/server/src/device/AgentDeviceTarget.ts @@ -0,0 +1,43 @@ +import * as NodeCrypto from "node:crypto"; +import * as Schema from "effect/Schema"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import type { AgentDeviceEndpoint } from "./DeviceHost.ts"; + +const encodeEndpoint = Schema.encodeEffect( + Schema.fromJsonString( + Schema.Struct({ daemonBaseUrl: Schema.String, daemonAuthToken: Schema.String }), + ), +); + +const key = (value: string) => + NodeCrypto.createHash("sha256").update(value).digest("hex").slice(0, 24); + +/** A stable file per host lets forwarded endpoints change without retargeting other commands. */ +export const agentDeviceConfigPath = (stateDir: string, hostId: string, path: Path.Path) => + path.join(stateDir, "device", "hosts", `${key(hostId)}.json`); + +export const agentDeviceSession = (threadId: string, hostId: string, deviceId: string) => + `t3-${key(JSON.stringify([threadId, hostId, deviceId]))}`; + +export const writeAgentDeviceConfig = Effect.fn("AgentDeviceTarget.writeConfig")(function* ( + file: string, + endpoint: AgentDeviceEndpoint, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.dirname(file), { recursive: true }); + const content = yield* encodeEndpoint({ + daemonBaseUrl: endpoint.baseUrl, + daemonAuthToken: endpoint.token, + }); + if ((yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))) === content) return; + const temporary = yield* fs.makeTempFile({ directory: path.dirname(file), prefix: ".endpoint-" }); + yield* Effect.gen(function* () { + yield* fs.chmod(temporary, 0o600); + yield* fs.writeFileString(temporary, content); + yield* fs.rename(temporary, file); + }).pipe(Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.ignore))); +}); diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts new file mode 100644 index 000000000000..c22dcfe86610 --- /dev/null +++ b/apps/server/src/device/DeviceActions.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from "@effect/vitest"; +import type { DeviceActionInput } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { readDeviceDetail, runDeviceAction, supportsAction } from "./DeviceActions.ts"; +import type { DeviceHostReady } from "./DeviceHost.ts"; + +type Call = { command: string; args: ReadonlyArray; stdin?: string }; + +const makeReady = ( + respond: (call: Call) => { stdout?: string; stderr?: string; code?: number } = () => ({}), + helpers: DeviceHostReady["helpers"] = { + serveSimAxSettings: "/hub/simax/serve-sim-ax-settings", + serveSimCli: "/hub/serve-sim.js", + }, +) => { + const calls: Call[] = []; + const ready: DeviceHostReady = { + nodePath: process.execPath, + hub: { origin: "http://127.0.0.1:1" }, + helpers, + run: (command, args, options) => { + const call = { command, args, ...(options?.stdin ? { stdin: options.stdin } : {}) }; + calls.push(call); + const result = respond(call); + return Effect.succeed({ + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + code: result.code ?? 0, + }); + }, + }; + return { ready, calls }; +}; + +const udid = "SIM-1"; + +describe("supportsAction", () => { + it("advertises platform-specific toggles", () => { + const toggle = (setting: Extract["setting"]) => + ({ type: "setToggle", deviceId: udid, setting, value: true }) as const; + expect(supportsAction("ios", toggle("voiceOver"))).toBe(true); + expect(supportsAction("android", toggle("voiceOver"))).toBe(false); + expect(supportsAction("android", toggle("networkEnabled"))).toBe(true); + expect(supportsAction("ios", toggle("networkEnabled"))).toBe(false); + expect(supportsAction("ios", { type: "setLiquidGlass", deviceId: udid, value: "clear" })).toBe( + true, + ); + expect( + supportsAction("android", { type: "setLiquidGlass", deviceId: udid, value: "clear" }), + ).toBe(false); + expect( + supportsAction("android", { type: "setOrientation", deviceId: udid, value: "portrait" }), + ).toBe(true); + expect( + supportsAction("ios", { type: "setOrientation", deviceId: udid, value: "portrait" }), + ).toBe(false); + }); +}); + +describe("runDeviceAction", () => { + it.effect("maps shared text sizes onto simctl content-size categories", () => + Effect.gen(function* () { + const { ready, calls } = makeReady(); + yield* runDeviceAction(ready, "ios", { + type: "setTextSize", + deviceId: udid, + value: "extra-large", + }); + expect(calls).toEqual([ + { command: "xcrun", args: ["simctl", "ui", udid, "content_size", "accessibility-large"] }, + ]); + }), + ); + + it.effect("maps shared text sizes onto Android font_scale", () => + Effect.gen(function* () { + const { ready, calls } = makeReady(); + yield* runDeviceAction(ready, "android", { + type: "setTextSize", + deviceId: "emulator-5554", + value: "large", + }); + expect(calls[0]?.args).toEqual([ + "-s", + "emulator-5554", + "shell", + "settings", + "put", + "system", + "font_scale", + "1.15", + ]); + }), + ); + + it.effect( + "rotates emulators through the accelerometer and physical devices through the lock", + () => + Effect.gen(function* () { + const emulator = makeReady(); + yield* runDeviceAction(emulator.ready, "android", { + type: "setOrientation", + deviceId: "emulator-5554", + value: "landscape_left", + }); + expect(emulator.calls.at(-1)?.args).toEqual([ + "-s", + "emulator-5554", + "emu", + "sensor", + "set", + "acceleration", + "9.81:0:0", + ]); + const phone = makeReady(); + yield* runDeviceAction(phone.ready, "android", { + type: "setOrientation", + deviceId: "R5CT1234", + value: "landscape_right", + }); + expect(phone.calls).toEqual([ + { + command: "adb", + args: ["-s", "R5CT1234", "shell", "cmd", "window", "user-rotation", "lock", "3"], + }, + ]); + }), + ); + + it.effect("runs accessibility toggles through the bundled helper via simctl spawn", () => + Effect.gen(function* () { + const { ready, calls } = makeReady(); + yield* runDeviceAction(ready, "ios", { + type: "setToggle", + deviceId: udid, + setting: "voiceOver", + value: true, + }); + expect(calls).toEqual([ + { + command: "xcrun", + args: [ + "simctl", + "spawn", + udid, + "/hub/simax/serve-sim-ax-settings", + "set", + "voiceover", + "on", + ], + }, + ]); + }), + ); + + it.effect("fails clearly when the helper is missing", () => + Effect.gen(function* () { + const { ready } = makeReady(() => ({}), { serveSimAxSettings: null, serveSimCli: null }); + const error = yield* Effect.flip( + runDeviceAction(ready, "ios", { + type: "setColorFilter", + deviceId: udid, + value: "grayscale", + }), + ); + expect(error._tag).toBe("DeviceActionUnavailableError"); + expect(error.message).toContain("requires a helper"); + }), + ); + + it.effect("rejects actions the platform does not support without running anything", () => + Effect.gen(function* () { + const { ready, calls } = makeReady(); + const error = yield* Effect.flip( + runDeviceAction(ready, "android", { + type: "sendPush", + deviceId: "emulator-5554", + appId: "com.example", + payload: "hi", + }), + ); + expect(error.message).toContain("not supported on android"); + expect(calls).toEqual([]); + }), + ); + + it.effect("surfaces non-zero exit codes as operation errors", () => + Effect.gen(function* () { + const { ready } = makeReady(() => ({ code: 1, stderr: "Invalid device: SIM-1" })); + const error = yield* Effect.flip( + runDeviceAction(ready, "ios", { type: "setAppearance", deviceId: udid, value: "dark" }), + ); + expect(error.operation).toBe("appearance"); + expect(error.message).toContain("exit code 1"); + expect(error.message).not.toContain("Invalid device: SIM-1"); + expect(error._tag === "DeviceOperationError" && error.cause).toMatchObject({ + stderr: "Invalid device: SIM-1", + }); + }), + ); + + it.effect("wraps a bare push string in an APNs alert and feeds it on stdin", () => + Effect.gen(function* () { + const { ready, calls } = makeReady(); + yield* runDeviceAction(ready, "ios", { + type: "sendPush", + deviceId: udid, + appId: "com.example.app", + payload: "Hello", + }); + expect(calls[0]?.args).toEqual(["simctl", "push", udid, "com.example.app", "-"]); + expect(calls[0]?.stdin).toBe('{"aps":{"alert":"Hello"}}'); + }), + ); +}); + +describe("readDeviceDetail", () => { + it.effect("reads iOS settings from simctl and the accessibility helper", () => + Effect.gen(function* () { + const { ready } = makeReady((call) => { + const key = call.args.join(" "); + if (key.endsWith("ui SIM-1 appearance")) return { stdout: "dark\n" }; + if (key.endsWith("ui SIM-1 content_size")) return { stdout: "extra-extra-large\n" }; + if (key.endsWith("ui SIM-1 increase_contrast")) return { stdout: "enabled\n" }; + if (key.includes("serve-sim-ax-settings status")) { + return { + stdout: + '{"reduce-motion":"on","reduce-transparency":"off","show-borders":"off","voiceover":"off","liquid-glass":"tinted","color-filter":"grayscale"}', + }; + } + return { code: 1 }; + }); + const detail = yield* readDeviceDetail(ready, "ios", udid); + expect(detail.settings).toEqual({ + appearance: "dark", + textSize: "large", + increaseContrast: true, + reduceMotion: true, + reduceTransparency: false, + showBorders: false, + voiceOver: false, + liquidGlass: "tinted", + colorFilter: "grayscale", + }); + }), + ); + + it.effect("degrades unreadable values to unknown instead of failing", () => + Effect.gen(function* () { + const { ready } = makeReady(() => ({ code: 1, stderr: "boom" })); + const detail = yield* readDeviceDetail(ready, "ios", udid); + expect(detail.settings).toEqual({}); + expect(detail.foregroundApp).toBeNull(); + }), + ); + + it.effect("reads Android settings and the focused package", () => + Effect.gen(function* () { + const { ready } = makeReady((call) => { + const key = call.args.join(" "); + if (key.endsWith("cmd uimode night")) return { stdout: "Night mode: yes\n" }; + if (key.endsWith("font_scale")) return { stdout: "0.85\n" }; + if (key.endsWith("animator_duration_scale")) return { stdout: "0\n" }; + if (key.endsWith("wifi_on")) return { stdout: "1\n" }; + if (key.endsWith("dumpsys window")) { + return { + stdout: + " mFocusedApp=ActivityRecord{155579877 u0 com.example.app/.MainActivity t15}\n mCurrentFocus=Window{1a2b u0 com.example.app/com.example.app.MainActivity}\n", + }; + } + return { code: 1 }; + }); + const detail = yield* readDeviceDetail(ready, "android", "emulator-5554"); + expect(detail.settings).toEqual({ + appearance: "dark", + textSize: "small", + reduceMotion: true, + networkEnabled: true, + }); + expect(detail.foregroundApp).toEqual({ id: "com.example.app" }); + }), + ); +}); diff --git a/apps/server/src/device/DeviceActions.ts b/apps/server/src/device/DeviceActions.ts new file mode 100644 index 000000000000..509dac526b93 --- /dev/null +++ b/apps/server/src/device/DeviceActions.ts @@ -0,0 +1,568 @@ +/** + * Device settings and one-shot actions, run by the server against the host's + * toolchain instead of through serve-sim's shell-exec channel. + * + * serve-sim's preview drives its "Simulator" panel by sending shell commands + * over a token-gated socket. Proxying that would hand any environment + * session arbitrary command execution on the host, so T3 runs the same + * underlying commands itself, typed per action: `xcrun simctl ui` and + * `simctl privacy` for iOS, the `serve-sim-ax-settings` helper that serve-sim + * bundles for the accessibility toggles, and `adb shell` for Android. + * + * Each platform advertises which actions it supports; the panel hides the + * rest rather than showing controls that cannot work. + */ +import { + type DeviceActionInput, + type DeviceActionType, + type DeviceForegroundApp, + DeviceOperationError, + DeviceActionUnavailableError, + type DeviceOrientation, + type DevicePlatform, + type DeviceSettings, + type DeviceTextSize, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import type { DeviceHostReady } from "./DeviceHost.ts"; + +type Runner = DeviceHostReady["run"]; + +const decodeAxStatus = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)), +); +const encodePushPayload = Schema.encodeUnknownEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +const IOS_ACTIONS: ReadonlySet = new Set([ + "setAppearance", + "setTextSize", + "setToggle", + "setLiquidGlass", + "setColorFilter", + "setLocation", + "clearLocation", + "setPermission", + "openUrl", + "launchApp", + "terminateApp", + "sendPush", +]); + +const ANDROID_ACTIONS: ReadonlySet = new Set([ + "setAppearance", + "setTextSize", + "setToggle", + "setOrientation", + "setLocation", + "clearLocation", + "setPermission", + "openUrl", + "launchApp", + "terminateApp", +]); + +/** Toggle settings each platform can actually flip. */ +const IOS_TOGGLES = new Set([ + "reduceMotion", + "increaseContrast", + "reduceTransparency", + "showBorders", + "voiceOver", +]); +const ANDROID_TOGGLES = new Set(["reduceMotion", "networkEnabled"]); + +export const supportsAction = (platform: DevicePlatform, input: DeviceActionInput): boolean => { + const actions = platform === "ios" ? IOS_ACTIONS : ANDROID_ACTIONS; + if (!actions.has(input.type)) return false; + if (input.type === "setToggle") { + return (platform === "ios" ? IOS_TOGGLES : ANDROID_TOGGLES).has(input.setting); + } + return true; +}; + +const ok = (operation: string) => (result: { code: number; stderr: string; stdout: string }) => + result.code === 0 + ? Effect.succeed(result.stdout) + : Effect.fail( + new DeviceOperationError({ + operation, + reason: "command_failed", + exitCode: result.code, + cause: result, + }), + ); + +// iOS text-size categories in ascending order; the four shared steps index +// into it. `default` is what a fresh simulator reports ("large"). +const IOS_TEXT_SIZES: Record = { + small: "small", + default: "large", + large: "extra-extra-large", + "extra-large": "accessibility-large", +}; +const ANDROID_TEXT_SIZES: Record = { + small: "0.85", + default: "1.0", + large: "1.15", + "extra-large": "1.3", +}; + +const textSizeFromIos = (category: string): DeviceTextSize | undefined => { + const entry = (Object.entries(IOS_TEXT_SIZES) as Array<[DeviceTextSize, string]>).find( + ([, value]) => value === category, + ); + if (entry) return entry[0]; + if (category.startsWith("accessibility")) return "extra-large"; + if (category.includes("extra")) return "large"; + return category === "extra-small" || category === "small" || category === "medium" + ? "small" + : "default"; +}; + +const textSizeFromAndroid = (scale: number): DeviceTextSize => { + if (scale <= 0.9) return "small"; + if (scale >= 1.25) return "extra-large"; + if (scale >= 1.1) return "large"; + return "default"; +}; + +const IOS_TOGGLE_OPTIONS: Record = { + reduceMotion: "reduce-motion", + increaseContrast: "increase-contrast", + reduceTransparency: "reduce-transparency", + showBorders: "show-borders", + voiceOver: "voiceover", +}; + +// serve-sim permission names -> the TCC service or simctl privacy service. +const IOS_TCC_SERVICES: Record = { + camera: "camera", + microphone: "microphone", + photos: "photos", + contacts: "contacts", + calendar: "calendar", + reminders: "reminders", + motion: "motion", + "media-library": "media-library", + faceid: "faceid", +}; + +const ANDROID_PERMISSIONS: Record> = { + camera: ["android.permission.CAMERA"], + microphone: ["android.permission.RECORD_AUDIO"], + photos: ["android.permission.READ_MEDIA_IMAGES", "android.permission.READ_EXTERNAL_STORAGE"], + contacts: ["android.permission.READ_CONTACTS", "android.permission.WRITE_CONTACTS"], + calendar: ["android.permission.READ_CALENDAR", "android.permission.WRITE_CALENDAR"], + location: [ + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", + ], + notifications: ["android.permission.POST_NOTIFICATIONS"], + motion: ["android.permission.ACTIVITY_RECOGNITION"], +}; + +// Gravity vector (x:y:z) that makes the emulator report each orientation, +// and the window-manager rotation index for the same. +const ANDROID_GRAVITY: Record = { + portrait: "0:9.81:0", + landscape_left: "9.81:0:0", + portrait_upside_down: "0:-9.81:0", + landscape_right: "-9.81:0:0", +}; +const ANDROID_ROTATION: Record = { + portrait: "0", + landscape_left: "1", + portrait_upside_down: "2", + landscape_right: "3", +}; + +export const runDeviceAction = Effect.fn("DeviceActions.run")(function* ( + ready: DeviceHostReady, + platform: DevicePlatform, + input: DeviceActionInput, +) { + if (!supportsAction(platform, input)) { + return yield* new DeviceActionUnavailableError({ + operation: input.type, + platform, + reason: "unsupported", + }); + } + if (platform === "ios") return yield* runIos(ready, input); + return yield* runAndroid(ready.run, input); +}); + +const simctl = (run: Runner, udid: string, args: ReadonlyArray, operation: string) => + run("xcrun", ["simctl", ...args.slice(0, 1), udid, ...args.slice(1)]).pipe( + Effect.flatMap(ok(operation)), + ); + +const axSettings = (ready: DeviceHostReady, udid: string, args: ReadonlyArray) => + Effect.gen(function* () { + const helper = ready.helpers.serveSimAxSettings; + if (!helper) { + return yield* new DeviceActionUnavailableError({ + operation: "accessibility", + platform: "ios", + reason: "helper_missing", + }); + } + return yield* ready + .run("xcrun", ["simctl", "spawn", udid, helper, ...args]) + .pipe(Effect.flatMap(ok("accessibility"))); + }); + +const runIos = Effect.fn("DeviceActions.runIos")(function* ( + ready: DeviceHostReady, + input: DeviceActionInput, +) { + const udid = input.deviceId; + const { run } = ready; + switch (input.type) { + case "setAppearance": + yield* simctl(run, udid, ["ui", "appearance", input.value], "appearance"); + return; + case "setTextSize": + yield* simctl(run, udid, ["ui", "content_size", IOS_TEXT_SIZES[input.value]], "text size"); + return; + case "setToggle": + if (input.setting === "increaseContrast") { + yield* simctl( + run, + udid, + ["ui", "increase_contrast", input.value ? "enabled" : "disabled"], + "increase contrast", + ); + return; + } + yield* axSettings(ready, udid, [ + "set", + IOS_TOGGLE_OPTIONS[input.setting]!, + input.value ? "on" : "off", + ]); + return; + case "setLiquidGlass": + yield* axSettings(ready, udid, ["set", "liquid-glass", input.value]); + return; + case "setColorFilter": + yield* axSettings(ready, udid, ["set", "color-filter", input.value]); + return; + case "setLocation": + yield* simctl( + run, + udid, + ["location", "set", `${input.latitude},${input.longitude}`], + "location", + ); + return; + case "clearLocation": + yield* simctl(run, udid, ["location", "clear"], "location"); + return; + case "setPermission": { + if (input.permission === "notifications") { + // simctl has no notification permission verb; serve-sim's CLI edits + // the BulletinBoard plist for it. + yield* serveSimPermissions(ready, udid, input); + return; + } + const service = + input.permission === "location" ? "location" : IOS_TCC_SERVICES[input.permission]; + if (!service) + return yield* new DeviceActionUnavailableError({ + operation: "permission", + platform: "ios", + reason: "unsupported", + }); + yield* simctl(run, udid, ["privacy", input.decision, service, input.appId], "permission"); + return; + } + case "openUrl": + yield* simctl(run, udid, ["openurl", input.url], "open url"); + return; + case "launchApp": + yield* simctl(run, udid, ["launch", input.appId], "launch"); + return; + case "terminateApp": + yield* simctl(run, udid, ["terminate", input.appId], "terminate"); + return; + case "sendPush": { + const payload = + typeof input.payload === "string" ? { aps: { alert: input.payload } } : input.payload; + const encoded = yield* encodePushPayload(payload).pipe( + Effect.mapError( + (cause) => + new DeviceOperationError({ operation: "push", reason: "invalid_payload", cause }), + ), + ); + yield* run("xcrun", ["simctl", "push", udid, input.appId, "-"], { stdin: encoded }).pipe( + Effect.flatMap(ok("push")), + ); + return; + } + case "shake": + case "setOrientation": + return yield* new DeviceActionUnavailableError({ + operation: input.type, + platform: "ios", + reason: "unsupported", + }); + } +}); + +const serveSimPermissions = ( + ready: DeviceHostReady, + udid: string, + input: Extract, +) => + Effect.gen(function* () { + const cli = ready.helpers.serveSimCli; + if (!cli) + return yield* new DeviceActionUnavailableError({ + operation: "permission", + platform: "ios", + reason: "helper_missing", + }); + yield* ready + .run(ready.nodePath, [ + cli, + "permissions", + input.decision, + input.permission, + input.appId, + "-d", + udid, + ]) + .pipe(Effect.flatMap(ok("permission"))); + }); + +const adb = (run: Runner, serial: string, args: ReadonlyArray, operation: string) => + run("adb", ["-s", serial, ...args]).pipe(Effect.flatMap(ok(operation))); + +const runAndroid = Effect.fn("DeviceActions.runAndroid")(function* ( + run: Runner, + input: DeviceActionInput, +) { + const serial = input.deviceId; + const shell = (args: ReadonlyArray, operation: string) => + adb(run, serial, ["shell", ...args], operation); + switch (input.type) { + case "setAppearance": + yield* shell(["cmd", "uimode", "night", input.value === "dark" ? "yes" : "no"], "appearance"); + return; + case "setTextSize": + yield* shell( + ["settings", "put", "system", "font_scale", ANDROID_TEXT_SIZES[input.value]], + "text size", + ); + return; + case "setToggle": + if (input.setting === "networkEnabled") { + const state = input.value ? "enable" : "disable"; + yield* shell(["svc", "wifi", state], "network"); + yield* shell(["svc", "data", state], "network"); + return; + } + if (input.setting === "reduceMotion") { + const scale = input.value ? "0" : "1"; + for (const key of [ + "animator_duration_scale", + "transition_animation_scale", + "window_animation_scale", + ]) { + yield* shell(["settings", "put", "global", key, scale], "reduce motion"); + } + return; + } + return yield* new DeviceActionUnavailableError({ + operation: input.type, + platform: "android", + reason: "unsupported", + }); + case "setOrientation": { + // `user-rotation lock` only rotates window content on recent images; + // the display the encoder captures stays put. Tilting the emulator's + // accelerometer rotates it for real, so that is used whenever the + // target is an emulator. Physical devices get the lock. + if (serial.startsWith("emulator-")) { + yield* shell(["settings", "put", "system", "accelerometer_rotation", "1"], "orientation"); + yield* shell(["cmd", "window", "user-rotation", "free"], "orientation"); + yield* adb( + run, + serial, + ["emu", "sensor", "set", "acceleration", ANDROID_GRAVITY[input.value]], + "orientation", + ); + return; + } + yield* shell( + ["cmd", "window", "user-rotation", "lock", ANDROID_ROTATION[input.value]], + "orientation", + ); + return; + } + case "setLocation": + yield* adb( + run, + serial, + ["emu", "geo", "fix", String(input.longitude), String(input.latitude)], + "location", + ); + return; + case "clearLocation": + // The emulator has no "clear"; leaving the fix in place is the closest + // behavior, so this is a no-op that still refreshes the reading. + return; + case "setPermission": { + const permissions = ANDROID_PERMISSIONS[input.permission]; + if (!permissions) { + return yield* new DeviceActionUnavailableError({ + operation: "permission", + platform: "android", + reason: "unsupported", + }); + } + const verb = input.decision === "grant" ? "grant" : "revoke"; + for (const permission of permissions) { + // Not every app declares every permission in a group; ignore those. + yield* shell(["pm", verb, input.appId, permission], "permission").pipe(Effect.ignore); + } + return; + } + case "openUrl": + yield* shell( + ["am", "start", "-a", "android.intent.action.VIEW", "-d", input.url], + "open url", + ); + return; + case "launchApp": + yield* shell( + ["monkey", "-p", input.appId, "-c", "android.intent.category.LAUNCHER", "1"], + "launch", + ); + return; + case "terminateApp": + yield* shell(["am", "force-stop", input.appId], "terminate"); + return; + case "setLiquidGlass": + case "setColorFilter": + case "shake": + case "sendPush": + return yield* new DeviceActionUnavailableError({ + operation: input.type, + platform: "android", + reason: "unsupported", + }); + } +}); + +/** Read the current settings and foreground app. Errors degrade to unknowns. */ +export const readDeviceDetail = Effect.fn("DeviceActions.readDetail")(function* ( + ready: DeviceHostReady, + platform: DevicePlatform, + deviceId: string, +): Effect.fn.Return<{ settings: DeviceSettings; foregroundApp: DeviceForegroundApp | null }> { + return platform === "ios" + ? yield* readIos(ready, deviceId) + : yield* readAndroid(ready.run, deviceId); +}); + +const quiet = (effect: Effect.Effect) => + effect.pipe(Effect.orElseSucceed((): A | undefined => undefined)); + +const readIos = Effect.fn("DeviceActions.readIos")(function* ( + ready: DeviceHostReady, + udid: string, +) { + const { run } = ready; + const uiValue = (option: string) => + quiet( + simctl(run, udid, ["ui", option], option).pipe(Effect.map((out) => out.trim().toLowerCase())), + ); + const [appearance, contentSize, contrast, axStatus] = yield* Effect.all( + [ + uiValue("appearance"), + uiValue("content_size"), + uiValue("increase_contrast"), + quiet(axSettings(ready, udid, ["status"]).pipe(Effect.flatMap(decodeAxStatus))), + ], + { concurrency: 4 }, + ); + const onOff = (value: string | undefined) => + value === "on" ? true : value === "off" ? false : undefined; + const settings: DeviceSettings = { + ...(appearance === "light" || appearance === "dark" ? { appearance } : {}), + ...(contentSize ? { textSize: textSizeFromIos(contentSize) } : {}), + ...(contrast ? { increaseContrast: contrast === "enabled" } : {}), + ...(axStatus + ? { + ...(onOff(axStatus["reduce-motion"]) === undefined + ? {} + : { reduceMotion: onOff(axStatus["reduce-motion"]) }), + ...(onOff(axStatus["reduce-transparency"]) === undefined + ? {} + : { reduceTransparency: onOff(axStatus["reduce-transparency"]) }), + ...(onOff(axStatus["show-borders"]) === undefined + ? {} + : { showBorders: onOff(axStatus["show-borders"]) }), + ...(onOff(axStatus.voiceover) === undefined + ? {} + : { voiceOver: onOff(axStatus.voiceover) }), + ...(axStatus["liquid-glass"] === "clear" || axStatus["liquid-glass"] === "tinted" + ? { liquidGlass: axStatus["liquid-glass"] } + : {}), + ...(isColorFilter(axStatus["color-filter"]) + ? { colorFilter: axStatus["color-filter"] } + : {}), + } + : {}), + }; + return { settings, foregroundApp: null }; +}); + +const isColorFilter = (value: unknown): value is DeviceSettings["colorFilter"] & string => + value === "none" || + value === "grayscale" || + value === "red-green" || + value === "green-red" || + value === "blue-yellow"; + +const readAndroid = Effect.fn("DeviceActions.readAndroid")(function* (run: Runner, serial: string) { + const shell = (args: ReadonlyArray) => + quiet( + adb(run, serial, ["shell", ...args], args[0] ?? "shell").pipe(Effect.map((s) => s.trim())), + ); + const [night, fontScale, animator, wifi, focus] = yield* Effect.all( + [ + shell(["cmd", "uimode", "night"]), + shell(["settings", "get", "system", "font_scale"]), + shell(["settings", "get", "global", "animator_duration_scale"]), + shell(["settings", "get", "global", "wifi_on"]), + // `dumpsys window windows` stopped printing the focus on API 36; the + // unfiltered dump still does. + shell(["dumpsys", "window"]), + ], + { concurrency: 5 }, + ); + const scale = fontScale && fontScale !== "null" ? Number(fontScale) : Number.NaN; + const focused = focus?.match(/m(?:CurrentFocus|FocusedApp)=\w+\{[^ ]+ u\d+ ([^/ ]+)\//); + const settings: DeviceSettings = { + ...(night?.includes("yes") + ? { appearance: "dark" } + : night?.includes("no") + ? { appearance: "light" } + : {}), + ...(Number.isFinite(scale) ? { textSize: textSizeFromAndroid(scale) } : {}), + ...(animator !== undefined && animator !== "null" + ? { reduceMotion: Number(animator) === 0 } + : {}), + ...(wifi === "1" || wifi === "0" ? { networkEnabled: wifi === "1" } : {}), + }; + return { + settings, + foregroundApp: focused ? { id: focused[1]! } : null, + }; +}); diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts new file mode 100644 index 000000000000..34ff2769362e --- /dev/null +++ b/apps/server/src/device/DeviceHost.ts @@ -0,0 +1,103 @@ +/** + * A device host is a machine with simulators or emulators on it. The service + * layer only ever talks to this interface, so a future SSH or cloud host slots + * in beside `LocalDeviceHost` without touching discovery, the proxy, or the + * MCP tools. + * + * Every ready host presents a loopback origin where expo-device-hub answers. + * Hosts add an agent-device daemon endpoint only after agent access is granted. + * For the local host both run on this machine; a remote host would forward + * them here. + */ +import type { + DeviceHostId, + DeviceHostSummary, + DevicePlatform, + DevicePlatformAvailability, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export class DeviceHostError extends Schema.TaggedError()("DeviceHostError", { + hostId: Schema.String, + step: Schema.String, + cause: Schema.Defect(), +}) { + override get message(): string { + return `Device host ${this.hostId} failed while ${this.step}.`; + } +} + +export class DeviceHostTimeoutError extends Schema.TaggedError()( + "DeviceHostTimeoutError", + { hostId: Schema.String, timeoutMs: Schema.Number }, +) { + override get message(): string { + return `Device host ${this.hostId} did not start agent tools within ${this.timeoutMs} ms.`; + } +} + +export interface DeviceHubEndpoint { + /** Loopback origin of expo-device-hub, e.g. `http://127.0.0.1:3400`. */ + readonly origin: string; +} + +export interface AgentDeviceEndpoint { + readonly baseUrl: string; + readonly token: string; + /** Host-local path of the agent-device entry script. The provider uses a separate local CLI install. */ + readonly entryPath: string; +} + +export interface DeviceHostReady { + readonly nodePath: string; + readonly hub: DeviceHubEndpoint; + /** + * Runs a host command (`xcrun`, `adb`, or a helper bundled with the hub) + * where the devices live. On the local host this is a plain spawn; a + * remote host would run it over its transport. + */ + readonly run: ( + command: string, + args: ReadonlyArray, + options?: { readonly timeoutMs?: number; readonly stdin?: string }, + ) => Effect.Effect<{ readonly stdout: string; readonly stderr: string; readonly code: number }>; + /** Absolute paths of helper binaries vendored with the hub, when present. */ + readonly helpers: { + readonly serveSimAxSettings: string | null; + readonly serveSimCli: string | null; + }; +} + +export interface DeviceHostAgentReady extends DeviceHostReady { + readonly agentDevice: AgentDeviceEndpoint; +} + +export class DeviceHost extends Context.Service< + DeviceHost, + { + readonly id: DeviceHostId; + readonly summary: Effect.Effect; + readonly platformAvailability: ( + platform: DevicePlatform, + ) => Effect.Effect; + /** + * Installs tools on first use and starts the helper processes. Idempotent: + * concurrent callers share one start, and a ready host returns immediately. + */ + readonly ensureReady: ( + onPhase: (phase: "installing" | "starting") => Effect.Effect, + ) => Effect.Effect; + /** Installs and starts agent-device after the user grants agent access. */ + readonly ensureAgentReady: ( + onPhase: (phase: "installing" | "starting") => Effect.Effect, + ) => Effect.Effect; + /** Current endpoints when already running, without starting anything. */ + readonly current: Effect.Effect; + /** Stops only agent-device. Manual viewing through the hub stays available. */ + readonly stopAgent: Effect.Effect; + /** Stops helpers. Devices themselves keep running; the user owns those. */ + readonly stop: Effect.Effect; + } +>()("t3/device/DeviceHost") {} diff --git a/apps/server/src/device/DeviceHubProxy.test.ts b/apps/server/src/device/DeviceHubProxy.test.ts new file mode 100644 index 000000000000..0f039274e207 --- /dev/null +++ b/apps/server/src/device/DeviceHubProxy.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { + AuthOrchestrationReadScope, + AuthOrchestrationOperateScope, + AuthSessionId, + LOCAL_DEVICE_HOST_ID, + type AuthEnvironmentScope, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientResponse, HttpRouter } from "effect/unstable/http"; +import { + EnvironmentAuth, + ServerAuthMissingCredentialError, + ServerAuthSessionCredentialValidationError, + type ServerAuthCredentialError, + type ServerAuthInternalError, +} from "../auth/EnvironmentAuth.ts"; +import { DeviceService } from "./DeviceService.ts"; +import { deviceHubProxyRouteLayer } from "./DeviceHubProxy.ts"; + +const disposers: Array<() => Promise> = []; +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose(); +}); + +const fixture = ( + scopes: ReadonlyArray, + fail = false, + authError?: ServerAuthCredentialError | ServerAuthInternalError, +) => { + let finalized = 0; + const requests: string[] = []; + const client = HttpClient.make((request, _url, signal) => + Effect.gen(function* () { + requests.push(request.url); + signal.addEventListener("abort", () => { + finalized++; + }); + if (fail) return yield* Effect.die(new Error("upstream failed")); + return HttpClientResponse.fromWeb(request, new Response("frame")); + }), + ); + const { handler, dispose } = HttpRouter.toWebHandler( + deviceHubProxyRouteLayer.pipe( + Layer.provideMerge( + Layer.succeed(EnvironmentAuth, { + authenticateWebSocketUpgrade: () => + authError + ? Effect.fail(authError) + : Effect.succeed({ + sessionId: AuthSessionId.make("test"), + subject: "test", + method: "bearer-access-token", + scopes, + }), + } as unknown as EnvironmentAuth["Service"]), + ), + Layer.provideMerge( + Layer.succeed(DeviceService, { + currentReadiness: () => + Effect.succeed({ hostId: LOCAL_DEVICE_HOST_ID, hub: { origin: "http://hub.test" } }), + } as DeviceService["Service"]), + ), + Layer.provideMerge(Layer.succeed(HttpClient.HttpClient, client)), + ), + { disableLogger: true }, + ); + disposers.push(dispose); + return { handler, requests, finalized: () => finalized }; +}; + +describe("device hub proxy", () => { + it("releases the upstream response after forwarding its body and strips tickets", async () => { + const { handler, requests, finalized } = fixture([AuthOrchestrationReadScope]); + const response = await handler( + new Request("http://t3.test/api/device-hub/api/devices?wsTicket=secret"), + ); + expect(response.status).toBe(200); + expect(await response.text()).toBe("frame"); + expect(requests).toEqual(["http://hub.test/api/devices"]); + expect(finalized()).toBe(1); + }); + + it("releases resources when upstream acquisition fails", async () => { + const { handler, finalized } = fixture([AuthOrchestrationReadScope], true); + const response = await handler(new Request("http://t3.test/api/device-hub/api/devices")); + expect(response.status).toBe(500); + expect(finalized()).toBe(1); + }); + + it.each(["/vendor/serve-sim/helper/ws", "/vendor/serve-emu/ws"])( + "rejects input socket %s for a read-only session", + async (path) => { + const { handler, requests } = fixture([AuthOrchestrationReadScope]); + const response = await handler( + new Request(`http://t3.test/api/device-hub${path}`, { headers: { upgrade: "websocket" } }), + ); + expect(response.status).toBe(403); + expect(requests).toEqual([]); + }, + ); + + it("requires operate scope for stream tuning", async () => { + const readOnly = fixture([AuthOrchestrationReadScope]); + const path = "http://t3.test/api/device-hub/vendor/serve-emu/api/stream-settings"; + expect((await readOnly.handler(new Request(path, { method: "POST" }))).status).toBe(403); + const operator = fixture([AuthOrchestrationOperateScope]); + const response = await operator.handler(new Request(path, { method: "POST" })); + expect(response.status).toBe(200); + await response.text(); + }); + + it("never forwards the vendor shell endpoint", async () => { + const { handler, requests } = fixture([AuthOrchestrationOperateScope]); + expect( + ( + await handler( + new Request("http://t3.test/api/device-hub/vendor/serve-sim/exec", { method: "POST" }), + ) + ).status, + ).toBe(404); + expect(requests).toEqual([]); + }); +}); + +it.each([ + [new ServerAuthMissingCredentialError({}), 401], + [ + new ServerAuthSessionCredentialValidationError({ + cause: new Error("private credential diagnostic"), + }), + 500, + ], +] as const)("translates authentication failure to HTTP %s", async (error, status) => { + const { handler, requests } = fixture([], false, error); + const response = await handler(new Request("http://t3.test/api/device-hub/api/devices")); + expect(response.status).toBe(status); + expect(await response.text()).not.toContain("private credential diagnostic"); + expect(requests).toEqual([]); +}); diff --git a/apps/server/src/device/DeviceHubProxy.ts b/apps/server/src/device/DeviceHubProxy.ts new file mode 100644 index 000000000000..d2cb618fc488 --- /dev/null +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -0,0 +1,225 @@ +/** + * Same-origin proxy in front of expo-device-hub. + * + * The hub binds loopback and is never reachable directly: serve-sim exposes a + * shell-exec route and serve-emu's action routes are unauthenticated, so the + * only way to a device stream is through this route, which requires an + * environment session with read scope (operate scope for input and tuning). Reusing the T3 + * origin is also what makes remote connections work unchanged — Tailscale and + * T3 Connect already carry `/api/*` and WebSocket upgrades for the app itself. + * + * Only the routes the Device panel needs are forwarded. Anything under the + * hub's dashboard, exec, or WebRTC surface is rejected here. + */ +import { + AuthOrchestrationReadScope, + AuthOrchestrationOperateScope, + type AuthEnvironmentScope, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { + HttpClient, + HttpClientRequest, + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http"; +import * as Socket from "effect/unstable/socket/Socket"; +import * as NodeSocket from "@effect/platform-node/NodeSocket"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import { + failEnvironmentAuthInvalid, + failEnvironmentInternal, + failEnvironmentScopeRequired, +} from "../auth/http.ts"; +import * as DeviceService from "./DeviceService.ts"; + +const ALLOWED_PATHS: ReadonlyArray = [ + /^\/api\/devices$/, + /^\/vendor\/serve-sim\/api$/, + /^\/vendor\/serve-sim\/api\/screenshot$/, + /^\/vendor\/serve-sim\/api\/event-log(\/events)?$/, + /^\/vendor\/serve-sim\/helper\/[^/]+\/(stream\.mjpeg|stream\.avcc|config|health|ax|foreground)$/, + /^\/vendor\/serve-sim\/appstate$/, + /^\/vendor\/serve-emu\/api\/(devices|screenshot|stream-mode|stream-settings|accessibility)$/, + /^\/vendor\/serve-emu\/health$/, +]; + +/** Read paths are GET-only; only these accept other methods (screenshot captures, stream tuning). */ +const MUTABLE_PATHS: ReadonlyArray = [ + /^\/vendor\/serve-sim\/api\/screenshot$/, + /^\/vendor\/serve-emu\/api\/(screenshot|stream-mode|stream-settings)$/, +]; + +const ALLOWED_WS_PATHS: ReadonlyArray = [ + /^\/api\/devices\/ws$/, + /^\/vendor\/serve-sim\/helper\/ws$/, + /^\/vendor\/serve-emu\/ws$/, +]; + +/** Hop-by-hop and origin headers that must not cross the proxy. */ +const DROPPED_REQUEST_HEADERS = new Set([ + "host", + "connection", + "upgrade", + "sec-websocket-key", + "sec-websocket-version", + "sec-websocket-extensions", + "sec-websocket-protocol", + "cookie", + "authorization", + "dpop", + "content-length", + "accept-encoding", +]); + +const isWebSocketUpgrade = (request: HttpServerRequest.HttpServerRequest) => + request.headers.upgrade?.toLowerCase() === "websocket"; + +/** + * `` and WebSocket cannot set headers, so every proxied request + * authenticates the way the `/ws` upgrade does: a cookie for browser + * sessions, or a short-lived `wsTicket` minted over authenticated HTTP for + * bearer and DPoP clients. The upgrade authenticator already implements that + * fallback order, so it is used for plain requests as well. + */ +const authenticate = (requiredScope: AuthEnvironmentScope) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( + Effect.catch((error) => + Effect.gen(function* () { + if (EnvironmentAuth.isServerAuthCredentialError(error)) { + return yield* failEnvironmentAuthInvalid( + EnvironmentAuth.serverAuthCredentialReason(error), + EnvironmentAuth.serverAuthDpopFailureReason(error), + ); + } + return yield* failEnvironmentInternal("internal_error", error); + }), + ), + ); + if (!session.scopes.includes(requiredScope)) { + return yield* failEnvironmentScopeRequired(requiredScope); + } + }); + +const forwardHeaders = (request: HttpServerRequest.HttpServerRequest, origin: string) => { + const headers: Record = {}; + for (const [name, value] of Object.entries(request.headers)) { + if (DROPPED_REQUEST_HEADERS.has(name) || value === undefined) continue; + headers[name] = value; + } + // serve-emu refuses mutations whose Origin differs from the request origin. + if (request.headers.origin !== undefined) headers.origin = origin; + return headers; +}; + +/** + * Pipe a client WebSocket to the hub's with no framing changes. Frames are + * opaque: H.264 access units one way, input packets the other. + */ +const proxyWebSocket = Effect.fn("DeviceHubProxy.proxyWebSocket")(function* ( + request: HttpServerRequest.HttpServerRequest, + upstreamUrl: string, +) { + const client = yield* request.upgrade; + const upstream = yield* Socket.makeWebSocket(upstreamUrl, { + openTimeout: "10 seconds", + }).pipe(Effect.provide(NodeSocket.layerWebSocketConstructor)); + yield* Effect.scoped( + Effect.gen(function* () { + const writeToClient = yield* client.writer; + const writeToUpstream = yield* upstream.writer; + const downstream = upstream.runRaw((data) => writeToClient(data)); + const upstreamPump = client.runRaw((data) => writeToUpstream(data)); + // Whichever side closes first ends the other via scope teardown. + yield* Effect.raceFirst(downstream, upstreamPump); + }), + ).pipe(Effect.catchCause(() => Effect.void)); + return HttpServerResponse.empty(); +}); + +const proxyHttp = Effect.fn("DeviceHubProxy.proxyHttp")(function* ( + request: HttpServerRequest.HttpServerRequest, + upstreamUrl: string, + hubOrigin: string, +) { + const httpClient = HttpClient.withScope(yield* HttpClient.HttpClient); + const method = request.method; + const upstreamRequest = HttpClientRequest.make(method)(upstreamUrl).pipe( + HttpClientRequest.setHeaders(forwardHeaders(request, hubOrigin)), + method === "GET" || method === "HEAD" + ? (self) => self + : HttpClientRequest.bodyStream(request.stream), + ); + const response = yield* httpClient.execute(upstreamRequest); + const headers: Record = {}; + for (const [name, value] of Object.entries(response.headers)) { + if (name === "content-encoding" || name === "transfer-encoding" || name === "connection") { + continue; + } + if (value !== undefined) headers[name] = value; + } + // Long-lived MJPEG and AVCC responses must not be buffered by compression. + headers["cache-control"] = "no-store, no-transform"; + return HttpServerResponse.stream(response.stream, { + status: response.status, + headers, + ...(headers["content-type"] ? { contentType: headers["content-type"] } : {}), + }); +}); + +const handler = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return HttpServerResponse.text("Bad Request", { status: 400 }); + } + const hubPath = url.value.pathname.slice(DeviceService.DEVICE_HUB_ROUTE_PREFIX.length) || "/"; + const upgrade = isWebSocketUpgrade(request); + const allowed = (upgrade ? ALLOWED_WS_PATHS : ALLOWED_PATHS).some((pattern) => + pattern.test(hubPath), + ); + if (!allowed) { + return HttpServerResponse.text("Not Found", { status: 404 }); + } + const readOnly = request.method === "GET" || request.method === "HEAD"; + if (!upgrade && !readOnly && !MUTABLE_PATHS.some((pattern) => pattern.test(hubPath))) { + return HttpServerResponse.text("Method Not Allowed", { status: 405 }); + } + const controlsDevice = + (upgrade && hubPath !== "/api/devices/ws") || + (!readOnly && /\/api\/stream-(mode|settings)$/.test(hubPath)); + yield* authenticate(controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope); + const devices = yield* DeviceService.DeviceService; + const ready = yield* devices.currentReadiness(url.value.searchParams.get("hostId") ?? undefined); + if (!ready) { + return HttpServerResponse.text("Device hub is not running", { status: 503 }); + } + // The hub runs in standalone mode at its origin root; the panel builds every + // stream and socket URL itself, so nothing depends on the hub knowing the + // T3 prefix. + // The ticket authenticates here and must not travel on to the hub. + const upstreamSearch = new URLSearchParams(url.value.search); + upstreamSearch.delete("wsTicket"); + upstreamSearch.delete("hostId"); + const search = upstreamSearch.size > 0 ? `?${upstreamSearch.toString()}` : ""; + const upstreamPath = `${hubPath}${search}`; + if (upgrade) { + return yield* proxyWebSocket( + request, + `${ready.hub.origin.replace(/^http/, "ws")}${upstreamPath}`, + ); + } + return yield* proxyHttp(request, `${ready.hub.origin}${upstreamPath}`, ready.hub.origin); +}); + +export const deviceHubProxyRouteLayer = HttpRouter.add( + "*", + `${DeviceService.DEVICE_HUB_ROUTE_PREFIX}/*`, + handler, +); diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts new file mode 100644 index 000000000000..7a40e1e53c72 --- /dev/null +++ b/apps/server/src/device/DeviceMultiHost.test.ts @@ -0,0 +1,126 @@ +import { expect, it } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { DeviceHostError, DeviceHost } from "./DeviceHost.ts"; +import { makeWithHosts } from "./DeviceService.ts"; + +it.effect("keeps hosts independent when serials collide and another host fails", () => + Effect.gen(function* () { + const host = (id: string, failed = false): DeviceHost["Service"] => { + const ready = { + nodePath: process.execPath, + hub: { origin: `http://${id}` }, + agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" }, + run: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), + helpers: { serveSimAxSettings: null, serveSimCli: null }, + }; + return { + id, + summary: Effect.succeed({ + id, + label: id, + kind: id === "b" ? "ssh" : "local", + hubInstalled: true, + agentDeviceInstalled: true, + platforms: id === "b" ? [] : [{ platform: "android", available: true }], + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + ensureReady: () => + failed + ? Effect.fail( + new DeviceHostError({ hostId: id, step: "connect", cause: new Error("offline") }), + ) + : Effect.succeed(ready), + ensureAgentReady: () => Effect.succeed(ready), + current: Effect.succeed(ready), + stopAgent: Effect.void, + stop: Effect.void, + }; + }; + const http = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + simulators: [], + emulators: [ + { + id: "emulator-5554", + name: "Pixel", + version: "36", + platform: "android", + booted: true, + physical: false, + }, + ], + }), + ), + ), + ); + const hosts = new Map(["a", "b", "offline"].map((id) => [id, host(id, id === "offline")])); + const writeStarted = yield* Deferred.make(); + const finishWrite = yield* Deferred.make(); + const order: string[] = []; + const service = yield* makeWithHosts(hosts, undefined, () => + Effect.gen(function* () { + order.push("write started"); + yield* Deferred.succeed(writeStarted, undefined); + yield* Deferred.await(finishWrite); + order.push("write finished"); + return "/host-config.json"; + }), + ).pipe(Effect.provideService(HttpClient.HttpClient, http)); + expect(yield* service.agentReadinessIfSupported("b")).not.toBeNull(); + const listed = yield* service.list; + expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]); + expect(listed.hostStatuses.offline?.status).toBe("failed"); + const threadId = ThreadId.make("thread"); + for (const hostId of ["a", "b"]) + yield* service.open({ threadId, hostId, deviceId: "emulator-5554", platform: "android" }); + yield* service.close({ threadId, hostId: "a", deviceId: "emulator-5554" }); + const state = yield* service.state; + expect(state.devices).toHaveLength(2); + expect(state.sessions.map((session) => session.hostId)).toEqual(["b"]); + expect(state.hostStatuses.a?.status).toBe("ready"); + expect(state.hostStatuses.offline?.status).toBe("failed"); + const targeting = yield* service + .agentTarget({ threadId, hostId: "b", deviceId: "emulator-5554" }) + .pipe(Effect.forkChild); + yield* Deferred.await(writeStarted); + const replacing = yield* service + .withLifecycleLock( + Effect.gen(function* () { + order.push("replace"); + hosts.set("b", host("b")); + yield* service.refreshHosts; + }), + ) + .pipe(Effect.forkChild); + yield* Deferred.succeed(finishWrite, undefined); + yield* Fiber.join(targeting); + yield* Fiber.join(replacing); + expect(order).toEqual(["write started", "write finished", "replace"]); + const replaced = yield* service.state; + expect(replaced.sessions).toEqual([]); + expect(replaced.devices.map((device) => device.hostId)).toEqual(["a"]); + expect(replaced.hostStatuses.b).toBeUndefined(); + yield* service.open({ threadId, hostId: "b", deviceId: "emulator-5554", platform: "android" }); + hosts.delete("b"); + yield* service.refreshHosts; + yield* service.setHostStatus("b", { status: "ready" }); + expect((yield* service.state).hostStatuses.b).toBeUndefined(); + expect((yield* service.state).sessions).toEqual([]); + yield* service.agentReadinessIfSupported("a"); + expect((yield* service.state).hostStatuses.a?.status).toBe("ready"); + yield* service.configure({ enabled: false }); + expect((yield* service.state).hostStatuses).toEqual({}); + }).pipe( + Effect.provide( + ServerSettingsService.layerTest({ enableDeviceSupport: true, enableAgentDeviceAccess: true }), + ), + ), +); diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts new file mode 100644 index 000000000000..46e159dbfc33 --- /dev/null +++ b/apps/server/src/device/DeviceService.test.ts @@ -0,0 +1,339 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + DEFAULT_SERVER_SETTINGS, + DeviceId, + LOCAL_DEVICE_HOST_ID, + ThreadId, + type DeviceServiceState, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { ServerSettingsService } from "../serverSettings.ts"; +import * as DeviceHost from "./DeviceHost.ts"; + +import { type DeviceService, makeWithHosts, stateStream } from "./DeviceService.ts"; + +const baseState: DeviceServiceState = { + hosts: [], + hostStatus: "idle", + hostStatuses: {}, + devices: [], + sessions: [], + onboardingCompleted: false, + agentAccessEnabled: false, + hubBasePath: "/api/device-hub", + revision: 0, +}; + +describe("DeviceService.stateStream", () => { + it.effect("emits the current snapshot and then every published change", () => + Effect.gen(function* () { + const pubsub = yield* PubSub.unbounded(); + const current = yield* Ref.make(baseState); + const service: Pick = { + state: Ref.get(current), + subscribe: PubSub.subscribe(pubsub), + }; + + const collected = yield* stateStream(service as DeviceService["Service"]).pipe( + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + for (const revision of [1, 2]) { + const next = { ...baseState, revision, hostStatus: "ready" as const }; + yield* Ref.set(current, next); + yield* PubSub.publish(pubsub, next); + } + const seen = yield* Fiber.join(collected); + expect(seen.map((state) => state.revision)).toEqual([0, 1, 2]); + }), + ); +}); + +const fixture = Effect.fn("fixture")(function* ( + onBoot: Effect.Effect = Effect.void, + bootError?: string, + failListAfterShutdown = false, +) { + const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); + const starts: string[] = []; + const agentStarts: string[] = []; + const agentStops: string[] = []; + const requests: string[] = []; + let booted = false; + let shutDown = false; + const ready: DeviceHost.DeviceHostReady = { + nodePath: process.execPath, + hub: { origin: "http://device.test" }, + helpers: { serveSimAxSettings: null, serveSimCli: null }, + run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }), + }; + const host: DeviceHost.DeviceHost["Service"] = { + id: LOCAL_DEVICE_HOST_ID, + summary: Effect.succeed({ + id: LOCAL_DEVICE_HOST_ID, + kind: "local", + label: "Test server", + platforms: [{ platform: "android", available: true }], + hubInstalled: true, + agentDeviceInstalled: false, + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + ensureReady: (onPhase) => + Effect.gen(function* () { + starts.push("start"); + yield* onPhase("starting"); + return ready; + }), + ensureAgentReady: (onPhase) => + Effect.gen(function* () { + agentStarts.push("start"); + yield* onPhase("starting"); + return { + ...ready, + agentDevice: { baseUrl: "http://agent.test", token: "test", entryPath: "/agent" }, + }; + }), + current: Effect.succeed(null), + stopAgent: Effect.sync(() => { + agentStops.push("stop"); + }), + stop: Effect.sync(() => { + starts.push("stop"); + }), + }; + const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe( + Effect.provideService(DeviceHost.DeviceHost, host), + Effect.provideService( + ServerSettingsService, + ServerSettingsService.of({ + start: Effect.void, + ready: Effect.void, + getSettings: Ref.get(settings), + updateSettings: (patch) => + Ref.updateAndGet(settings, (current) => ({ + ...current, + enableDeviceSupport: patch.enableDeviceSupport ?? current.enableDeviceSupport, + enableAgentDeviceAccess: + patch.enableAgentDeviceAccess ?? current.enableAgentDeviceAccess, + deviceOnboardingCompleted: + patch.deviceOnboardingCompleted ?? current.deviceOnboardingCompleted, + })), + streamChanges: Stream.empty, + subscribeChanges: Effect.succeed(Stream.empty), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + requests.push(request.url); + if (request.url.includes("/api/screenshot")) { + return HttpClientResponse.fromWeb( + request, + new Response(new Uint8Array([137, 80, 78, 71])), + ); + } + if (request.url.endsWith("/shutdown")) { + shutDown = true; + booted = false; + return HttpClientResponse.fromWeb(request, Response.json({ ok: true })); + } + if (shutDown && failListAfterShutdown) { + return HttpClientResponse.fromWeb( + request, + new Response("Discovery busy", { status: 503 }), + ); + } + if (request.url.endsWith("/boot")) { + yield* onBoot; + booted = true; + return HttpClientResponse.fromWeb( + request, + Response.json( + bootError ? { ok: false, error: bootError } : { ok: true, serial: "emulator-5554" }, + ), + ); + } + return HttpClientResponse.fromWeb( + request, + Response.json({ + simulators: [], + emulators: booted + ? [ + { + id: "emulator-5554", + name: "Pixel_API_35", + platform: "android", + version: "Android 15", + booted: true, + physical: false, + }, + ] + : [], + }), + ); + }), + ), + ), + ); + return { service, starts, agentStarts, agentStops, requests, settings }; +}); + +describe("device setup consent", () => { + it.effect("listing and provider startup do not start helpers before consent", () => + Effect.gen(function* () { + const { service, starts, requests } = yield* fixture(); + expect((yield* service.list).hostStatus).toBe("disabled"); + expect(yield* service.readinessIfSupported()).toBeNull(); + const readiness = yield* service.readiness().pipe(Effect.result); + expect(readiness._tag).toBe("Failure"); + expect(starts).toEqual([]); + expect(requests).toEqual([]); + }).pipe(Effect.scoped), + ); + + it.effect( + "explicit setup discovers never-booted AVDs; disabling stops helpers and blocks agents", + () => + Effect.gen(function* () { + const { service, starts, settings } = yield* fixture(); + const state = yield* service.configure({ enabled: true }); + expect((yield* Ref.get(settings)).enableDeviceSupport).toBe(true); + expect(state.devices.map((device) => [device.id, device.booted])).toEqual([ + ["Pixel_API_35", false], + ]); + expect(starts).toEqual(["start"]); + const disabled = yield* service.configure({ enabled: false }); + expect(disabled.hostStatus).toBe("disabled"); + expect(disabled.devices).toEqual([]); + expect((yield* Ref.get(settings)).enableDeviceSupport).toBe(false); + expect(yield* service.readinessIfSupported()).toBeNull(); + expect(starts).toEqual(["start", "stop"]); + }).pipe(Effect.scoped), + ); + + it.effect("boots a stopped Android AVD and uses its emulator serial without duplicating it", () => + Effect.gen(function* () { + const { service, requests } = yield* fixture(); + yield* service.configure({ enabled: true }); + const session = yield* service.open({ + threadId: ThreadId.make("thread-1"), + deviceId: "Pixel_API_35", + platform: "android", + }); + expect(session.deviceId).toBe("emulator-5554"); + expect(requests.filter((url) => url.endsWith("/boot"))).toHaveLength(1); + const state = yield* service.state; + expect(state.devices.map((device) => device.id)).toEqual(["emulator-5554"]); + expect(state.bootingDevices).toEqual([]); + }).pipe(Effect.scoped), + ); + + it.effect("installs agent support only after the separate agent permission", () => + Effect.gen(function* () { + const { service, agentStarts, agentStops, settings } = yield* fixture(); + yield* service.configure({ enabled: true }); + expect(agentStarts).toEqual([]); + expect(yield* service.agentReadinessIfSupported()).toBeNull(); + + yield* service.configure({ agentAccessEnabled: true }); + expect(agentStarts).toEqual(["start"]); + expect((yield* Ref.get(settings)).enableAgentDeviceAccess).toBe(true); + expect((yield* service.state).agentAccessEnabled).toBe(true); + + yield* service.configure({ agentAccessEnabled: false, onboardingCompleted: true }); + expect(agentStops).toEqual(["stop"]); + expect((yield* service.state).onboardingCompleted).toBe(true); + expect((yield* Ref.get(settings)).deviceOnboardingCompleted).toBe(true); + }).pipe(Effect.scoped), + ); +}); + +it.effect("publishes boot progress and does not restore sessions after support is disabled", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const finish = yield* Deferred.make(); + const { service } = yield* fixture( + Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(finish))), + ); + yield* service.configure({ enabled: true }); + const opening = yield* service + .open({ threadId: ThreadId.make("thread-1"), deviceId: "Pixel_API_35", platform: "android" }) + .pipe(Effect.result, Effect.forkChild); + yield* Deferred.await(started); + expect((yield* service.state).bootingDevices?.map((device) => device.name)).toEqual([ + "Pixel_API_35", + ]); + yield* service.configure({ enabled: false }); + yield* Deferred.succeed(finish, undefined); + expect((yield* Fiber.join(opening))._tag).toBe("Failure"); + const state = yield* service.state; + expect(state.hostStatus).toBe("disabled"); + expect(state.devices).toEqual([]); + expect(state.sessions).toEqual([]); + expect(state.bootingDevices).toEqual([]); + }).pipe(Effect.scoped), +); + +describe("device discovery after server restart", () => { + it.effect("captures an explicit device before any client lists devices", () => + Effect.gen(function* () { + const { service, settings, requests } = yield* fixture(); + yield* Ref.update(settings, (current) => ({ ...current, enableDeviceSupport: true })); + expect((yield* service.state).devices).toEqual([]); + const capture = yield* service.screenshot({ deviceId: DeviceId.make("Pixel_API_35") }); + expect(capture.device.id).toBe("Pixel_API_35"); + expect(Array.from(capture.png)).toEqual([137, 80, 78, 71]); + expect(requests.some((url) => url.endsWith("/api/devices"))).toBe(true); + }).pipe(Effect.scoped), + ); +}); + +for (const [diagnostic, reason, message] of [ + ["Insufficient disk space at /private/user/path", "disk_space", "not enough free disk space"], + ["Timed out spawning /private/user/command", "timeout", "did not become ready in time"], + ["Unexpected failure: secret-token", "launch_failed", "could not start"], +] as const) { + it.effect(`normalizes boot failure: ${reason}`, () => + Effect.gen(function* () { + const { service } = yield* fixture(Effect.void, diagnostic); + yield* service.configure({ enabled: true }); + const error = yield* service + .open({ + threadId: ThreadId.make("boot-failure"), + deviceId: "Pixel_API_35", + platform: "android", + }) + .pipe(Effect.flip); + expect(error._tag).toBe("DeviceBootError"); + expect(error.message).toContain(message); + expect(error.message).not.toContain(diagnostic); + expect((yield* service.state).bootingDevices).toEqual([]); + }).pipe(Effect.scoped), + ); +} + +it.effect("keeps shutdown successful when subsequent discovery fails", () => + Effect.gen(function* () { + const { service } = yield* fixture(Effect.void, undefined, true); + yield* service.configure({ enabled: true }); + const threadId = ThreadId.make("shutdown-refresh"); + const session = yield* service.open({ + threadId, + deviceId: "Pixel_API_35", + platform: "android", + }); + yield* service.close({ threadId, deviceId: session.deviceId, shutdown: true }); + const state = yield* service.state; + expect(state.sessions).toEqual([]); + expect(state.devices.find((device) => device.id === session.deviceId)?.booted).toBe(false); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts new file mode 100644 index 000000000000..a60cda76c648 --- /dev/null +++ b/apps/server/src/device/DeviceService.ts @@ -0,0 +1,1025 @@ +/** + * Device discovery, per-thread device sessions, and the state stream clients + * render the Device panel from. + * + * Discovery and boot go through expo-device-hub's JSON API rather than + * shelling out to simctl and adb here: the hub already normalizes both + * platforms into one device shape and is the process that has to know a + * device is booted before it can stream it. Sessions are the server's own + * bookkeeping — which thread is looking at which device — so the panel and + * the `device_*` tools agree, and so a `device_open` from an agent surfaces in + * every connected client the way `preview_open` does. + */ +import { + type DeviceActionInput, + type DeviceCloseInput, + type DeviceConfigureInput, + type DeviceDetail, + type DeviceDetailInput, + type DeviceError, + type DeviceHostId, + type DeviceId, + DeviceBootError, + DeviceHostUnavailableError, + DeviceNotFoundError, + DeviceOperationError, + type DeviceOpenInput, + type DevicePlatform, + DevicePlatformUnavailableError, + type DeviceServiceState, + type DeviceSession, + type DeviceShutdownInput, + type DeviceSummary, + type SshDeviceHostConfig, + type DeviceHostSummary, + LOCAL_DEVICE_HOST_ID, + type ThreadId, +} from "@t3tools/contracts"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { ensureAgentDevice } from "./DeviceToolchain.ts"; +import * as ServerConfig from "../config.ts"; +import { + agentDeviceConfigPath, + agentDeviceSession, + writeAgentDeviceConfig, +} from "./AgentDeviceTarget.ts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import * as ServerSettings from "../serverSettings.ts"; + +import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as DeviceHost from "./DeviceHost.ts"; +import * as SshDeviceHost from "./SshDeviceHost.ts"; +import * as Exit from "effect/Exit"; +import * as LocalDeviceHost from "./LocalDeviceHost.ts"; + +/** Origin-relative prefix the hub is proxied under. See DeviceHubProxy. */ +export const DEVICE_HUB_ROUTE_PREFIX = "/api/device-hub"; + +const BOOT_TIMEOUT = Duration.minutes(3); +const SCREENSHOT_TIMEOUT = Duration.seconds(20); + +const HubDevice = Schema.Struct({ + id: Schema.String, + name: Schema.String, + version: Schema.String, + platform: Schema.Literals(["ios", "android"]), + booted: Schema.Boolean, + physical: Schema.Boolean, +}); +const HubDeviceList = Schema.Struct({ + simulators: Schema.Array(HubDevice), + emulators: Schema.Array(HubDevice), + errors: Schema.optional(Schema.Array(Schema.Struct({ message: Schema.String }))), +}); +const HubActionResult = Schema.Struct({ + ok: Schema.Boolean, + id: Schema.optional(Schema.String), + serial: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), +}); + +export interface DeviceScreenshot { + readonly device: DeviceSummary; + readonly png: Uint8Array; +} + +export interface DeviceReadiness extends DeviceHost.DeviceHostReady { + readonly hostId: DeviceHostId; +} + +export interface DeviceAgentReadiness extends DeviceReadiness { + readonly agentDevice: DeviceHost.AgentDeviceEndpoint; +} + +export class DeviceService extends Context.Service< + DeviceService, + { + readonly agentCli: Effect.Effect; + readonly testHost: ( + config: SshDeviceHostConfig, + ) => Effect.Effect; + readonly agentTarget: (input: { + threadId: ThreadId; + hostId: DeviceHostId; + deviceId: DeviceId; + }) => Effect.Effect, DeviceError>; + readonly state: Effect.Effect; + readonly subscribe: Effect.Effect, never, Scope.Scope>; + readonly configure: ( + input: DeviceConfigureInput, + ) => Effect.Effect; + /** Refreshes devices only after device support has been enabled. */ + readonly list: Effect.Effect; + readonly open: (input: DeviceOpenInput) => Effect.Effect; + readonly close: (input: DeviceCloseInput) => Effect.Effect; + readonly shutdown: (input: DeviceShutdownInput) => Effect.Effect; + /** Current settings and foreground app for one device. */ + readonly detail: (input: DeviceDetailInput) => Effect.Effect; + /** Runs one action, then returns the refreshed detail. */ + readonly action: (input: DeviceActionInput) => Effect.Effect; + readonly screenshot: (input: { + readonly hostId?: DeviceHostId | undefined; + readonly deviceId: DeviceId; + }) => Effect.Effect; + /** Host endpoints for the proxy and the provider environment. */ + readonly readiness: (hostId?: DeviceHostId) => Effect.Effect; + /** + * `readiness` only when the host can run at least one platform; a machine + * with no simulator toolchain never installs or starts anything. + */ + readonly readinessIfSupported: ( + hostId?: DeviceHostId, + ) => Effect.Effect; + readonly agentReadinessIfSupported: ( + hostId?: DeviceHostId, + ) => Effect.Effect; + readonly currentReadiness: (hostId?: DeviceHostId) => Effect.Effect; + readonly sessionsForThread: (threadId: ThreadId) => Effect.Effect>; + } +>()("t3/device/DeviceService") {} + +interface ServiceState { + readonly state: DeviceServiceState; +} + +const vendorPrefix = (platform: DevicePlatform) => + platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"; + +export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ( + hosts: ReadonlyMap, + testHost: DeviceService["Service"]["testHost"] = (host) => + Effect.fail( + new DeviceHostUnavailableError({ + hostId: host.id, + reason: "SSH probing is unavailable in this device service.", + }), + ), + configureAgent: ( + hostId: DeviceHostId, + ready: DeviceHost.DeviceHostAgentReady, + ) => Effect.Effect = (hostId) => + Effect.fail( + new DeviceHostUnavailableError({ + hostId, + reason: "Agent configuration is unavailable in this device service.", + }), + ), +) { + const settings = yield* ServerSettings.ServerSettingsService; + const lifecycleLock = yield* Semaphore.make(1); + const readDeviceSettings = settings.getSettings.pipe( + Effect.map((value) => ({ + enabled: value.enableDeviceSupport, + agentAccessEnabled: value.enableAgentDeviceAccess, + onboardingCompleted: value.deviceOnboardingCompleted, + })), + Effect.mapError( + (cause) => + new DeviceOperationError({ operation: "settings", reason: "settings_failed", cause }), + ), + ); + const initialSettings = yield* readDeviceSettings; + + const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); + const statePubSub = yield* PubSub.unbounded(); + const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary); + let publishedHosts = new Map(hosts); + const stateRef = yield* SynchronizedRef.make({ + state: { + hosts: initialHosts, + hostStatus: initialSettings.enabled ? "idle" : "disabled", + hostStatuses: {}, + devices: [], + sessions: [], + onboardingCompleted: initialSettings.onboardingCompleted, + agentAccessEnabled: initialSettings.agentAccessEnabled, + hubBasePath: DEVICE_HUB_ROUTE_PREFIX, + revision: 0, + }, + }); + + const publish = (update: (state: DeviceServiceState) => DeviceServiceState) => + SynchronizedRef.updateAndGetEffect(stateRef, ({ state }) => { + const next = { ...update(state), revision: state.revision + 1 }; + return PubSub.publish(statePubSub, next).pipe(Effect.as({ state: next })); + }).pipe(Effect.map(({ state }) => state)); + + const resolveHost = (hostId: DeviceHostId | undefined) => + Effect.gen(function* () { + const id = hostId ?? LOCAL_DEVICE_HOST_ID; + const host = hosts.get(id); + if (!host) { + return yield* new DeviceHostUnavailableError({ hostId: id, reason: "Unknown host." }); + } + return host; + }); + + const setHostStatus = ( + hostId: DeviceHostId, + status: DeviceServiceState["hostStatuses"][string], + ) => + Effect.suspend(() => + !hosts.has(hostId) + ? SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)) + : publish((state) => ({ + ...state, + ...(hostId === LOCAL_DEVICE_HOST_ID + ? { hostStatus: status.status, hostStatusDetail: status.detail } + : {}), + hostStatuses: { ...state.hostStatuses, [hostId]: status }, + })), + ); + + const readiness: DeviceService["Service"]["readiness"] = Effect.fn("DeviceService.readiness")( + function* (hostId) { + const host = yield* resolveHost(hostId); + if (!(yield* readDeviceSettings).enabled) { + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + reason: + "Device support is off. Enable it in the Device panel before installing or starting device tools.", + }); + } + const ready = yield* host + .ensureReady((status) => setHostStatus(host.id, { status }).pipe(Effect.asVoid)) + .pipe( + Effect.tapError((error) => + setHostStatus(host.id, { status: "failed", detail: error.message }), + ), + Effect.mapError( + (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), + ), + ); + if (hosts.get(host.id) !== host) + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + reason: "Host configuration changed. Retry the operation.", + }); + const { state } = yield* SynchronizedRef.get(stateRef); + if (state.hostStatuses[host.id]?.status !== "ready") { + yield* setHostStatus(host.id, { status: "ready" }); + } + return { hostId: host.id, ...ready }; + }, + lifecycleLock.withPermit, + ); + + const readinessIfSupported: DeviceService["Service"]["readinessIfSupported"] = Effect.fn( + "DeviceService.readinessIfSupported", + )(function* (hostId) { + if (!(yield* readDeviceSettings).enabled) return null; + const host = yield* resolveHost(hostId); + const summary = yield* host.summary; + if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available)) + return null; + return yield* readiness(host.id); + }); + + const agentReadinessIfSupported: DeviceService["Service"]["agentReadinessIfSupported"] = + Effect.fn("DeviceService.agentReadinessIfSupported")(function* (hostId) { + const deviceSettings = yield* readDeviceSettings; + if (!deviceSettings.enabled || !deviceSettings.agentAccessEnabled) return null; + const host = yield* resolveHost(hostId); + const summary = yield* host.summary; + if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available)) + return null; + const ready = yield* host + .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid)) + .pipe( + Effect.tapError((error) => + setHostStatus(host.id, { status: "failed", detail: error.message }), + ), + Effect.mapError( + (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), + ), + ); + const hostSummaries = yield* Effect.forEach(hosts.values(), (candidate) => candidate.summary); + yield* publish((state) => ({ ...state, hosts: hostSummaries })); + yield* setHostStatus(host.id, { status: "ready" }); + return { hostId: host.id, ...ready }; + }, lifecycleLock.withPermit); + + const currentReadiness: DeviceService["Service"]["currentReadiness"] = (hostId) => + resolveHost(hostId).pipe( + Effect.flatMap((host) => + host.current.pipe(Effect.map((ready) => (ready ? { hostId: host.id, ...ready } : null))), + ), + Effect.orElseSucceed(() => null), + ); + + const hubJson = ( + request: HttpClientRequest.HttpClientRequest, + schema: Schema.Codec, + operation: string, + timeout: Duration.Input = Duration.seconds(15), + ) => + httpClient.execute(request).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)), + Effect.scoped, + Effect.timeout(timeout), + Effect.mapError( + (cause) => + new DeviceOperationError({ + operation, + reason: "request_failed", + cause, + }), + ), + ); + + const fetchDevices = Effect.fn("DeviceService.fetchDevices")(function* (ready: DeviceReadiness) { + const list = yield* hubJson( + HttpClientRequest.get(`${ready.hub.origin}/api/devices`), + HubDeviceList, + "list", + ); + const toSummary = (device: typeof HubDevice.Type): DeviceSummary => ({ + hostId: ready.hostId, + id: device.id, + platform: device.platform, + name: device.name, + version: device.version, + booted: device.booted, + physical: device.physical, + }); + const devices = [...list.simulators, ...list.emulators].map(toSummary); + const host = yield* resolveHost(ready.hostId); + if ((yield* host.platformAvailability("android")).available) { + const avds = yield* ready.run("emulator", ["-list-avds"]); + if (avds.code !== 0) { + return yield* new DeviceOperationError({ + operation: "list", + reason: "command_failed", + exitCode: avds.code, + cause: avds, + }); + } + for (const name of avds.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean)) { + if (!devices.some((device) => device.platform === "android" && device.name === name)) { + devices.push({ + hostId: ready.hostId, + id: name, + name, + platform: "android", + version: "Android", + booted: false, + physical: false, + }); + } + } + } + return { devices, detail: list.errors?.map((error) => error.message).join("\n") || undefined }; + }); + + const refresh = Effect.fn("DeviceService.refresh")(function* (ready: DeviceReadiness) { + const host = hosts.get(ready.hostId); + const { devices, detail } = yield* fetchDevices(ready); + const hostSummaries = yield* Effect.forEach(hosts.values(), (host) => host.summary); + return yield* lifecycleLock.withPermit( + Effect.gen(function* () { + if (!(yield* readDeviceSettings).enabled || !host || hosts.get(ready.hostId) !== host) + return (yield* SynchronizedRef.get(stateRef)).state; + return yield* publish((state) => ({ + ...state, + hosts: hostSummaries, + ...(ready.hostId === LOCAL_DEVICE_HOST_ID ? { hostStatusDetail: detail } : {}), + devices: [ + ...state.devices.filter((device) => device.hostId !== ready.hostId), + ...devices, + ], + hostStatuses: { + ...state.hostStatuses, + [ready.hostId]: { status: "ready", ...(detail ? { detail } : {}) }, + }, + })); + }), + ); + }); + + const list: DeviceService["Service"]["list"] = Effect.gen(function* () { + if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state; + yield* Effect.forEach( + hosts.values(), + (host) => + Effect.gen(function* () { + const ready = yield* readinessIfSupported(host.id); + if (ready) yield* refresh(ready); + }).pipe( + Effect.catch((error) => + setHostStatus(host.id, { status: "failed", detail: error.message }), + ), + ), + { concurrency: 4 }, + ); + return (yield* SynchronizedRef.get(stateRef)).state; + }).pipe(Effect.withSpan("DeviceService.list")); + + const configure: DeviceService["Service"]["configure"] = Effect.fn("DeviceService.configure")( + function* (input) { + const currentSettings = yield* readDeviceSettings; + const nextEnabled = input.enabled ?? currentSettings.enabled; + const nextAgentAccess = input.agentAccessEnabled ?? currentSettings.agentAccessEnabled; + yield* lifecycleLock.withPermit( + Effect.gen(function* () { + yield* settings + .updateSettings({ + ...(input.enabled === undefined ? {} : { enableDeviceSupport: input.enabled }), + ...(input.agentAccessEnabled === undefined + ? {} + : { enableAgentDeviceAccess: input.agentAccessEnabled }), + ...(input.onboardingCompleted === undefined + ? {} + : { deviceOnboardingCompleted: input.onboardingCompleted }), + }) + .pipe( + Effect.mapError( + (cause) => + new DeviceOperationError({ + operation: "configure", + reason: "settings_failed", + cause, + }), + ), + ); + if (!nextEnabled) { + yield* Effect.forEach(hosts.values(), (host) => host.stop, { discard: true }); + } else if (input.agentAccessEnabled === false) { + yield* Effect.forEach(hosts.values(), (host) => host.stopAgent, { discard: true }); + } + yield* publish((state) => ({ + ...state, + hostStatus: nextEnabled ? "idle" : "disabled", + hostStatusDetail: undefined, + hostStatuses: {}, + devices: nextEnabled ? state.devices : [], + sessions: nextEnabled ? state.sessions : [], + bootingDevices: nextEnabled ? state.bootingDevices : [], + agentAccessEnabled: nextAgentAccess, + onboardingCompleted: input.onboardingCompleted ?? state.onboardingCompleted, + })); + }), + ); + if (nextEnabled && nextAgentAccess && input.agentAccessEnabled === true) { + yield* agentReadinessIfSupported(); + } + return yield* list; + }, + ); + + const findDevice = ( + state: DeviceServiceState, + hostId: DeviceHostId, + deviceId: DeviceId, + ): DeviceSummary | undefined => + state.devices.find((device) => device.hostId === hostId && device.id === deviceId); + + const ensurePlatform = Effect.fn("DeviceService.ensurePlatform")(function* ( + host: DeviceHost.DeviceHost["Service"], + platform: DevicePlatform, + ) { + const availability = yield* host.platformAvailability(platform); + if (!availability.available) { + return yield* new DevicePlatformUnavailableError({ + hostId: host.id, + platform, + reason: availability.reason ?? "Platform toolchain missing.", + }); + } + }); + + /** + * Boot through the hub so its device list and the streaming helper both see + * the device come up. Android AVDs change id when they boot (AVD name to + * emulator serial), so the returned id is authoritative. + */ + const boot = Effect.fn("DeviceService.boot")(function* ( + ready: DeviceReadiness, + device: DeviceSummary, + ) { + const result = yield* HttpClientRequest.post(`${ready.hub.origin}/api/devices/boot`).pipe( + HttpClientRequest.bodyJson({ platform: device.platform, id: device.id, name: device.name }), + Effect.mapError( + (cause) => + new DeviceOperationError({ operation: "boot", reason: "invalid_payload", cause }), + ), + Effect.flatMap((request) => hubJson(request, HubActionResult, "boot", BOOT_TIMEOUT)), + ); + if (!result.ok) { + return yield* new DeviceBootError({ + hostId: ready.hostId, + deviceId: device.id, + reason: /insufficient.*(?:disk|space)|not enough.*(?:disk|space)|no space left/i.test( + result.error ?? "", + ) + ? "disk_space" + : /timed? out|timeout/i.test(result.error ?? "") + ? "timeout" + : "launch_failed", + cause: result, + }); + } + if (device.platform === "ios") { + // Booting alone does not attach a serve-sim helper; the grid start + // does both and is idempotent for a booted simulator. + yield* HttpClientRequest.post( + `${ready.hub.origin}${vendorPrefix("ios")}/grid/api/start`, + ).pipe( + HttpClientRequest.bodyJson({ udid: device.id }), + Effect.mapError( + (cause) => + new DeviceOperationError({ operation: "boot", reason: "invalid_payload", cause }), + ), + Effect.flatMap((request) => + hubJson(request, HubActionResult, "attach stream", BOOT_TIMEOUT), + ), + ); + } + return result.serial ?? result.id ?? device.id; + }); + + const open: DeviceService["Service"]["open"] = Effect.fn("DeviceService.open")(function* (input) { + const host = yield* resolveHost(input.hostId); + yield* ensurePlatform(host, input.platform); + const ready = yield* readiness(host.id); + let state = yield* refresh(ready); + let device = findDevice(state, host.id, input.deviceId); + if (!device) { + return yield* new DeviceNotFoundError({ hostId: host.id, deviceId: input.deviceId }); + } + if (!device.booted && input.boot !== false) { + const booting = { ...device, threadId: input.threadId }; + yield* publish((current) => ({ + ...current, + bootingDevices: [ + ...(current.bootingDevices ?? []).filter( + (entry) => entry.hostId !== booting.hostId || entry.id !== booting.id, + ), + booting, + ], + })); + const bootedId = yield* boot(ready, device).pipe( + Effect.ensuring( + publish((current) => ({ + ...current, + bootingDevices: (current.bootingDevices ?? []).filter( + (entry) => entry.hostId !== booting.hostId || entry.id !== booting.id, + ), + })), + ), + ); + state = yield* refresh(ready); + device = findDevice(state, host.id, bootedId) ?? findDevice(state, host.id, device.id); + if (!device) { + return yield* new DeviceNotFoundError({ hostId: host.id, deviceId: bootedId }); + } + } else if (device.platform === "ios" && device.booted) { + // A simulator booted outside T3 has no helper attached yet. + yield* HttpClientRequest.post( + `${ready.hub.origin}${vendorPrefix("ios")}/grid/api/start`, + ).pipe( + HttpClientRequest.bodyJson({ udid: device.id }), + Effect.mapError( + (cause) => + new DeviceOperationError({ operation: "open", reason: "invalid_payload", cause }), + ), + Effect.flatMap((request) => + hubJson(request, HubActionResult, "attach stream", BOOT_TIMEOUT), + ), + ); + } + if (hosts.get(host.id) !== host) + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + reason: "Host configuration changed. Retry the operation.", + }); + const openedAt = DateTime.formatIso(yield* DateTime.now); + const session: DeviceSession = { + threadId: input.threadId, + hostId: host.id, + deviceId: device.id, + platform: device.platform, + openedAt, + }; + yield* lifecycleLock.withPermit( + Effect.gen(function* () { + if (!(yield* readDeviceSettings).enabled) + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + reason: "Device support was turned off while the device was opening.", + }); + yield* publish((current) => ({ + ...current, + sessions: [ + ...current.sessions.filter( + (existing) => + !( + existing.threadId === session.threadId && + existing.hostId === session.hostId && + existing.deviceId === session.deviceId + ), + ), + session, + ], + })); + }), + ); + return session; + }); + + const shutdownDevice = Effect.fn("DeviceService.shutdownDevice")(function* ( + hostId: DeviceHostId, + deviceId: DeviceId, + platform: DevicePlatform, + ) { + const ready = yield* readiness(hostId); + yield* HttpClientRequest.post(`${ready.hub.origin}/api/devices/shutdown`).pipe( + HttpClientRequest.bodyJson({ platform, id: deviceId }), + Effect.mapError( + (cause) => + new DeviceOperationError({ operation: "shutdown", reason: "invalid_payload", cause }), + ), + Effect.flatMap((request) => hubJson(request, HubActionResult, "shutdown")), + Effect.flatMap((result) => + result.ok + ? Effect.void + : Effect.fail( + new DeviceOperationError({ + operation: "shutdown", + reason: "hub_rejected", + cause: result, + }), + ), + ), + ); + yield* publish((state) => ({ + ...state, + devices: state.devices.map((device) => + device.hostId === ready.hostId && device.id === deviceId + ? { ...device, booted: false } + : device, + ), + sessions: state.sessions.filter( + (session) => !(session.hostId === ready.hostId && session.deviceId === deviceId), + ), + })); + // Discovery can stall while an emulator saves its snapshot. A failed + // refresh must not turn an accepted shutdown into an action failure. + yield* refresh(ready).pipe( + Effect.catch((cause) => + Effect.logWarning("Device discovery unavailable after shutdown", { cause }), + ), + ); + }); + + const close: DeviceService["Service"]["close"] = Effect.fn("DeviceService.close")( + function* (input) { + const { state } = yield* SynchronizedRef.get(stateRef); + const closing = state.sessions.filter( + (session) => + session.threadId === input.threadId && + (input.hostId === undefined || session.hostId === input.hostId) && + (input.deviceId === undefined || session.deviceId === input.deviceId), + ); + if (closing.length === 0) return; + yield* publish((current) => ({ + ...current, + sessions: current.sessions.filter((session) => !closing.includes(session)), + })); + if (input.shutdown) { + yield* Effect.forEach( + closing, + (session) => shutdownDevice(session.hostId, session.deviceId, session.platform), + { discard: true }, + ); + } + }, + ); + + const shutdown: DeviceService["Service"]["shutdown"] = Effect.fn("DeviceService.shutdown")( + function* (input) { + const host = yield* resolveHost(input.hostId); + yield* shutdownDevice(host.id, input.deviceId, input.platform); + // Sessions on a powered-off device are stale in every thread. + yield* publish((current) => ({ + ...current, + sessions: current.sessions.filter( + (session) => !(session.hostId === host.id && session.deviceId === input.deviceId), + ), + })); + }, + ); + + const screenshot: DeviceService["Service"]["screenshot"] = Effect.fn("DeviceService.screenshot")( + function* (input) { + const { ready, device } = yield* resolveDevice(input.hostId, input.deviceId); + const url = `${ready.hub.origin}${vendorPrefix(device.platform)}/api/screenshot?device=${encodeURIComponent(device.id)}`; + const png = yield* httpClient.execute(HttpClientRequest.post(url)).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.arrayBuffer), + Effect.map((buffer) => new Uint8Array(buffer)), + Effect.scoped, + Effect.timeout(SCREENSHOT_TIMEOUT), + Effect.mapError( + (cause) => + new DeviceOperationError({ + operation: "screenshot", + reason: "request_failed", + cause, + }), + ), + ); + return { device, png }; + }, + ); + + const resolveDevice = Effect.fn("DeviceService.resolveDevice")(function* ( + hostId: DeviceHostId | undefined, + deviceId: DeviceId, + ) { + const host = yield* resolveHost(hostId); + const ready = yield* readiness(host.id); + const { state } = yield* SynchronizedRef.get(stateRef); + const device = + findDevice(state, host.id, deviceId) ?? findDevice(yield* refresh(ready), host.id, deviceId); + if (!device) return yield* new DeviceNotFoundError({ hostId: host.id, deviceId }); + return { ready, device }; + }); + + const detail: DeviceService["Service"]["detail"] = Effect.fn("DeviceService.detail")( + function* (input) { + const { ready, device } = yield* resolveDevice(input.hostId, input.deviceId); + const read = yield* readDeviceDetail(ready, device.platform, device.id); + return { + hostId: ready.hostId, + deviceId: device.id, + settings: read.settings, + foregroundApp: read.foregroundApp, + readAt: DateTime.formatIso(yield* DateTime.now), + }; + }, + ); + + const action: DeviceService["Service"]["action"] = Effect.fn("DeviceService.action")( + function* (input) { + const { ready, device } = yield* resolveDevice(input.hostId, input.deviceId); + yield* runDeviceAction(ready, device.platform, input); + return yield* detail({ hostId: ready.hostId, deviceId: device.id }); + }, + ); + + const sessionsForThread: DeviceService["Service"]["sessionsForThread"] = (threadId) => + SynchronizedRef.get(stateRef).pipe( + Effect.map(({ state }) => state.sessions.filter((session) => session.threadId === threadId)), + ); + + return { + ...DeviceService.of({ + testHost, + agentCli: Effect.fail( + new DeviceHostUnavailableError({ + hostId: LOCAL_DEVICE_HOST_ID, + reason: "Agent CLI installation is unavailable in this device service.", + }), + ), + agentTarget: (input) => + Effect.gen(function* () { + const host = yield* resolveHost(input.hostId); + const ready = yield* agentReadinessIfSupported(input.hostId); + if (!ready) + return yield* new DeviceHostUnavailableError({ + hostId: input.hostId, + reason: + "Agent device access requires enabled device support, agent access, and an available simulator platform on this host.", + }); + const configPath = yield* lifecycleLock.withPermit( + Effect.gen(function* () { + if (hosts.get(host.id) !== host) + return yield* new DeviceHostUnavailableError({ + hostId: host.id, + reason: "Host configuration changed. Retry the operation.", + }); + return yield* configureAgent(input.hostId, ready); + }), + ); + return [ + "--config", + configPath, + "--session", + agentDeviceSession(input.threadId, input.hostId, input.deviceId), + ]; + }), + state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)), + subscribe: PubSub.subscribe(statePubSub), + configure, + list, + open, + close, + shutdown, + detail, + action, + screenshot, + readiness, + readinessIfSupported, + agentReadinessIfSupported, + currentReadiness, + sessionsForThread, + }), + setHostStatus, + withLifecycleLock: lifecycleLock.withPermit, + refreshHosts: Effect.gen(function* () { + const summaries = yield* Effect.forEach(hosts.values(), (host) => host.summary); + const unchanged = (id: DeviceHostId) => + hosts.has(id) && hosts.get(id) === publishedHosts.get(id); + yield* publish((state) => ({ + ...state, + hosts: summaries, + hostStatuses: Object.fromEntries( + Object.entries(state.hostStatuses).filter(([id]) => unchanged(id)), + ), + devices: state.devices.filter((device) => unchanged(device.hostId)), + sessions: state.sessions.filter((session) => unchanged(session.hostId)), + })); + publishedHosts = new Map(hosts); + }), + }; +}); + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const localHost = yield* DeviceHost.DeviceHost; + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runner = yield* ProcessRunner.ProcessRunner; + const settings = yield* ServerSettings.ServerSettingsService; + const scope = yield* Scope.Scope; + const hosts = new Map([ + [localHost.id, localHost], + ]); + const configureAgent = (hostId: DeviceHostId, ready: DeviceHost.DeviceHostAgentReady) => { + const file = agentDeviceConfigPath(config.stateDir, hostId, path); + return writeAgentDeviceConfig(file, ready.agentDevice).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new DeviceOperationError({ + operation: "configure agent", + reason: "settings_failed", + cause, + }), + ), + Effect.as(file), + ); + }; + const probeContext = + yield* Effect.context>>(); + const service = yield* makeWithHosts( + hosts, + (host) => + SshDeviceHost.probe(host).pipe( + Effect.provide(probeContext), + Effect.mapError( + (error) => + new DeviceOperationError({ + operation: "probe host", + reason: "request_failed", + cause: error, + }), + ), + ), + configureAgent, + ); + const hostContext = + yield* Effect.context>>(); + const configured = new Map(); + const reconcile = (next: ReadonlyArray) => + Effect.gen(function* () { + const removed = yield* service.withLifecycleLock( + Effect.gen(function* () { + const removed: Array<{ id: string; scope: Scope.Closeable }> = []; + for (const [id, previous] of configured) { + if ( + next.some( + (host) => + host.id === id && + host.label === previous.config.label && + host.target === previous.config.target && + host.port === previous.config.port && + host.identityFile === previous.config.identityFile, + ) + ) + continue; + hosts.delete(id); + configured.delete(id); + removed.push({ id, scope: previous.scope }); + } + yield* service.refreshHosts; + return removed; + }), + ); + // Stop old writers before deleting config files or publishing replacements, without blocking healthy hosts. + yield* Effect.forEach( + removed, + ({ id, scope }) => + Effect.gen(function* () { + yield* Scope.close(scope, Exit.void); + yield* fs + .remove(agentDeviceConfigPath(config.stateDir, id, path), { force: true }) + .pipe(Effect.ignore); + }), + { concurrency: 4, discard: true }, + ); + yield* service.withLifecycleLock( + Effect.gen(function* () { + for (const host of next) { + if (configured.has(host.id)) continue; + const hostScope = yield* Scope.fork(scope); + const instance = yield* SshDeviceHost.make( + host, + (ready) => + configureAgent(host.id, ready).pipe( + Effect.asVoid, + Effect.mapError( + (error) => + new DeviceHost.DeviceHostError({ + hostId: host.id, + step: "configuring agent access", + cause: error, + }), + ), + ), + (status, detail) => + service + .setHostStatus(host.id, { status, ...(detail ? { detail } : {}) }) + .pipe(Effect.asVoid), + ).pipe(Effect.provideService(Scope.Scope, hostScope), Effect.provide(hostContext)); + hosts.set(host.id, instance); + configured.set(host.id, { config: host, scope: hostScope }); + } + yield* service.refreshHosts; + }), + ); + }); + const changes = yield* settings.subscribeChanges; + yield* reconcile((yield* settings.getSettings).deviceHosts); + yield* changes.pipe( + Stream.runForEach((value) => reconcile(value.deviceHosts)), + Effect.forkIn(scope), + ); + yield* Effect.addFinalizer(() => + Effect.forEach(configured.values(), (value) => Scope.close(value.scope, Exit.void), { + discard: true, + concurrency: 4, + }), + ); + return { + ...service, + agentCli: ensureAgentDevice(config.baseDir).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.map((tool) => tool.entryPath), + Effect.mapError( + (error) => + new DeviceOperationError({ + operation: "install agent CLI", + reason: "command_failed", + cause: error, + }), + ), + ), + }; +}); + +export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer)); + +/** State stream for WS subscribers: current snapshot first, then every change. */ +export const stateStream = (service: DeviceService["Service"]): Stream.Stream => + Stream.unwrap( + Effect.gen(function* () { + // Subscribe before reading the snapshot so no change between the two + // is lost; the scope lives as long as the stream does. + const subscription = yield* service.subscribe; + const initial = yield* service.state; + return Stream.concat(Stream.make(initial), Stream.fromSubscription(subscription)); + }), + ).pipe(Stream.scoped); diff --git a/apps/server/src/device/DeviceToolchain.test.ts b/apps/server/src/device/DeviceToolchain.test.ts new file mode 100644 index 000000000000..9f70ca91a8e1 --- /dev/null +++ b/apps/server/src/device/DeviceToolchain.test.ts @@ -0,0 +1,39 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as ProcessRunner from "../processRunner.ts"; +import { ensureDeviceHub, isDeviceHubInstalled } from "./DeviceToolchain.ts"; + +it.effect("failed installation cleans staging and exposes only a safe failure message", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-install-" }); + const result = { + code: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "registry rejected https://private:credential@example.test/package", + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + const error = yield* ensureDeviceHub(baseDir).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: () => Effect.succeed(result), + }), + Effect.flip, + ); + expect(error.message).toBe( + "Installing expo-device-hub failed while running npm install (exit code 1).", + ); + expect(error.cause).toBe(result); + expect(yield* isDeviceHubInstalled(baseDir)).toBe(false); + expect(yield* fs.readDirectory(path.join(baseDir, "tools", "expo-device-hub"))).toEqual([]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts new file mode 100644 index 000000000000..fa8d8cd11d17 --- /dev/null +++ b/apps/server/src/device/DeviceToolchain.ts @@ -0,0 +1,223 @@ +/** + * Pinned installs of the two external tools device support is built on. + * + * `expo-device-hub` streams simulator and emulator screens and `agent-device` + * drives them. Each is npm-installed separately after its matching consent + * step into `/tools//` and executed from there with the + * server's own Node, never `npx`: an ephemeral + * npx cache would make every first `device_open` after a reboot depend on the + * registry, and the pinned versions are part of the contract the injected + * agent instructions describe. + * + * Install follows the pinned-runtime recipe: stage into a temp sibling, write a + * sentinel only after npm exits 0, then rename into place. npm extracts files + * before it finishes, so an entry file alone does not prove a usable tree. + */ +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import * as ProcessRunner from "../processRunner.ts"; + +const DEVICE_HUB_PACKAGE = "expo-device-hub"; +export const DEVICE_HUB_VERSION = "0.9.0"; +const AGENT_DEVICE_PACKAGE = "agent-device"; +export const AGENT_DEVICE_VERSION = "0.20.10"; + +const INSTALL_TIMEOUT = Duration.minutes(10); +const installLock = Semaphore.makeUnsafe(1); + +export interface DeviceToolPaths { + readonly installDir: string; + /** Absolute path of the tool's entry script, run with the server's Node. */ + readonly entryPath: string; + readonly sentinelPath: string; +} + +export interface DeviceToolchainPaths { + readonly hub: DeviceToolPaths; + readonly agentDevice: DeviceToolPaths; +} + +export class DeviceToolchainInstallError extends Schema.TaggedError()( + "DeviceToolchainInstallError", + { + tool: Schema.String, + step: Schema.String, + exitCode: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const suffix = this.exitCode === undefined ? "" : ` (exit code ${this.exitCode})`; + return `Installing ${this.tool} failed while ${this.step}${suffix}.`; + } +} + +interface ToolSpec { + readonly name: string; + readonly version: string; + readonly entry: ReadonlyArray; +} + +const HUB_SPEC: ToolSpec = { + name: DEVICE_HUB_PACKAGE, + version: DEVICE_HUB_VERSION, + entry: ["dist", "server", "cli.mjs"], +}; + +const AGENT_DEVICE_SPEC: ToolSpec = { + name: AGENT_DEVICE_PACKAGE, + version: AGENT_DEVICE_VERSION, + entry: ["bin", "agent-device.mjs"], +}; + +const toolPaths = (path: Path.Path, baseDir: string, spec: ToolSpec): DeviceToolPaths => { + const installDir = path.join(baseDir, "tools", spec.name, spec.version); + return { + installDir, + entryPath: path.join(installDir, "node_modules", spec.name, ...spec.entry), + sentinelPath: path.join(installDir, ".install-complete"), + }; +}; + +const deviceToolchainPaths = (path: Path.Path, baseDir: string): DeviceToolchainPaths => ({ + hub: toolPaths(path, baseDir, HUB_SPEC), + agentDevice: toolPaths(path, baseDir, AGENT_DEVICE_SPEC), +}); + +/** Keep daemon state (daemon.json, sessions) in userdata, separate from tool installs. */ +export const agentDeviceStateDir = (path: Path.Path, stateDir: string): string => + path.join(stateDir, "device", "agent-device"); + +const isInstalled = Effect.fn("DeviceToolchain.isInstalled")(function* ( + fs: FileSystem.FileSystem, + paths: DeviceToolPaths, + version: string, +) { + const [entryExists, sentinel] = yield* Effect.all([ + fs.exists(paths.entryPath), + fs.readFileString(paths.sentinelPath).pipe(Effect.option), + ]).pipe(Effect.orElseSucceed(() => [false, Option.none()] as const)); + return entryExists && Option.isSome(sentinel) && sentinel.value.trim() === version; +}); + +const installTool = Effect.fn("DeviceToolchain.installTool")(function* ( + spec: ToolSpec, + paths: DeviceToolPaths, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runner = yield* ProcessRunner.ProcessRunner; + const fail = (step: string) => (cause: unknown) => + new DeviceToolchainInstallError({ tool: spec.name, step, cause }); + + if (yield* isInstalled(fs, paths, spec.version)) return paths; + + const parentDir = path.dirname(paths.installDir); + yield* fs + .remove(paths.installDir, { recursive: true, force: true }) + .pipe(Effect.mapError(fail("removing an incomplete install"))); + yield* fs + .makeDirectory(parentDir, { recursive: true }) + .pipe(Effect.mapError(fail("preparing the install directory"))); + const stagingDir = yield* fs + .makeTempDirectory({ directory: parentDir, prefix: ".staging-" }) + .pipe(Effect.mapError(fail("preparing the install directory"))); + + return yield* Effect.gen(function* () { + const installArgs = [ + "install", + "--prefix", + stagingDir, + "--no-fund", + "--no-audit", + `${spec.name}@${spec.version}`, + ]; + const result = yield* runner + .run({ command: "npm", args: installArgs, timeout: INSTALL_TIMEOUT }) + .pipe( + Effect.catchTags({ + ProcessSpawnError: (error) => + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" + ? runner.run({ + command: "pnpm", + args: ["--package=npm@11", "dlx", "npm", ...installArgs], + timeout: INSTALL_TIMEOUT, + }) + : Effect.fail(error), + }), + Effect.mapError(fail("running npm install")), + ); + if (result.code !== 0) { + return yield* new DeviceToolchainInstallError({ + tool: spec.name, + step: "running npm install", + exitCode: Number(result.code), + cause: result, + }); + } + const stagedEntry = path.join(stagingDir, "node_modules", spec.name, ...spec.entry); + if (!(yield* fs.exists(stagedEntry).pipe(Effect.orElseSucceed(() => false)))) { + return yield* new DeviceToolchainInstallError({ + tool: spec.name, + step: "verifying the installed entry point", + }); + } + yield* fs + .writeFileString(path.join(stagingDir, ".install-complete"), `${spec.version}\n`) + .pipe(Effect.mapError(fail("recording the completed install"))); + yield* fs.rename(stagingDir, paths.installDir).pipe( + Effect.catch((cause) => + // A concurrent server may have published the same version first. + isInstalled(fs, paths, spec.version).pipe( + Effect.flatMap((published) => + published ? Effect.void : Effect.fail(fail("publishing the install")(cause)), + ), + ), + ), + ); + return paths; + }).pipe( + Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)), + ); +}); + +const ensureTool = Effect.fn("DeviceToolchain.ensureTool")(function* ( + baseDir: string, + spec: ToolSpec, + select: (paths: DeviceToolchainPaths) => DeviceToolPaths, +) { + const path = yield* Path.Path; + const paths = deviceToolchainPaths(path, baseDir); + return yield* installLock.withPermit(installTool(spec, select(paths))); +}); + +export const ensureDeviceHub = (baseDir: string) => + ensureTool(baseDir, HUB_SPEC, (paths) => paths.hub); + +export const ensureAgentDevice = (baseDir: string) => + ensureTool(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice); + +const isToolInstalled = Effect.fn("DeviceToolchain.isToolInstalled")(function* ( + baseDir: string, + spec: ToolSpec, + select: (paths: DeviceToolchainPaths) => DeviceToolPaths, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const paths = deviceToolchainPaths(path, baseDir); + return yield* isInstalled(fs, select(paths), spec.version); +}); + +export const isDeviceHubInstalled = (baseDir: string) => + isToolInstalled(baseDir, HUB_SPEC, (paths) => paths.hub); + +export const isAgentDeviceInstalled = (baseDir: string) => + isToolInstalled(baseDir, AGENT_DEVICE_SPEC, (paths) => paths.agentDevice); diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts new file mode 100644 index 000000000000..17d274e73529 --- /dev/null +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as NodePath from "@effect/platform-node/NodePath"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as FileSystem from "effect/FileSystem"; + +import * as LocalDeviceHost from "./LocalDeviceHost.ts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { HttpClient } from "effect/unstable/http"; +import * as NetService from "@t3tools/shared/Net"; +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; + +const diagnose = (files: ReadonlyArray, environment: NodeJS.ProcessEnv) => + LocalDeviceHost.__testing.platformReason("android").pipe( + Effect.provideService(HostProcessEnvironment, environment), + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: (file) => Effect.succeed(files.includes(file)), + }), + ), + Effect.provide(NodePath.layer), + ); + +describe("Android SDK availability", () => { + it.effect("explains that adb alone is insufficient to launch an emulator", () => + Effect.gen(function* () { + const reason = yield* diagnose(["/sdk/platform-tools/adb"], { ANDROID_HOME: "/sdk" }); + expect(reason).toContain("Android Emulator is missing"); + }), + ); + + it.effect("identifies command-line tools required by the device hub", () => + Effect.gen(function* () { + const reason = yield* diagnose(["/sdk/platform-tools/adb", "/sdk/emulator/emulator"], { + ANDROID_HOME: "/sdk", + }); + expect(reason).toContain("Command-line Tools (latest)"); + }), + ); + + it.effect("discovers the standard macOS SDK without ANDROID_HOME", () => + Effect.gen(function* () { + const root = "/test/home/Library/Android/sdk"; + const reason = yield* diagnose( + [ + `${root}/platform-tools/adb`, + `${root}/emulator/emulator`, + `${root}/cmdline-tools/latest/bin/avdmanager`, + ], + { HOME: "/test/home" }, + ); + expect(reason).toBeNull(); + }), + ); + + it.effect("reports an absent SDK without running or installing tools", () => + Effect.gen(function* () { + expect(yield* diagnose([], { HOME: "/test/home" })).toContain("Android SDK was not found"); + }), + ); +}); + +it.effect("puts detected Android tools on the helper PATH without losing existing commands", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const environment = LocalDeviceHost.__testing.deviceHostEnvironment( + { PATH: "/usr/bin", HOME: "/test/home" }, + "/sdk", + "darwin", + path, + ); + expect(environment.PATH).toBe("/sdk/platform-tools:/sdk/emulator:/usr/bin"); + expect(environment.ANDROID_HOME).toBe("/sdk"); + expect(environment.HOME).toBe("/test/home"); + }).pipe(Effect.provide(NodePath.layer)), +); + +it.effect( + "constructs and inspects an unconfigured host without installing or starting helpers", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-consent-" }); + const host = yield* LocalDeviceHost.make().pipe( + Effect.provide(Layer.mergeAll(ServerConfig.layerTest(baseDir, baseDir), NetService.layer)), + Effect.provideService(HostProcessEnvironment, { HOME: baseDir, PATH: "" }), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.die(new Error("Host construction must not spawn processes")), + ), + ), + Effect.provideService(ProcessRunner.ProcessRunner, { + run: () => Effect.die(new Error("Host construction must not run commands")), + }), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => + Effect.die(new Error("Host construction must not make network requests")), + ), + ), + ); + expect(yield* host.current).toBeNull(); + yield* host.stop; + expect(yield* fs.exists(`${baseDir}/tools`)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts new file mode 100644 index 000000000000..5a18d6e826a6 --- /dev/null +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -0,0 +1,698 @@ +/** + * The device host that is this machine. + * + * Runs expo-device-hub as a supervised child on a loopback port and starts the + * agent-device daemon in HTTP mode under a T3-owned state directory. Both are + * lazy: the device service requires explicit setup consent before it calls + * ensureReady to install tools or start helper processes. + * + * The hub runs in its standalone mode (origin root). The T3 proxy strips its + * own prefix, and the Device panel derives stream and socket URLs from the + * prefix itself rather than from anything the hub prints. + */ +import { + type DeviceHostSummary, + type DevicePlatform, + type DevicePlatformAvailability, + LOCAL_DEVICE_HOST_ID, +} from "@t3tools/contracts"; +import { waitForHttpReady } from "@t3tools/shared/httpReadiness"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import { isCommandAvailable } from "@t3tools/shared/shell"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as ServerConfig from "../config.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as DeviceHost from "./DeviceHost.ts"; +import { + agentDeviceStateDir, + type DeviceToolPaths, + ensureAgentDevice, + ensureDeviceHub, + isAgentDeviceInstalled, + isDeviceHubInstalled, +} from "./DeviceToolchain.ts"; + +const HUB_READY_TIMEOUT_MS = 30_000; +const DAEMON_READY_TIMEOUT_MS = 30_000; +const DAEMON_POLL_MS = 100; +const HUB_RESTART_STABLE_UPTIME_MS = 60_000; +const HUB_RESTART_MAX_DELAY_MS = 30_000; + +/** + * Written beside the agent-device state so a server that dies without running + * its finalizers (SIGKILL, dev-runner restarts) does not leave a hub bound to + * a loopback port forever. The next start reads it, kills only a process that + * is still that hub, and replaces the file. + */ +const HubStateFile = Schema.Struct({ + pid: Schema.Int, + port: Schema.Int, + entryPath: Schema.String, +}); +const decodeHubStateFile = Schema.decodeUnknownEffect(Schema.fromJsonString(HubStateFile)); +const encodeHubStateFile = Schema.encodeUnknownEffect(Schema.fromJsonString(HubStateFile)); + +const AgentDeviceDaemonFile = Schema.Struct({ + httpPort: Schema.Int, + token: Schema.String, + pid: Schema.optional(Schema.Int), +}); +const decodeDaemonFile = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentDeviceDaemonFile)); + +interface HubProcess { + readonly child: ChildProcessSpawner.ChildProcessHandle; + readonly scope: Scope.Closeable; + readonly origin: string; + readonly startedAtMillis: number; +} + +interface RunningHost { + readonly hub: HubProcess; + readonly agentDevice: DeviceHost.AgentDeviceEndpoint | null; + readonly helpers: DeviceHost.DeviceHostReady["helpers"]; +} + +const platformReason = Effect.fn("LocalDeviceHost.platformReason")(function* ( + platform: DevicePlatform, +): Effect.fn.Return { + const hostPlatform = yield* HostProcessPlatform; + if (platform === "ios") { + if (hostPlatform !== "darwin") return "iOS Simulators need macOS with Xcode."; + if (!(yield* isCommandAvailable("xcrun"))) return "Xcode command line tools were not found."; + return null; + } + const sdk = yield* androidSdk; + if (!sdk.root) + return "Android SDK was not found. Install it with Android Studio or set ANDROID_HOME to your SDK directory."; + if (!sdk.adb) + return `Android SDK Platform-Tools are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`; + if (!sdk.emulator) + return `Android Emulator is missing from ${sdk.root}. Install it in Android Studio's SDK Manager.`; + if (!sdk.avdmanager) + return `Android SDK Command-line Tools (latest) are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`; + return null; +}); + +/** Resolve the SDK once for both diagnostics and the environment passed to helpers. */ +const androidSdk = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const environment = yield* HostProcessEnvironment; + const platform = yield* HostProcessPlatform; + const home = environment.HOME ?? environment.USERPROFILE ?? ""; + const explicit = environment.ANDROID_HOME?.trim() || environment.ANDROID_SDK_ROOT?.trim(); + const candidates = explicit + ? [explicit] + : [ + path.join(home, "Library", "Android", "sdk"), + path.join(home, "Android", "Sdk"), + path.join( + environment.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), + "Android", + "Sdk", + ), + ]; + if (!explicit) { + for (const directory of (environment.PATH ?? "").split(platform === "win32" ? ";" : ":")) { + if (!directory) continue; + const resolved = yield* fs + .realPath(path.join(directory, platform === "win32" ? "adb.exe" : "adb")) + .pipe(Effect.option); + if (resolved._tag === "Some") candidates.push(path.dirname(path.dirname(resolved.value))); + } + } + const exists = (file: string) => fs.exists(file).pipe(Effect.orElseSucceed(() => false)); + for (const root of candidates) { + const adb = yield* exists( + path.join(root, "platform-tools", platform === "win32" ? "adb.exe" : "adb"), + ); + const emulator = yield* exists( + path.join(root, "emulator", platform === "win32" ? "emulator.exe" : "emulator"), + ); + if (explicit || adb || emulator) { + const avdmanager = yield* exists( + path.join( + root, + "cmdline-tools", + "latest", + "bin", + platform === "win32" ? "avdmanager.bat" : "avdmanager", + ), + ); + return { root, adb, emulator, avdmanager }; + } + } + return { root: null, adb: false, emulator: false, avdmanager: false }; +}); + +const deviceHostEnvironment = ( + environment: NodeJS.ProcessEnv, + sdkRoot: string | null, + hostPlatform: NodeJS.Platform, + path: Path.Path, +): NodeJS.ProcessEnv => { + return sdkRoot + ? { + ...environment, + ANDROID_HOME: sdkRoot, + PATH: [ + path.join(sdkRoot, "platform-tools"), + path.join(sdkRoot, "emulator"), + environment.PATH ?? environment.Path ?? "", + ].join(hostPlatform === "win32" ? ";" : ":"), + } + : environment; +}; + +export const make = Effect.fn("LocalDeviceHost.make")(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const config = yield* ServerConfig.ServerConfig; + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const net = yield* NetService.NetService; + const runner = yield* ProcessRunner.ProcessRunner; + const httpClient = yield* HttpClient.HttpClient; + const environment = yield* HostProcessEnvironment; + const hostPlatform = yield* HostProcessPlatform; + const sdk = yield* androidSdk; + const hostEnvironment = deviceHostEnvironment(environment, sdk.root, hostPlatform, path); + const startLock = yield* Semaphore.make(1); + const runningRef = yield* Ref.make(null); + const restartDelayRef = yield* Ref.make(0); + const hostId = LOCAL_DEVICE_HOST_ID; + + const platformAvailability = Effect.fn("LocalDeviceHost.platformAvailability")(function* ( + platform: DevicePlatform, + ): Effect.fn.Return { + const reason = yield* platformReason(platform).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + return reason === null ? { platform, available: true } : { platform, available: false, reason }; + }); + + const summary: Effect.Effect = Effect.gen(function* () { + const [platforms, hubInstalled, agentDeviceInstalled] = yield* Effect.all([ + Effect.all([platformAvailability("ios"), platformAvailability("android")]), + isDeviceHubInstalled(config.baseDir).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ), + isAgentDeviceInstalled(config.baseDir).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ), + ]); + return { + id: hostId, + kind: "local", + label: "This machine", + platforms, + hubInstalled, + agentDeviceInstalled, + }; + }); + + const hubEnvironment = (): NodeJS.ProcessEnv => ({ + ...hostEnvironment, + FORCE_COLOR: "0", + NO_COLOR: "1", + }); + + const stopHub = (hub: HubProcess | undefined) => + hub ? Scope.close(hub.scope, Exit.void).pipe(Effect.ignore) : Effect.void; + + const hubStatePath = () => path.join(agentDeviceStateDir(path, config.stateDir), "hub.json"); + + const isProcessAlive = (pid: number) => + Effect.sync(() => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }); + + /** + * A hub left behind by a previous server is identified by pid plus the + * command line's entry path, so a recycled pid belonging to something else + * is never touched. + */ + const reapStaleHub = Effect.gen(function* () { + const previous = yield* fs + .readFileString(hubStatePath()) + .pipe(Effect.flatMap(decodeHubStateFile), Effect.option); + if (previous._tag === "None") return; + const alive = yield* isProcessAlive(previous.value.pid); + if (alive) { + const commandLine = yield* runner + .run({ + command: "ps", + args: ["-o", "command=", "-p", String(previous.value.pid)], + timeout: Duration.seconds(5), + timeoutBehavior: "timedOutResult", + }) + .pipe( + Effect.map((result) => result.stdout), + Effect.orElseSucceed(() => ""), + ); + if (commandLine.includes(previous.value.entryPath)) { + yield* Effect.logWarning("Stopping a device hub left behind by a previous server", { + pid: previous.value.pid, + port: previous.value.port, + }); + yield* Effect.sync(() => { + try { + process.kill(previous.value.pid, "SIGTERM"); + } catch { + // Already gone. + } + }); + } + } + yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore); + }).pipe(Effect.catchCause(() => Effect.void)); + + const recordHub = (hub: HubProcess, hubTool: DeviceToolPaths) => + encodeHubStateFile({ + pid: Number(hub.child.pid), + port: Number(new URL(hub.origin).port), + entryPath: hubTool.entryPath, + }).pipe( + Effect.flatMap((json) => fs.writeFileString(hubStatePath(), json)), + Effect.ignore, + ); + + const spawnHub = Effect.fn("LocalDeviceHost.spawnHub")(function* ( + hubTool: DeviceToolPaths, + ): Effect.fn.Return { + yield* reapStaleHub; + yield* fs + .makeDirectory(agentDeviceStateDir(path, config.stateDir), { recursive: true }) + .pipe(Effect.ignore); + const port = yield* net.reserveLoopbackPort("127.0.0.1").pipe( + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ + hostId, + step: "reserving a port for the device hub", + cause, + }), + ), + ); + const origin = `http://127.0.0.1:${port}`; + const scope = yield* Scope.make("sequential"); + const child = yield* spawner + .spawn( + ChildProcess.make( + process.execPath, + [ + hubTool.entryPath, + "--port", + String(port), + "--host", + "127.0.0.1", + "--hide-sidebar", + "--hide-boot-device", + ], + { + detached: false, + shell: false, + stdout: "pipe", + stderr: "pipe", + env: hubEnvironment(), + }, + ), + ) + .pipe( + Effect.provideService(Scope.Scope, scope), + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ + hostId, + step: "starting the device hub", + cause, + }), + ), + ); + const startedAtMillis = yield* Clock.currentTimeMillis; + const hub: HubProcess = { child, scope, origin, startedAtMillis }; + yield* Effect.forkIn(observeHubOutput(hub), scope); + yield* waitForHttpReady({ + baseUrl: origin, + path: "/readyz", + timeoutMs: HUB_READY_TIMEOUT_MS, + makeError: (info) => + new DeviceHost.DeviceHostError({ + hostId, + step: "waiting for the device hub to answer", + cause: info.cause, + }), + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.tapError(() => stopHub(hub)), + ); + yield* recordHub(hub, hubTool); + yield* Effect.logInfo("Device hub started", { pid: Number(child.pid), port }); + return hub; + }); + + const observeHubOutput = (hub: HubProcess) => + hub.child.all.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.map((line) => line.trim()), + Stream.filter((line) => line.length > 0), + Stream.runForEach((line) => + Effect.logDebug("Device hub output", { pid: Number(hub.child.pid), output: line }), + ), + Effect.catchCause(() => Effect.void), + ); + + /** + * Restart the hub when it dies under us, with the same doubling backoff the + * relay connector uses so a hub that crashes on boot cannot spin. + */ + const superviseHub = (hub: HubProcess, hubTool: DeviceToolPaths): Effect.Effect => + Effect.gen(function* () { + yield* Effect.result(hub.child.exitCode); + const running = yield* Ref.get(runningRef); + if (running?.hub.child.pid !== hub.child.pid) return; + const uptime = (yield* Clock.currentTimeMillis) - hub.startedAtMillis; + const delay = yield* Ref.modify(restartDelayRef, (current) => { + if (uptime >= HUB_RESTART_STABLE_UPTIME_MS) return [0, 0]; + const next = current === 0 ? 1_000 : Math.min(current * 2, HUB_RESTART_MAX_DELAY_MS); + return [current, next]; + }); + yield* Effect.logWarning("Device hub exited; restarting", { + pid: Number(hub.child.pid), + delayMs: delay, + }); + yield* Effect.sleep(Duration.millis(delay)); + yield* startLock.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(runningRef); + if (current?.hub.child.pid !== hub.child.pid) return; + const replacement = yield* spawnHub(hubTool); + yield* Ref.set(runningRef, { ...current, hub: replacement }); + yield* Effect.forkDetach(superviseHub(replacement, hubTool)); + }), + ); + }).pipe( + Effect.catchCause((cause) => Effect.logWarning("Device hub supervisor failed", { cause })), + ); + + const daemonFilePath = () => path.join(agentDeviceStateDir(path, config.stateDir), "daemon.json"); + + const readDaemonFile = Effect.fn("LocalDeviceHost.readDaemonFile")(function* () { + const raw = yield* fs.readFileString(daemonFilePath()); + return yield* decodeDaemonFile(raw); + }); + + /** + * agent-device auto-starts its daemon on any command. A trivial `devices` + * call in HTTP mode is the documented way to bring it up; its output is the + * daemon.json this reads back. + */ + const startAgentDeviceDaemon = Effect.fn("LocalDeviceHost.startAgentDeviceDaemon")(function* ( + agentTool: DeviceToolPaths, + ): Effect.fn.Return { + const stateDir = agentDeviceStateDir(path, config.stateDir); + yield* fs.makeDirectory(stateDir, { recursive: true }).pipe(Effect.ignore); + const existing = yield* readDaemonFile().pipe(Effect.option); + const daemonEnvironment: NodeJS.ProcessEnv = { + ...hostEnvironment, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: "http", + // The daemon idles out after five minutes by default; the server owns + // its lifetime here and stops it explicitly. + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: "0", + AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1", + FORCE_COLOR: "0", + NO_COLOR: "1", + }; + const toEndpoint = ( + file: typeof AgentDeviceDaemonFile.Type, + ): DeviceHost.AgentDeviceEndpoint => ({ + baseUrl: `http://127.0.0.1:${file.httpPort}`, + token: file.token, + entryPath: agentTool.entryPath, + }); + if (existing._tag === "Some") { + const alive = yield* HttpClient.withScope(httpClient) + .get(`http://127.0.0.1:${existing.value.httpPort}/health`) + .pipe( + Effect.timeout(Duration.seconds(2)), + Effect.flatMap((response) => + response.arrayBuffer.pipe(Effect.as(response.status === 200)), + ), + Effect.scoped, + Effect.orElseSucceed(() => false), + ); + if (alive) return toEndpoint(existing.value); + yield* fs.remove(daemonFilePath(), { force: true }).pipe(Effect.ignore); + } + // There is no `daemon start`; the first command in a state dir spawns the + // daemon and blocks until it answers. `devices` is the cheapest one. + yield* runner + .run({ + command: process.execPath, + args: [agentTool.entryPath, "devices", "--json"], + env: daemonEnvironment, + timeout: Duration.millis(DAEMON_READY_TIMEOUT_MS), + timeoutBehavior: "timedOutResult", + }) + .pipe(Effect.ignore); + const deadline = (yield* Clock.currentTimeMillis) + DAEMON_READY_TIMEOUT_MS; + while (true) { + const file = yield* readDaemonFile().pipe(Effect.option); + if (file._tag === "Some") return toEndpoint(file.value); + if ((yield* Clock.currentTimeMillis) > deadline) { + return yield* new DeviceHost.DeviceHostTimeoutError({ + hostId, + timeoutMs: DAEMON_READY_TIMEOUT_MS, + }); + } + yield* Effect.sleep(Duration.millis(DAEMON_POLL_MS)); + } + }); + + const stopAgentDeviceDaemon = (agentTool: DeviceToolPaths | null) => + agentTool + ? runner + .run({ + command: process.execPath, + args: [ + agentTool.entryPath, + "daemon", + "stop", + "--state-dir", + agentDeviceStateDir(path, config.stateDir), + ], + env: { ...hostEnvironment, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1" }, + timeout: Duration.seconds(10), + timeoutBehavior: "timedOutResult", + }) + .pipe(Effect.ignore) + : Effect.void; + + let agentToolRef: DeviceToolPaths | null = null; + + const ensureHubReady = Effect.fn("LocalDeviceHost.ensureHubReady")(function* ( + onPhase: (phase: "installing" | "starting") => Effect.Effect, + ): Effect.fn.Return { + const running = yield* Ref.get(runningRef); + if (running) { + const alive = yield* running.hub.child.isRunning.pipe(Effect.orElseSucceed(() => false)); + if (alive) return running; + yield* Ref.set(runningRef, null); + } + const installed = yield* isDeviceHubInstalled(config.baseDir).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + if (!installed) yield* onPhase("installing"); + const hubTool = yield* ensureDeviceHub(config.baseDir).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ + hostId, + step: "installing device support", + cause, + }), + ), + ); + yield* onPhase("starting"); + const hub = yield* spawnHub(hubTool); + const candidate = helperPaths(hubTool); + const [axExists, cliExists] = yield* Effect.all([ + fs.exists(candidate.serveSimAxSettings).pipe(Effect.orElseSucceed(() => false)), + fs.exists(candidate.serveSimCli).pipe(Effect.orElseSucceed(() => false)), + ]); + const next: RunningHost = { + hub, + agentDevice: null, + helpers: { + serveSimAxSettings: axExists ? candidate.serveSimAxSettings : null, + serveSimCli: cliExists ? candidate.serveSimCli : null, + }, + }; + yield* Ref.set(runningRef, next); + yield* Ref.set(restartDelayRef, 0); + yield* Effect.forkDetach(superviseHub(hub, hubTool)); + return next; + }); + + const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = (onPhase) => + startLock.withPermits(1)(ensureHubReady(onPhase).pipe(Effect.map(toReady))); + + const ensureAgentReady: DeviceHost.DeviceHost["Service"]["ensureAgentReady"] = (onPhase) => + startLock.withPermits(1)( + Effect.gen(function* (): Generator< + Effect.Effect, + DeviceHost.DeviceHostAgentReady + > { + const running = yield* ensureHubReady(onPhase); + if (running.agentDevice) return { ...toReady(running), agentDevice: running.agentDevice }; + const installed = yield* isAgentDeviceInstalled(config.baseDir).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + if (!installed) yield* onPhase("installing"); + const agentTool = yield* ensureAgentDevice(config.baseDir).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ + hostId, + step: "installing agent tools", + cause, + }), + ), + ); + agentToolRef = agentTool; + yield* onPhase("starting"); + const agentDevice = yield* startAgentDeviceDaemon(agentTool); + const next = { ...running, agentDevice }; + yield* Ref.set(runningRef, next); + return { ...toReady(next), agentDevice }; + }), + ); + + const helperPaths = (hubTool: DeviceToolPaths) => { + const serveSimDist = path.join( + hubTool.installDir, + "node_modules", + "expo-device-hub", + "vendor", + "serve-sim", + "dist", + ); + return { + serveSimAxSettings: path.join(serveSimDist, "simax", "serve-sim-ax-settings"), + serveSimCli: path.join(serveSimDist, "serve-sim.js"), + }; + }; + + const run: DeviceHost.DeviceHostReady["run"] = (command, args, options) => + runner + .run({ + command: + command === "emulator" && sdk.root + ? path.join( + sdk.root, + "emulator", + hostPlatform === "win32" ? "emulator.exe" : "emulator", + ) + : command, + args, + env: hostEnvironment, + timeout: Duration.millis(options?.timeoutMs ?? 20_000), + timeoutBehavior: "timedOutResult", + ...(options?.stdin === undefined ? {} : { stdin: options.stdin }), + }) + .pipe( + Effect.map((result) => ({ + stdout: result.stdout, + stderr: result.stderr, + code: Number(result.code), + })), + Effect.catch((cause) => Effect.succeed({ stdout: "", stderr: String(cause), code: 127 })), + ); + + const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({ + hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint, + nodePath: process.execPath, + run, + helpers: running.helpers, + }); + + const current: DeviceHost.DeviceHost["Service"]["current"] = Ref.get(runningRef).pipe( + Effect.map((running) => (running ? toReady(running) : null)), + ); + + const stopAgent: DeviceHost.DeviceHost["Service"]["stopAgent"] = startLock.withPermits(1)( + Effect.gen(function* () { + yield* stopAgentDeviceDaemon(agentToolRef); + yield* Ref.update(runningRef, (running) => + running ? { ...running, agentDevice: null } : running, + ); + }), + ); + + const stop: DeviceHost.DeviceHost["Service"]["stop"] = startLock.withPermits(1)( + Effect.gen(function* () { + const running = yield* Ref.getAndSet(runningRef, null); + yield* stopHub(running?.hub); + yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore); + yield* stopAgentDeviceDaemon(agentToolRef); + }), + ); + + // Never leave the hub or daemon behind when the server's scope closes. + yield* Effect.addFinalizer(() => stop); + + const host: DeviceHost.DeviceHost["Service"] = { + id: hostId, + summary, + platformAvailability, + ensureReady, + ensureAgentReady, + current, + stopAgent, + stop, + }; + return host; +}); + +export const layer = Layer.effect(DeviceHost.DeviceHost, make()); + +/** Exposed for tests. */ +export const __testing = { + AgentDeviceDaemonFile, + androidSdk, + platformReason, + deviceHostEnvironment, +}; diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts new file mode 100644 index 000000000000..d968d3383fae --- /dev/null +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -0,0 +1,131 @@ +// @effect-diagnostics preferSchemaOverJson:off - the external process fixture emits raw JSON over SSH stdout. +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Net from "@t3tools/shared/Net"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as ServerConfig from "../config.ts"; +import * as DeviceHost from "./DeviceHost.ts"; +import * as SshDeviceHost from "./SshDeviceHost.ts"; + +it.effect("preserves installed status after probes and cleans failed agent activation", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const modes: string[] = []; + let forwards = 0; + let failForward = true; + let rejectConfig = true; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command"); + const forwarding = command.args.includes("-N"); + let output = ""; + if (forwarding) { + if (failForward) { + failForward = false; + return yield* PlatformError.systemError({ + _tag: "AlreadyExists", + module: "ChildProcess", + method: "spawn", + description: "Port already bound", + }); + } + forwards++; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + forwards--; + }), + ); + } else { + const stdin = command.options.stdin; + if ( + !stdin || + typeof stdin !== "object" || + !("stream" in stdin) || + !Stream.isStream(stdin.stream) + ) + return yield* Effect.die("Missing script"); + const script = yield* stdin.stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (a, b) => a + b, + ), + ); + const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? ""; + modes.push(mode); + output = JSON.stringify({ + nodePath: "/node", + platforms: [{ platform: "ios", available: true }], + hubPort: 1234, + helpers: { serveSimAxSettings: null, serveSimCli: null }, + ...(mode === "agent-start" + ? { daemonPort: 1235, token: "fixture", entryPath: "/agent.mjs" } + : {}), + }); + } + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.make(new TextEncoder().encode(output)), + stderr: Stream.empty, + all: Stream.empty, + exitCode: forwarding ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(forwarding), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + }), + ); + const host = yield* SshDeviceHost.make( + { id: "test", label: "Test", target: "test.example" }, + () => + rejectConfig + ? Effect.fail( + new DeviceHost.DeviceHostError({ + hostId: "test", + step: "configuring agent access", + cause: new Error("fixture failure"), + }), + ) + : Effect.void, + ).pipe( + Effect.provide(Layer.mergeAll(ServerConfig.layerTest(home, home), Net.layer)), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("ok"))), + ), + ), + ); + yield* host.ensureReady(() => Effect.void); + expect(forwards).toBe(1); + expect(modes.filter((mode) => mode === "start")).toHaveLength(2); + yield* host.platformAvailability("ios"); + expect((yield* host.summary).hubInstalled).toBe(true); + const failed = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.result); + expect(failed._tag).toBe("Failure"); + expect(forwards).toBe(0); + expect(modes.at(-1)).toBe("stop-agent"); + expect(yield* host.current).toBeNull(); + rejectConfig = false; + yield* host.ensureAgentReady(() => Effect.void); + yield* host.platformAvailability("ios"); + expect((yield* host.summary).agentDeviceInstalled).toBe(true); + yield* host.stopAgent; + expect(forwards).toBe(1); + yield* host.stop; + expect(forwards).toBe(0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts new file mode 100644 index 000000000000..4ccda0fdefe9 --- /dev/null +++ b/apps/server/src/device/SshDeviceHost.ts @@ -0,0 +1,434 @@ +import * as NodeCrypto from "node:crypto"; +import { + type DeviceHostSummary, + DevicePlatformAvailability, + type SshDeviceHostConfig, +} from "@t3tools/contracts"; +import { runSshCommand, baseSshArgs, resolveSshCommand } from "@t3tools/ssh/command"; +import * as NetService from "@t3tools/shared/Net"; +import { waitForHttpReady } from "@t3tools/shared/httpReadiness"; +import * as Exit from "effect/Exit"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as ServerConfig from "../config.ts"; +import * as DeviceHost from "./DeviceHost.ts"; +import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts"; + +const Probe = Schema.Struct({ + nodePath: Schema.String, + platforms: Schema.Array(DevicePlatformAvailability), +}); +const Started = Schema.Struct({ + ...Probe.fields, + hubPort: Schema.Int, + daemonPort: Schema.optionalKey(Schema.Int), + token: Schema.optionalKey(Schema.String), + entryPath: Schema.optionalKey(Schema.String), + helpers: Schema.Struct({ + serveSimAxSettings: Schema.NullOr(Schema.String), + serveSimCli: Schema.NullOr(Schema.String), + }), +}); +const decodeProbe = Schema.decodeUnknownEffect(Schema.fromJsonString(Probe)); +const decodeStarted = Schema.decodeUnknownEffect(Schema.fromJsonString(Started)); +const targetFor = (config: SshDeviceHostConfig) => ({ + alias: config.target, + hostname: config.target, + username: null, + port: config.port ?? null, +}); +const identityArgs = (config: SshDeviceHostConfig) => + config.identityFile ? ["-i", config.identityFile] : []; +const commandArgs = (script: string) => [ + "sh", + "-c", + quoteRemoteArg(remoteDeviceEnvironment + script), +]; +const bootstrap = ( + config: SshDeviceHostConfig, + owner: string, + mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop", +) => + runSshCommand(targetFor(config), { + preHostArgs: identityArgs(config), + remoteCommandArgs: commandArgs( + 'command -v node >/dev/null 2>&1 || { echo "Node is missing from the non-interactive SSH PATH" >&2; exit 1; }; exec node', + ), + stdin: remoteDeviceScript(owner, mode), + timeoutMs: mode === "start" || mode === "agent-start" ? 1_300_000 : 45_000, + }).pipe( + Effect.mapError( + (cause) => new DeviceHost.DeviceHostError({ hostId: config.id, step: mode, cause }), + ), + ); + +export const probe = Effect.fn("SshDeviceHost.probe")(function* (config: SshDeviceHostConfig) { + const result = yield* bootstrap(config, "probe", "probe"); + const value = yield* decodeProbe(result.stdout.trim()).pipe( + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ hostId: config.id, step: "reading probe result", cause }), + ), + ); + return { + id: config.id, + label: config.label, + kind: "ssh", + hubInstalled: false, + agentDeviceInstalled: false, + platforms: value.platforms, + } satisfies DeviceHostSummary; +}); + +export const make = Effect.fn("SshDeviceHost.make")(function* ( + config: SshDeviceHostConfig, + onReady: ( + ready: DeviceHost.DeviceHostAgentReady, + ) => Effect.Effect = () => Effect.void, + onStatus: ( + status: "starting" | "ready" | "failed", + detail?: string, + ) => Effect.Effect = () => Effect.void, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const server = yield* ServerConfig.ServerConfig; + const net = yield* NetService.NetService; + const http = yield* HttpClient.HttpClient; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const parentScope = yield* Scope.Scope; + const ssh = yield* resolveSshCommand; + const environmentId = yield* fs + .readFileString(server.environmentIdPath) + .pipe(Effect.orElseSucceed(() => server.stateDir)); + const owner = NodeCrypto.createHash("sha256") + .update(`${environmentId}\0${server.stateDir}\0${config.id}`) + .digest("hex") + .slice(0, 24); + const provide = ( + effect: Effect.Effect< + A, + E, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + >, + ) => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const lock = yield* Semaphore.make(1); + let stopped = false; + let activated = false; + let wantsAgent = false; + let ready: + | (DeviceHost.DeviceHostReady & { + agentDevice?: DeviceHost.DeviceHostAgentReady["agentDevice"]; + }) + | null = null; + let connectionScope: Scope.Closeable | null = null; + let summary: DeviceHostSummary = { + id: config.id, + label: config.label, + kind: "ssh", + hubInstalled: false, + agentDeviceInstalled: false, + platforms: [], + }; + + const run: DeviceHost.DeviceHostReady["run"] = (command, args, options) => + provide( + runSshCommand(targetFor(config), { + preHostArgs: identityArgs(config), + remoteCommandArgs: commandArgs(`exec ${[command, ...args].map(quoteRemoteArg).join(" ")}`), + ...(options?.stdin === undefined ? {} : { stdin: options.stdin }), + ...(options?.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), + }), + ).pipe( + Effect.map((result) => ({ ...result, code: 0 })), + Effect.catch((error) => + Effect.succeed({ + stdout: "stdout" in error ? (error.stdout ?? "") : "", + stderr: error.message, + code: "exitCode" in error ? (error.exitCode ?? 127) : 127, + }), + ), + ); + + const connectOnce = Effect.fn("SshDeviceHost.connectOnce")(function* (): Effect.fn.Return< + DeviceHost.DeviceHostReady & { agentDevice?: DeviceHost.DeviceHostAgentReady["agentDevice"] }, + DeviceHost.DeviceHostError + > { + activated = true; + const result = yield* provide(bootstrap(config, owner, wantsAgent ? "agent-start" : "start")); + yield* onStatus("starting"); + const remote = yield* decodeStarted(result.stdout.trim()).pipe( + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ + hostId: config.id, + step: "reading host endpoints", + cause, + }), + ), + ); + summary = { + ...summary, + platforms: remote.platforms, + hubInstalled: true, + agentDeviceInstalled: wantsAgent || summary.agentDeviceInstalled, + }; + const hubPort = yield* net.reserveLoopbackPort("127.0.0.1").pipe( + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ + hostId: config.id, + step: "reserving hub port", + cause, + }), + ), + ); + const daemonPort = yield* net.reserveLoopbackPort("127.0.0.1").pipe( + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ + hostId: config.id, + step: "reserving daemon port", + cause, + }), + ), + ); + const scope = yield* Scope.make(); + connectionScope = scope; + const child = yield* spawner + .spawn( + ChildProcess.make( + ssh, + [ + ...baseSshArgs(targetFor(config), { batchMode: "yes" }), + ...identityArgs(config), + "-o", + "ExitOnForwardFailure=yes", + "-o", + "ServerAliveInterval=10", + "-o", + "ServerAliveCountMax=3", + "-N", + "-L", + `127.0.0.1:${hubPort}:127.0.0.1:${remote.hubPort}`, + ...(remote.daemonPort === undefined + ? [] + : ["-L", `127.0.0.1:${daemonPort}:127.0.0.1:${remote.daemonPort}`]), + config.target, + ], + { stdin: "ignore", stdout: "ignore", stderr: "pipe" }, + ), + ) + .pipe( + Effect.provideService(Scope.Scope, scope), + Effect.mapError( + (cause) => + new DeviceHost.DeviceHostError({ hostId: config.id, step: "forwarding ports", cause }), + ), + ); + let stderr = ""; + yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + Effect.sync(() => { + stderr = (stderr + chunk).slice(-2000); + }), + ), + Effect.forkIn(scope), + ); + const next = { + nodePath: remote.nodePath, + hub: { origin: `http://127.0.0.1:${hubPort}` }, + ...(remote.daemonPort !== undefined && + remote.token !== undefined && + remote.entryPath !== undefined + ? { + agentDevice: { + baseUrl: `http://127.0.0.1:${daemonPort}`, + token: remote.token, + entryPath: remote.entryPath, + }, + } + : {}), + helpers: remote.helpers, + run, + }; + for (const [baseUrl, route] of [ + [next.hub.origin, "/readyz"], + ...(next.agentDevice ? [[next.agentDevice.baseUrl, "/health"]] : []), + ]) { + yield* waitForHttpReady({ + baseUrl: baseUrl!, + path: route!, + timeoutMs: 15000, + makeError: () => + new DeviceHost.DeviceHostError({ + hostId: config.id, + step: "waiting for SSH forward", + cause: new Error(stderr || "Forwarded endpoint did not answer."), + }), + }).pipe(Effect.provideService(HttpClient.HttpClient, http)); + } + if (next.agentDevice) yield* onReady({ ...next, agentDevice: next.agentDevice }); + ready = next; + yield* onStatus("ready"); + // Reconnect also repairs helpers that died while SSH itself stayed connected. + const unhealthy = Effect.gen(function* () { + while (true) { + yield* Effect.sleep("10 seconds"); + const alive = yield* http.get(`${next.hub.origin}/readyz`).pipe( + Effect.timeout("5 seconds"), + Effect.map((r) => r.status === 200), + Effect.orElseSucceed(() => false), + ); + const daemonAlive = next.agentDevice + ? yield* http.get(`${next.agentDevice!.baseUrl}/health`).pipe( + Effect.timeout("5 seconds"), + Effect.map((r) => r.status === 200), + Effect.orElseSucceed(() => false), + ) + : true; + if (!alive || !daemonAlive) return; + } + }); + yield* Effect.gen(function* () { + yield* Effect.raceFirst(child.exitCode.pipe(Effect.ignore), unhealthy); + if (stopped || connectionScope !== scope) return; + ready = null; + yield* onStatus("starting", "Reconnecting to device host…"); + yield* Scope.close(scope, Exit.void); + let delay = 1000; + while (true) { + if (stopped || connectionScope !== scope) return; + yield* Effect.sleep(delay); + const result = yield* lock + .withPermit( + Effect.suspend(() => (stopped || ready ? Effect.void : connect().pipe(Effect.asVoid))), + ) + .pipe(Effect.result); + if (result._tag === "Success") return; + yield* onStatus("failed", result.failure.message); + if (connectionScope && connectionScope !== scope) + yield* Scope.close(connectionScope, Exit.void); + connectionScope = scope; + delay = Math.min(delay * 2, 30000); + } + }).pipe(Effect.forkIn(parentScope)); + return next; + }); + + const connect = Effect.fn("SshDeviceHost.connect")(function* () { + for (let attempt = 0; ; attempt++) { + const result = yield* connectOnce().pipe(Effect.result); + if (result._tag === "Success") return result.success; + const failedScope = connectionScope; + connectionScope = null; + if (failedScope) yield* Scope.close(failedScope, Exit.void); + // SSH binds after the reservation is released, so a competing bind needs fresh ports. + if ( + attempt >= 2 || + !["forwarding ports", "waiting for SSH forward"].includes(result.failure.step) + ) + return yield* result.failure; + } + }); + + const ensureReady: DeviceHost.DeviceHost["Service"]["ensureReady"] = (onPhase) => + lock.withPermit( + Effect.gen(function* () { + stopped = false; + if (ready) return ready; + summary = yield* provide(probe(config)); + yield* onPhase("installing"); + return yield* connect().pipe( + Effect.tapError(() => + connectionScope ? Scope.close(connectionScope, Exit.void) : Effect.void, + ), + ); + }), + ); + const stop = lock.withPermit( + Effect.gen(function* () { + stopped = true; + ready = null; + if (connectionScope) yield* Scope.close(connectionScope, Exit.void); + connectionScope = null; + if (activated) yield* provide(bootstrap(config, owner, "stop")).pipe(Effect.ignore); + activated = false; + wantsAgent = false; + }), + ); + const changeAgent = (enabled: boolean) => + lock.withPermit( + Effect.gen(function* () { + wantsAgent = enabled; + if (enabled && ready?.agentDevice) return { ...ready, agentDevice: ready.agentDevice }; + if (!enabled && !ready?.agentDevice) return null; + ready = null; + const previousScope = connectionScope; + connectionScope = null; + if (previousScope) yield* Scope.close(previousScope, Exit.void); + if (!enabled) yield* provide(bootstrap(config, owner, "stop-agent")); + return yield* connect().pipe( + Effect.onError(() => + Effect.gen(function* () { + const failedScope = connectionScope; + connectionScope = null; + if (failedScope) yield* Scope.close(failedScope, Exit.void); + if (enabled) + yield* provide(bootstrap(config, owner, "stop-agent")).pipe(Effect.ignore); + }), + ), + ); + }), + ); + yield* Effect.addFinalizer(() => stop); + return { + id: config.id, + summary: Effect.sync(() => summary), + current: Effect.sync(() => ready), + ensureReady, + ensureAgentReady: (onPhase) => + onPhase("installing").pipe( + Effect.flatMap(() => changeAgent(true)), + Effect.flatMap((value) => + value?.agentDevice + ? Effect.succeed({ ...value, agentDevice: value.agentDevice }) + : Effect.fail( + new DeviceHost.DeviceHostError({ + hostId: config.id, + step: "starting agent tools", + cause: new Error("Daemon endpoint missing"), + }), + ), + ), + ), + stopAgent: changeAgent(false).pipe(Effect.asVoid, Effect.ignore), + stop, + platformAvailability: (platform) => + provide(probe(config)).pipe( + Effect.map((value) => { + summary = { ...summary, platforms: value.platforms }; + return value.platforms.find((p) => p.platform === platform)!; + }), + Effect.orElseSucceed(() => ({ + platform, + available: false, + reason: "Cannot reach device host. Test its SSH connection in Settings.", + })), + ), + } satisfies DeviceHost.DeviceHost["Service"]; +}); diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts new file mode 100644 index 000000000000..eadd85fd047d --- /dev/null +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -0,0 +1,220 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off preferSchemaOverJson:off - verifies generated remote scripts using real shell and Node processes. +import * as Effect from "effect/Effect"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { describe, expect, it } from "@effect/vitest"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; +import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts"; +import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; + +const exec = NodeUtil.promisify(NodeChildProcess.execFile); + +it.effect("finds Android Studio Java for a non-interactive SSH session", () => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + yield* Effect.promise(async () => { + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ssh-java-")); + try { + const javaHome = NodePath.join(home, ".local/opt/android-studio/jbr"); + await NodeFSP.mkdir(NodePath.join(javaHome, "bin"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(javaHome, "bin/java"), + "#!/bin/sh\necho test-java\n", + { mode: 0o755 }, + ); + const result = await exec("/bin/sh", ["-c", `${remoteDeviceEnvironment}\njava`], { + env: { HOME: home, PATH: "/nonexistent", JAVA_HOME: "" }, + }); + expect(result.stdout.trim()).toBe("test-java"); + } finally { + await NodeFSP.rm(home, { recursive: true, force: true }); + } + }); + }), +); + +it.effect("preserves shell metacharacters and newlines in remote arguments", () => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + yield* Effect.promise(async () => { + const value = "quotes ' \" ; $(echo expanded) $HOME\nnext line"; + const result = await exec("sh", ["-c", `printf %s ${quoteRemoteArg(value)}`]); + expect(result.stdout).toBe(value); + }); + }), +); + +describe("remote helper lifecycle", () => { + it.effect("reuses its own healthy helpers and stops only its own runtime", () => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + yield* Effect.promise(async () => { + const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-remote-script-")); + const bin = NodePath.join(home, "bin"); + await NodeFSP.mkdir(bin); + await NodeFSP.writeFile(NodePath.join(bin, "adb"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const root = NodePath.join(home, ".t3/device"); + const hubDir = NodePath.join(root, `tools/expo-device-hub@${DEVICE_HUB_VERSION}`); + const agentDir = NodePath.join(root, `tools/agent-device@${AGENT_DEVICE_VERSION}`); + const hub = NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server/cli.mjs"); + const agent = NodePath.join(agentDir, "node_modules/agent-device/bin/agent-device.mjs"); + await NodeFSP.mkdir(NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server"), { + recursive: true, + }); + await NodeFSP.mkdir(NodePath.join(agentDir, "node_modules/agent-device/bin"), { + recursive: true, + }); + await NodeFSP.writeFile(NodePath.join(hubDir, ".install-complete"), DEVICE_HUB_VERSION); + await NodeFSP.writeFile(NodePath.join(agentDir, ".install-complete"), AGENT_DEVICE_VERSION); + await NodeFSP.writeFile( + hub, + `import http from 'node:http'; import fs from 'node:fs'; +if(fs.existsSync('fail-start-once')) {fs.unlinkSync('fail-start-once');process.exit(1);} +const args=process.argv.slice(2); http.createServer((req,res)=>{res.statusCode=fs.existsSync('unhealthy-'+process.pid)?503:200;res.end('ok');}).listen(Number(args[args.indexOf('--port')+1]),'127.0.0.1');`, + ); + await NodeFSP.writeFile( + agent, + `import fs from 'node:fs'; import path from 'node:path'; import http from 'node:http'; import {spawn} from 'node:child_process'; +const args=process.argv.slice(2); +const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir')+1]; +const file=path.join(state,'daemon.json'); +if(args[0]==='daemon') { const data=JSON.parse(fs.readFileSync(file,'utf8')); fs.writeFileSync(path.join(state,'stopped-agent'),String(data.pid)); try {process.kill(data.pid,'SIGTERM')} catch {} } +else if(args[0]==='serve') { const server=http.createServer((req,res)=>{res.statusCode=fs.existsSync(path.join(state,'unhealthy-agent-'+process.pid))?503:200;res.end('ok');}); server.listen(0,'127.0.0.1',()=>{fs.writeFileSync(file,JSON.stringify({httpPort:server.address().port,pid:process.pid,token:'test'}));process.send?.('ready');process.disconnect?.();}); } +else { const child=spawn(process.execPath,[process.argv[1],'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); } +`, + ); + const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade"; + const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade"; + let invocation = 0; + const invoke = async ( + owner: string, + mode: "start" | "agent-start" | "stop-agent" | "stop", + upgraded = false, + ) => { + const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`); + await NodeFSP.writeFile( + file, + `const originalKill = process.kill; process.kill = (pid, signal) => { if (signal === 'SIGTERM') require('node:fs').appendFileSync(${JSON.stringify(NodePath.join(home, "stops"))}, pid+'\\n'); return originalKill(pid, signal); };\n` + + remoteDeviceScript(owner, mode) + .replace(DEVICE_HUB_VERSION, upgraded ? nextHubVersion : DEVICE_HUB_VERSION) + .replace(AGENT_DEVICE_VERSION, upgraded ? nextAgentVersion : AGENT_DEVICE_VERSION), + ); + const result = await exec(process.execPath, [file], { + env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` }, + }); + return result.stdout ? JSON.parse(result.stdout) : null; + }; + const template = NodePath.join(home, "hub-template"); + await NodeFSP.cp(hubDir, template, { recursive: true }); + await NodeFSP.rm(NodePath.join(hubDir, ".install-complete")); + const installLock = hubDir + ".lock"; + await NodeFSP.symlink("2147483647:exited-installer", installLock); + await NodeFSP.writeFile( + NodePath.join(bin, "npm"), + `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`, + { mode: 0o755 }, + ); + await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true }); + await NodeFSP.writeFile(NodePath.join(root, "hosts/one/fail-start-once"), ""); + try { + const [manual, concurrent] = await Promise.all([ + invoke("one", "start"), + invoke("one", "start"), + ]); + expect(concurrent.hubPort).toBe(manual.hubPort); + expect(manual.daemonPort).toBeUndefined(); + await expect( + NodeFSP.stat(NodePath.join(root, "hosts/one/daemon.json")), + ).rejects.toThrow(); + const [first, concurrentAgent] = await Promise.all([ + invoke("one", "agent-start"), + invoke("one", "agent-start"), + ]); + expect(concurrentAgent.hubPort).toBe(first.hubPort); + expect(concurrentAgent.daemonPort).toBe(first.daemonPort); + const second = await invoke("two", "agent-start"); + const reused = await invoke("one", "agent-start"); + expect(reused.hubPort).toBe(first.hubPort); + expect(reused.daemonPort).toBe(first.daemonPort); + expect(second.hubPort).not.toBe(first.hubPort); + expect(second.daemonPort).not.toBe(first.daemonPort); + const firstHub = JSON.parse( + await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"), + ); + const secondHub = JSON.parse( + await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8"), + ); + await NodeFSP.writeFile(NodePath.join(root, `hosts/one/unhealthy-${firstHub.pid}`), ""); + let repaired = await invoke("one", "agent-start"); + expect(repaired.hubPort).not.toBe(first.hubPort); + const stopped = (await NodeFSP.readFile(NodePath.join(home, "stops"), "utf8")) + .trim() + .split("\n"); + expect(stopped).toContain(String(firstHub.pid)); + expect(stopped).not.toContain(String(secondHub.pid)); + const previousDaemon = JSON.parse( + await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"), + ); + for (const [source, name, version] of [ + [hubDir, "expo-device-hub", nextHubVersion], + [agentDir, "agent-device", nextAgentVersion], + ]) { + const destination = NodePath.join(root, `tools/${name}@${version}`); + await NodeFSP.cp(source!, destination, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(destination, ".install-complete"), version!); + } + const upgraded = await invoke("one", "agent-start", true); + expect(upgraded.entryPath).toContain(nextAgentVersion); + const upgradedHub = JSON.parse( + await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"), + ); + expect(upgradedHub.entryPath).toContain(nextHubVersion); + const upgradedDaemon = JSON.parse( + await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"), + ); + expect(upgradedDaemon.pid).not.toBe(previousDaemon.pid); + expect(await invoke("one", "agent-start", true)).toEqual(upgraded); + await NodeFSP.writeFile( + NodePath.join(root, `hosts/one/unhealthy-agent-${upgradedDaemon.pid}`), + "", + ); + repaired = await invoke("one", "agent-start", true); + expect( + await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"), + ).toBe(String(upgradedDaemon.pid)); + expect(repaired.daemonPort).not.toBe(upgraded.daemonPort); + // Stop still uses the recorded entry when a future pinned package is not installed yet. + const originalScript = remoteDeviceScript("one", "stop-agent"); + const upgradedStop = NodePath.join(home, "upgraded-stop.cjs"); + await NodeFSP.writeFile( + upgradedStop, + originalScript.replace(AGENT_DEVICE_VERSION, "999.0.0"), + ); + await exec(process.execPath, [upgradedStop], { + env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` }, + }); + const daemon = JSON.parse( + await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"), + ); + expect( + await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"), + ).toBe(String(daemon.pid)); + expect((await fetch(`http://127.0.0.1:${repaired.hubPort}/readyz`)).ok).toBe(true); + await invoke("one", "stop"); + expect((await fetch(`http://127.0.0.1:${second.hubPort}/readyz`)).ok).toBe(true); + expect( + JSON.parse(await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8")) + .owner, + ).toBe("two"); + } finally { + await invoke("one", "stop").catch(() => {}); + await invoke("two", "stop").catch(() => {}); + await NodeFSP.rm(home, { recursive: true, force: true }); + } + }); + }), + ); +}); diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts new file mode 100644 index 000000000000..bbdb828c1a74 --- /dev/null +++ b/apps/server/src/device/sshDeviceScript.ts @@ -0,0 +1,192 @@ +import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts"; + +export const quoteRemoteArg = (value: string) => `'${value.replaceAll("'", "'\"'\"'")}'`; + +/** Resolve common non-interactive SDK and Node locations without sourcing user shell scripts. */ +export const remoteDeviceEnvironment = `export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" +if [ -z "$ANDROID_HOME" ]; then + if [ -d "$HOME/Library/Android/sdk" ]; then export ANDROID_HOME="$HOME/Library/Android/sdk"; + elif [ -d "$HOME/Android/Sdk" ]; then export ANDROID_HOME="$HOME/Android/Sdk"; fi +fi +if [ -n "$ANDROID_HOME" ]; then export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"; fi +if [ -z "$JAVA_HOME" ] && ! command -v java >/dev/null 2>&1; then + for device_java_home in "$HOME/.local/opt/android-studio/jbr" /opt/android-studio/jbr /Applications/Android\\ Studio.app/Contents/jbr "$HOME/Applications/Android Studio.app/Contents/jbr"; do + if [ -x "$device_java_home/bin/java" ]; then export JAVA_HOME="$device_java_home"; break; fi + done +fi +if [ -n "$JAVA_HOME" ]; then export PATH="$JAVA_HOME/bin:$PATH"; fi +`; + +/** Node runs this on the host. All paths it returns belong to that host. */ +export const remoteDeviceScript = ( + owner: string, + mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop", +) => + ` +const owner = ${JSON.stringify(owner)}; +const mode = ${JSON.stringify(mode)}; +const hubVersion = ${JSON.stringify(DEVICE_HUB_VERSION)}; +const agentVersion = ${JSON.stringify(AGENT_DEVICE_VERSION)}; +` + + String.raw` +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const net = require('node:net'); +const { spawn, spawnSync } = require('node:child_process'); +const root = path.join(os.homedir(), '.t3', 'device'); +const state = path.join(root, 'hosts', owner); +const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', timeout: 30000, ...options }); +const read = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }; +const write = (file, value) => { const tmp = file + '.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); fs.renameSync(tmp, file); }; +const stopHub = hub => { + if (!hub || hub.owner !== owner) return; + const command = run('ps', ['-p', String(hub.pid), '-o', 'command=']).stdout || ''; + if (command.includes(hub.entryPath) && command.includes(String(hub.port))) { + try { process.kill(hub.pid, 'SIGTERM'); } catch {} + } +}; +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +const healthy = async (port, route) => { try { return (await fetch('http://127.0.0.1:' + port + route, { signal: AbortSignal.timeout(2000) })).ok; } catch { return false; } }; +const port = () => new Promise((resolve, reject) => { const server = net.createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const value = server.address().port; server.close(() => resolve(value)); }); }); +async function acquireLock(lock, complete = () => false) { + const deadline = Date.now() + 600000; + const token = process.pid + ':' + require('node:crypto').randomUUID(); + const owner = () => { try { return fs.readlinkSync(lock); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } }; + while (true) { + try { + // Publishing the PID and token is atomic; suspension cannot leave an incomplete owner. + fs.symlinkSync(token, lock); + return () => { if (owner() === token) fs.unlinkSync(lock); }; + } catch (error) { + if (error.code !== 'EEXIST') throw error; + if (complete()) return null; + const previous = owner(); + if (previous === null) continue; + const pid = Number(previous.split(':')[0]); + if (!Number.isSafeInteger(pid) || pid <= 0) throw Error('Invalid device lock at ' + lock); + try { process.kill(pid, 0); } catch (error) { + if (error.code === 'ESRCH' && owner() === previous) { + try { fs.unlinkSync(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; } + continue; + } + } + if (Date.now() > deadline) throw Error('Device operation is locked at ' + lock + '. Check the other installer before removing the lock.'); + await sleep(500); + } + } +} +async function install(name, version, entry) { + const dir = path.join(root, 'tools', name + '@' + version); + const file = path.join(dir, 'node_modules', name, entry); + const complete = () => fs.existsSync(file) && fs.existsSync(path.join(dir, '.install-complete')) && fs.readFileSync(path.join(dir, '.install-complete'), 'utf8').trim() === version; + if (complete()) return file; + fs.mkdirSync(path.dirname(dir), { recursive: true }); + const lock = dir + '.lock'; + const release = await acquireLock(lock, complete); + if (!release) return file; + let staging; + try { + if (complete()) return file; + staging = fs.mkdtempSync(path.join(path.dirname(dir), '.install-')); + const result = run('npm', ['install', '--prefix', staging, '--no-fund', '--no-audit', name + '@' + version], { timeout: 600000, maxBuffer: 8 * 1024 * 1024 }); + if (result.status !== 0) throw Error('Installing ' + name + ': ' + (result.error?.message || result.stderr?.slice(-2000))); + if (!fs.existsSync(path.join(staging, 'node_modules', name, entry))) throw Error('Missing installed entry for ' + name); + fs.writeFileSync(path.join(staging, '.install-complete'), version); + fs.rmSync(dir, { recursive: true, force: true }); + fs.renameSync(staging, dir); + return file; + } finally { + if (staging) fs.rmSync(staging, { recursive: true, force: true }); + release(); + } +} +(async () => { + const ios = process.platform === 'darwin' && run('xcrun', ['simctl', 'help']).status === 0; + const android = run('adb', ['version']).status === 0; + const platforms = [ + { platform: 'ios', available: ios, ...(!ios ? { reason: 'iOS needs macOS with Xcode and working xcrun simctl.' } : {}) }, + { platform: 'android', available: android, ...(!android ? { reason: 'Android SDK missing. Set ANDROID_HOME or put adb on the SSH PATH.' } : {}) }, + ]; + if (mode === 'probe') { + if (Number(process.versions.node.split('.')[0]) < 22) throw Error('Node 22 or newer is required on the device host.'); + if (run('npm', ['--version']).status !== 0) throw Error('npm is missing from the non-interactive SSH PATH.'); + console.log(JSON.stringify({ nodePath: process.execPath, platforms })); return; + } + fs.mkdirSync(state, { recursive: true, mode: 0o700 }); + // Serialize starts and stops for this environment/host owner, including agent startup. + const hostLock = path.join(state, 'runtime.lock'); + const releaseHost = await acquireLock(hostLock); + try { + const hubFile = path.join(state, 'hub.json'); + const daemonFile = path.join(state, 'daemon.json'); + const agentFile = path.join(state, 'agent.json'); + if (mode === 'stop' || mode === 'stop-agent') { + const hub = read(hubFile); + if (mode === 'stop' && hub && hub.owner === owner) { + stopHub(hub); + fs.rmSync(hubFile, { force: true }); + } + const entry = read(agentFile)?.entryPath || path.join(root, 'tools', 'agent-device@' + agentVersion, 'node_modules', 'agent-device', 'bin', 'agent-device.mjs'); + if (fs.existsSync(entry)) run(process.execPath, [entry, 'daemon', 'stop', '--state-dir', state]); + return; + } + if (!ios && !android) throw Error(platforms.map(p => p.reason).join(' ')); + fs.mkdirSync(state, { recursive: true, mode: 0o700 }); + const hubEntry = await install('expo-device-hub', hubVersion, 'dist/server/cli.mjs'); + let hub = read(hubFile); + if (!hub || hub.owner !== owner || hub.entryPath !== hubEntry || !await healthy(hub.port, '/readyz')) { + stopHub(hub); + for (let attempt = 0; attempt < 5; attempt++) { + const hubPort = await port(); + const log = fs.openSync(path.join(state, 'hub.log'), 'a'); + const child = spawn(process.execPath, [hubEntry, '--port', String(hubPort), '--host', '127.0.0.1', '--hide-sidebar', '--hide-boot-device'], { + cwd: state, detached: true, stdio: ['ignore', log, log], env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' }, + }); + try { await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); }); } + finally { fs.closeSync(log); } + child.unref(); + hub = { owner, pid: child.pid, port: hubPort, entryPath: hubEntry }; + write(hubFile, hub); + const deadline = Date.now() + 30000; + let listening = false; + while (child.exitCode === null && child.signalCode === null) { + if (await healthy(hub.port, '/readyz')) { listening = true; break; } + if (Date.now() > deadline) { stopHub(hub); throw Error('Device hub did not become ready. See ' + path.join(state, 'hub.log')); } + await sleep(200); + } + if (listening) break; + // Port reservation and binding happen in different processes. Retry an early exit with a fresh port. + fs.rmSync(hubFile, { force: true }); + if (attempt === 4) throw Error('Device hub exited before becoming ready. See ' + path.join(state, 'hub.log')); + } + } + let agentResult = {}; + if (mode === 'agent-start') { + const agentEntry = await install('agent-device', agentVersion, 'bin/agent-device.mjs'); + const previousAgent = read(agentFile)?.entryPath; + let daemon = read(daemonFile); + if (daemon && (previousAgent !== agentEntry || !await healthy(daemon.httpPort, '/health'))) { + const stopped = run(process.execPath, [previousAgent || agentEntry, 'daemon', 'stop', '--state-dir', state]); + if (stopped.status !== 0) throw Error('Could not stop the previous agent-device version.'); + fs.rmSync(daemonFile, { force: true }); + daemon = null; + } + if (!daemon) { + fs.rmSync(daemonFile, { force: true }); + const env = { ...process.env, AGENT_DEVICE_STATE_DIR: state, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', AGENT_DEVICE_NO_UPDATE_NOTIFIER: '1' }; + delete env.AGENT_DEVICE_DAEMON_BASE_URL; delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; delete env.AGENT_DEVICE_CONFIG; + run(process.execPath, [agentEntry, 'devices', '--json'], { env }); + daemon = read(daemonFile); + } + if (!daemon || !await healthy(daemon.httpPort, '/health')) throw Error('agent-device daemon did not become ready in ' + state); + write(agentFile, { entryPath: agentEntry }); + agentResult = { daemonPort: daemon.httpPort, token: daemon.token, entryPath: agentEntry }; + } + const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist'); + const optional = file => fs.existsSync(file) ? file : null; + console.log(JSON.stringify({ nodePath: process.execPath, platforms, hubPort: hub.port, ...agentResult, + helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } })); + } finally { releaseHost(); } +})().catch(error => { console.error(error.message); process.exitCode = 1; }); +`; diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index a674a25c1ec8..a12a8242b0d2 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -169,6 +169,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.usagePriceOverrides).toBe(true); expect(second.capabilities.threadActiveReorder).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); + expect(second.capabilities.threadPullRequests).toBe(true); expect(second.capabilities.threadPullRequestLinking).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); }), diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index bf02cd90fbdf..2aab17b27a76 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -230,6 +230,8 @@ export const make = Effect.gen(function* () { threadPinReorder: true, threadActiveReorder: true, threadTitleRegeneration: true, + threadPullRequests: true, + pullRequestStackActions: true, threadPullRequestLinking: true, environmentIcon: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), diff --git a/apps/server/src/git/linkCreatedPullRequest.test.ts b/apps/server/src/git/linkCreatedPullRequest.test.ts new file mode 100644 index 000000000000..2c33d82792bd --- /dev/null +++ b/apps/server/src/git/linkCreatedPullRequest.test.ts @@ -0,0 +1,230 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type GitRunStackedActionResult, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; + +import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { createdPullRequestKey, linkCreatedPullRequest } from "./linkCreatedPullRequest.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const THREAD_ID = ThreadId.make("thread-1"); +const commandId = Effect.succeed(CommandId.make("server:pr-created-link:test")); + +const project: OrchestrationProjectShell = { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + repositoryIdentity: { + canonicalKey: "github.acme.test/platform/api", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.acme.test:Platform/API.git", + }, + provider: "github", + displayName: "Platform/API", + owner: "Platform", + name: "API", + }, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", +}; + +const thread: OrchestrationThreadShell = { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, +}; + +function prResult(pr: GitRunStackedActionResult["pr"]): Pick { + return { pr }; +} + +const makeDependencies = ( + dispatch: OrchestrationEngineShape["dispatch"], + threadShell: OrchestrationThreadShell | null = thread, +) => + Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getThreadShellById: () => Effect.succeed(Option.fromNullishOr(threadShell)), + getProjectShellById: () => Effect.succeed(Option.some(project)), + }), + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + ); + +const recordingDispatch = Effect.fn("recordingDispatch")(function* () { + const commands = yield* Ref.make>([]); + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => + Ref.update(commands, (recorded) => [...recorded, command]).pipe(Effect.as({ sequence: 1 })); + return { commands, dispatch }; +}); + +describe("createdPullRequestKey", () => { + it("reads host and repository from the URL when it is recognisable", () => { + expect( + createdPullRequestKey( + prResult({ + status: "created", + number: 12, + url: "https://github.com/Other/Fork/pull/12", + }), + project, + ), + ).toEqual({ + host: "github.com", + repository: "other/fork", + number: 12, + url: "https://github.com/Other/Fork/pull/12", + }); + }); + + it("falls back to the project's host and repository for an unreadable URL", () => { + expect( + createdPullRequestKey( + prResult({ status: "opened_existing", number: 3, url: "https://ghe.internal/x/3" }), + project, + ), + ).toEqual({ + host: "github.acme.test", + repository: "platform/api", + number: 3, + url: "https://ghe.internal/x/3", + }); + expect( + createdPullRequestKey( + prResult({ status: "created", number: 3, url: "https://ghe.internal/x/3" }), + undefined, + ), + ).toBeNull(); + }); + + it("yields nothing when no pull request came out of the action", () => { + expect( + createdPullRequestKey(prResult({ status: "skipped_not_requested" }), project), + ).toBeNull(); + expect( + createdPullRequestKey( + prResult({ status: "created", url: "https://github.com/a/b/pull/1" }), + project, + ), + ).toBeNull(); + expect(createdPullRequestKey(prResult({ status: "created", number: 1 }), project)).toBeNull(); + }); +}); + +describe("linkCreatedPullRequest", () => { + it.effect("links a created pull request to the thread with source created", () => + Effect.gen(function* () { + const { commands, dispatch } = yield* recordingDispatch(); + yield* linkCreatedPullRequest({ + threadId: THREAD_ID, + result: prResult({ + status: "created", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + }), + commandId, + }).pipe(Effect.provide(makeDependencies(dispatch))); + + expect(yield* Ref.get(commands)).toEqual([ + { + type: "thread.pull-request.link", + commandId: "server:pr-created-link:test", + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "created", + }, + ]); + }), + ); + + it.effect("dispatches nothing when the action produced no pull request", () => + Effect.gen(function* () { + const { commands, dispatch } = yield* recordingDispatch(); + const dependencies = makeDependencies(dispatch); + yield* linkCreatedPullRequest({ + threadId: THREAD_ID, + result: prResult({ status: "skipped_not_requested" }), + commandId, + }).pipe(Effect.provide(dependencies)); + yield* linkCreatedPullRequest({ + threadId: THREAD_ID, + result: prResult({ status: "created", url: "https://github.com/t3tools/t3code/pull/42" }), + commandId, + }).pipe(Effect.provide(dependencies)); + + expect(yield* Ref.get(commands)).toEqual([]); + }), + ); + + it.effect("swallows an already-linked rejection and other dispatch failures", () => + Effect.gen(function* () { + const rejecting: OrchestrationEngineShape["dispatch"] = (command) => + Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "already linked", + }), + ); + const result = prResult({ + status: "opened_existing", + number: 7, + url: "https://github.com/t3tools/t3code/pull/7", + }); + yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe( + Effect.provide(makeDependencies(rejecting)), + ); + yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe( + Effect.provide(makeDependencies(() => Effect.die(new Error("engine down")))), + ); + // A thread that vanished between the action and the link is not an error either. + yield* linkCreatedPullRequest({ threadId: THREAD_ID, result, commandId }).pipe( + Effect.provide(makeDependencies(() => Effect.die(new Error("unreachable")), null)), + ); + }), + ); +}); diff --git a/apps/server/src/git/linkCreatedPullRequest.ts b/apps/server/src/git/linkCreatedPullRequest.ts new file mode 100644 index 000000000000..4eaeea1dc6f9 --- /dev/null +++ b/apps/server/src/git/linkCreatedPullRequest.ts @@ -0,0 +1,98 @@ +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; +import { + type CommandId, + pullRequestHostOf, + type GitRunStackedActionResult, + type OrchestrationProjectShell, + type SourceControlProviderKind, + type ThreadId, +} from "@t3tools/contracts"; +import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; + +export interface CreatedPullRequestKey { + readonly host: string; + readonly repository: string; + readonly number: number; + readonly url: string; +} + +/** + * The identity a stacked action's pull request should be linked under, or + * null when the action did not leave one behind. Reading the host and + * repository from the URL keeps the link host-level even when the checkout's + * remote differs from where the PR was opened (a fork, say); the project is + * only consulted when the URL is one this cannot read. + */ +export function createdPullRequestKey( + result: Pick, + project: OrchestrationProjectShell | undefined, +): CreatedPullRequestKey | null { + const { status, number, url } = result.pr; + if ((status !== "created" && status !== "opened_existing") || number === undefined || !url) { + return null; + } + const parsed = parseChangeRequestUrl(url); + if (parsed !== null) return { ...parsed, url }; + const identity = project?.repositoryIdentity; + const kind = identity?.provider as SourceControlProviderKind | undefined; + const repository = sourceControlRepositorySelector(identity); + if (!identity || kind === undefined || repository === null) return null; + return { + host: pullRequestHostOf(identity, kind), + repository: repository.toLowerCase(), + number, + url, + }; +} + +/** + * Links the pull request a `create_pr`-shaped action produced to the thread it + * ran beside. Never fails: the git action already succeeded and its result is + * on its way to the client, so a link that cannot be made is logged and + * dropped. A duplicate link is the decider saying the thread already knew. + */ +export const linkCreatedPullRequest = (input: { + readonly threadId: ThreadId; + readonly result: Pick; + readonly commandId: Effect.Effect; +}): Effect.Effect< + void, + never, + OrchestrationEngine.OrchestrationEngineService | ProjectionSnapshotQuery.ProjectionSnapshotQuery +> => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const thread = yield* snapshots.getThreadShellById(input.threadId); + if (Option.isNone(thread)) return; + const project = Option.getOrUndefined( + yield* snapshots.getProjectShellById(thread.value.projectId), + ); + const key = createdPullRequestKey(input.result, project); + if (key === null) return; + const commandId = yield* input.commandId; + yield* engine + .dispatch({ + type: "thread.pull-request.link", + commandId, + threadId: input.threadId, + ...key, + source: "created", + }) + .pipe(Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.void })); + }).pipe( + Effect.withSpan("linkCreatedPullRequest"), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause as Cause.Cause) + : Effect.logWarning("failed to link created pull request to thread", { + threadId: input.threadId, + }), + ), + ); diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts new file mode 100644 index 000000000000..3a9307314c39 --- /dev/null +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -0,0 +1,176 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + DeviceHostUnavailableError, + EnvironmentId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { McpSchema, McpServer } from "effect/unstable/ai"; + +import * as ServerConfig from "../config.ts"; +import * as DeviceService from "../device/DeviceService.ts"; +import * as McpHttpServer from "./McpHttpServer.ts"; +import * as McpInvocationContext from "./McpInvocationContext.ts"; + +const environmentId = EnvironmentId.make("environment-device-test"); +const threadId = ThreadId.make("thread-device-test"); +const invocation = (capabilities: ReadonlyArray) => ({ + environmentId, + threadId, + providerSessionId: "provider-session-device-test", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(capabilities), + issuedAt: 1, +}); +const client = McpSchema.McpServerClient.of({ + clientId: 1, + clientCapabilities: {}, + clientInfo: { name: "mcp-test", version: "1.0.0" }, + protocolVersion: "2025-06-18", + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "mcp-test", version: "1.0.0" }, + }, + getClient: Effect.die("unused"), +}); + +const device = { + hostId: "local", + id: "UDID-1", + platform: "ios" as const, + name: "iPhone 17 Pro", + version: "iOS 27.0", + booted: true, + physical: false, +}; +const state = { + hosts: [ + { + id: "local", + kind: "local" as const, + label: "This machine", + platforms: [ + { platform: "ios" as const, available: true }, + { platform: "android" as const, available: false, reason: "No SDK" }, + ], + hubInstalled: true, + agentDeviceInstalled: true, + }, + ], + hostStatus: "ready" as const, + hostStatuses: { local: { status: "ready" as const } }, + devices: [device], + sessions: [], + onboardingCompleted: true, + agentAccessEnabled: true, + hubBasePath: "/api/device-hub", + revision: 1, +}; +const png = new Uint8Array(24); +new DataView(png.buffer).setUint32(0, 0x89504e47); +new DataView(png.buffer).setUint32(4, 0x0d0a1a0a); +new DataView(png.buffer).setUint32(12, 0x49484452); +new DataView(png.buffer).setUint32(16, 1206); +new DataView(png.buffer).setUint32(20, 2622); + +const DeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ + state: Effect.succeed(state), + list: Effect.succeed(state), + open: (input) => + Effect.succeed({ + threadId: input.threadId, + hostId: "local", + deviceId: input.deviceId, + platform: input.platform, + openedAt: "2026-09-08T00:00:00.000Z", + }), + sessionsForThread: () => Effect.succeed([]), + screenshot: () => Effect.succeed({ device, png }), + close: () => Effect.void, + agentCli: Effect.succeed("/cli"), + testHost: () => Effect.die("not used"), + agentTarget: () => Effect.succeed(["--config", "/host.json", "--session", "thread-device"]), +}); + +const TestLayer = McpHttpServer.DeviceToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(DeviceServiceMock), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" })), + Layer.provide(NodeServices.layer), +); + +it.effect("registers the device tools and returns the screenshot as image content", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const names = server.tools.map(({ tool }) => tool.name).toSorted(); + expect(names).toEqual(["device_close", "device_list", "device_open", "device_screenshot"]); + + const callWith = (capabilities: ReadonlyArray) => + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(capabilities)); + + const opened = yield* server + .callTool({ name: "device_open", arguments: { platform: "ios" } }) + .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client)); + expect(opened.isError).toBe(false); + const openedContent = opened.structuredContent as { quickStart: string }; + expect(openedContent.quickStart).toContain("--udid UDID-1"); + + const shot = yield* server + .callTool({ name: "device_screenshot", arguments: { deviceId: "UDID-1" } }) + .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client)); + expect(shot.isError).toBe(false); + expect(shot.content.map((entry) => entry.type)).toEqual(["text", "image"]); + expect(shot.structuredContent).toMatchObject({ + screenshot: { mimeType: "image/png", width: 1206, height: 2622 }, + }); + + const denied = yield* server + .callTool({ name: "device_list", arguments: {} }) + .pipe(callWith(["preview"]), Effect.provideService(McpSchema.McpServerClient, client)); + expect(denied.isError).toBe(true); + }), + ).pipe(Effect.provide(TestLayer)), +); + +it.effect("rejects unavailable agent access before booting or opening a device", () => { + const unavailable = Layer.mock(DeviceService.DeviceService)({ + list: Effect.succeed(state), + agentTarget: () => + Effect.fail( + new DeviceHostUnavailableError({ hostId: "local", reason: "Agent access is disabled." }), + ), + open: () => Effect.die("Must not boot or register a device when agent access fails"), + }); + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ name: "device_open", arguments: { platform: "ios" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(["device"])), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("Agent access is disabled."), + }), + ]), + ); + }).pipe( + Effect.scoped, + Effect.provide( + McpHttpServer.DeviceToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(unavailable), + Layer.provide(NodeServices.layer), + ), + ), + ); +}); diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 133e71bc3bcb..9cbc3fde2977 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -6,12 +6,15 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai"; import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ServerConfig from "../config.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; @@ -48,6 +51,18 @@ const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-server-test-" })), Layer.provideMerge(NodeServices.layer), ); +const PullRequestsTestLayer = McpHttpServer.PullRequestsToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getThreadShellById: () => Effect.succeed(Option.none()), + }), + Layer.mock(OrchestrationEngineService)({}), + NodeServices.layer, + ), + ), +); const snapshotResult = { url: "http://example.test/", @@ -235,7 +250,10 @@ it.effect.each([ const { accessibilityTree: _tree, ...boundedMetadata } = metadata; expect(snapshot.isError).toBe(false); expect(snapshot.structuredContent).toEqual(metadata); - const [text, ...rest] = snapshot.content; + const [identity, text, ...rest] = snapshot.content; + expect(identity?.type === "text" ? decodeJsonText(identity.text) : null).toEqual({ + url: page.url, + }); expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual(boundedMetadata); expect(rest).toEqual([ { @@ -264,7 +282,12 @@ it.effect.each([ Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.provideService(McpSchema.McpServerClient, client), ); - expect(nextDefault.content.map((content) => content.type)).toEqual(["text", "text", "image"]); + expect(nextDefault.content.map((content) => content.type)).toEqual([ + "text", + "text", + "text", + "image", + ]); expect(nextDefault.structuredContent).toEqual({ ...page, title: "Snapshot 7", screenshot }); expect(requests).toBe(7); }), @@ -314,7 +337,7 @@ it.effect("saves the snapshot PNG on request and reports its path", () => /^browser-screenshot-example-test-[0-9a-z]+-[0-9a-f]{8}\.png$/, ); expect(Buffer.from(yield* fileSystem.readFile(screenshotPath!)).toString()).toBe("png"); - const text = snapshot.content.find((content) => content.type === "text"); + const [, text] = snapshot.content; expect(text?.type === "text" ? text.text : "").toContain(screenshotPath); const unsaved = yield* callSnapshot({}); @@ -345,6 +368,38 @@ it.effect("reports a tagged error when the screenshot cannot be saved", () => ).pipe(Effect.provide(TestLayer)), ); +it.effect( + "registers the pull request toolkit and surfaces a missing capability as a tool error", + () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const names = server.tools.map(({ tool }) => tool.name); + expect(names).toEqual( + expect.arrayContaining([ + "link_pull_request", + "unlink_pull_request", + "list_thread_pull_requests", + ]), + ); + const linkTool = server.tools.find(({ tool }) => tool.name === "link_pull_request"); + expect(linkTool?.tool.annotations?.idempotentHint).toBe(true); + expect(linkTool?.tool.annotations?.openWorldHint).toBe(false); + expect(linkTool?.tool.description).toContain("Register every pull request you open"); + + const denied = yield* server + .callTool({ name: "list_thread_pull_requests", arguments: {} }) + .pipe( + // A preview-only credential: the token predates the toolkit or was minted elsewhere. + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(denied.isError).toBe(true); + expect(denied.content).toEqual([ + { type: "text", text: "MCP credential does not grant the pull-requests capability." }, + ]); + }).pipe(Effect.provide(PullRequestsTestLayer)), +); + it.effect("keeps the snapshot text under the agent's output ceiling", () => Effect.scoped( Effect.gen(function* () { @@ -382,7 +437,10 @@ it.effect("keeps the snapshot text under the agent's output ceiling", () => const snapshot = yield* callSnapshot({ includeImage: false }); expect(snapshot.isError).toBe(false); - const [text, notice] = snapshot.content; + const [identity, text, notice] = snapshot.content; + expect(identity?.type === "text" ? decodeJsonText(identity.text) : null).toEqual({ + url: oversized.url, + }); expect(text?.type).toBe("text"); const body = text?.type === "text" ? text.text : ""; expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( @@ -424,7 +482,7 @@ it.effect("bounds the snapshot text even when nothing but logs and the title are const snapshot = yield* callSnapshot({ includeImage: false }); - const [text] = snapshot.content; + const [, text] = snapshot.content; const body = text?.type === "text" ? text.text : ""; expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, @@ -435,7 +493,7 @@ it.effect("bounds the snapshot text even when nothing but logs and the title are }; expect(parsed.title.length).toBe(2_049); expect(parsed.consoleEntries[0]?.text.length).toBe(501); - const notice = snapshot.content[1]; + const notice = snapshot.content[2]; const noticeText = notice?.type === "text" ? notice.text : ""; expect(noticeText).toContain("url or title after 2048 characters"); expect(noticeText).toContain("console entries text after 500 characters"); @@ -486,7 +544,7 @@ it.effect("sheds log entries before locators when every list is full", () => const snapshot = yield* callSnapshot({ includeImage: false }); - const [text, notice] = snapshot.content; + const [, text, notice] = snapshot.content; const body = text?.type === "text" ? text.text : ""; expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, @@ -568,6 +626,10 @@ it.effect("registers annotated tools and preserves authenticated request context Effect.gen(function* () { const server = yield* McpServer.McpServer; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const toolIcon = { + _tag: "website" as const, + pageUrl: "http://example.test/", + }; const routedRequests: Array<{ readonly operation: string; readonly tabId?: string | undefined; @@ -617,7 +679,7 @@ it.effect("registers annotated tools and preserves authenticated request context expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false); expect(clickTool?.tool.annotations?.destructiveHint).toBe(true); expect(clickTool?.tool.annotations?.openWorldHint).toBe(true); - expect(clickTool?.tool.outputSchema).toEqual({ + expect(clickTool?.tool.outputSchema).toMatchObject({ type: "object", additionalProperties: false, description: "The preview action completed successfully.", @@ -674,10 +736,12 @@ it.effect("registers annotated tools and preserves authenticated request context Effect.provideService(McpSchema.McpServerClient, client), ); expect(evaluated.isError).toBe(false); - expect(evaluated.structuredContent).toEqual({ value: ["Connect", "Continue"] }); - expect(evaluated.content).toEqual([ - { type: "text", text: '{"value":["Connect","Continue"]}' }, - ]); + expect(evaluated.structuredContent).toEqual({ value: ["Connect", "Continue"], toolIcon }); + const evaluatedText = evaluated.content[0]; + expect(evaluatedText?.type === "text" ? decodeJsonText(evaluatedText.text) : null).toEqual({ + toolIcon, + value: ["Connect", "Continue"], + }); const actionRequests = [ { name: "preview_click", arguments: { x: 10, y: 10 } }, @@ -694,8 +758,10 @@ it.effect("registers annotated tools and preserves authenticated request context Effect.provideService(McpSchema.McpServerClient, client), ); expect(result.isError).toBe(false); - expect(result.structuredContent).toEqual({}); - expect(result.content).toEqual([{ type: "text", text: "{}" }]); + expect(result.structuredContent).toEqual({ toolIcon }); + expect(routedRequests.at(-1)?.operation).toBe("status"); + const text = result.content[0]; + expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual({ toolIcon }); } }), ).pipe(Effect.provide(TestLayer)), diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 3f3e48ebe4b2..5a8cb573ad88 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -16,6 +16,7 @@ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstab import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; +import * as DeviceService from "../device/DeviceService.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as McpSessionRegistry from "./McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; @@ -28,6 +29,17 @@ import { PreviewSnapshotToolkit, PreviewStandardToolkit, } from "./toolkits/preview/tools.ts"; +import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts"; +import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; +import { + DeviceScreenshotToolkitHandlersLive, + DeviceStandardToolkitHandlersLive, +} from "./toolkits/device/handlers.ts"; +import { + DeviceScreenshotTool, + DeviceScreenshotToolkit, + DeviceStandardToolkit, +} from "./toolkits/device/tools.ts"; const unauthorized = HttpServerResponse.jsonUnsafe( { @@ -398,6 +410,13 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot isError: false, structuredContent: metadata, content: [ + // Keep the page identity readable even if a provider truncates the snapshot. + { + type: "text", + text: encodeJsonText({ + url: cutText(snapshot.url, MAX_SNAPSHOT_IDENTIFIER_CHARS), + }), + }, { type: "text", text: bounded.text }, ...(bounded.omitted.length === 0 ? [] @@ -424,6 +443,154 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot }); }); +interface ImageToolResult { + readonly screenshot: { + readonly mimeType: "image/png"; + readonly data: string; + readonly width: number; + readonly height: number; + }; + readonly [key: string]: unknown; +} + +/** + * Failures surface only their tag: the remote message may carry renderer or + * device output the agent should not see, and the tag is what it can act on. + */ +const imageToolFailure = + (toolName: string, operation: string, failureText: string) => + (cause: Cause.Cause) => { + if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) { + return Effect.failCause(cause).pipe(Effect.orDie); + } + const failures = cause.reasons.filter(Cause.isFailReason); + const firstFailure = failures[0]?.error; + const errorTag = + typeof firstFailure === "object" && + firstFailure !== null && + "_tag" in firstFailure && + typeof firstFailure._tag === "string" + ? firstFailure._tag + : `${toolName}Error`; + const result = new McpSchema.CallToolResult({ + isError: true, + structuredContent: { + error: { + _tag: errorTag, + operation, + failureCount: failures.length, + }, + }, + content: [{ type: "text", text: failureText }], + }); + return Effect.logWarning(`${toolName} failed`, { + operation, + errorTag, + failureCount: failures.length, + }).pipe(Effect.as(result)); + }; + +/** + * `McpServer.toolkit` serializes every result as JSON text, which is the + * wrong shape for a screenshot: the model needs image content. Tools whose + * result carries a `screenshot` field are registered by hand so the PNG goes + * out as an image block and the rest of the payload as JSON metadata. + */ +const registerImageTool = ( + tool: T, + handle: (payload: Tool.Parameters) => Effect.Effect<{ readonly encodedResult: unknown }, E, R>, + provide: ( + effect: Effect.Effect<{ readonly encodedResult: unknown }, E, R>, + ) => Effect.Effect< + { readonly encodedResult: unknown }, + E, + McpInvocationContext.McpInvocationContext + >, + operation: string, + failureText: string, +) => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + yield* server.addTool({ + tool: new McpSchema.Tool({ + name: tool.name, + description: Tool.getDescription(tool), + inputSchema: Tool.getJsonSchema(tool), + annotations: { + ...Context.getOption(tool.annotations, Tool.Title).pipe( + Option.map((title) => ({ title })), + Option.getOrUndefined, + ), + readOnlyHint: Context.get(tool.annotations, Tool.Readonly), + destructiveHint: Context.get(tool.annotations, Tool.Destructive), + idempotentHint: Context.get(tool.annotations, Tool.Idempotent), + openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), + }, + }), + annotations: tool.annotations, + handle: (payload) => + Effect.withFiber((fiber) => { + const invocation = Context.getUnsafe( + fiber.context, + McpInvocationContext.McpInvocationContext, + ); + return provide(handle(payload as Tool.Parameters)).pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.matchCauseEffect({ + onFailure: imageToolFailure(tool.name, operation, failureText), + onSuccess: ({ encodedResult }) => { + const { screenshot, ...rest } = encodedResult as ImageToolResult; + const includeImage = + (payload as { readonly includeImage?: boolean } | undefined)?.includeImage !== + false; + const metadata = { + ...rest, + screenshot: { + mimeType: screenshot.mimeType, + width: screenshot.width, + height: screenshot.height, + }, + }; + return Effect.succeed( + new McpSchema.CallToolResult({ + isError: false, + structuredContent: metadata, + content: [ + { type: "text", text: JSON.stringify(metadata) }, + ...(includeImage + ? [ + { + type: "image" as const, + data: new Uint8Array(Buffer.from(screenshot.data, "base64")), + mimeType: screenshot.mimeType, + }, + ] + : []), + ], + }), + ); + }, + }), + ); + }), + }); + }); + +const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreenshot")(function* () { + const devices = yield* DeviceService.DeviceService; + const built = yield* DeviceScreenshotToolkit; + yield* registerImageTool( + DeviceScreenshotTool, + (payload) => + built + .handle("device_screenshot", payload) + .pipe(Stream.unwrap, Stream.run(Sink.last()), Effect.flatMap(Effect.fromOption)), + (effect) => effect.pipe(Effect.provideService(DeviceService.DeviceService, devices)), + "screenshot", + "Device screenshot failed.", + ); +}); + const PreviewStandardToolkitRegistrationLive = McpServer.toolkit(PreviewStandardToolkit).pipe( Layer.provide(PreviewStandardToolkitHandlersLive), ); @@ -437,6 +604,23 @@ export const PreviewToolkitRegistrationLive = Layer.mergeAll( PreviewSnapshotRegistrationLive, ); +export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequestsToolkit).pipe( + Layer.provide(PullRequestsToolkitHandlersLive), +); + +const DeviceStandardToolkitRegistrationLive = McpServer.toolkit(DeviceStandardToolkit).pipe( + Layer.provide(DeviceStandardToolkitHandlersLive), +); + +const DeviceScreenshotRegistrationLive = Layer.effectDiscard(registerDeviceScreenshot()).pipe( + Layer.provide(DeviceScreenshotToolkitHandlersLive), +); + +export const DeviceToolkitRegistrationLive = Layer.mergeAll( + DeviceStandardToolkitRegistrationLive, + DeviceScreenshotRegistrationLive, +); + const McpTransportLive = McpServer.layerHttp({ name: "T3 Code", version: packageJson.version, @@ -444,4 +628,8 @@ const McpTransportLive = McpServer.layerHttp({ protocols: [McpProtocol.v2025_06_18], }).pipe(Layer.provide(McpAuthMiddlewareLive)); -export const layer = PreviewToolkitRegistrationLive.pipe(Layer.provideMerge(McpTransportLive)); +export const layer = Layer.mergeAll( + PreviewToolkitRegistrationLive, + PullRequestsToolkitRegistrationLive, + DeviceToolkitRegistrationLive, +).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/McpInvocationContext.test.ts b/apps/server/src/mcp/McpInvocationContext.test.ts index 569917325bef..123944206e37 100644 --- a/apps/server/src/mcp/McpInvocationContext.test.ts +++ b/apps/server/src/mcp/McpInvocationContext.test.ts @@ -1,6 +1,7 @@ import { expect, it } from "@effect/vitest"; import { EnvironmentId, + McpCapabilityUnavailableError, PreviewAutomationUnavailableError, ProviderInstanceId, ThreadId, @@ -36,3 +37,29 @@ it.effect("reports the scoped credential context when preview capability is unav expect(error.message).toBe("MCP credential does not grant the preview capability."); }); }); + +it.effect("reports other missing capabilities with the neutral error", () => { + const invocation: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview"]), + issuedAt: 1, + }; + + return Effect.gen(function* () { + const error = yield* McpInvocationContext.requireMcpCapability("pull-requests").pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.flip, + ); + + expect(error).toBeInstanceOf(McpCapabilityUnavailableError); + expect(error).toMatchObject({ capability: "pull-requests", threadId: invocation.threadId }); + + const scope = yield* McpInvocationContext.requireMcpCapability("preview").pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + ); + expect(scope).toBe(invocation); + }); +}); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 49273485a44d..eddfa7270a77 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -1,5 +1,6 @@ import { type EnvironmentId, + McpCapabilityUnavailableError, PreviewAutomationUnavailableError, type ProviderInstanceId, type ThreadId, @@ -7,7 +8,7 @@ import { import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -export type McpCapability = "preview"; +export type McpCapability = "preview" | "device" | "pull-requests"; export interface McpInvocationScope { readonly environmentId: EnvironmentId; @@ -23,18 +24,32 @@ export class McpInvocationContext extends Context.Service< McpInvocationScope >()("t3/mcp/McpInvocationContext") {} -export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* ( +/** The error a missing capability surfaces as; preview keeps its own so the broker can route it. */ +export type McpCapabilityError = C extends "preview" + ? PreviewAutomationUnavailableError + : McpCapabilityUnavailableError; + +const missingCapability = ( + invocation: McpInvocationScope, capability: McpCapability, -) { - const invocation = yield* McpInvocationContext; - if (!invocation.capabilities.has(capability)) { - return yield* new PreviewAutomationUnavailableError({ - capability, - environmentId: invocation.environmentId, - threadId: invocation.threadId, - providerSessionId: invocation.providerSessionId, - providerInstanceId: invocation.providerInstanceId, - }); - } - return invocation; -}); +): PreviewAutomationUnavailableError | McpCapabilityUnavailableError => { + const fields = { + environmentId: invocation.environmentId, + threadId: invocation.threadId, + providerSessionId: invocation.providerSessionId, + providerInstanceId: invocation.providerInstanceId, + }; + return capability === "preview" + ? new PreviewAutomationUnavailableError({ capability, ...fields }) + : new McpCapabilityUnavailableError({ capability, ...fields }); +}; + +export const requireMcpCapability = ( + capability: C, +): Effect.Effect, McpInvocationContext> => + Effect.flatMap(McpInvocationContext, (invocation) => + invocation.capabilities.has(capability) + ? Effect.succeed(invocation) + : // The conditional type narrows what the literal argument decided at runtime. + Effect.fail(missingCapability(invocation, capability) as McpCapabilityError), + ).pipe(Effect.withSpan("mcp.requireCapability")); diff --git a/apps/server/src/mcp/McpProviderSession.test.ts b/apps/server/src/mcp/McpProviderSession.test.ts new file mode 100644 index 000000000000..1c3c3af5091e --- /dev/null +++ b/apps/server/src/mcp/McpProviderSession.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vite-plus/test"; +import { withAgentDeviceEnvironment } from "./McpProviderSession.ts"; + +describe("device CLI environment", () => { + it("preserves provider credentials and commands while routing devices to the owned daemon", () => { + const environment = withAgentDeviceEnvironment( + { PATH: "/provider/bin:/usr/bin", PROVIDER_KEY: "fixture" }, + { + agentDeviceEnvironment: { + PATH: "/t3/device/bin", + PATH_SEPARATOR: ":", + AGENT_DEVICE_DAEMON_BASE_URL: "http://127.0.0.1:9000", + AGENT_DEVICE_DAEMON_AUTH_TOKEN: "fixture-device", + }, + }, + ); + expect(environment).toEqual({ + PATH: "/t3/device/bin:/provider/bin:/usr/bin", + PROVIDER_KEY: "fixture", + AGENT_DEVICE_DAEMON_BASE_URL: "http://127.0.0.1:9000", + AGENT_DEVICE_DAEMON_AUTH_TOKEN: "fixture-device", + }); + }); + + it("does not grant CLI access when device access was not supplied", () => { + const environment = { PATH: "/usr/bin", PROVIDER_KEY: "fixture" }; + expect(withAgentDeviceEnvironment(environment, undefined)).toBe(environment); + expect(withAgentDeviceEnvironment(environment, {})).toBe(environment); + }); +}); diff --git a/apps/server/src/mcp/McpProviderSession.ts b/apps/server/src/mcp/McpProviderSession.ts index d5dc582046c1..4019cb37bd04 100644 --- a/apps/server/src/mcp/McpProviderSession.ts +++ b/apps/server/src/mcp/McpProviderSession.ts @@ -7,6 +7,31 @@ export interface McpProviderSessionConfig { readonly providerInstanceId: ProviderInstanceId; readonly endpoint: string; readonly authorizationHeader: string; + /** Capabilities the credential grants ("preview", "device"). */ + readonly capabilities: ReadonlySet; + /** + * Set when the session may drive devices. Adapters spread this into the + * provider subprocess environment so the `agent-device` CLI is on PATH and + * already pointed at the server's daemon; the agent never handles a token. + */ + readonly agentDeviceEnvironment?: Readonly>; +} + +/** Provider env with the device variables applied over `base`, or `base` untouched. */ +export function withAgentDeviceEnvironment( + base: NodeJS.ProcessEnv, + config: Pick | undefined, +): NodeJS.ProcessEnv { + const extra = config?.agentDeviceEnvironment; + if (!extra) return base; + const separator = extra.PATH_SEPARATOR ?? ":"; + const basePath = base.PATH ?? base.Path; + const { PATH: shimDir, PATH_SEPARATOR: _separator, ...rest } = extra; + return { + ...base, + ...rest, + ...(shimDir ? { PATH: basePath ? `${shimDir}${separator}${basePath}` : shimDir } : {}), + }; } const sessionsByThread = new Map(); diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index 1d8aead99d0d..a1e333bd07b0 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -39,6 +39,7 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t const issued = yield* registry.issue({ threadId, providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview"]), }); expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp"); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); @@ -54,6 +55,35 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t }), ); +it.effect("always grants pull-requests and gates browser and device access independently", () => + Effect.gen(function* () { + const registry = yield* makeRegistry(() => 1_000); + const withPreview = yield* registry.issue({ + threadId: ThreadId.make("thread-preview"), + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview"]), + }); + const withoutPreview = yield* registry.issue({ + threadId: ThreadId.make("thread-no-preview"), + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(), + }); + const withDevice = yield* registry.issue({ + threadId: ThreadId.make("thread-device"), + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["device"]), + }); + const capabilitiesOf = (issued: typeof withPreview) => + registry + .resolve(issued.config.authorizationHeader.replace(/^Bearer\s+/, "")) + .pipe(Effect.map((scope) => [...(scope?.capabilities ?? [])].sort())); + + expect(yield* capabilitiesOf(withPreview)).toEqual(["preview", "pull-requests"]); + expect(yield* capabilitiesOf(withoutPreview)).toEqual(["pull-requests"]); + expect(yield* capabilitiesOf(withDevice)).toEqual(["device", "pull-requests"]); + }), +); + it.effect("builds MCP endpoints from the bound server host", () => Effect.gen(function* () { const cases = [ @@ -68,6 +98,7 @@ it.effect("builds MCP endpoints from the bound server host", () => const issued = yield* registry.issue({ threadId: ThreadId.make(`thread-${hostname}`), providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview"]), }); expect(issued.config.endpoint).toBe(expectedEndpoint); } @@ -81,6 +112,7 @@ it.effect("expires credentials once their session stops showing signs of life", const issued = yield* registry.issue({ threadId: ThreadId.make("thread-2"), providerInstanceId: ProviderInstanceId.make("claude"), + capabilities: new Set(["preview"]), }); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); timestamp += 101; @@ -96,6 +128,7 @@ it.effect("keeps a credential alive across turns that never touch an MCP tool", const issued = yield* registry.issue({ threadId, providerInstanceId: ProviderInstanceId.make("claude"), + capabilities: new Set(["preview"]), }); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); @@ -117,6 +150,7 @@ it.effect("does not keep credentials of other threads alive", () => const issued = yield* registry.issue({ threadId: ThreadId.make("thread-4"), providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview"]), }); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index f19a4f4e8c49..383bfbfec390 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -14,6 +14,7 @@ import * as McpProviderSession from "./McpProviderSession.ts"; export interface McpCredentialRequest { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; + readonly capabilities: ReadonlySet; } export interface McpIssuedCredential { @@ -68,7 +69,7 @@ export interface McpSessionRegistryOptions { * * The bound matters because `/mcp` is mounted outside the environment auth * stack and is reachable on whatever host the server binds to, so this token is - * the only thing guarding the preview toolkit on a remote-reachable server. + * the only thing guarding the `t3-code` toolkits on a remote-reachable server. */ const DEFAULT_LIVENESS_WINDOW_MS = 24 * 60 * 60 * 1_000; @@ -128,7 +129,10 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( threadId: ThreadId.make(request.threadId), providerSessionId, providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), - capabilities: new Set(["preview"]), + capabilities: new Set([ + "pull-requests", + ...request.capabilities, + ]), issuedAt, }; yield* SynchronizedRef.update(state, ({ records }) => { @@ -144,6 +148,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( providerInstanceId: scope.providerInstanceId, endpoint, authorizationHeader: `Bearer ${rawToken}`, + capabilities: scope.capabilities, }, }; }, diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 42f849f5edf3..27557d43b701 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -127,50 +127,70 @@ it.effect("targets multiple tabs explicitly while retaining a default tab", () = ), ); -it.effect("does not let an older response replace a newer explicit tab target", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const olderTabId = PreviewTabId.make("tab-older-request"); - const newerTabId = PreviewTabId.make("tab-newer-request"); - const releaseOlderResponse = yield* Deferred.make(); - const routedRequests: RoutedRequest[] = []; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => { - routedRequests.push(request); - const response = Effect.gen(function* () { - if (request.tabId === olderTabId) { - yield* Deferred.await(releaseOlderResponse); - } - yield* broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: { url: "http://localhost:3200" }, +it.effect.each([true, false])( + "keeps an older target stable while a newer explicit tab responds (implicit: %s)", + (implicit) => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const olderTabId = PreviewTabId.make("tab-older-request"); + const newerTabId = PreviewTabId.make("tab-newer-request"); + const releaseOlderResponse = yield* Deferred.make(); + const routedRequests: RoutedRequest[] = []; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(requests, (request) => { + routedRequests.push(request); + const response = Effect.gen(function* () { + if (request.tabId === olderTabId && request.operation === "snapshot") { + yield* Deferred.await(releaseOlderResponse); + } + yield* broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: { url: "http://localhost:3200" }, + }); + if (request.tabId === newerTabId) { + yield* Deferred.succeed(releaseOlderResponse, undefined); + } }); - if (request.tabId === newerTabId) { - yield* Deferred.succeed(releaseOlderResponse, undefined); - } + return response.pipe(Effect.forkScoped, Effect.asVoid); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + yield* broker.invoke({ scope, operation: "status", input: {}, tabId: olderTabId }); + let capturedTabId: PreviewTabId | undefined; + const older = yield* broker + .invoke({ + scope, + operation: "snapshot", + input: {}, + ...(implicit ? {} : { tabId: olderTabId }), + onTargetTab: (tabId) => { + capturedTabId = tabId; + }, + }) + .pipe(Effect.forkScoped); + yield* Effect.yieldNow; + const newer = yield* broker + .invoke({ scope, operation: "snapshot", input: {}, tabId: newerTabId }) + .pipe(Effect.forkScoped); + yield* Fiber.join(newer); + yield* Fiber.join(older); + yield* broker.invoke({ + scope, + operation: "status", + input: {}, + tabId: olderTabId, + updateCurrentTab: false, }); - return response.pipe(Effect.forkScoped, Effect.asVoid); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const older = yield* broker - .invoke({ scope, operation: "snapshot", input: {}, tabId: olderTabId }) - .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - const newer = yield* broker - .invoke({ scope, operation: "snapshot", input: {}, tabId: newerTabId }) - .pipe(Effect.forkScoped); - yield* Fiber.join(newer); - yield* Fiber.join(older); - yield* broker.invoke({ scope, operation: "snapshot", input: {} }); + yield* broker.invoke({ scope, operation: "snapshot", input: {} }); - expect(routedRequests.at(-1)?.tabId).toBe(newerTabId); - }), - ), + expect(routedRequests.at(-1)?.tabId).toBe(newerTabId); + expect(capturedTabId).toBe(olderTabId); + }), + ), ); it.effect("tracks the tab returned by a targeted recording stop", () => diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index d8f17973c218..8d92059bde8a 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -44,6 +44,10 @@ export interface PreviewAutomationInvokeInput { readonly input: unknown; readonly tabId?: PreviewTabId; readonly timeoutMs?: number; + /** Background metadata reads must not change the agent's current tab. */ + readonly updateCurrentTab?: boolean; + /** Capture the routed tab before another request changes the current assignment. */ + readonly onTargetTab?: (tabId: PreviewTabId | undefined) => void; } export class PreviewAutomationBroker extends Context.Service< @@ -541,6 +545,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); } const { connection, requestId, requestContext, requestSequence } = route; + input.onTargetTab?.(requestContext.tabId); const removePending = SynchronizedRef.update(state, (next) => { if (!next.pending.has(requestId)) return next; const pending = new Map(next.pending); @@ -575,6 +580,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); }); const result = yield* awaitResponse().pipe(Effect.ensuring(removePending)); + if (input.updateCurrentTab === false) return result; const responseTabId = readResultTabId(result); const resultTabId = responseTabId === undefined ? input.tabId : responseTabId; if (resultTabId === undefined) return result; diff --git a/apps/server/src/mcp/toolkits/device/handlers.test.ts b/apps/server/src/mcp/toolkits/device/handlers.test.ts new file mode 100644 index 000000000000..110d4cbba72a --- /dev/null +++ b/apps/server/src/mcp/toolkits/device/handlers.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { agentDeviceQuickStart, agentDeviceTargetArgs, pngDimensions } from "./handlers.ts"; + +const device = { + hostId: "local", + id: "ABCD-1234", + platform: "ios" as const, + name: "iPhone 17 Pro", + version: "iOS 27.0", + booted: true, + physical: false, +}; + +describe("device tool helpers", () => { + it("pins agent-device commands to the device by platform-specific flag", () => { + expect(agentDeviceTargetArgs(device)).toEqual(["--platform", "ios", "--udid", "ABCD-1234"]); + expect(agentDeviceTargetArgs({ ...device, platform: "android", id: "emulator-5554" })).toEqual([ + "--platform", + "android", + "--serial", + "emulator-5554", + ]); + }); + + it("writes the quick start around the pinned target", () => { + const text = agentDeviceQuickStart(device); + expect(text).toContain("agent-device snapshot -i --platform ios --udid ABCD-1234"); + expect(text).toContain("iPhone 17 Pro (iOS 27.0)"); + expect(text).toContain("XCTest runner"); + }); + + it("uses the absolute launcher in every quick-start command", () => { + const text = agentDeviceQuickStart( + device, + ["--session", "thread-1", "--config", "/tmp/host.json"], + "/tmp/t3 tools/agent-device", + ); + expect(text).toContain( + "'/tmp/t3 tools/agent-device' snapshot -i --session thread-1 --config /tmp/host.json", + ); + expect(text).not.toContain(" agent-device "); + expect(text).not.toContain("is on PATH"); + }); + + it("reads PNG dimensions from the IHDR chunk", () => { + const png = new Uint8Array(24); + new DataView(png.buffer).setUint32(0, 0x89504e47); + new DataView(png.buffer).setUint32(4, 0x0d0a1a0a); + new DataView(png.buffer).setUint32(12, 0x49484452); + new DataView(png.buffer).setUint32(16, 1179); + new DataView(png.buffer).setUint32(20, 2556); + expect(pngDimensions(png)).toEqual({ width: 1179, height: 2556 }); + expect(pngDimensions(new Uint8Array([1, 2, 3]))).toEqual({ width: 0, height: 0 }); + }); +}); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts new file mode 100644 index 000000000000..feb69ec630d6 --- /dev/null +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -0,0 +1,260 @@ +import { + type DeviceError, + type DeviceHostId, + type DeviceId, + type DevicePlatform, + type DeviceSummary, + DeviceToolUnavailableError, + LOCAL_DEVICE_HOST_ID, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { ServerConfig } from "../../../config.ts"; +import { ensureAgentDeviceShim } from "../../../device/AgentDeviceShim.ts"; + +import * as DeviceService from "../../../device/DeviceService.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { DeviceScreenshotToolkit, DeviceStandardToolkit, DeviceToolkit } from "./tools.ts"; + +/** The flags that pin every agent-device command to one device. */ +export function agentDeviceTargetArgs(device: DeviceSummary): ReadonlyArray { + return device.platform === "ios" + ? ["--platform", "ios", "--udid", device.id] + : ["--platform", "android", "--serial", device.id]; +} + +/** + * Just-in-time guidance returned from `device_open`. This is the one place + * the agent learns how to drive the device, so it lives with the tool result + * rather than in the always-on prompt block; threads that never open a device + * never pay for it. + */ +export function agentDeviceQuickStart( + device: DeviceSummary, + targetArgs = agentDeviceTargetArgs(device), + command = "agent-device", +): string { + const executable = /^[a-zA-Z0-9_./:-]+$/.test(command) + ? command + : "'" + command.replaceAll("'", "'\"'\"'") + "'"; + const target = targetArgs + .map((arg) => + /^[a-zA-Z0-9_./:-]+$/.test(arg) ? arg : "'" + arg.replaceAll("'", "'\"'\"'") + "'", + ) + .join(" "); + const platformNotes = + device.platform === "ios" + ? "First use builds an XCTest runner and can take a couple of minutes; later commands are fast." + : "The Android snapshot helper installs itself on first use."; + return [ + `The user is watching ${device.name} (${device.version}) in the Device panel.`, + `Drive it with ${executable}. Use this exact executable path; login shells may reset PATH. Always pass ${target}.`, + "Typical loop:", + ` ${executable} open ${target} # or: open `, + ` ${executable} snapshot -i ${target} # accessibility tree with @eN refs`, + ` ${executable} click @e3 ${target}`, + ` ${executable} fill @e5 "text" ${target}`, + ` ${executable} screenshot /tmp/shot.png ${target} # or call device_screenshot`, + ` ${executable} install ${target}`, + `Prefer snapshot refs over coordinates. Run ${executable} help for workflow guides and ${executable} --help for flags.`, + "Do not call simctl, adb, xcrun, or serve-sim directly while these tools are attached; use agent-device.", + "For remote hosts, arrange builds, app installation, and any Metro reverse forwarding yourself. T3 provides discovery, streaming, and control only.", + "Keep the returned --config and --session flags on every command. Other hosts can be used concurrently; opening one does not switch these commands.", + platformNotes, + ].join("\n"); +} + +const requireDeviceAccess = McpInvocationContext.requireMcpCapability("device").pipe( + Effect.mapError( + () => + new DeviceToolUnavailableError({ + reason: "Agent device access is turned off for this environment.", + }), + ), +); + +const pickDevice = ( + devices: ReadonlyArray, + input: { + readonly deviceId?: DeviceId | undefined; + readonly platform?: DevicePlatform | undefined; + readonly hostId?: DeviceHostId | undefined; + }, +): Effect.Effect => + Effect.gen(function* () { + const hostId = input.hostId ?? LOCAL_DEVICE_HOST_ID; + if (input.deviceId !== undefined) { + const match = devices.find( + (device) => device.hostId === hostId && device.id === input.deviceId, + ); + if (match) return match; + return yield* new DeviceToolUnavailableError({ + reason: `No device ${input.deviceId} on host ${hostId}. Call device_list for current ids.`, + }); + } + const candidates = devices.filter( + (device) => + device.hostId === hostId && + (input.platform === undefined || device.platform === input.platform), + ); + if (candidates.length === 0) { + return yield* new DeviceToolUnavailableError({ + reason: + input.platform === undefined + ? "No simulators or emulators were found. Call device_list to see why." + : `No ${input.platform} devices were found on host ${hostId}. Call device_list to see why.`, + }); + } + const platforms = new Set(candidates.map((device) => device.platform)); + if (input.platform === undefined && platforms.size > 1) { + return yield* new DeviceToolUnavailableError({ + reason: "Both iOS and Android devices are available; pass platform or deviceId.", + }); + } + return candidates.find((device) => device.booted) ?? candidates[0]!; + }); + +const toolError = (error: DeviceError | DeviceToolUnavailableError) => error; + +const handlers = { + device_list: (input) => + Effect.gen(function* () { + const scope = yield* requireDeviceAccess; + const devices = yield* DeviceService.DeviceService; + const state = yield* devices.list; + if (state.hostStatus === "disabled") { + return yield* new DeviceToolUnavailableError({ + reason: + "Device support is off. Ask the user to enable it in the Device panel before installing or starting device tools.", + }); + } + const hostId = input?.hostId; + const open = state.sessions + .filter((session) => session.threadId === scope.threadId) + .map((session) => ({ hostId: session.hostId, deviceId: session.deviceId })); + return { + hostStatuses: Object.fromEntries( + Object.entries(state.hostStatuses).filter(([id]) => !hostId || id === hostId), + ), + hosts: hostId ? state.hosts.filter((host) => host.id === hostId) : state.hosts, + devices: hostId + ? state.devices.filter((device) => device.hostId === hostId) + : state.devices, + open, + }; + }).pipe(Effect.mapError(toolError)), + device_open: (input) => + Effect.gen(function* () { + const scope = yield* requireDeviceAccess; + const devices = yield* DeviceService.DeviceService; + const state = yield* devices.list; + if (state.hostStatus === "disabled") { + return yield* new DeviceToolUnavailableError({ + reason: + "Device support is off. Ask the user to enable it in the Device panel before installing or starting device tools.", + }); + } + const target = yield* pickDevice(state.devices, input); + // Resolve consent and agent connectivity before booting or registering a session. + const agentArgs = yield* devices.agentTarget({ + threadId: scope.threadId, + hostId: target.hostId, + deviceId: target.id, + }); + const session = yield* devices.open({ + threadId: scope.threadId, + hostId: target.hostId, + deviceId: target.id, + platform: target.platform, + }); + const after = yield* devices.state; + const device = + after.devices.find( + (candidate) => candidate.hostId === session.hostId && candidate.id === session.deviceId, + ) ?? target; + const targetArgs = [...agentDeviceTargetArgs(device), ...agentArgs]; + const config = yield* ServerConfig; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const shimDir = yield* ensureAgentDeviceShim({ + entryPath: yield* devices.agentCli, + stateDir: config.stateDir, + }).pipe( + Effect.mapError( + () => + new DeviceToolUnavailableError({ + reason: "Could not prepare the agent-device launcher.", + }), + ), + ); + const command = path.join( + shimDir, + platform === "win32" ? "agent-device.cmd" : "agent-device", + ); + return { + device, + agentDevice: { command, targetArgs }, + quickStart: agentDeviceQuickStart(device, targetArgs, command), + }; + }).pipe(Effect.mapError(toolError)), + device_screenshot: (input) => + Effect.gen(function* () { + const scope = yield* requireDeviceAccess; + const devices = yield* DeviceService.DeviceService; + const sessions = yield* devices.sessionsForThread(scope.threadId); + const target = + input.deviceId !== undefined + ? { hostId: input.hostId ?? LOCAL_DEVICE_HOST_ID, deviceId: input.deviceId } + : sessions + .filter((session) => input.hostId === undefined || session.hostId === input.hostId) + .at(-1); + if (!target) { + return yield* new DeviceToolUnavailableError({ + reason: "No device is open in this thread. Call device_open first.", + }); + } + const shot = yield* devices.screenshot(target); + return { + device: shot.device, + screenshot: { + mimeType: "image/png" as const, + data: Buffer.from(shot.png).toString("base64"), + ...pngDimensions(shot.png), + }, + }; + }).pipe(Effect.mapError(toolError)), + device_close: (input) => + Effect.gen(function* () { + const scope = yield* requireDeviceAccess; + const devices = yield* DeviceService.DeviceService; + yield* devices.close({ + threadId: scope.threadId, + ...(input.hostId === undefined ? {} : { hostId: input.hostId }), + ...(input.deviceId === undefined ? {} : { deviceId: input.deviceId }), + ...(input.shutdown === undefined ? {} : { shutdown: input.shutdown }), + }); + return {}; + }).pipe(Effect.mapError(toolError)), +} satisfies Parameters[0]; + +/** Width and height from the IHDR chunk; a PNG that lacks one reports 0×0. */ +export function pngDimensions(png: Uint8Array): { width: number; height: number } { + if (png.length < 24) return { width: 0, height: 0 }; + const view = new DataView(png.buffer, png.byteOffset, png.byteLength); + const isPng = + view.getUint32(0) === 0x89504e47 && + view.getUint32(4) === 0x0d0a1a0a && + view.getUint32(12) === 0x49484452; + return isPng + ? { width: view.getUint32(16), height: view.getUint32(20) } + : { width: 0, height: 0 }; +} + +const { device_screenshot, ...standardHandlers } = handlers; + +export const DeviceStandardToolkitHandlersLive = DeviceStandardToolkit.toLayer(standardHandlers); + +export const DeviceScreenshotToolkitHandlersLive = DeviceScreenshotToolkit.toLayer({ + device_screenshot, +}); diff --git a/apps/server/src/mcp/toolkits/device/tools.ts b/apps/server/src/mcp/toolkits/device/tools.ts new file mode 100644 index 000000000000..58bfed41d940 --- /dev/null +++ b/apps/server/src/mcp/toolkits/device/tools.ts @@ -0,0 +1,101 @@ +import { + DeviceToolCloseInput, + DeviceToolError, + DeviceToolListResult, + DeviceToolOpenInput, + DeviceToolOpenResult, + DeviceToolScreenshotResult, + DeviceToolTargetInput, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { ServerConfig } from "../../../config.ts"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as DeviceService from "../../../device/DeviceService.ts"; + +const dependencies = [McpInvocationContext.McpInvocationContext, DeviceService.DeviceService]; + +/** + * Deliberately a small surface: lifecycle, visibility for the user, and one + * image-returning verb. Driving the device (taps, typing, install, logs) + * happens through the preconfigured `agent-device` CLI, which has the + * semantic snapshot model agents need and stays current with its own + * releases. Wrapping its commands here would only lag behind it. + */ +const DeviceListTool = Tool.make("device_list", { + description: + "List iOS Simulators and Android Emulators on this environment's device hosts, which platforms each host can run, and which devices are already open in this thread's Device panel. Call this before device_open when you do not know a device id.", + // An empty struct serializes as `anyOf [object, array]`, which some + // providers reject and then drop every tool on the server with it. + parameters: Schema.Struct({ + hostId: Schema.optional( + Schema.String.annotate({ description: "Limit to one device host. Defaults to all hosts." }), + ), + }), + success: DeviceToolListResult, + failure: DeviceToolError, + dependencies, +}) + .annotate(Tool.Title, "List devices") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const DeviceOpenTool = Tool.make("device_open", { + description: + "Open a simulator or emulator for this thread: boots it if needed, starts its live stream, and shows it in the user's Device panel so they can watch. Returns the agent-device CLI invocation pinned to the device; drive the device with that CLI afterwards.", + parameters: DeviceToolOpenInput, + success: DeviceToolOpenResult, + failure: DeviceToolError, + dependencies: [...dependencies, FileSystem.FileSystem, Path.Path, ServerConfig], +}) + .annotate(Tool.Title, "Open device") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, true); + +export const DeviceScreenshotTool = Tool.make("device_screenshot", { + description: + "Capture the current screen of an open device as a PNG image. Use it to see what the user sees; for taps and text use the agent-device CLI.", + parameters: DeviceToolTargetInput, + success: DeviceToolScreenshotResult, + failure: DeviceToolError, + dependencies, +}) + .annotate(Tool.Title, "Screenshot device") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, true); + +const DeviceCloseTool = Tool.make("device_close", { + description: + "Remove a device from this thread's Device panel. Pass shutdown=true to also power the simulator or emulator off.", + parameters: DeviceToolCloseInput, + success: Schema.Record(Schema.String, Schema.Never).annotate({ + description: "The device was closed.", + }), + failure: DeviceToolError, + dependencies, +}) + .annotate(Tool.Title, "Close device") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, true); + +export const DeviceStandardToolkit = Toolkit.make(DeviceListTool, DeviceOpenTool, DeviceCloseTool); + +export const DeviceScreenshotToolkit = Toolkit.make(DeviceScreenshotTool); + +export const DeviceToolkit = Toolkit.make( + DeviceListTool, + DeviceOpenTool, + DeviceScreenshotTool, + DeviceCloseTool, +); diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index d34c2d3ba3af..caa4cbd157cf 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -7,6 +7,7 @@ import { PreviewAutomationRecordingTransferError, PreviewAutomationRecordingDesktopUpdateRequiredError, PreviewAutomationRecordingArtifact, + type ToolActivityIcon, type ThreadId, type PreviewAutomationOperation, type PreviewAutomationOpenInput, @@ -55,22 +56,47 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* ( timeoutMs?: number, tabId?: PreviewTabId, ): Effect.fn.Return< - A, + { result: A; toolIcon?: ToolActivityIcon }, import("@t3tools/contracts").PreviewAutomationError, McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker > { const scope = yield* McpInvocationContext.requireMcpCapability("preview"); const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; - return yield* broker.invoke({ + let targetTabId = tabId; + const result = yield* broker.invoke({ + onTargetTab: (resolvedTabId) => { + targetTabId = resolvedTabId; + }, scope, operation, input, ...(timeoutMs === undefined ? {} : { timeoutMs }), ...(tabId === undefined ? {} : { tabId }), }); + if (["status", "open", "navigate", "snapshot"].includes(operation)) return { result }; + const statusTabId = + (operation !== "evaluate" && typeof result === "object" && result !== null + ? (result as { tabId?: PreviewTabId }).tabId + : undefined) ?? targetTabId; + const page = yield* broker + .invoke({ + scope, + operation: "status", + input: {}, + timeoutMs: 500, + updateCurrentTab: false, + ...(statusTabId === undefined ? {} : { tabId: statusTabId }), + }) + .pipe(Effect.catch(() => Effect.succeed(null))); + return { + result, + ...(page?.url && /^https?:\/\//i.test(page.url) && page.url.length <= 4096 + ? { toolIcon: { _tag: "website" as const, pageUrl: page.url } } + : {}), + }; }); -const invokeTargeted = ( +const invokeTargeted = ( operation: PreviewAutomationOperation, input: { readonly tabId?: PreviewTabId | undefined; @@ -79,7 +105,12 @@ const invokeTargeted = ( timeoutMs?: number, ) => { const { tabId, ...operationInput } = input; - return invoke(operation, operationInput, timeoutMs, tabId); + return invoke(operation, operationInput, timeoutMs, tabId).pipe( + Effect.map(({ result, toolIcon }) => ({ + ...result, + ...(toolIcon ? { toolIcon } : {}), + })), + ); }; const UploadedRecordingArtifact = Schema.Struct({ @@ -170,28 +201,32 @@ const handlers = { const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {}; return invokeTargeted("snapshot", operationInput); }, - preview_click: (input) => - invokeTargeted("click", input, input.timeoutMs).pipe(Effect.as({})), - preview_type: (input) => invokeTargeted("type", input, input.timeoutMs).pipe(Effect.as({})), - preview_press: (input) => invokeTargeted("press", input).pipe(Effect.as({})), - preview_scroll: (input) => invokeTargeted("scroll", input).pipe(Effect.as({})), - preview_evaluate: (input) => - invokeTargeted("evaluate", input).pipe( - Effect.map((result) => ({ value: result ?? null })), + preview_click: (input) => invokeTargeted("click", input, input.timeoutMs), + preview_type: (input) => invokeTargeted("type", input, input.timeoutMs), + preview_press: (input) => invokeTargeted("press", input), + preview_scroll: (input) => invokeTargeted("scroll", input), + preview_evaluate: ({ tabId, ...input }) => + invoke("evaluate", input, undefined, tabId).pipe( + Effect.map(({ result, toolIcon }) => ({ + value: result ?? null, + ...(toolIcon ? { toolIcon } : {}), + })), ), - preview_wait_for: (input) => - invokeTargeted("waitFor", input, input.timeoutMs).pipe(Effect.as({})), + preview_wait_for: (input) => invokeTargeted("waitFor", input, input.timeoutMs), preview_recording_start: (input) => invokeTargeted("recordingStart", input ?? {}), preview_recording_stop: (input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.requireMcpCapability("preview"); - const response = yield* invokeTargeted( + const { tabId, ...operationInput } = input; + const response = yield* invoke( "recordingStop", - { ...input, transferToEnvironment: true }, + { ...operationInput, transferToEnvironment: true }, PREVIEW_RECORDING_STOP_TIMEOUT_MS, + tabId, ); - return yield* claimPreviewRecording(scope.threadId, response); + const artifact = yield* claimPreviewRecording(scope.threadId, response.result); + return { ...artifact, ...(response.toolIcon ? { toolIcon: response.toolIcon } : {}) }; }), } satisfies Parameters[0]; diff --git a/apps/server/src/mcp/toolkits/preview/tools.test.ts b/apps/server/src/mcp/toolkits/preview/tools.test.ts index 2cdc67ad7d92..3deef11f671e 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.test.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.test.ts @@ -67,6 +67,7 @@ it("exports exact object result schemas for preview actions", () => { for (const name of actionNames) { expect(Tool.getJsonSchemaFromSchema(PreviewToolkit.tools[name].successSchema)).toEqual({ type: "object", + properties: { toolIcon: expect.any(Object) }, additionalProperties: false, description: "The preview action completed successfully.", }); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 28a2b96228b5..3f80e84e9a59 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -1,4 +1,5 @@ import { + ToolActivityIcon, PreviewAutomationClickInput, PreviewAutomationError, PreviewAutomationEvaluateInput, @@ -31,7 +32,9 @@ const dependencies = [ PreviewAutomationBroker.PreviewAutomationBroker, ]; -const PreviewActionResult = Schema.Record(Schema.String, Schema.Never).annotate({ +const presentationFields = { toolIcon: Schema.optional(ToolActivityIcon) }; + +const PreviewActionResult = Schema.Struct(presentationFields).annotate({ description: "The preview action completed successfully.", }); @@ -89,7 +92,7 @@ const PreviewResizeTool = safeBrowserTool( description: "Resize a collaborative browser tab, optionally selected by tabId. Use {mode:'fill'}, {mode:'freeform',width:1024,height:768}, or {mode:'preset',preset:'iphone-12-pro',orientation:'portrait'}. This changes CSS layout breakpoints without changing the desktop browser user agent.", parameters: PreviewAutomationResizeInput, - success: PreviewAutomationResizeResult, + success: Schema.Struct({ ...PreviewAutomationResizeResult.fields, ...presentationFields }), failure: PreviewAutomationError, dependencies, }) @@ -102,7 +105,10 @@ const PreviewSetAppearanceTool = safeBrowserTool( description: "Emulate prefers-color-scheme in a collaborative browser tab, optionally selected by tabId. Use {colorScheme:'dark'} or {colorScheme:'light'} to preview the page in that appearance, and {colorScheme:'system'} to clear the override and follow the OS appearance.", parameters: PreviewAutomationSetColorSchemeInput, - success: PreviewAutomationSetColorSchemeResult, + success: Schema.Struct({ + ...PreviewAutomationSetColorSchemeResult.fields, + ...presentationFields, + }), failure: PreviewAutomationError, dependencies, }) @@ -185,6 +191,7 @@ const PreviewScrollTool = safeBrowserTool( * null valid instead of failing only for non-object expressions. */ export const PreviewEvaluateResult = Schema.Struct({ + ...presentationFields, value: Schema.Unknown.annotate({ description: "The JSON-serializable value the expression produced, or null.", }), @@ -217,7 +224,7 @@ const PreviewRecordingStartTool = safeBrowserTool( description: "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.", parameters: PreviewAutomationTabTargetInput, - success: PreviewAutomationRecordingStatus, + success: Schema.Struct({ ...PreviewAutomationRecordingStatus.fields, ...presentationFields }), failure: PreviewAutomationError, dependencies, }).annotate(Tool.Title, "Start browser recording"), @@ -228,7 +235,7 @@ const PreviewRecordingStopTool = safeBrowserTool( description: "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and transfer the compressed recording once (up to 50 MiB) to an evidence file readable in this agent's environment. Returns its environment-local path after transfer succeeds.", parameters: PreviewAutomationTabTargetInput, - success: PreviewAutomationRecordingArtifact, + success: Schema.Struct({ ...PreviewAutomationRecordingArtifact.fields, ...presentationFields }), failure: PreviewAutomationError, dependencies: [...dependencies, FileSystem.FileSystem, ServerConfig.ServerConfig], }).annotate(Tool.Title, "Stop browser recording"), diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts new file mode 100644 index 000000000000..18062a216bb4 --- /dev/null +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -0,0 +1,418 @@ +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationThreadShell, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import type { Tool } from "effect/unstable/ai"; + +import { OrchestrationCommandInvariantError } from "../../../orchestration/Errors.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { listThreadPullRequests, PullRequestsToolkitHandlersLive } from "./handlers.ts"; +import { PullRequestLinkFailedError, PullRequestsToolkit } from "./tools.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const THREAD_ID = ThreadId.make("thread-1"); + +const testCrypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(7), + digest: (_algorithm, data) => Effect.succeed(data), +}); + +const invocation = ( + capabilities: ReadonlyArray, +): McpInvocationContext.McpInvocationScope => ({ + environmentId: EnvironmentId.make("environment-1"), + threadId: THREAD_ID, + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(capabilities), + issuedAt: 1, +}); + +function makeProject( + repositoryIdentity: OrchestrationProjectShell["repositoryIdentity"] = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.com:T3Tools/T3Code.git", + }, + provider: "github", + displayName: "T3Tools/T3Code", + owner: "T3Tools", + name: "T3Code", + }, +): OrchestrationProjectShell { + return { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + repositoryIdentity, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }; +} + +function makeThread(pullRequests: ReadonlyArray): OrchestrationThreadShell { + return { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests, + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +function makeLink( + number: number, + overrides: Partial & { + readonly headBranch?: string; + readonly baseBranch?: string; + } = {}, +): ThreadPullRequestLink { + const { headBranch, baseBranch, ...rest } = overrides; + return { + host: "github.com", + repository: "t3tools/t3code", + number, + url: `https://github.com/t3tools/t3code/pull/${number}`, + source: "manual", + linkedAt: "2026-08-10T00:00:00.000Z", + snapshot: + headBranch === undefined + ? null + : { + state: "open", + title: `PR ${number}`, + headBranch, + baseBranch: baseBranch ?? "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-08-27T00:00:00.000Z", + }, + stack: null, + ...rest, + }; +} + +interface HarnessOptions { + readonly thread?: OrchestrationThreadShell | null; + readonly project?: OrchestrationProjectShell | null; + readonly reject?: (command: OrchestrationCommand) => OrchestrationCommandInvariantError | null; +} + +const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* ( + options: HarnessOptions = {}, +) { + const commands = yield* Ref.make>([]); + const thread = options.thread === undefined ? makeThread([]) : options.thread; + const project = options.project === undefined ? makeProject() : options.project; + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => + Effect.gen(function* () { + const rejection = options.reject?.(command) ?? null; + if (rejection !== null) return yield* rejection; + yield* Ref.update(commands, (recorded) => [...recorded, command]); + return { sequence: 1 }; + }); + const dependencies = Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getThreadShellById: (threadId) => + Effect.succeed(threadId === THREAD_ID ? Option.fromNullishOr(thread) : Option.none()), + getProjectShellById: () => Effect.succeed(Option.fromNullishOr(project)), + }), + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + Layer.succeed(Crypto.Crypto, testCrypto), + ); + const toolkit = yield* PullRequestsToolkit.pipe( + Effect.provide(PullRequestsToolkitHandlersLive.pipe(Layer.provide(dependencies))), + ); + const call = ( + name: Name, + params: Parameters>[1], + capabilities: ReadonlyArray = ["pull-requests"], + ) => + toolkit.handle(name, params).pipe( + Stream.unwrap, + Stream.runCollect, + // Failure mode is "error", so a delivered result is always the success shape. + Effect.map( + (chunk) => chunk.at(-1)!.result as Tool.Success<(typeof PullRequestsToolkit.tools)[Name]>, + ), + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(capabilities)), + Effect.provide(dependencies), + ); + return { commands, call }; +}); + +describe("pull request toolkit handlers", () => { + it.effect("refuses a credential without the pull-requests capability", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const error = yield* harness + .call("list_thread_pull_requests", {}, ["preview"]) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "McpCapabilityUnavailableError", + capability: "pull-requests", + threadId: THREAD_ID, + }); + expect(yield* Ref.get(harness.commands)).toEqual([]); + }), + ); + + it.effect("links by URL with source agent on the token's thread", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const result = yield* harness.call("link_pull_request", { + url: "https://github.com/T3Tools/T3Code/pull/123/files", + }); + expect(result).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 123, + url: "https://github.com/T3Tools/T3Code/pull/123/files", + alreadyLinked: false, + }); + expect(yield* Ref.get(harness.commands)).toMatchObject([ + { + type: "thread.pull-request.link", + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 123, + source: "agent", + }, + ]); + }), + ); + + it.effect("links by repository and number, defaulting the host to the project's", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const result = yield* harness.call("link_pull_request", { + repository: "T3Tools/Other", + number: 7, + }); + expect(result).toEqual({ + host: "github.com", + repository: "t3tools/other", + number: 7, + url: "https://github.com/t3tools/other/pull/7", + alreadyLinked: false, + }); + }), + ); + + it.effect("builds the URL in the project host's own shape", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + project: makeProject({ + canonicalKey: "gitlab.com/group/sub/project", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@gitlab.com:group/sub/project.git", + }, + provider: "gitlab", + displayName: "group/sub/project", + }), + }); + const result = yield* harness.call("link_pull_request", { + repository: "group/sub/project", + number: 42, + }); + expect(result.url).toBe("https://gitlab.com/group/sub/project/-/merge_requests/42"); + expect(result.host).toBe("gitlab.com"); + }), + ); + + it.effect("rejects a target that names neither a URL nor repository and number", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const error = yield* harness + .call("link_pull_request", { repository: "x/y" }) + .pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "PullRequestTargetIncompleteError" }); + const unknown = yield* harness + .call("link_pull_request", { + url: "https://github.com/t3tools/t3code/issues/1?token=private-value", + }) + .pipe(Effect.flip); + expect(unknown).toMatchObject({ _tag: "PullRequestUrlInvalidError" }); + expect(unknown.message).not.toContain("private-value"); + expect(yield* Ref.get(harness.commands)).toEqual([]); + }), + ); + + it.effect("treats a duplicate link as alreadyLinked rather than an error", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + thread: makeThread([makeLink(123)]), + reject: (command) => + command.type === "thread.pull-request.link" + ? new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "already linked", + }) + : null, + }); + const result = yield* harness.call("link_pull_request", { + url: "https://github.com/t3tools/t3code/pull/123", + }); + expect(result.alreadyLinked).toBe(true); + }), + ); + + it.effect("unlinks a linked pull request and reports a missing one as wasLinked=false", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + thread: makeThread([makeLink(5)]), + reject: (command) => + command.type === "thread.pull-request.unlink" && command.number !== 5 + ? new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "not linked", + }) + : null, + }); + const linked = yield* harness.call("unlink_pull_request", { + repository: "t3tools/t3code", + number: 5, + }); + expect(linked).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 5, + wasLinked: true, + }); + const missing = yield* harness.call("unlink_pull_request", { + url: "https://github.com/t3tools/t3code/pull/9", + }); + expect(missing.wasLinked).toBe(false); + expect(yield* Ref.get(harness.commands)).toMatchObject([ + { type: "thread.pull-request.unlink", number: 5 }, + ]); + }), + ); + + it.effect("fails cleanly when the token's thread no longer exists", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ thread: null }); + const error = yield* harness.call("list_thread_pull_requests", {}).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "PullRequestThreadNotFoundError", threadId: THREAD_ID }); + }), + ); + + it.effect("lists visible links with host state and derived chain order", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + thread: makeThread([ + makeLink(3, { headBranch: "feat-c", baseBranch: "feat-b", source: "agent" }), + makeLink(1, { headBranch: "feat-a", baseBranch: "main", source: "created" }), + makeLink(2, { headBranch: "feat-b", baseBranch: "feat-a", source: "agent" }), + makeLink(9, { source: "stack-dismissed" }), + makeLink(10), + ]), + }); + const result = yield* harness.call("list_thread_pull_requests", {}); + expect(result.pullRequests.map((entry) => entry.number)).toEqual([3, 1, 2, 10]); + expect(result.pullRequests[0]).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 3, + url: "https://github.com/t3tools/t3code/pull/3", + source: "agent", + state: "open", + title: "PR 3", + headBranch: "feat-c", + baseBranch: "feat-b", + isDraft: false, + stack: { kind: "derived", position: 3, size: 3 }, + }); + expect(result.pullRequests[3]).toMatchObject({ + number: 10, + state: null, + title: null, + headBranch: null, + stack: null, + }); + expect(result.chains).toEqual([ + { kind: "derived", numbers: [1, 2, 3] }, + { kind: "derived", numbers: [10] }, + ]); + }), + ); +}); + +describe("listThreadPullRequests", () => { + it("reports a native stack position for each member", () => { + const stack = { + kind: "native" as const, + id: "stack-1", + number: 1, + url: "https://github.com/t3tools/t3code/stack/1", + base: "main", + layers: [ + { number: 1, headBranch: "a", state: "open" as const }, + { number: 2, headBranch: "b", state: "open" as const }, + ], + }; + const result = listThreadPullRequests({ + pullRequests: [ + makeLink(2, { stack, source: "stack" }), + makeLink(1, { stack, source: "created" }), + ], + }); + expect(result.pullRequests.map((entry) => [entry.number, entry.stack])).toEqual([ + [2, { kind: "native", position: 2, size: 2 }], + [1, { kind: "native", position: 1, size: 2 }], + ]); + expect(result.chains).toEqual([{ kind: "native", numbers: [1, 2] }]); + }); +}); + +it("keeps failure diagnostics as the cause rather than exposing them in the tool message", () => { + const cause = new Error("database internals"); + const failure = new PullRequestLinkFailedError({ cause }); + expect(failure.message).toBe("Could not link the pull request."); + expect(failure.cause).toBe(cause); +}); diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts new file mode 100644 index 000000000000..1106bf435327 --- /dev/null +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -0,0 +1,243 @@ +import { + CommandId, + pullRequestHostOf, + type OrchestrationProjectShell, + type OrchestrationThreadShell, + type SourceControlProviderKind, + type ThreadId, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { + resolveThreadPullRequestChains, + threadPullRequestKeyOf, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { + type ListThreadPullRequestsResult, + PullRequestLinkFailedError, + PullRequestUrlInvalidError, + PullRequestTargetIncompleteError, + PullRequestHostRequiredError, + PullRequestUnlinkFailedError, + PullRequestListFailedError, + type PullRequestTargetInput, + PullRequestThreadNotFoundError, + PullRequestsToolkit, + type ThreadPullRequestEntry, +} from "./tools.ts"; + +interface ResolvedTarget { + readonly host: string; + readonly repository: string; + readonly number: number; + readonly url: string; +} + +/** The project's host and provider supply defaults for a repository-and-number input. */ +function projectHostAndProvider(project: OrchestrationProjectShell | undefined): { + readonly host: string | null; + readonly kind: SourceControlProviderKind | null; +} { + const identity = project?.repositoryIdentity; + const kind = (identity?.provider as SourceControlProviderKind | undefined) ?? null; + if (!identity || kind === null) return { host: null, kind: null }; + return { + host: pullRequestHostOf(identity, kind), + kind, + }; +} + +/** + * Turns whichever shape the agent passed into one host-level identity. A URL + * wins outright; otherwise the repository and number are completed with the + * thread's project host, which is where an agent working in that checkout + * almost always opened the pull request. + */ +const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( + input: PullRequestTargetInput, + project: OrchestrationProjectShell | undefined, +) { + if (input.url !== undefined) { + const parsed = parseChangeRequestUrl(input.url); + if (parsed === null) { + return yield* new PullRequestUrlInvalidError({}); + } + return { ...parsed, url: input.url } satisfies ResolvedTarget; + } + if (input.repository === undefined || input.number === undefined) { + return yield* new PullRequestTargetIncompleteError({}); + } + const projectHost = projectHostAndProvider(project); + const host = (input.host ?? projectHost.host)?.toLowerCase(); + if (host === undefined) { + return yield* new PullRequestHostRequiredError({}); + } + const repository = input.repository.toLowerCase(); + const url = + changeRequestUrlFor( + // The project's kind only describes its own host; another host gets no URL guess. + host === projectHost.host ? projectHost.kind : null, + host, + repository, + input.number, + ) ?? `https://${host}/${repository}/pull/${input.number}`; + return { host, repository, number: input.number, url } satisfies ResolvedTarget; +}); + +function entryOf( + link: ThreadPullRequestLink, + chains: ReturnType, +): ThreadPullRequestEntry { + const key = threadPullRequestKeyOf(link); + let stack: ThreadPullRequestEntry["stack"] = null; + for (const chain of chains) { + if (chain.layers.length < 2) continue; + const index = chain.layers.findIndex((layer) => threadPullRequestKeyOf(layer) === key); + if (index !== -1) { + stack = { kind: chain.kind, position: index + 1, size: chain.layers.length }; + break; + } + } + return { + host: link.host, + repository: link.repository, + number: link.number, + url: link.url, + source: link.source, + state: link.snapshot?.state ?? null, + title: link.snapshot?.title ?? null, + headBranch: link.snapshot?.headBranch ?? null, + baseBranch: link.snapshot?.baseBranch ?? null, + isDraft: link.snapshot?.isDraft ?? null, + stack, + }; +} + +/** What the tools report from a thread shell; exported so the shape is testable without a layer. */ +export function listThreadPullRequests( + thread: Pick, +): ListThreadPullRequestsResult { + const chains = resolveThreadPullRequestChains(thread.pullRequests); + return { + pullRequests: visibleThreadPullRequests(thread.pullRequests).map((link) => + entryOf(link, chains), + ), + chains: chains.map((chain) => ({ + kind: chain.kind, + numbers: chain.layers.map((layer) => layer.number), + })), + }; +} + +const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const crypto = yield* Crypto.Crypto; + + const commandId = (tag: string, threadId: ThreadId) => + crypto.randomUUIDv4.pipe( + Effect.orDie, + Effect.map((uuid) => CommandId.make(`server:${tag}:${threadId}:${uuid}`)), + ); + + const requireThread = Effect.fn("PullRequestsToolkit.requireThread")(function* ( + Failure: + | typeof PullRequestLinkFailedError + | typeof PullRequestUnlinkFailedError + | typeof PullRequestListFailedError, + ) { + const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests"); + const thread = yield* snapshots + .getThreadShellById(scope.threadId) + .pipe(Effect.mapError((cause) => new Failure({ cause }))); + if (Option.isNone(thread)) { + return yield* new PullRequestThreadNotFoundError({ threadId: scope.threadId }); + } + return thread.value; + }); + + const projectOf = ( + thread: OrchestrationThreadShell, + Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError, + ) => + snapshots.getProjectShellById(thread.projectId).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError((cause) => new Failure({ cause })), + ); + + const dispatchFailure = + (Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError) => + ( + cause: Cause.Cause, + ): Effect.Effect => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause as Cause.Cause) + : Effect.fail(new Failure({ cause })); + + return PullRequestsToolkit.of({ + link_pull_request: (input) => + Effect.gen(function* () { + const thread = yield* requireThread(PullRequestLinkFailedError); + const project = yield* projectOf(thread, PullRequestLinkFailedError); + const target = yield* resolveTarget(input, project); + const alreadyLinked = yield* engine + .dispatch({ + type: "thread.pull-request.link", + commandId: yield* commandId("mcp-pr-link", thread.id), + threadId: thread.id, + host: target.host, + repository: target.repository, + number: target.number, + url: target.url, + source: "agent", + }) + .pipe( + Effect.as(false), + // The decider rejects a second link of the same PR; for the agent that is + // the outcome it asked for, not an error. + Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.succeed(true) }), + Effect.catchCause(dispatchFailure(PullRequestLinkFailedError)), + ); + return { ...target, alreadyLinked }; + }), + unlink_pull_request: (input) => + Effect.gen(function* () { + const thread = yield* requireThread(PullRequestUnlinkFailedError); + const project = yield* projectOf(thread, PullRequestUnlinkFailedError); + const target = yield* resolveTarget(input, project); + const wasLinked = yield* engine + .dispatch({ + type: "thread.pull-request.unlink", + commandId: yield* commandId("mcp-pr-unlink", thread.id), + threadId: thread.id, + host: target.host, + repository: target.repository, + number: target.number, + }) + .pipe( + Effect.as(true), + Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.succeed(false) }), + Effect.catchCause(dispatchFailure(PullRequestUnlinkFailedError)), + ); + return { + host: target.host, + repository: target.repository, + number: target.number, + wasLinked, + }; + }), + list_thread_pull_requests: () => + requireThread(PullRequestListFailedError).pipe(Effect.map(listThreadPullRequests)), + }); +}); + +export const PullRequestsToolkitHandlersLive = PullRequestsToolkit.toLayer(make); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts new file mode 100644 index 000000000000..e0ab5561a3a1 --- /dev/null +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -0,0 +1,231 @@ +import { + McpCapabilityUnavailableError, + PositiveInt, + PullRequestState, + ThreadPullRequestLinkSource, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Tool from "effect/unstable/ai/Tool"; +import * as Toolkit from "effect/unstable/ai/Toolkit"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; + +const dependencies = [ + McpInvocationContext.McpInvocationContext, + OrchestrationEngine.OrchestrationEngineService, + ProjectionSnapshotQuery.ProjectionSnapshotQuery, +]; + +const REGISTER_EVERY_PR = + "Register every pull request you open for this thread, including each layer of a stack, right after creating it."; + +/** + * Either the pull request's URL or its repository and number. Both forms + * resolve to the same host-level identity, so the agent can pass whichever + * the host CLI handed back. + */ +export const PullRequestTargetInput = Schema.Struct({ + url: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: + "The pull request's web URL, for example https://github.com/owner/repo/pull/123. Preferred when you have it; host, repository and number are read from it.", + }), + ), + repository: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: + "Repository path below the host, for example owner/repo. Required with number when url is omitted.", + }), + ), + number: Schema.optional( + PositiveInt.annotate({ + description: "Pull request number. Required with repository when url is omitted.", + }), + ), + host: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: + "Host the repository lives on, for example github.com. Defaults to the host of this thread's project.", + }), + ), +}); +export type PullRequestTargetInput = typeof PullRequestTargetInput.Type; + +export class PullRequestUrlInvalidError extends Schema.TaggedError()( + "PullRequestUrlInvalidError", + {}, +) { + override get message(): string { + return "This is not a recognised pull request URL. Pass repository and number instead."; + } +} + +export class PullRequestTargetIncompleteError extends Schema.TaggedError()( + "PullRequestTargetIncompleteError", + {}, +) { + override get message(): string { + return "Pass either url, or both repository and number."; + } +} + +export class PullRequestHostRequiredError extends Schema.TaggedError()( + "PullRequestHostRequiredError", + {}, +) { + override get message(): string { + return "This thread's project has no recognised remote. Pass host or url."; + } +} + +export class PullRequestThreadNotFoundError extends Schema.TaggedError()( + "PullRequestThreadNotFoundError", + { threadId: Schema.String }, +) { + override get message(): string { + return `Thread ${this.threadId} was not found.`; + } +} + +export class PullRequestLinkFailedError extends Schema.TaggedError()( + "PullRequestLinkFailedError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not link the pull request."; + } +} + +export class PullRequestUnlinkFailedError extends Schema.TaggedError()( + "PullRequestUnlinkFailedError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not unlink the pull request."; + } +} + +export class PullRequestListFailedError extends Schema.TaggedError()( + "PullRequestListFailedError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not list the pull request."; + } +} + +export const PullRequestToolError = Schema.Union([ + McpCapabilityUnavailableError, + PullRequestUrlInvalidError, + PullRequestTargetIncompleteError, + PullRequestHostRequiredError, + PullRequestThreadNotFoundError, + PullRequestLinkFailedError, + PullRequestUnlinkFailedError, + PullRequestListFailedError, +]); +export type PullRequestToolError = typeof PullRequestToolError.Type; + +const PullRequestIdentity = { + host: Schema.String, + repository: Schema.String, + number: Schema.Int, + url: Schema.String, +}; + +export const LinkPullRequestResult = Schema.Struct({ + ...PullRequestIdentity, + alreadyLinked: Schema.Boolean.annotate({ + description: "True when the pull request was linked to this thread before the call.", + }), +}); +export type LinkPullRequestResult = typeof LinkPullRequestResult.Type; + +export const UnlinkPullRequestResult = Schema.Struct({ + host: Schema.String, + repository: Schema.String, + number: Schema.Int, + wasLinked: Schema.Boolean.annotate({ + description: "False when the pull request was not linked to this thread to begin with.", + }), +}); +export type UnlinkPullRequestResult = typeof UnlinkPullRequestResult.Type; + +export const ThreadPullRequestEntry = Schema.Struct({ + ...PullRequestIdentity, + source: ThreadPullRequestLinkSource, + state: Schema.NullOr(PullRequestState), + title: Schema.NullOr(Schema.String), + headBranch: Schema.NullOr(Schema.String), + baseBranch: Schema.NullOr(Schema.String), + isDraft: Schema.NullOr(Schema.Boolean), + stack: Schema.NullOr( + Schema.Struct({ + kind: Schema.Literals(["native", "derived"]), + /** 1-based, bottom of the stack first. */ + position: Schema.Int, + size: Schema.Int, + }), + ), +}); +export type ThreadPullRequestEntry = typeof ThreadPullRequestEntry.Type; + +export const ListThreadPullRequestsResult = Schema.Struct({ + pullRequests: Schema.Array(ThreadPullRequestEntry), + chains: Schema.Array( + Schema.Struct({ + kind: Schema.Literals(["native", "derived"]), + /** Bottom to top. */ + numbers: Schema.Array(Schema.Int), + }), + ), +}); +export type ListThreadPullRequestsResult = typeof ListThreadPullRequestsResult.Type; + +const LinkPullRequestTool = Tool.make("link_pull_request", { + description: `${REGISTER_EVERY_PR} Links a pull request to this thread so T3 Code tracks it, shows its status beside the thread, and settles the thread when it merges. Pass the URL, or repository plus number. Linking an already-linked pull request succeeds with alreadyLinked=true.`, + parameters: PullRequestTargetInput, + success: LinkPullRequestResult, + failure: PullRequestToolError, + dependencies, +}) + .annotate(Tool.Title, "Link pull request to thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const UnlinkPullRequestTool = Tool.make("unlink_pull_request", { + description: + "Remove a pull request link from this thread, for example after closing a pull request you opened by mistake. Pass the URL, or repository plus number. Unlinking a pull request that is not linked succeeds with wasLinked=false.", + parameters: PullRequestTargetInput, + success: UnlinkPullRequestResult, + failure: PullRequestToolError, + dependencies, +}) + .annotate(Tool.Title, "Unlink pull request from thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { + description: `List the pull requests linked to this thread with their last known host state, and how they chain into stacks (bottom to top). ${REGISTER_EVERY_PR}`, + success: ListThreadPullRequestsResult, + failure: PullRequestToolError, + dependencies, +}) + .annotate(Tool.Title, "List thread pull requests") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const PullRequestsToolkit = Toolkit.make( + LinkPullRequestTool, + UnlinkPullRequestTool, + ListThreadPullRequestsTool, +); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index bf09ed959e17..e6468ff8f789 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -249,6 +249,84 @@ describe("projectActivityPayload", () => { expect(JSON.stringify(projected.payload).length).toBeLessThan(500); }); + it.each([ + { + item: { + server: "t3-code", + tool: "preview_open", + result: { structuredContent: { url: "https://example.com/" } }, + }, + }, + { + toolName: "mcp__t3-code__preview_navigate", + result: { content: '{"url":"https://example.com/"}' }, + }, + { tool: "t3-code_preview_status", state: { output: '{"url":"https://example.com/"}' } }, + { + toolName: "mcp__t3_code__preview_snapshot", + result: { + content: [ + { type: "text", text: '{"url":"https://example.com/"}' }, + { type: "text", text: "Snapshot text was bounded. Omitted: accessibilityTree." }, + ], + }, + }, + { + toolName: "mcp__t3-code__preview_click", + result: { content: '{"toolIcon":{"_tag":"website","pageUrl":"https://example.com/"}}' }, + }, + { + toolName: "mcp__t3_code__preview_snapshot", + result: { content: '{"url":"https://example.com/"}\n{"accessibilityTree":"truncated' }, + }, + ...[false, true].map((truncated) => ({ + toolName: "mcp__t3_code__preview_snapshot", + result: { + content: JSON.stringify({ + content: [{ type: "text", text: '{"url":"https://example.com/"}' }], + structuredContent: { url: "https://example.com/", visibleText: "page" }, + }).slice(0, truncated ? -5 : undefined), + }, + })), + ...[ + "type", + "press", + "scroll", + "resize", + "set_appearance", + "evaluate", + "wait_for", + "recording_start", + "recording_stop", + ].map((action) => ({ + toolName: `mcp__t3_code__preview_${action}`, + result: { content: '{"toolIcon":{"_tag":"website","pageUrl":"https://example.com/"}}' }, + })), + ])("preserves the preview page favicon through result slimming", (data) => { + const projected = projectActivityPayload(activity({ itemType: "mcp_tool_call", data })); + const icon = { _tag: "website", pageUrl: "https://example.com/" }; + expect(projected.payload).toMatchObject({ toolIcon: icon }); + expect(projectActivityPayload(projected).payload).toMatchObject({ toolIcon: icon }); + }); + + it.each([ + { toolName: "mcp__other__preview_open", result: { content: '{"url":"https://example.com/"}' } }, + { + toolName: "mcp__t3-code__preview_evaluate", + result: { content: '{"url":"https://example.com/"}' }, + }, + { + toolName: "mcp__t3-code__preview_open", + result: { isError: true, content: '{"url":"https://example.com/"}' }, + }, + { toolName: "mcp__t3-code__preview_open", result: { content: "malformed JSON" } }, + { toolName: "mcp__t3-code__preview_open", result: { content: '{"url":"about:blank"}' } }, + ])("keeps the fallback for unrelated tools, failed navigation, and missing page URLs", (data) => { + expect( + projectActivityPayload(activity({ itemType: "mcp_tool_call", data })).payload, + ).not.toHaveProperty("toolIcon"); + }); + it("passes task lifecycle payloads (no data field) through untouched", () => { const source = activity({ taskId: "task-9", diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 98294e63b35c..0525aae7b72b 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -1,9 +1,11 @@ +import { projectQuestionToolInput } from "@t3tools/shared/toolActivity"; import type { OrchestrationEvent, OrchestrationThreadActivity, OrchestrationThreadDetailSnapshot, } from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; function asRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) @@ -240,6 +242,70 @@ function summarizeMcpResult(result: unknown): Record | undefine return summary ? { content: summary } : undefined; } +/** Reuse the page URL already returned by preview tools before slimming their output. */ +function projectPreviewToolMetadata(data: Record, status: unknown) { + const item = asRecord(data.item); + const name = item ? `mcp__${item.server}__${item.tool}` : (data.toolName ?? data.tool); + if ( + typeof name !== "string" || + !/^(?:mcp__)?(?:t3-code|t3_code|t3code)_{1,2}preview_(?:open|navigate|status|snapshot|click|type|press|scroll|resize|set_appearance|evaluate|wait_for|recording_start|recording_stop)$/.test( + name, + ) + ) + return {}; + const state = asRecord(data.state); + const result = item?.result ?? data.result ?? state?.output; + const record = asRecord(result); + if ( + status === "failed" || + status === "declined" || + state?.status === "error" || + item?.error != null || + record?.isError === true || + record?.is_error === true + ) + return {}; + + let page = record; + let output: unknown = result; + for (let depth = 0; depth < 3; depth += 1) { + if (page?.isError === true || page?.is_error === true) return {}; + const structured = asRecord(page?.structuredContent); + if (structured) { + page = structured; + break; + } + const text = extractMcpResultText(output)?.slice(0, 2 * 1024 * 1024); + if (!text) break; + try { + page = asRecord(JSON.parse(extractJsonObject(text))); + } catch { + // A truncated MCP envelope can still contain a complete first text block. + const firstBlock = /^\s*\{\s*"content"\s*:\s*\[\s*/.exec(text); + if (!firstBlock) return {}; + try { + const block = asRecord(JSON.parse(extractJsonObject(text.slice(firstBlock[0].length)))); + page = block?.type === "text" ? { content: [block] } : null; + } catch { + return {}; + } + } + output = page; + } + const rawUrl = asTrimmedString( + asRecord(page?.toolIcon)?.pageUrl ?? + (/preview_(?:open|navigate|status|snapshot)$/.test(name) ? page?.url : undefined), + ); + if (!rawUrl || rawUrl.length > 4096) return {}; + try { + const url = new URL(rawUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") return {}; + return { toolIcon: { _tag: "website", pageUrl: url.href } }; + } catch { + return {}; + } +} + /** * MCP tool calls carry full tool results (`data.item.result` on Codex, * `data.result` on Claude/OpenCode) that used to bypass slimming entirely to @@ -366,22 +432,27 @@ export function projectActivityPayload( } const itemStatus = asRecord(data.item)?.status; - const projectedPayload = + const statusPayload = payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined") ? { ...payload, status: itemStatus } : payload; + const projectedPayload = { + ...projectPreviewToolMetadata(data, statusPayload.status), + ...statusPayload, + }; + const questionInput = projectQuestionToolInput(data, payload.title); if (payload.itemType === "mcp_tool_call") { return { ...activity, payload: { ...projectedPayload, - data: projectMcpToolCallData(data), + data: { ...projectMcpToolCallData(data), ...questionInput }, }, }; } - const projectedData: Record = {}; + const projectedData: Record = { ...questionInput }; const item = projectCommandData(data); if (item) { projectedData.item = item; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 2cb4ed7566ad..6d062ebcab4b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -27,7 +27,7 @@ import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { PersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; @@ -60,7 +60,10 @@ const asMessageId = (value: string): MessageId => MessageId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); -function makeOrchestrationLayer(databasePath?: string) { +function makeOrchestrationLayer( + databasePath?: string, + repositoryIdentityResolver?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"], +) { const persistence = databasePath ? makeSqlitePersistenceLive(databasePath) : SqlitePersistenceMemory; @@ -78,15 +81,27 @@ function makeOrchestrationLayer(databasePath?: string) { Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationEventStoreLive), Layer.provideMerge(OrchestrationCommandReceiptRepositoryLive), - Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide( + repositoryIdentityResolver + ? Layer.succeed( + RepositoryIdentityResolver.RepositoryIdentityResolver, + repositoryIdentityResolver, + ) + : RepositoryIdentityResolver.layer, + ), Layer.provide(persistence), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); } -async function createOrchestrationSystem(databasePath?: string) { - const runtime = ManagedRuntime.make(makeOrchestrationLayer(databasePath)); +async function createOrchestrationSystem( + databasePath?: string, + repositoryIdentityResolver?: RepositoryIdentityResolver.RepositoryIdentityResolver["Service"], +) { + const runtime = ManagedRuntime.make( + makeOrchestrationLayer(databasePath, repositoryIdentityResolver), + ); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); return { @@ -373,6 +388,7 @@ describe("OrchestrationEngine", () => { runtimeMode: "full-access" as const, branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: "2026-03-03T00:00:02.000Z", updatedAt: "2026-03-03T00:00:03.000Z", @@ -1009,7 +1025,22 @@ describe("OrchestrationEngine", () => { it.each(["unlink", "relink", "branch", "worktree", "project", "delete"] as const)( "rejects PR discovery completed after a newer %s command", async (change) => { - const system = await createOrchestrationSystem(); + const system = await createOrchestrationSystem(undefined, { + resolve: (workspaceRoot) => + Effect.succeed({ + canonicalKey: "example.test/owner/repository", + provider: "github", + displayName: "owner/repository", + rootPath: workspaceRoot, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://example.test/owner/repository.git", + }, + }), + }); + // Same-tick links must replace the old PR, not rely on timestamp ordering. + const clock = vi.spyOn(Date, "now").mockReturnValue(Date.parse(now())); try { const projectId = ProjectId.make("pr-race-project"); const threadId = ThreadId.make("pr-race-thread"); @@ -1058,6 +1089,7 @@ describe("OrchestrationEngine", () => { linkedPullRequest: previous, }), ); + expect((await system.readModel()).threads[0]?.linkedPullRequest).toEqual(previous); const metadataChanges = { unlink: { linkedPullRequest: null }, relink: { @@ -1111,6 +1143,9 @@ describe("OrchestrationEngine", () => { if (change === "delete") return; const current = (await system.readModel()).threads[0]; expect(current?.branchPullRequest ?? null).toBeNull(); + expect(current?.pullRequests.map((link) => link.number)).toEqual( + change === "unlink" ? [] : change === "relink" ? [3] : [1], + ); expect(current?.linkedPullRequest ?? null).toEqual( change === "unlink" ? null @@ -1119,6 +1154,7 @@ describe("OrchestrationEngine", () => { : previous, ); } finally { + clock.mockRestore(); await system.dispose(); } }, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 6557888c38ae..fb2fadde5e63 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -212,6 +212,29 @@ const makeOrchestrationEngine = Effect.gen(function* () { }); } + // New and moved projects do not carry a resolved identity in the event-derived + // command model. Legacy PR edits need it to identify the link they replace. + if ( + envelope.command.type === "thread.meta.update" && + envelope.command.linkedPullRequest !== undefined + ) { + const threadId = envelope.command.threadId; + const thread = commandReadModel.threads.find((thread) => thread.id === threadId); + if (thread !== undefined) { + const project = yield* projectionSnapshotQuery.getProjectShellById(thread.projectId); + if (Option.isSome(project)) { + commandReadModel = { + ...commandReadModel, + projects: commandReadModel.projects.map((entry) => + entry.id === thread.projectId + ? { ...entry, repositoryIdentity: project.value.repositoryIdentity } + : entry, + ), + }; + } + } + } + // Command snapshots omit activities at startup and cap them while running. // Read this request's durable state before deciding how to send the answer. const userInputActivity = diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index dd76721defa0..5fcc32a34fba 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; +import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts"; import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; @@ -84,6 +85,16 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(PullRequestSyncReactor.PullRequestSyncReactor, { + start: () => { + started.push("pull-request-sync-reactor"); + return Effect.void; + }, + drain: Effect.void, + requestSync: () => Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, @@ -107,6 +118,7 @@ describe("OrchestrationReactor", () => { "thread-deletion-reactor", "thread-pull-request-reactor", "thread-settlement-reactor", + "pull-request-sync-reactor", "agent-awareness-relay", ]); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index a86907b0d78b..ff632240d3e7 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -10,6 +10,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ThreadDeletionReactor } from "../Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts"; +import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts"; import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; @@ -19,6 +20,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + const pullRequestSyncReactor = yield* PullRequestSyncReactor.PullRequestSyncReactor; const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; @@ -29,6 +31,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* threadDeletionReactor.start(); yield* threadPullRequestReactor.start(); yield* threadSettlementReactor.start(); + yield* pullRequestSyncReactor.start(); yield* agentAwarenessRelay.start(); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 29ecd516441e..a038a5662169 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -8,6 +8,7 @@ import { MessageId, ProjectId, ThreadId, + type ThreadPullRequestSnapshot, ThreadLinkedPullRequest, TurnId, ProviderInstanceId, @@ -686,6 +687,242 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( }, ); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull-requests-")))( + "OrchestrationProjectionPipeline pull request links", + (it) => { + it.effect("projects link, sync, unlink, legacy replay and delete into the link table", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-pr"); + const projectId = ProjectId.make("project-pr"); + const t0 = "2026-01-01T00:00:00.000Z"; + let counter = 0; + const base = (occurredAt: string) => { + counter += 1; + return { + eventId: EventId.make(`evt-pr-${counter}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt, + commandId: CommandId.make(`cmd-pr-${counter}`), + causationEventId: null, + correlationId: CommandId.make(`cmd-pr-${counter}`), + metadata: {}, + } as const; + }; + const readLinks = () => + sql<{ + readonly host: string; + readonly repository: string; + readonly number: number; + readonly source: string; + readonly linkedAt: string; + readonly snapshotJson: string | null; + readonly stackJson: string | null; + }>` + SELECT + host, + repository, + number, + source, + linked_at AS "linkedAt", + snapshot_json AS "snapshotJson", + stack_json AS "stackJson" + FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + ORDER BY number ASC + `; + const readThreadUpdatedAt = () => + sql<{ readonly updatedAt: string }>` + SELECT updated_at AS "updatedAt" FROM projection_threads WHERE thread_id = ${threadId} + `; + + yield* eventStore.append({ + ...base(t0), + type: "thread.created", + payload: { + threadId, + projectId, + title: "Thread PR", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: t0, + updatedAt: t0, + }, + }); + + // Legacy single-link event replays into a manual row with the URL host. + yield* eventStore.append({ + ...base("2026-01-01T00:00:01.000Z"), + type: "thread.meta-updated", + payload: { + threadId, + linkedPullRequest: { + projectId, + repository: "web", + number: 41, + url: "https://org-a.visualstudio.com/DefaultCollection/project/_git/web/pullrequest/41", + }, + updatedAt: "2026-01-01T00:00:01.000Z", + }, + }); + yield* eventStore.append({ + ...base("2026-01-01T00:00:02.000Z"), + type: "thread.pull-request-linked", + payload: { + threadId, + link: { + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + source: "created", + linkedAt: "2026-01-01T00:00:02.000Z", + snapshot: null, + stack: null, + }, + updatedAt: "2026-01-01T00:00:02.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + + assert.deepEqual(yield* readLinks(), [ + { + host: "dev.azure.com", + repository: "org-a/project/_git/web", + number: 41, + source: "manual", + linkedAt: "2026-01-01T00:00:01.000Z", + snapshotJson: null, + stackJson: null, + }, + { + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + source: "created", + linkedAt: "2026-01-01T00:00:02.000Z", + snapshotJson: null, + stackJson: null, + }, + ]); + assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:02.000Z" }]); + + // Sync fills snapshot/stack on the matching row; a sync for an unknown + // link is ignored. + const snapshot: ThreadPullRequestSnapshot = { + state: "open", + title: "Add links", + headBranch: "feat/links", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-01-01T00:00:02.500Z", + syncedAt: "2026-01-01T00:00:03.000Z", + }; + yield* eventStore.append({ + ...base("2026-01-01T00:00:03.000Z"), + type: "thread.pull-request-synced", + payload: { + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + snapshot, + stack: null, + updatedAt: "2026-01-01T00:00:03.000Z", + }, + }); + yield* eventStore.append({ + ...base("2026-01-01T00:00:03.500Z"), + type: "thread.pull-request-synced", + payload: { + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 99, + snapshot, + stack: null, + updatedAt: "2026-01-01T00:00:03.500Z", + }, + }); + yield* projectionPipeline.bootstrap; + + const synced = yield* readLinks(); + assert.equal(synced.length, 2); + assert.equal(synced[0]?.snapshotJson, null); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.deepEqual(JSON.parse(synced[1]?.snapshotJson ?? "null"), snapshot); + assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:03.000Z" }]); + + // A legacy null clears only the manual row; created/agent/stack rows stay. + yield* eventStore.append({ + ...base("2026-01-01T00:00:04.000Z"), + type: "thread.meta-updated", + payload: { + threadId, + linkedPullRequest: null, + updatedAt: "2026-01-01T00:00:04.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + assert.deepEqual( + (yield* readLinks()).map((row) => row.number), + [42], + ); + + yield* eventStore.append({ + ...base("2026-01-01T00:00:05.000Z"), + type: "thread.pull-request-unlinked", + payload: { + threadId, + host: "GitHub.COM", + repository: "PingDotGG/T3Code", + number: 42, + updatedAt: "2026-01-01T00:00:05.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + assert.deepEqual(yield* readLinks(), []); + assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:05.000Z" }]); + + // Deleting the thread clears whatever links it still had. + yield* eventStore.append({ + ...base("2026-01-01T00:00:06.000Z"), + type: "thread.pull-request-linked", + payload: { + threadId, + link: { + host: "github.com", + repository: "pingdotgg/t3code", + number: 43, + url: "https://github.com/pingdotgg/t3code/pull/43", + source: "agent", + linkedAt: "2026-01-01T00:00:06.000Z", + snapshot: null, + stack: null, + }, + updatedAt: "2026-01-01T00:00:06.000Z", + }, + }); + yield* eventStore.append({ + ...base("2026-01-01T00:00:07.000Z"), + type: "thread.deleted", + payload: { + threadId, + deletedAt: "2026-01-01T00:00:07.000Z", + }, + }); + yield* projectionPipeline.bootstrap; + assert.deepEqual(yield* readLinks(), []); + }), + ); + }, +); + it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-safe-")))( "OrchestrationProjectionPipeline", (it) => { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e4638a329b6c..e303e7323729 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -16,6 +16,10 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { + legacyThreadPullRequestKey, + threadPullRequestKeysEqual, +} from "@t3tools/shared/threadPullRequests"; import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; @@ -32,6 +36,7 @@ import { type ProjectionThreadProposedPlan, ProjectionThreadProposedPlanRepository, } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; +import * as ProjectionThreadPullRequests from "../../persistence/ProjectionThreadPullRequests.ts"; import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { type ProjectionTurn, @@ -480,6 +485,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const projectionThreadRepository = yield* ProjectionThreadRepository; const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository; + const projectionThreadPullRequestRepository = + yield* ProjectionThreadPullRequests.ProjectionThreadPullRequestRepository; const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; const projectionTurnRepository = yield* ProjectionTurnRepository; @@ -599,6 +606,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti )(function* (event, attachmentSideEffects) { switch (event.type) { case "thread.created": + // A draft retry can re-create this id; links belong to the old incarnation. + yield* projectionThreadPullRequestRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); yield* projectionThreadRepository.upsert({ threadId: event.payload.threadId, projectId: event.payload.projectId, @@ -820,6 +831,94 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti : {}), updatedAt: event.payload.updatedAt, }); + // Legacy single-link events replay into the link table. The old + // field held one user-chosen link, so it only ever owns the manual + // rows; created/agent/stack links are left alone. + if (event.payload.linkedPullRequest !== undefined) { + yield* projectionThreadPullRequestRepository.deleteByThreadIdAndSource({ + threadId: event.payload.threadId, + source: "manual", + }); + if (event.payload.linkedPullRequest !== null) { + const linked = event.payload.linkedPullRequest; + yield* projectionThreadPullRequestRepository.upsert({ + threadId: event.payload.threadId, + ...legacyThreadPullRequestKey(linked), + url: linked.url, + source: "manual", + linkedAt: event.payload.updatedAt, + snapshot: null, + stack: null, + }); + } + } + return; + } + + case "thread.pull-request-linked": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadPullRequestRepository.upsert({ + threadId: event.payload.threadId, + ...event.payload.link, + }); + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.pull-request-unlinked": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadPullRequestRepository.delete({ + threadId: event.payload.threadId, + host: event.payload.host.toLowerCase(), + repository: event.payload.repository.toLowerCase(), + number: event.payload.number, + }); + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.pull-request-synced": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + // A sync for a link the user removed in the meantime is stale; drop it. + const links = yield* projectionThreadPullRequestRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + const link = links.find((candidate) => + threadPullRequestKeysEqual(candidate, event.payload), + ); + if (link === undefined) { + return; + } + yield* projectionThreadPullRequestRepository.upsert({ + ...link, + snapshot: event.payload.snapshot, + stack: event.payload.stack, + }); + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + updatedAt: event.payload.updatedAt, + }); return; } @@ -866,6 +965,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (!recreatedLater) { attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); } + // A tombstoned thread must not show up as linked to a pull request. + yield* projectionThreadPullRequestRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, }); @@ -2068,6 +2171,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(ProjectionThreadRepositoryLive), Layer.provideMerge(ProjectionThreadMessageRepositoryLive), Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive), + Layer.provideMerge(ProjectionThreadPullRequests.layer), Layer.provideMerge(ProjectionThreadActivityRepositoryLive), Layer.provideMerge(ProjectionThreadSessionRepositoryLive), Layer.provideMerge(ProjectionTurnRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index e262bce34aaf..5849123c55d6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -6,6 +6,7 @@ import { MessageId, ProjectId, ThreadId, + type ThreadPullRequestLink, ThreadLinkedPullRequest, TurnId, ProviderInstanceId, @@ -66,6 +67,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { yield* sql`DELETE FROM projection_projects`; yield* sql`DELETE FROM projection_state`; yield* sql`DELETE FROM projection_thread_proposed_plans`; + yield* sql`DELETE FROM projection_thread_pull_requests`; yield* sql`DELETE FROM projection_turns`; yield* sql` @@ -91,6 +93,45 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ) `; + // A merged link plus a newer open one: the multi-link projection must + // resolve to the open pull request, not the stale JSON column below. + yield* sql` + INSERT INTO projection_thread_pull_requests ( + thread_id, + host, + repository, + number, + url, + source, + linked_at, + snapshot_json, + stack_json + ) + VALUES + ( + 'thread-1', + 'github.com', + 'pingdotgg/t3code', + 41, + 'https://github.com/pingdotgg/t3code/pull/41', + 'created', + '2026-02-24T00:00:02.500Z', + '{"state":"merged","title":"Groundwork","headBranch":"feat/groundwork","baseBranch":"main","isDraft":false,"updatedAt":"2026-02-24T00:00:02.600Z","syncedAt":"2026-02-24T00:00:02.700Z"}', + NULL + ), + ( + 'thread-1', + 'github.com', + 'pingdotgg/t3code', + 42, + 'https://github.com/pingdotgg/t3code/pull/42', + 'manual', + '2026-02-24T00:00:03.000Z', + NULL, + NULL + ) + `; + yield* sql` INSERT INTO projection_threads ( thread_id, @@ -124,7 +165,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'default', NULL, NULL, - '{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}', + '{"projectId":"project-1","repository":"pingdotgg/t3code","number":41,"url":"https://github.com/pingdotgg/t3code/pull/41"}', ${encodeThreadLinkedPullRequest(branchPullRequest)}, 'turn-1', '2026-02-24T00:00:04.000Z', @@ -286,6 +327,37 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { sequence += 1; } + const expectedPullRequests: ReadonlyArray = [ + { + host: "github.com", + repository: "pingdotgg/t3code", + number: 41, + url: "https://github.com/pingdotgg/t3code/pull/41", + source: "created", + linkedAt: "2026-02-24T00:00:02.500Z", + snapshot: { + state: "merged", + title: "Groundwork", + headBranch: "feat/groundwork", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-02-24T00:00:02.600Z", + syncedAt: "2026-02-24T00:00:02.700Z", + }, + stack: null, + }, + { + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + source: "manual", + linkedAt: "2026-02-24T00:00:03.000Z", + snapshot: null, + stack: null, + }, + ]; + const snapshot = yield* snapshotQuery.getSnapshot(); assert.equal(snapshot.snapshotSequence, 5); @@ -331,12 +403,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, - linkedPullRequest: { - projectId: asProjectId("project-1"), - repository: "pingdotgg/t3code", - number: 42, - url: "https://github.com/pingdotgg/t3code/pull/42", - }, + pullRequests: expectedPullRequests, branchPullRequest, latestTurn: { turnId: asTurnId("turn-1"), @@ -461,12 +528,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, - linkedPullRequest: { - projectId: asProjectId("project-1"), - repository: "pingdotgg/t3code", - number: 42, - url: "https://github.com/pingdotgg/t3code/pull/42", - }, + pullRequests: expectedPullRequests, branchPullRequest, latestTurn: { turnId: asTurnId("turn-1"), @@ -516,13 +578,27 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { assert.deepEqual(threadDetail.value, snapshot.threads[0]); } - const commandSnapshot = yield* snapshotQuery.getCommandReadModel(); - assert.equal(commandSnapshot.threads[0]?.activeOrderKey, "hq"); - assert.deepEqual(commandSnapshot.threads[0]?.branchPullRequest, branchPullRequest); const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); assert.equal(threadShell._tag, "Some"); if (threadShell._tag === "Some") { - assert.deepEqual(threadShell.value.branchPullRequest, branchPullRequest); + assert.deepEqual(threadShell.value, shellSnapshot.threads[0]); + } + + const commandReadModel = yield* snapshotQuery.getCommandReadModel(); + assert.deepEqual(commandReadModel.threads[0]?.pullRequests, expectedPullRequests); + assert.deepEqual( + commandReadModel.threads[0]?.linkedPullRequest, + snapshot.threads[0]?.linkedPullRequest, + ); + + // Without link rows the legacy field is omitted, whatever the old JSON + // column still holds. + yield* sql`DELETE FROM projection_thread_pull_requests`; + const unlinkedShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); + assert.equal(unlinkedShell._tag, "Some"); + if (unlinkedShell._tag === "Some") { + assert.deepEqual(unlinkedShell.value.pullRequests, []); + assert.equal("linkedPullRequest" in unlinkedShell.value, false); } yield* sql` @@ -3259,3 +3335,56 @@ projectionSnapshotLayer("ProjectionSnapshotQuery imported sources", (it) => { }), ); }); + +it.effect("omits foreign-host PRs from legacy snapshots while preserving native links", () => { + const layer = OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/web", + provider: "github", + displayName: "acme/web", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/acme/web.git", + }, + }), + }), + ), + Layer.provideMerge(SqlitePersistenceMemory), + ); + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('project-1', 'Project', '/repo', '[]', '2026-09-09T00:00:00Z', '2026-09-09T00:00:00Z')`; + yield* sql`INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, created_at, updated_at) + VALUES ('thread-1', 'project-1', 'Thread', '{"provider":"codex","model":"gpt-5"}', 'full-access', 'default', '2026-09-09T00:00:00Z', '2026-09-09T00:00:00Z')`; + yield* sql`INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at) + VALUES ('thread-1', 'github.enterprise.test', 'acme/web', 42, 'https://github.enterprise.test/acme/web/pull/42', 'manual', '2026-09-09T00:00:00Z')`; + const readThreads = Effect.gen(function* () { + const full = yield* query.getSnapshot(); + const shell = yield* query.getShellSnapshot(); + const detail = yield* query.getThreadDetailById(ThreadId.make("thread-1")); + const individual = yield* query.getThreadShellById(ThreadId.make("thread-1")); + return [ + full.threads[0]!, + shell.threads[0]!, + Option.getOrThrow(detail), + Option.getOrThrow(individual), + ]; + }); + for (const thread of yield* readThreads) { + assert.equal(thread.linkedPullRequest ?? null, null); + assert.equal(thread.pullRequests[0]?.host, "github.enterprise.test"); + } + yield* sql`UPDATE projection_thread_pull_requests SET host = 'github.com', url = 'https://github.com/acme/web/pull/42'`; + for (const thread of yield* readThreads) { + assert.equal(thread.linkedPullRequest?.url, "https://github.com/acme/web/pull/42"); + } + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 5f82a26e2a36..066c60760ca5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -29,7 +29,11 @@ import { ProjectId, ThreadLinkedPullRequest, ThreadId, + ThreadPullRequestSnapshot, + ThreadPullRequestStack, + type ThreadPullRequestLink, } from "@t3tools/contracts"; +import { legacyLinkedPullRequestOf } from "@t3tools/shared/threadPullRequests"; import * as Arr from "effect/Array"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -54,6 +58,7 @@ import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts"; import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; +import { ProjectionThreadPullRequest } from "../../persistence/ProjectionThreadPullRequests.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; import { @@ -112,6 +117,12 @@ const ProjectionTurnStartMessageDbRowSchema = ProjectionThreadMessageDbRowSchema Struct.assign({ hasOtherUserMessages: Schema.Number }), ); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; +const ProjectionThreadPullRequestDbRowSchema = ProjectionThreadPullRequest.mapFields( + Struct.assign({ + snapshot: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestSnapshot)), + stack: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestStack)), + }), +); const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), @@ -403,6 +414,49 @@ function mapProposedPlanRow( }; } +function mapPullRequestRow( + row: Schema.Schema.Type, +): ThreadPullRequestLink { + return { + host: row.host, + repository: row.repository, + number: row.number, + url: row.url, + source: row.source, + linkedAt: row.linkedAt, + snapshot: row.snapshot, + stack: row.stack, + }; +} + +function groupPullRequestRowsByThread( + rows: ReadonlyArray>, +): Map> { + const byThread = new Map>(); + for (const row of rows) { + const links = byThread.get(row.threadId) ?? []; + links.push(mapPullRequestRow(row)); + byThread.set(row.threadId, links); + } + return byThread; +} + +/** + * The link array plus the legacy single-link field derived from it, so clients + * from before `pullRequests` keep seeing the thread's current pull request. + */ +function mapThreadPullRequests( + pullRequests: ReadonlyArray, + projectId: ProjectId, + identity?: OrchestrationProject["repositoryIdentity"], +): Pick { + const linkedPullRequest = legacyLinkedPullRequestOf(pullRequests, projectId, identity); + return { + pullRequests, + ...(linkedPullRequest === null ? {} : { linkedPullRequest }), + }; +} + function mapThreadActivityRow( row: Schema.Schema.Type, ): OrchestrationThreadActivity { @@ -649,6 +703,74 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadPullRequestRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadPullRequestDbRowSchema, + execute: () => + sql` + SELECT + thread_id AS "threadId", + host, + repository, + number, + url, + source, + linked_at AS "linkedAt", + snapshot_json AS "snapshot", + stack_json AS "stack" + FROM projection_thread_pull_requests + ORDER BY thread_id ASC, linked_at ASC, number ASC + `, + }); + + const listActiveThreadPullRequestRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadPullRequestDbRowSchema, + execute: () => + sql` + SELECT + links.thread_id AS "threadId", + links.host, + links.repository, + links.number, + links.url, + links.source, + links.linked_at AS "linkedAt", + links.snapshot_json AS "snapshot", + links.stack_json AS "stack" + FROM projection_thread_pull_requests links + INNER JOIN projection_threads threads + ON threads.thread_id = links.thread_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NULL + ORDER BY links.thread_id ASC, links.linked_at ASC, links.number ASC + `, + }); + + const listArchivedThreadPullRequestRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadPullRequestDbRowSchema, + execute: () => + sql` + SELECT + links.thread_id AS "threadId", + links.host, + links.repository, + links.number, + links.url, + links.source, + links.linked_at AS "linkedAt", + links.snapshot_json AS "snapshot", + links.stack_json AS "stack" + FROM projection_thread_pull_requests links + INNER JOIN projection_threads threads + ON threads.thread_id = links.thread_id + WHERE threads.deleted_at IS NULL + AND threads.archived_at IS NOT NULL + ORDER BY links.thread_id ASC, links.linked_at ASC, links.number ASC + `, + }); + const listThreadActivityRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionThreadActivityDbRowSchema, @@ -1208,6 +1330,27 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listThreadPullRequestRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadPullRequestDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + thread_id AS "threadId", + host, + repository, + number, + url, + source, + linked_at AS "linkedAt", + snapshot_json AS "snapshot", + stack_json AS "stack" + FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + ORDER BY linked_at ASC, number ASC + `, + }); + const listThreadActivityRowsByThread = SqlSchema.findAll({ Request: ThreadIdLookupInput, Result: ProjectionThreadActivityDbRowSchema, @@ -1852,6 +1995,14 @@ pending_approval_requests AS ( ), ), ), + listThreadPullRequestRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadPullRequests:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadPullRequests:decodeRows", + ), + ), + ), listThreadActivityRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -1901,6 +2052,7 @@ pending_approval_requests AS ( threadRows, messageRows, proposedPlanRows, + pullRequestRows, activityRows, sessionRows, checkpointRows, @@ -1910,6 +2062,7 @@ pending_approval_requests AS ( Effect.gen(function* () { const messagesByThread = new Map>(); const proposedPlansByThread = new Map>(); + const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); const activitiesByThread = new Map>(); const checkpointsByThread = new Map>(); const sessionsByThread = new Map(); @@ -2071,10 +2224,12 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...mapThreadPullRequests( + pullRequestsByThread.get(row.threadId) ?? [], + row.projectId, + repositoryIdentities.get(row.projectId), + ), branchPullRequest: row.branchPullRequest, - ...(row.linkedPullRequest === null - ? {} - : { linkedPullRequest: row.linkedPullRequest }), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2146,6 +2301,14 @@ pending_approval_requests AS ( ), ), ), + listThreadPullRequestRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listThreadPullRequests:query", + "ProjectionSnapshotQuery.getCommandReadModel:listThreadPullRequests:decodeRows", + ), + ), + ), listThreadSessionRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2174,8 +2337,25 @@ pending_approval_requests AS ( ) .pipe( Effect.flatMap( - ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) => - Effect.sync(() => { + ([ + projectRows, + threadRows, + proposedPlanRows, + pullRequestRows, + sessionRows, + latestTurnRows, + stateRows, + ]) => + Effect.gen(function* () { + const linkedThreadIds = new Set(pullRequestRows.map((row) => row.threadId)); + const linkedProjectIds = new Set( + threadRows + .filter((row) => linkedThreadIds.has(row.threadId)) + .map((row) => row.projectId), + ); + const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( + projectRows.filter((row) => linkedProjectIds.has(row.projectId)), + ); let updatedAt: string | null = null; const projects: OrchestrationProject[] = []; const threads: OrchestrationThread[] = []; @@ -2190,6 +2370,7 @@ pending_approval_requests AS ( id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, + repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, defaultModelSelection: row.defaultModelSelection, defaultThreadEnvMode: row.defaultThreadEnvMode, autoPull: row.autoPull === 1, @@ -2252,6 +2433,7 @@ pending_approval_requests AS ( latestTurnByThread.set(row.threadId, mapLatestTurn(row)); } const proposedPlansByThread = new Map>(); + const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); const sessionByThread = new Map(); for (let index = 0; index < sessionRows.length; index += 1) { @@ -2286,10 +2468,12 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...mapThreadPullRequests( + pullRequestsByThread.get(row.threadId) ?? [], + row.projectId, + repositoryIdentities.get(row.projectId), + ), branchPullRequest: row.branchPullRequest, - ...(row.linkedPullRequest === null - ? {} - : { linkedPullRequest: row.linkedPullRequest }), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2356,6 +2540,14 @@ pending_approval_requests AS ( ), ), ), + listActiveThreadPullRequestRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:listThreadPullRequests:query", + "ProjectionSnapshotQuery.getShellSnapshot:listThreadPullRequests:decodeRows", + ), + ), + ), listActiveLatestTurnRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2375,99 +2567,104 @@ pending_approval_requests AS ( ]), ) .pipe( - Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) => - Effect.gen(function* () { - let updatedAt: string | null = null; - for (const row of projectRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of threadRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of sessionRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of latestTurnRows) { - updatedAt = maxIso(updatedAt, row.requestedAt); - if (row.startedAt !== null) { - updatedAt = maxIso(updatedAt, row.startedAt); + Effect.flatMap( + ([projectRows, threadRows, sessionRows, pullRequestRows, latestTurnRows, stateRows]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); } - if (row.completedAt !== null) { - updatedAt = maxIso(updatedAt, row.completedAt); + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); } - } - for (const row of stateRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects(projectRows); - const latestTurnByThread = new Map( - latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), - ); - const sessionByThread = new Map( - sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), - ); + const repositoryIdentities = + yield* resolveRepositoryIdentitiesForProjects(projectRows); + const latestTurnByThread = new Map( + latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), + ); + const sessionByThread = new Map( + sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), + ); + const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); - const snapshot = { - snapshotSequence: computeSnapshotSequence(stateRows), - projects: Arr.filterMap(projectRows, (row) => - row.deletedAt === null - ? Result.succeed( - mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), - ) - : Result.failVoid, - ), - threads: Arr.filterMap(threadRows, (row) => - row.deletedAt === null - ? Result.succeed({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: row.modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - branchPullRequest: row.branchPullRequest, - ...(row.linkedPullRequest === null - ? {} - : { linkedPullRequest: row.linkedPullRequest }), - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - archivedAt: row.archivedAt, - settledOverride: row.settledOverride, - settledAt: row.settledAt, - unsettledAt: row.unsettledAt, - snoozedUntil: row.snoozedUntil, - snoozedAt: row.snoozedAt, - pinnedAt: row.pinnedAt, - pinOrderKey: row.pinOrderKey ?? null, - activeOrderKey: row.activeOrderKey ?? null, - titleRegeneration: mapTitleRegeneration(row), - session: sessionByThread.get(row.threadId) ?? null, - latestUserMessageAt: row.latestUserMessageAt, - hasPendingApprovals: row.pendingApprovalCount > 0, - hasPendingUserInput: row.pendingUserInputCount > 0, - hasActionableProposedPlan: row.hasActionableProposedPlan > 0, - backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( - row.threadId, - ), - planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), - } satisfies OrchestrationThreadShell) - : Result.failVoid, - ), - updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", - }; + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: Arr.filterMap(projectRows, (row) => + row.deletedAt === null + ? Result.succeed( + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ) + : Result.failVoid, + ), + threads: Arr.filterMap(threadRows, (row) => + row.deletedAt === null + ? Result.succeed({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + branchPullRequest: row.branchPullRequest, + ...mapThreadPullRequests( + pullRequestsByThread.get(row.threadId) ?? [], + row.projectId, + repositoryIdentities.get(row.projectId), + ), + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + unsettledAt: row.unsettledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, + activeOrderKey: row.activeOrderKey ?? null, + titleRegeneration: mapTitleRegeneration(row), + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( + row.threadId, + ), + planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + } satisfies OrchestrationThreadShell) + : Result.failVoid, + ), + updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", + }; - return yield* decodeShellSnapshot(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getShellSnapshot:decodeShellSnapshot", + ), ), - ), - ); - }), + ); + }), ), Effect.mapError((error) => { if (isPersistenceError(error)) { @@ -2505,6 +2702,14 @@ pending_approval_requests AS ( ), ), ), + listArchivedThreadPullRequestRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadPullRequests:query", + "ProjectionSnapshotQuery.getArchivedShellSnapshot:listThreadPullRequests:decodeRows", + ), + ), + ), listArchivedLatestTurnRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2524,98 +2729,102 @@ pending_approval_requests AS ( ]), ) .pipe( - Effect.flatMap(([projectRows, threadRows, sessionRows, latestTurnRows, stateRows]) => - Effect.gen(function* () { - let updatedAt: string | null = null; - for (const row of projectRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of threadRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of sessionRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of latestTurnRows) { - updatedAt = maxIso(updatedAt, row.requestedAt); - if (row.startedAt !== null) { - updatedAt = maxIso(updatedAt, row.startedAt); + Effect.flatMap( + ([projectRows, threadRows, sessionRows, pullRequestRows, latestTurnRows, stateRows]) => + Effect.gen(function* () { + let updatedAt: string | null = null; + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); } - if (row.completedAt !== null) { - updatedAt = maxIso(updatedAt, row.completedAt); + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); } - } - for (const row of stateRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - const activeProjectIds = new Set(threadRows.map((row) => row.projectId)); - const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( - projectRows.filter((row) => activeProjectIds.has(row.projectId)), - ); - const latestTurnByThread = new Map( - latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), - ); - const sessionByThread = new Map( - sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), - ); + const pullRequestsByThread = groupPullRequestRowsByThread(pullRequestRows); + const activeProjectIds = new Set(threadRows.map((row) => row.projectId)); + const repositoryIdentities = yield* resolveRepositoryIdentitiesForProjects( + projectRows.filter((row) => activeProjectIds.has(row.projectId)), + ); + const latestTurnByThread = new Map( + latestTurnRows.map((row) => [row.threadId, mapLatestTurn(row)] as const), + ); + const sessionByThread = new Map( + sessionRows.map((row) => [row.threadId, mapSessionRow(row)] as const), + ); - const snapshot = { - snapshotSequence: computeSnapshotSequence(stateRows), - projects: Arr.filterMap(projectRows, (row) => - row.deletedAt === null && activeProjectIds.has(row.projectId) - ? Result.succeed( - mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), - ) - : Result.failVoid, - ), - threads: threadRows.map((row): OrchestrationThreadShell => ({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: row.modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - branchPullRequest: row.branchPullRequest, - ...(row.linkedPullRequest === null - ? {} - : { linkedPullRequest: row.linkedPullRequest }), - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - archivedAt: row.archivedAt, - settledOverride: row.settledOverride, - settledAt: row.settledAt, - unsettledAt: row.unsettledAt, - snoozedUntil: row.snoozedUntil, - snoozedAt: row.snoozedAt, - pinnedAt: row.pinnedAt, - pinOrderKey: row.pinOrderKey ?? null, - activeOrderKey: row.activeOrderKey ?? null, - titleRegeneration: mapTitleRegeneration(row), - session: sessionByThread.get(row.threadId) ?? null, - latestUserMessageAt: row.latestUserMessageAt, - hasPendingApprovals: row.pendingApprovalCount > 0, - hasPendingUserInput: row.pendingUserInputCount > 0, - hasActionableProposedPlan: row.hasActionableProposedPlan > 0, - backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( - row.threadId, + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects: Arr.filterMap(projectRows, (row) => + row.deletedAt === null && activeProjectIds.has(row.projectId) + ? Result.succeed( + mapProjectShellRow(row, repositoryIdentities.get(row.projectId) ?? null), + ) + : Result.failVoid, ), - planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), - })), - updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", - }; + threads: threadRows.map((row): OrchestrationThreadShell => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + branchPullRequest: row.branchPullRequest, + ...mapThreadPullRequests( + pullRequestsByThread.get(row.threadId) ?? [], + row.projectId, + repositoryIdentities.get(row.projectId), + ), + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + unsettledAt: row.unsettledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, + pinOrderKey: row.pinOrderKey ?? null, + activeOrderKey: row.activeOrderKey ?? null, + titleRegeneration: mapTitleRegeneration(row), + session: sessionByThread.get(row.threadId) ?? null, + latestUserMessageAt: row.latestUserMessageAt, + hasPendingApprovals: row.pendingApprovalCount > 0, + hasPendingUserInput: row.pendingUserInputCount > 0, + hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( + row.threadId, + ), + planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + })), + updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", + }; - return yield* decodeShellSnapshot(snapshot).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot", + return yield* decodeShellSnapshot(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError( + "ProjectionSnapshotQuery.getArchivedShellSnapshot:decodeShellSnapshot", + ), ), - ), - ); - }), + ); + }), ), Effect.mapError((error) => { if (isPersistenceError(error)) { @@ -2861,7 +3070,7 @@ pending_approval_requests AS ( const getThreadShellById: ProjectionSnapshotQueryShape["getThreadShellById"] = (threadId) => Effect.gen(function* () { - const [threadRow, latestTurnRow, sessionRow] = yield* Effect.all([ + const [threadRow, latestTurnRow, sessionRow, pullRequestRows] = yield* Effect.all([ getActiveThreadRowById({ threadId }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2886,6 +3095,14 @@ pending_approval_requests AS ( ), ), ), + listThreadPullRequestRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadShellById:listPullRequests:query", + "ProjectionSnapshotQuery.getThreadShellById:listPullRequests:decodeRows", + ), + ), + ), ]); if (Option.isNone(threadRow)) { @@ -2901,10 +3118,15 @@ pending_approval_requests AS ( interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + ...mapThreadPullRequests( + pullRequestRows.map(mapPullRequestRow), + threadRow.value.projectId, + pullRequestRows.length === 0 + ? null + : Option.getOrNull(yield* getProjectShellById(threadRow.value.projectId)) + ?.repositoryIdentity, + ), branchPullRequest: threadRow.value.branchPullRequest, - ...(threadRow.value.linkedPullRequest === null - ? {} - : { linkedPullRequest: threadRow.value.linkedPullRequest }), latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, @@ -3112,6 +3334,7 @@ pending_approval_requests AS ( threadRow, messageRows, proposedPlanRows, + pullRequestRows, activities, checkpointRows, latestTurnRow, @@ -3144,6 +3367,14 @@ pending_approval_requests AS ( ), ), ), + listThreadPullRequestRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPullRequests:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPullRequests:decodeRows", + ), + ), + ), activitiesEffect, listCheckpointRowsByThread({ threadId }).pipe( Effect.mapError( @@ -3184,10 +3415,15 @@ pending_approval_requests AS ( interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + ...mapThreadPullRequests( + pullRequestRows.map(mapPullRequestRow), + threadRow.value.projectId, + pullRequestRows.length === 0 + ? null + : Option.getOrNull(yield* getProjectShellById(threadRow.value.projectId)) + ?.repositoryIdentity, + ), branchPullRequest: threadRow.value.branchPullRequest, - ...(threadRow.value.linkedPullRequest === null - ? {} - : { linkedPullRequest: threadRow.value.linkedPullRequest }), latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d93fec5a3cf6..c927e72fe737 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -175,6 +175,8 @@ describe("ProviderCommandReactor", () => { readonly titleRegenerationBeforeStart?: "one" | "two"; readonly serverActivation?: Effect.Effect; readonly beforeReadySessionDispatch?: () => Effect.Effect; + readonly beforeTurnStartDispatch?: () => Effect.Effect; + readonly afterTurnStartDispatch?: () => Effect.Effect; readonly compactThreadEffect?: () => Effect.Effect; readonly interruptTurnEffect?: () => Effect.Effect; readonly stopSessionEffect?: () => Effect.Effect; @@ -431,11 +433,21 @@ describe("ProviderCommandReactor", () => { return Effect.die(new Error("Injected title regeneration completion failure")); } } - return ( + const isReplay = + command.type === "thread.turn.start" && + command.commandId.startsWith("server:after-compaction:"); + const before = command.type === "thread.session.set" && command.session.status === "ready" - ? (input?.beforeReadySessionDispatch?.() ?? Effect.void) - : Effect.void - ).pipe(Effect.andThen(engine.dispatch(command))); + ? input?.beforeReadySessionDispatch + : isReplay + ? input?.beforeTurnStartDispatch + : undefined; + return (before?.() ?? Effect.void).pipe( + Effect.andThen(engine.dispatch(command)), + Effect.tap(() => + isReplay ? (input?.afterTurnStartDispatch?.() ?? Effect.void) : Effect.void, + ), + ); }, get streamDomainEvents() { return engine.streamDomainEvents; @@ -973,86 +985,203 @@ describe("ProviderCommandReactor", () => { }), ); - effectIt.effect("keeps turns blocked until compaction restores the session", () => - Effect.gen(function* () { - const readyDispatchStarted = yield* Deferred.make(); - const releaseReadyDispatch = yield* Deferred.make(); - let blockReadyDispatch = false; - const harness = yield* Effect.promise(() => - createHarness({ - beforeReadySessionDispatch: () => - blockReadyDispatch - ? Deferred.succeed(readyDispatchStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseReadyDispatch)), - ) - : Effect.void, - }), - ); - const threadId = ThreadId.make("thread-1"); - const now = "2026-01-01T00:00:00.000Z"; - const dispatchTurn = (id: string, text: string, createdAt: string) => - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make(`cmd-${id}`), + effectIt.effect.each(["resume", "stop before resume", "stop after send"])( + "queues messages until compaction restores the session (%s)", + (scenario) => + Effect.gen(function* () { + const stopBeforeResume = scenario === "stop before resume"; + const readyDispatchStarted = yield* Deferred.make(); + const releaseReadyDispatch = yield* Deferred.make(); + const firstSent = yield* Deferred.make(); + const queuedSent = yield* Deferred.make(); + const resumeStarted = yield* Deferred.make(); + const releaseResume = yield* Deferred.make(); + const resumeDispatched = yield* Deferred.make(); + const queuedSendStarted = yield* Deferred.make(); + const releaseQueuedSend = yield* Deferred.make(); + let blockReadyDispatch = false; + const harness = yield* Effect.promise(() => + createHarness({ + beforeTurnStartDispatch: () => + stopBeforeResume + ? Deferred.succeed(resumeStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseResume)), + ) + : Effect.void, + afterTurnStartDispatch: () => Deferred.succeed(resumeDispatched, undefined), + beforeReadySessionDispatch: () => + blockReadyDispatch + ? Deferred.succeed(readyDispatchStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseReadyDispatch)), + ) + : Effect.void, + }), + ); + const threadId = ThreadId.make("thread-1"); + let sentCount = 0; + harness.sendTurn.mockImplementation(() => + Effect.succeed({ threadId, turnId: asTurnId("turn-1") }).pipe( + Effect.tap(() => { + sentCount++; + return sentCount === 1 + ? Deferred.succeed(firstSent, undefined) + : sentCount === 2 && scenario === "stop after send" + ? Deferred.succeed(queuedSendStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseQueuedSend)), + ) + : sentCount === 3 + ? Deferred.succeed(queuedSent, undefined) + : Effect.void; + }), + ), + ); + const now = "2026-01-01T00:00:00.000Z"; + const dispatchTurn = (id: string, text: string, createdAt: string) => + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-${id}`), + threadId, + message: { + messageId: asMessageId(`user-message-${id}`), + role: "user", + text, + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }); + + yield* dispatchTurn("before-blocked-compact", "hello", now); + yield* Deferred.await(firstSent); + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-ready-before-blocked-compact"), threadId, - message: { - messageId: asMessageId(`user-message-${id}`), - role: "user", - text, - attachments: [], + session: { + threadId, + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt, + createdAt: now, }); - yield* dispatchTurn("before-blocked-compact", "hello", now); - yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); - yield* harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-ready-before-blocked-compact"), - threadId, - session: { - threadId, - status: "ready", - providerName: "codex", - providerInstanceId: ProviderInstanceId.make("codex"), - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: now, - }, - createdAt: now, - }); - - blockReadyDispatch = true; - yield* dispatchTurn("blocked-compact", "/compact", "2026-01-01T00:00:01.000Z"); - yield* Deferred.await(readyDispatchStarted); + blockReadyDispatch = true; + yield* dispatchTurn("blocked-compact", "/compact", "2026-01-01T00:00:01.000Z"); + yield* Deferred.await(readyDispatchStarted); - yield* dispatchTurn("during-compact-recovery", "too soon", "2026-01-01T00:00:02.000Z"); - yield* Effect.promise(() => - waitFor(async () => { - const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); - return ( - thread?.activities.some( - (activity) => activity.kind === "provider.turn.start.failed", - ) === true + yield* harness.engine.dispatch({ + type: "thread.interaction-mode.set", + commandId: CommandId.make("cmd-queued-mode-plan"), + threadId, + interactionMode: "plan", + createdAt: now, + }); + yield* dispatchTurn("during-compact-recovery", "first queued", "2026-01-01T00:00:02.000Z"); + yield* harness.engine.dispatch({ + type: "thread.interaction-mode.set", + commandId: CommandId.make("cmd-queued-mode-default"), + threadId, + interactionMode: "default", + createdAt: now, + }); + yield* dispatchTurn( + "during-compact-recovery-2", + "second queued", + "2026-01-01T00:00:03.000Z", + ); + yield* Effect.promise(() => harness.drain()); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + const beforeRestore = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect( + beforeRestore?.activities.filter( + (activity) => activity.kind === "provider.turn.start.failed", + ), + ).toEqual([]); + expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([ + { threadId: "thread-1" }, + ]); + + yield* Deferred.succeed(releaseReadyDispatch, undefined); + if (scenario === "stop after send") { + yield* Deferred.await(queuedSendStarted); + yield* harness.engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-stop-after-queued-send"), + threadId, + createdAt: "2026-01-01T00:00:04.000Z", + }); + yield* Effect.promise(() => harness.drain()); + const stoppedThread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, ); - }), - ); - expect(harness.sendTurn).toHaveBeenCalledTimes(1); - expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([ - { threadId: "thread-1" }, - ]); - - yield* Deferred.succeed(releaseReadyDispatch, undefined); - yield* Effect.promise(() => - waitFor(async () => { - const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); - return thread?.session?.status === "ready"; - }), - ); - }), + expect(stoppedThread?.session?.status).toBe("stopped"); + expect( + stoppedThread?.activities.filter( + (activity) => activity.summary === "Queued message was not sent", + ), + ).toEqual([ + expect.objectContaining({ + payload: { + requestId: "user-message-during-compact-recovery-2", + detail: expect.any(String), + }, + }), + ]); + expect(harness.sendTurn).toHaveBeenCalledTimes(2); + yield* Deferred.succeed(releaseQueuedSend, undefined); + return; + } + if (stopBeforeResume) { + yield* Deferred.await(resumeStarted); + yield* dispatchTurn("compact-during-resume", "/compact", "2026-01-01T00:00:04.000Z"); + yield* Effect.promise(() => harness.drain()); + expect(harness.compactThread).toHaveBeenCalledTimes(1); + yield* harness.engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-stop-before-queued-resume"), + threadId, + createdAt: "2026-01-01T00:00:04.000Z", + }); + yield* Effect.promise(() => harness.drain()); + yield* Deferred.succeed(releaseResume, undefined); + yield* Deferred.await(resumeDispatched); + yield* Effect.promise(() => harness.drain()); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + const stoppedThread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(stoppedThread?.session?.status).toBe("stopped"); + expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([]); + expect( + stoppedThread?.activities.filter( + (activity) => activity.summary === "Queued message was not sent", + ), + ).toHaveLength(2); + return; + } + yield* Deferred.await(queuedSent); + expect(harness.sendTurn.mock.calls.slice(1).map(([request]) => request)).toEqual([ + expect.objectContaining({ input: "first queued", interactionMode: "plan" }), + expect.objectContaining({ input: "second queued", interactionMode: "default" }), + ]); + const afterRestore = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect( + afterRestore?.messages.filter((message) => message.text === "first queued"), + ).toHaveLength(1); + expect( + afterRestore?.messages.filter((message) => message.text === "second queued"), + ).toHaveLength(1); + }), ); effectIt.effect("does not overwrite concurrent session state after compaction failure", () => @@ -1136,6 +1265,21 @@ describe("ProviderCommandReactor", () => { (entry) => entry.id === threadId, ); expect(compactingThread?.session?.status).toBe("starting"); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-queued-before-stop"), + threadId, + message: { + messageId: asMessageId("user-message-queued-before-stop"), + role: "user", + text: "do not restart after stopping", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + yield* Effect.promise(() => harness.drain()); yield* harness.engine.dispatch({ type: "thread.session.stop", commandId: CommandId.make("cmd-stop-during-compact"), @@ -1151,7 +1295,7 @@ describe("ProviderCommandReactor", () => { ); return ( compactingThread?.activities.some( - (activity) => activity.kind === "provider.turn.start.failed", + (activity) => activity.summary === "Context compaction failed", ) === true ); }), @@ -1167,6 +1311,14 @@ describe("ProviderCommandReactor", () => { (entry) => entry.id === threadId, ); expect(recoveredThread?.session?.status).toBe("ready"); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + expect( + recoveredThread?.activities.find( + (activity) => activity.summary === "Queued message was not sent", + ), + ).toMatchObject({ + payload: { requestId: "user-message-queued-before-stop" }, + }); expect( recoveredThread?.activities.find( (activity) => activity.kind === "provider.session.stop.failed", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 6df08bfadb9c..9b125922137c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -18,6 +18,7 @@ import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; @@ -346,6 +347,19 @@ const make = Effect.gen(function* () { const threadModelSelections = new Map(); const compactingThreadIds = new Set(); + type QueuedTurnStart = Extract; + // Turn starts received while a thread compacts, replayed in order once its session is restored. + const turnsAfterCompaction = new Map>(); + // Replay command id → the queued turn start it re-requests. `sent` settles once the replay's + // provider send finishes, which is what lets the next queued turn follow it in order. + const resumedTurnStarts = new Map< + CommandId, + { + readonly event: QueuedTurnStart; + readonly queued: Array; + readonly sent: Deferred.Deferred; + } + >(); const stoppingThreadIds = new Set(); const appendProviderFailureActivity = (input: { @@ -388,6 +402,71 @@ const make = Effect.gen(function* () { ), ); + const cancelTurnsAfterCompaction = Effect.fn("cancelTurnsAfterCompaction")(function* ( + threadId: ThreadId, + detail: string, + ) { + const queued = turnsAfterCompaction.get(threadId) ?? []; + turnsAfterCompaction.delete(threadId); + for (const event of queued) { + yield* appendProviderFailureActivity({ + threadId, + kind: "provider.turn.start.failed", + summary: "Queued message was not sent", + detail, + turnId: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + requestId: event.payload.messageId, + }).pipe(Effect.ignore({ log: true, message: "failed to report canceled queued message" })); + } + }); + + const resumeTurnsAfterCompaction = Effect.fn("resumeTurnsAfterCompaction")(function* ( + threadId: ThreadId, + ) { + const queued = turnsAfterCompaction.get(threadId) ?? []; + while (queued.length > 0 && turnsAfterCompaction.get(threadId) === queued) { + const event = queued[0]!; + const turnStart = yield* projectionSnapshotQuery.getTurnStartMessage({ + threadId, + messageId: event.payload.messageId, + }); + if (turnsAfterCompaction.get(threadId) !== queued) return; + // In flight from here on: a cancellation reports it when the replay runs, not from the queue. + queued.shift(); + if (Option.isNone(turnStart)) continue; + // Reissue the durable request after restoration clears compaction's + // pending slot. Reusing the message id preserves a single user bubble. + const commandId = yield* serverCommandId("after-compaction"); + const sent = yield* Deferred.make(); + resumedTurnStarts.set(commandId, { event, queued, sent }); + const { messageId, ...request } = event.payload; + yield* orchestrationEngine + .dispatch({ + type: "thread.turn.start", + commandId, + ...request, + message: { + messageId, + role: "user", + text: turnStart.value.message.text, + attachments: turnStart.value.message.attachments ?? [], + }, + }) + .pipe( + Effect.onError(() => + Effect.sync(() => { + resumedTurnStarts.delete(commandId); + queued.unshift(event); + }), + ), + ); + yield* Deferred.await(sent); + resumedTurnStarts.delete(commandId); + } + if (turnsAfterCompaction.get(threadId) === queued) turnsAfterCompaction.delete(threadId); + }); + const formatFailureDetail = (cause: Cause.Cause): string => { const failReason = cause.reasons.find(Cause.isFailReason); if (isProviderAdapterRequestError(failReason?.error)) { @@ -1181,8 +1260,11 @@ const make = Effect.gen(function* () { ); const processTurnStartRequested = Effect.fn("processTurnStartRequested")(function* ( - event: Extract, + receivedEvent: Extract, ) { + const resumed = + receivedEvent.commandId !== null ? resumedTurnStarts.get(receivedEvent.commandId) : undefined; + const event = resumed ? { ...receivedEvent, payload: resumed.event.payload } : receivedEvent; const key = turnStartKeyForEvent(event); if (yield* hasHandledTurnStartRecently(key)) { return; @@ -1219,6 +1301,12 @@ const make = Effect.gen(function* () { createdAt: event.payload.createdAt, requestId: event.payload.messageId, }); + if (resumed && turnsAfterCompaction.get(event.payload.threadId) !== resumed.queued) { + return yield* appendTurnStartFailure( + "Queued message was not sent", + "The queued message was canceled before it could resume. Send it again to continue.", + ); + } const handleTurnStartFailure = (cause: Cause.Cause) => { if (Cause.hasInterruptsOnly(cause)) { @@ -1381,6 +1469,7 @@ const make = Effect.gen(function* () { const latestThread = yield* resolveThreadShell(event.payload.threadId); if ( compactingThreadIds.has(event.payload.threadId) || + turnsAfterCompaction.has(event.payload.threadId) || latestThread?.session?.status === "starting" || latestThread?.session?.status === "running" ) { @@ -1391,6 +1480,9 @@ const make = Effect.gen(function* () { return; } compactingThreadIds.add(event.payload.threadId); + const clearCompacting = Effect.sync( + () => void compactingThreadIds.delete(event.payload.threadId), + ); yield* Effect.gen(function* () { yield* ensureSessionForThread( event.payload.threadId, @@ -1410,17 +1502,32 @@ const make = Effect.gen(function* () { ); }).pipe( Effect.andThen(restoreCompaction(event.payload.threadId, true)), - Effect.catchCause(recoverCompactionFailure), - Effect.ensuring(Effect.sync(() => void compactingThreadIds.delete(event.payload.threadId))), + Effect.andThen(clearCompacting), + Effect.andThen(resumeTurnsAfterCompaction(event.payload.threadId)), + Effect.catchCause((cause) => + recoverCompactionFailure(cause).pipe( + Effect.ensuring(clearCompacting), + Effect.andThen( + cancelTurnsAfterCompaction( + event.payload.threadId, + "Context compaction failed. Send this message again to continue.", + ), + ), + ), + ), Effect.forkScoped, ); return; } - if (compactingThreadIds.has(event.payload.threadId)) { - return yield* appendTurnStartFailure( - "Provider turn start failed", - "Wait for context compaction to finish before sending another message.", - ); + if ( + !resumed && + (compactingThreadIds.has(event.payload.threadId) || + turnsAfterCompaction.has(event.payload.threadId)) + ) { + const queued = turnsAfterCompaction.get(event.payload.threadId) ?? []; + queued.push(event); + turnsAfterCompaction.set(event.payload.threadId, queued); + return; } const sendTurnRequest = yield* buildSendTurnRequestForThread({ threadId: event.payload.threadId, @@ -1440,14 +1547,24 @@ const make = Effect.gen(function* () { return; } - yield* providerService + const send = providerService .sendTurn(sendTurnRequest.value) - .pipe(Effect.asVoid, Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); + .pipe(Effect.asVoid, Effect.catchCause(recoverTurnStartFailure)); + // The forked send settles `sent` from here on, so drop the entry the post-processing hook uses. + if (resumed && event.commandId !== null) resumedTurnStarts.delete(event.commandId); + yield* send.pipe( + Effect.ensuring(resumed ? Deferred.succeed(resumed.sent, undefined) : Effect.void), + Effect.forkScoped, + ); }); const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( event: Extract, ) { + yield* cancelTurnsAfterCompaction( + event.payload.threadId, + "Context compaction was interrupted. Send this message again to continue.", + ); const thread = yield* resolveThreadShell(event.payload.threadId); if (!thread) { return; @@ -1643,11 +1760,15 @@ const make = Effect.gen(function* () { const wasCompacting = compactingThreadIds.has(thread.id); stoppingThreadIds.add(thread.id); const clearStopping = Effect.sync(() => void stoppingThreadIds.delete(thread.id)); - yield* ( - thread.session && thread.session.status !== "stopped" - ? providerService.stopSession({ threadId: thread.id }) - : Effect.void + yield* cancelTurnsAfterCompaction( + thread.id, + "The session was stopped during context compaction. Send this message again to continue.", ).pipe( + Effect.andThen( + thread.session && thread.session.status !== "stopped" + ? providerService.stopSession({ threadId: thread.id }) + : Effect.void, + ), Effect.matchCauseEffect({ onFailure: (cause) => { if (Cause.hasInterruptsOnly(cause)) { @@ -1761,6 +1882,14 @@ const make = Effect.gen(function* () { const processDomainEventSafely = (event: ProviderIntentEvent) => processDomainEvent(event).pipe( + // A replay that returned before forking its send still holds its entry; settle it so + // the compaction queue moves on. Forked sends drop the entry first and settle it themselves. + Effect.ensuring( + Effect.suspend(() => { + const resumed = event.commandId !== null && resumedTurnStarts.get(event.commandId); + return resumed ? Deferred.succeed(resumed.sent, undefined) : Effect.void; + }), + ), Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) { return Effect.interrupt; diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts new file mode 100644 index 000000000000..414f76f32496 --- /dev/null +++ b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts @@ -0,0 +1,705 @@ +import { + ProjectId, + ProviderInstanceId, + PullRequestOperationError, + ThreadId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThreadShell, + type PullRequestRef, + type PullRequestStack, + type PullRequestSummary, + type ThreadPullRequestLink, + type ThreadPullRequestSnapshot, +} from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { TestClock } from "effect/testing"; + +import { PullRequestService } from "../pullRequest/PullRequestService.ts"; +import { ServerActivation } from "../serverActivation.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "./Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import * as PullRequestSyncReactor from "./PullRequestSyncReactor.ts"; + +const NOW = "2026-08-28T12:00:00.000Z"; +const PROJECT_ID = ProjectId.make("sync-project"); + +type SyncCommand = Extract< + OrchestrationCommand, + { readonly type: "thread.pull-request-link.sync" } +>; +type LinkCommand = Extract; + +const testCrypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(1), + digest: (_algorithm, data) => Effect.succeed(data), +}); + +function makeProject(id: ProjectId = PROJECT_ID): OrchestrationProjectShell { + return { + id, + title: `Project ${id}`, + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: NOW, + }; +} + +function makeThread( + id: string, + overrides: Partial = {}, +): OrchestrationThreadShell { + return { + id: ThreadId.make(id), + projectId: PROJECT_ID, + title: id, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestTurn: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-20T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: "2026-08-20T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + +function makeLink( + number: number, + snapshot: Partial | null = null, + overrides: Partial = {}, +): ThreadPullRequestLink { + return { + host: "github.com", + repository: "owner/repository", + number, + url: `https://github.com/owner/repository/pull/${number}`, + source: "manual", + linkedAt: "2026-08-10T00:00:00.000Z", + snapshot: + snapshot === null + ? null + : { + state: "open", + title: "Pull request", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-08-27T00:00:00.000Z", + syncedAt: "2026-08-27T00:00:00.000Z", + ...snapshot, + }, + stack: null, + ...overrides, + }; +} + +function makeSnapshot( + threads: ReadonlyArray, + snapshotSequence = 1, +): OrchestrationShellSnapshot { + return { + snapshotSequence, + projects: [makeProject()], + threads, + updatedAt: NOW, + }; +} + +function makeSummary( + input: PullRequestRef, + overrides: Partial = {}, +): PullRequestSummary { + return { + provider: "github", + projectId: input.projectId, + repository: input.repository, + number: input.number, + title: "Pull request", + url: `https://github.com/${input.repository}/pull/${input.number}`, + state: "open", + headBranch: "feature", + baseBranch: "main", + updatedAt: "2026-08-27T00:00:00.000Z", + ...overrides, + }; +} + +interface HarnessOptions { + readonly invalidate?: PullRequestService["Service"]["invalidate"]; + readonly snapshot: OrchestrationShellSnapshot; + readonly summary?: ( + input: PullRequestRef, + ) => Effect.Effect; + readonly stack?: ( + input: PullRequestRef, + ) => Effect.Effect; +} + +const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: HarnessOptions) { + const activation = yield* Deferred.make(); + const snapshots = yield* Ref.make(options.snapshot); + const snapshotReads = yield* Queue.unbounded(); + const syncCommands = yield* Ref.make>([]); + const linkCommands = yield* Ref.make>([]); + const summaryCalls = yield* Ref.make>([]); + const stackCalls = yield* Ref.make>([]); + + const summary: PullRequestService["Service"]["summary"] = (input, readOptions) => + Effect.gen(function* () { + assert.strictEqual(readOptions?.recoverTransientFailure, false); + yield* Ref.update(summaryCalls, (calls) => [...calls, input]); + return yield* options.summary?.(input) ?? Effect.succeed(makeSummary(input)); + }); + + const stack: PullRequestService["Service"]["stack"] = (input, readOptions) => + Effect.gen(function* () { + assert.strictEqual(readOptions?.includeDetails, false); + yield* Ref.update(stackCalls, (calls) => [...calls, input]); + return yield* options.stack?.(input) ?? Effect.succeed(null); + }); + + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => { + if (command.type === "thread.pull-request-link.sync") { + return Ref.update(syncCommands, (recorded) => [...recorded, command]).pipe( + Effect.as({ sequence: 1 }), + ); + } + if (command.type === "thread.pull-request.link") { + return Ref.update(linkCommands, (recorded) => [...recorded, command]).pipe( + Effect.as({ sequence: 1 }), + ); + } + return Effect.die(new Error(`Unexpected command: ${command.type}`)); + }; + + const dependencies = Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getShellSnapshot: () => + Queue.offer(snapshotReads, undefined).pipe(Effect.andThen(Ref.get(snapshots))), + }), + Layer.mock(PullRequestService)({ + summary, + stack, + invalidate: options.invalidate ?? (() => Effect.void), + }), + Layer.mock(OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch, + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + }), + Layer.succeed(ServerActivation, Deferred.await(activation)), + Layer.succeed(Crypto.Crypto, testCrypto), + ); + + return { + activation, + snapshots, + snapshotReads, + syncCommands, + linkCommands, + summaryCalls, + stackCalls, + layer: PullRequestSyncReactor.layer.pipe(Layer.provide(dependencies)), + }; +}); + +type Harness = Effect.Success>; + +const startAndSweep = Effect.fn("startPullRequestSyncHarness")(function* (fixture: Harness) { + const reactor = yield* PullRequestSyncReactor.PullRequestSyncReactor; + yield* reactor.start(); + yield* Deferred.succeed(fixture.activation, undefined); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + return reactor; +}); + +const sweepAgain = Effect.fn("sweepPullRequestSyncHarness")(function* ( + fixture: Harness, + reactor: PullRequestSyncReactor.PullRequestSyncReactor["Service"], +) { + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; +}); + +/** What the reactor would have persisted, so the next sweep sees its own writes. */ +function applySync( + snapshot: OrchestrationShellSnapshot, + commands: ReadonlyArray, +): OrchestrationShellSnapshot { + return { + ...snapshot, + snapshotSequence: snapshot.snapshotSequence + 1, + threads: snapshot.threads.map((thread) => ({ + ...thread, + pullRequests: thread.pullRequests.map((link) => { + const command = commands.findLast( + (candidate) => candidate.threadId === thread.id && candidate.number === link.number, + ); + return command === undefined + ? link + : { ...link, snapshot: command.snapshot, stack: command.stack }; + }), + })), + }; +} + +describe("PullRequestSyncReactor", () => { + it.effect("retries a failed stack read after the summary becomes terminal", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + let attempts = 0; + const nativeStack: PullRequestStack = { + id: "stack", + number: 7, + url: "https://github.com/owner/repository/stacks/7", + base: "main", + layers: [{ number: 7, headBranch: "feature", state: "merged" }], + }; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(7)] })]), + summary: (input) => + Effect.succeed(makeSummary(input, { state: "merged", mergedAt: NOW })), + stack: () => + ++attempts === 1 + ? Effect.fail( + new PullRequestOperationError({ + operation: "stack", + detail: "temporary failure", + }), + ) + : Effect.succeed(nativeStack), + }); + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + const commands = yield* Ref.get(fixture.syncCommands); + yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, commands)); + yield* sweepAgain(fixture, reactor); + assert.strictEqual(attempts, 2); + assert.deepStrictEqual((yield* Ref.get(fixture.syncCommands)).at(-1)?.stack, { + kind: "native", + ...nativeStack, + }); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("explicit refresh reads a changed stack even when its PR summary is unchanged", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(7, {})] })]), + }); + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 0); + yield* reactor.requestSync({ + host: "github.com", + repository: "owner/repository", + number: 7, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("snapshots an unsynced link once and writes it to the thread", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(42)] })]), + summary: (input) => + Effect.succeed(makeSummary(input, { title: "Ship it", isDraft: true })), + }); + + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ + { + projectId: PROJECT_ID, + host: "github.com", + repository: "owner/repository", + number: 42, + }, + ]); + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map(({ commandId: _, ...rest }) => rest), + [ + { + type: "thread.pull-request-link.sync", + threadId: ThreadId.make("one"), + host: "github.com", + repository: "owner/repository", + number: 42, + snapshot: { + state: "open", + title: "Ship it", + headBranch: "feature", + baseBranch: "main", + isDraft: true, + updatedAt: "2026-08-27T00:00:00.000Z", + syncedAt: NOW, + closedAt: null, + mergedAt: null, + }, + stack: null, + }, + ], + ); + assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("asks the host once for a pull request shared by two threads", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("one", { pullRequests: [makeLink(42)] }), + makeThread("two", { + pullRequests: [makeLink(42, null, { repository: "Owner/Repository" })], + }), + ]), + }); + + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + const commands = yield* Ref.get(fixture.syncCommands); + assert.deepStrictEqual( + commands + .map((command) => [command.threadId, command.repository] as const) + .sort((left, right) => left[0].localeCompare(right[0])), + [ + [ThreadId.make("one"), "owner/repository"], + [ThreadId.make("two"), "Owner/Repository"], + ], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("dispatches nothing when the host snapshot is unchanged", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(42)] })]), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + const firstSweep = yield* Ref.get(fixture.syncCommands); + assert.strictEqual(firstSweep.length, 1); + yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, firstSweep)); + + yield* sweepAgain(fixture, reactor); + + // Still open on an active thread, so the host was asked again, but nothing changed. + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2); + assert.strictEqual((yield* Ref.get(fixture.stackCalls)).length, 1); + assert.strictEqual((yield* Ref.get(fixture.syncCommands)).length, 1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("refreshes closed links through the reactor's project after reopening elsewhere", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const stale = yield* Ref.make(true); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("first", { pullRequests: [makeLink(42, { state: "closed" })] }), + makeThread("second", { + projectId: ProjectId.make("second-project"), + pullRequests: [makeLink(42, { state: "closed" })], + }), + ]), + invalidate: ({ reference }) => + reference?.projectId === makeProject().id && reference.host === "github.com" + ? Ref.set(stale, false) + : Effect.void, + summary: (input) => + Ref.get(stale).pipe( + Effect.map((cached) => makeSummary(input, { state: cached ? "closed" : "open" })), + ), + }); + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + yield* reactor.requestSync({ + host: "github.com", + repository: "owner/repository", + number: 42, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + const commands = yield* Ref.get(fixture.syncCommands); + yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, commands)); + const snapshot = yield* Ref.get(fixture.snapshots); + assert.deepStrictEqual( + snapshot.threads.map((thread) => thread.pullRequests[0]?.snapshot?.state), + ["open", "open"], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("stops asking the host once a pull request is merged", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("merged", { pullRequests: [makeLink(1, { state: "merged" })] }), + ]), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + yield* sweepAgain(fixture, reactor); + + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); + assert.deepStrictEqual(yield* Ref.get(fixture.syncCommands), []); + + yield* reactor.requestSync({ + host: "github.com", + repository: "owner/repository", + number: 1, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.summaryCalls)).map((call) => call.number), + [1], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("discovers externally reopened pull requests after fifteen minutes", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make<"closed" | "open">("closed"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("closed", { pullRequests: [makeLink(2, { state: "closed" })] }), + ]), + summary: (input) => + Ref.get(state).pipe(Effect.map((state) => makeSummary(input, { state }))), + }); + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + yield* Ref.set(state, "open"); + for (let index = 0; index < 14; index += 1) yield* sweepAgain(fixture, reactor); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + yield* sweepAgain(fixture, reactor); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2); + assert.strictEqual((yield* Ref.get(fixture.syncCommands)).at(-1)?.snapshot.state, "open"); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("preserves a refresh requested while an older host read is in flight", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const reading = yield* Deferred.make(); + const release = yield* Deferred.make(); + let calls = 0; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([]), + summary: (input) => + Effect.gen(function* () { + calls += 1; + if (calls === 1) { + yield* Deferred.succeed(reading, undefined); + yield* Deferred.await(release); + } + return makeSummary(input, { state: calls === 1 ? "closed" : "open" }); + }), + }); + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + yield* Ref.set( + fixture.snapshots, + makeSnapshot([ + makeThread("closed", { pullRequests: [makeLink(2, { state: "closed" })] }), + ]), + ); + const key = { host: "github.com", repository: "owner/repository", number: 2 }; + yield* reactor.requestSync(key); + yield* Deferred.await(reading); + yield* reactor.requestSync(key); + yield* Deferred.succeed(release, undefined); + yield* reactor.drain; + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2); + assert.strictEqual((yield* Ref.get(fixture.syncCommands)).at(-1)?.snapshot.state, "open"); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("polls open pull requests on settled threads every fifteen minutes", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("settled", { + settledOverride: "settled", + settledAt: "2026-08-21T00:00:00.000Z", + pullRequests: [makeLink(5, { state: "open" })], + }), + ]), + }); + + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + + yield* sweepAgain(fixture, reactor); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + + for (let index = 0; index < 13; index += 1) yield* sweepAgain(fixture, reactor); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + + yield* sweepAgain(fixture, reactor); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("auto-links missing native stack layers and leaves dismissed ones alone", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const stack: PullRequestStack = { + id: "stack-1", + number: 42, + url: "https://github.com/owner/repository/stack/1", + base: "main", + layers: [ + { number: 41, headBranch: "layer-1", state: "merged" }, + { number: 42, headBranch: "layer-2", state: "open" }, + { number: 43, headBranch: "layer-3", state: "open" }, + ], + }; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("one", { + pullRequests: [ + makeLink(42), + makeLink(41, { state: "merged" }, { source: "stack-dismissed" }), + ], + }), + ]), + stack: () => Effect.succeed(stack), + }); + + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + + const syncCommands = yield* Ref.get(fixture.syncCommands); + assert.deepStrictEqual( + syncCommands.map((command) => [command.number, command.stack] as const), + [[42, { kind: "native", ...stack }]], + ); + assert.deepStrictEqual( + (yield* Ref.get(fixture.linkCommands)).map(({ commandId: _, ...rest }) => rest), + [ + { + type: "thread.pull-request.link", + threadId: ThreadId.make("one"), + host: "github.com", + repository: "owner/repository", + number: 43, + url: "https://github.com/owner/repository/pull/43", + source: "stack", + }, + ], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("keeps existing snapshots and continues when the host fails", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("failing", { pullRequests: [makeLink(7, { state: "open" })] }), + makeThread("fine", { pullRequests: [makeLink(8)] }), + ]), + summary: (input) => + input.number === 7 + ? Effect.fail( + new PullRequestOperationError({ operation: "summary", detail: "host down" }), + ) + : Effect.succeed(makeSummary(input)), + }); + + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.syncCommands)).map((command) => command.number), + [8], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); +}); diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.ts b/apps/server/src/orchestration/PullRequestSyncReactor.ts new file mode 100644 index 000000000000..0a39fa5d91d1 --- /dev/null +++ b/apps/server/src/orchestration/PullRequestSyncReactor.ts @@ -0,0 +1,328 @@ +import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { + CommandId, + type OrchestrationThreadShell, + type PullRequestSummary, + type ThreadPullRequestKey, + type ThreadPullRequestLink, + type ThreadPullRequestSnapshot, + type ThreadPullRequestStack, +} from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { + threadPullRequestKeyOf, + threadPullRequestKeysEqual, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; + +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import { forkParked } from "../serverActivation.ts"; +import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; + +const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000; + +type SnapshotFields = Omit; + +interface LinkEntry { + readonly thread: OrchestrationThreadShell; + readonly link: ThreadPullRequestLink; +} + +function snapshotFieldsOf(summary: PullRequestSummary): SnapshotFields { + return { + state: summary.state, + title: summary.title, + headBranch: summary.headBranch, + baseBranch: summary.baseBranch, + isDraft: summary.isDraft ?? false, + updatedAt: summary.updatedAt, + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, + ...(summary.author === undefined ? {} : { author: summary.author }), + ...(summary.additions === undefined ? {} : { additions: summary.additions }), + ...(summary.deletions === undefined ? {} : { deletions: summary.deletions }), + ...(summary.changedFiles === undefined ? {} : { changedFiles: summary.changedFiles }), + ...(summary.reviewDecision === undefined ? {} : { reviewDecision: summary.reviewDecision }), + ...(summary.checksState === undefined ? {} : { checksState: summary.checksState }), + ...(summary.mergeability === undefined ? {} : { mergeability: summary.mergeability }), + }; +} + +function snapshotFieldsEqual(left: SnapshotFields, right: SnapshotFields): boolean { + return ( + left.state === right.state && + left.title === right.title && + left.headBranch === right.headBranch && + left.baseBranch === right.baseBranch && + left.isDraft === right.isDraft && + left.updatedAt === right.updatedAt && + (left.closedAt ?? null) === (right.closedAt ?? null) && + (left.mergedAt ?? null) === (right.mergedAt ?? null) && + (left.author?.login ?? null) === (right.author?.login ?? null) && + (left.author?.avatarUrl ?? null) === (right.author?.avatarUrl ?? null) && + left.additions === right.additions && + left.deletions === right.deletions && + left.changedFiles === right.changedFiles && + (left.reviewDecision ?? null) === (right.reviewDecision ?? null) && + (left.checksState ?? null) === (right.checksState ?? null) && + left.mergeability === right.mergeability + ); +} + +function stacksEqual( + left: ThreadPullRequestStack | null, + right: ThreadPullRequestStack | null, +): boolean { + if (left === null || right === null) return left === right; + return ( + left.kind === right.kind && + left.id === right.id && + left.number === right.number && + left.url === right.url && + left.base === right.base && + left.layers.length === right.layers.length && + left.layers.every((layer, index) => { + const other = right.layers[index]!; + return ( + layer.number === other.number && + layer.headBranch === other.headBranch && + layer.state === other.state + ); + }) + ); +} + +function isUnsettled(thread: OrchestrationThreadShell): boolean { + return thread.settledOverride !== "settled" && thread.settledAt === null; +} + +/** + * Keeps every thread ↔ pull request link's host snapshot current. One sweep a minute reads + * the shell snapshot, groups visible links by pull request so the host is asked once per PR + * no matter how many threads share it, and writes back only what changed. Native stacks the + * host reports are auto-linked to the thread as `source: "stack"`. + */ +export class PullRequestSyncReactor extends Context.Service< + PullRequestSyncReactor, + { + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; + /** Force the next sweep to re-read this pull request, even when its snapshot is terminal. */ + readonly requestSync: (key: ThreadPullRequestKey) => Effect.Effect; + } +>()("t3/orchestration/PullRequestSyncReactor") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const pullRequests = yield* PullRequestService.PullRequestService; + const crypto = yield* Crypto.Crypto; + + const lastSyncedAt = new Map(); + const requested = new Map(); + let requestGeneration = 0; + const retryStacks = new Set(); + + const isDue = (key: string, entries: ReadonlyArray, nowMs: number): boolean => { + if (requested.has(key) || retryStacks.has(key)) return true; + if (entries.some((entry) => entry.link.snapshot === null)) return true; + if (entries.every((entry) => entry.link.snapshot?.state === "merged")) return false; + if (entries.some((entry) => entry.link.snapshot?.state === "open" && isUnsettled(entry.thread))) + return true; + // Closed requests can reopen on the host, including after the thread settles. + const last = lastSyncedAt.get(key); + return last === undefined || nowMs - last >= SLOW_SYNC_INTERVAL_MS; + }; + + const logSkipped = + (message: string, fields: Record) => + (cause: Cause.Cause): Effect.Effect => + Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning(message, fields); + + const sweep = Effect.fn("PullRequestSyncReactor.sweep")(function* () { + const snapshot = yield* snapshots.getShellSnapshot(); + const now = yield* DateTime.now; + const nowMs = DateTime.toEpochMillis(now); + const nowIso = DateTime.formatIso(now); + + const groups = new Map>(); + for (const thread of snapshot.threads) { + if (thread.archivedAt !== null) continue; + for (const link of visibleThreadPullRequests(thread.pullRequests)) { + const key = threadPullRequestKeyOf(link); + const entries = groups.get(key) ?? []; + entries.push({ thread, link }); + groups.set(key, entries); + } + } + + for (const key of lastSyncedAt.keys()) if (!groups.has(key)) lastSyncedAt.delete(key); + for (const key of retryStacks) if (!groups.has(key)) retryStacks.delete(key); + for (const key of requested.keys()) if (!groups.has(key)) requested.delete(key); + + // Layers auto-linked this sweep, so two links of one thread that share a + // stack do not both try to add the same sibling. + const linkedThisSweep = new Set(); + + const syncEntry = Effect.fn("PullRequestSyncReactor.syncEntry")(function* ( + entry: LinkEntry, + fields: SnapshotFields, + fetchedStack: { readonly stack: ThreadPullRequestStack | null } | null, + ) { + const { thread, link } = entry; + const nextStack = fetchedStack === null ? link.stack : fetchedStack.stack; + const changed = + link.snapshot === null || + !snapshotFieldsEqual(link.snapshot, fields) || + !stacksEqual(link.stack, nextStack); + if (changed) { + const uuid = yield* crypto.randomUUIDv4; + yield* engine.dispatch({ + type: "thread.pull-request-link.sync", + commandId: CommandId.make(`server:pr-sync:${thread.id}:${uuid}`), + threadId: thread.id, + host: link.host, + repository: link.repository, + number: link.number, + snapshot: { ...fields, syncedAt: nowIso }, + stack: nextStack, + }); + } + if (fetchedStack === null || fetchedStack.stack === null) return; + for (const layer of fetchedStack.stack.layers) { + const layerKey = { host: link.host, repository: link.repository, number: layer.number }; + const dedupeKey = `${thread.id}:${threadPullRequestKeyOf(layerKey)}`; + if (linkedThisSweep.has(dedupeKey)) continue; + // Tombstones count as present: a dismissed layer is never re-added. + if ( + thread.pullRequests.some((existing) => threadPullRequestKeysEqual(existing, layerKey)) + ) { + continue; + } + const url = siblingPullRequestUrl(link.url, layer.number); + if (url === null) continue; + linkedThisSweep.add(dedupeKey); + const uuid = yield* crypto.randomUUIDv4; + yield* engine + .dispatch({ + type: "thread.pull-request.link", + commandId: CommandId.make(`server:pr-stack-link:${thread.id}:${uuid}`), + threadId: thread.id, + ...layerKey, + url, + source: "stack", + }) + .pipe( + Effect.catchCause( + logSkipped("pull request stack layer link skipped", { + threadId: thread.id, + number: layer.number, + }), + ), + ); + } + }); + + const syncGroup = Effect.fn("PullRequestSyncReactor.syncGroup")(function* ( + key: string, + entries: ReadonlyArray, + ) { + const first = entries[0]!; + const ref = { + projectId: first.thread.projectId, + host: first.link.host, + repository: first.link.repository, + number: first.link.number, + }; + const generation = requested.get(key); + if (generation !== undefined) yield* pullRequests.invalidate({ reference: ref }); + const summary = yield* pullRequests.summary(ref, { recoverTransientFailure: false }); + const fields = snapshotFieldsOf(summary); + const needsStack = + generation !== undefined || + retryStacks.has(key) || + entries.some( + (entry) => + entry.link.snapshot === null || !snapshotFieldsEqual(entry.link.snapshot, fields), + ); + const fetchedStack = needsStack + ? yield* pullRequests.stack(ref, { includeDetails: false }).pipe( + Effect.map((stack) => ({ + stack: stack === null ? null : ({ kind: "native", ...stack } as const), + })), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("pull request stack lookup failed", { + key, + }).pipe(Effect.as(null)), + ), + ) + : null; + if (needsStack) { + if (fetchedStack === null) retryStacks.add(key); + else retryStacks.delete(key); + } + // The host answered, so the cadence clock ticks even if a dispatch below is rejected. + lastSyncedAt.set(key, nowMs); + // A refresh requested while the host read was in flight belongs to the next sweep. + if (requested.get(key) === generation) requested.delete(key); + yield* Effect.forEach( + entries, + (entry) => + syncEntry(entry, fields, fetchedStack).pipe( + Effect.catchCause( + logSkipped("pull request sync skipped", { threadId: entry.thread.id, key }), + ), + ), + { discard: true }, + ); + }); + + yield* Effect.forEach( + groups, + ([key, entries]) => + isDue(key, entries, nowMs) + ? syncGroup(key, entries).pipe( + Effect.catchCause(logSkipped("pull request sync skipped", { key })), + ) + : Effect.void, + { concurrency: 8, discard: true }, + ); + }); + + const worker = yield* makeDrainableWorker(() => + sweep().pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))), + ); + + const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn( + "PullRequestSyncReactor.start", + )(function* () { + yield* forkParked( + Effect.gen(function* () { + yield* worker.enqueue(undefined); + yield* worker.drain; + }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid), + ); + }); + + const requestSync: PullRequestSyncReactor["Service"]["requestSync"] = (key) => + Effect.suspend(() => { + requested.set(threadPullRequestKeyOf(key), ++requestGeneration); + return worker.enqueue(undefined); + }); + + return { start, drain: worker.drain, requestSync } satisfies PullRequestSyncReactor["Service"]; +}); + +export const layer = Layer.effect(PullRequestSyncReactor, make); diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 7e866cf89592..29468dc3f84e 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -16,6 +16,9 @@ import { ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema, + ThreadPullRequestLinkedPayload as ContractsThreadPullRequestLinkedPayloadSchema, + ThreadPullRequestUnlinkedPayload as ContractsThreadPullRequestUnlinkedPayloadSchema, + ThreadPullRequestSyncedPayload as ContractsThreadPullRequestSyncedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -48,6 +51,9 @@ export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; +export const ThreadPullRequestLinkedPayload = ContractsThreadPullRequestLinkedPayloadSchema; +export const ThreadPullRequestUnlinkedPayload = ContractsThreadPullRequestUnlinkedPayloadSchema; +export const ThreadPullRequestSyncedPayload = ContractsThreadPullRequestSyncedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts index f9872e6dd22a..d6572083dad2 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts @@ -1,9 +1,11 @@ import { + CheckpointRef, EventId, GitManagerError, ProjectId, ProviderInstanceId, ThreadId, + TurnId, type OrchestrationCommand, type OrchestrationEvent, type OrchestrationProjectShell, @@ -87,6 +89,7 @@ function thread( modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, runtimeMode: "full-access", interactionMode: "default", + pullRequests: [], branch: "feature", worktreePath: null, latestTurn: null, @@ -310,54 +313,79 @@ describe("ThreadPullRequestReactor", () => { ), ); - it.effect("refreshes discovery after a turn ends without client demand", () => - Effect.scoped( - Effect.gen(function* () { - const current = thread("turn-thread"); - const detected = yield* Ref.make(null); - const fixture = yield* makeHarness({ - threads: [current], - branchPullRequest: (_input, options) => - options?.refresh - ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected))) - : Ref.get(detected), - }); - yield* Effect.gen(function* () { - const reactor = yield* fixture.start(); - expect(yield* Ref.get(fixture.commands)).toHaveLength(0); - yield* fixture.publish({ - type: "thread.session-set", - sequence: 2, - eventId: EventId.make("turn-finished"), - aggregateKind: "thread", - aggregateId: current.id, - occurredAt: NOW, - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - payload: { - threadId: current.id, - session: { + it.effect.each(["session-first", "checkpoint-first"] as const)( + "refreshes discovery once after a turn ends with %s event ordering", + (order) => + Effect.scoped( + Effect.gen(function* () { + const current = thread("turn-thread"); + const detected = yield* Ref.make(null); + const fixture = yield* makeHarness({ + threads: [current], + branchPullRequest: (_input, options) => + options?.refresh + ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected))) + : Ref.get(detected), + }); + yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); + const sessionEvent: OrchestrationEvent = { + type: "thread.session-set", + sequence: 2, + eventId: EventId.make("turn-finished"), + aggregateKind: "thread", + aggregateId: current.id, + occurredAt: NOW, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { threadId: current.id, + session: { + threadId: current.id, + status: "ready", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }, + }; + const checkpointEvent: OrchestrationEvent = { + ...sessionEvent, + type: "thread.turn-diff-completed", + sequence: 3, + eventId: EventId.make("checkpoint-finished"), + payload: { + threadId: current.id, + turnId: TurnId.make("turn"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("checkpoint"), status: "ready", - providerName: "Codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: NOW, + files: [], + assistantMessageId: null, + completedAt: NOW, }, - }, - }); - yield* Queue.take(fixture.reads); - yield* reactor.drain; - expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); - expect((yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh)).toHaveLength( - 1, - ); - }).pipe(Effect.provide(fixture.layer)); - }), - ), + }; + const events = + order === "session-first" + ? [sessionEvent, checkpointEvent] + : [checkpointEvent, sessionEvent]; + for (const event of events) { + yield* fixture.publish(event); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + } + expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); + expect( + (yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh), + ).toHaveLength(1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), ); it.effect("uses live worktrees and falls back to the project for removed worktrees", () => @@ -454,54 +482,56 @@ describe("ThreadPullRequestReactor", () => { ), ); - it.effect("retries failed settled backfills and stops querying them after success", () => - Effect.scoped( - Effect.gen(function* () { - const online = yield* Ref.make(false); - const fixture = yield* makeHarness({ - threads: [ - thread("backfill", { settledOverride: "settled", settledAt: NOW }), - thread("known", { - branch: "known", - settledOverride: "settled", - settledAt: NOW, - branchPullRequest: reference(1), - }), - ], - branchPullRequest: ({ cwd }) => - Ref.get(online).pipe( - Effect.flatMap((connected) => - connected - ? Effect.succeed(branchPullRequest(42, "merged")) - : Effect.fail( - new GitManagerError({ - operation: "branchPullRequest", - cwd, - detail: "Offline", - }), - ), + it.effect.each(["settled", null] as const)( + "retries settled backfills and stops after success with settledOverride %s", + (settledOverride) => + Effect.scoped( + Effect.gen(function* () { + const online = yield* Ref.make(false); + const fixture = yield* makeHarness({ + threads: [ + thread("backfill", { settledOverride, settledAt: NOW }), + thread("known", { + branch: "known", + settledOverride, + settledAt: NOW, + branchPullRequest: reference(1), + }), + ], + branchPullRequest: ({ cwd }) => + Ref.get(online).pipe( + Effect.flatMap((connected) => + connected + ? Effect.succeed(branchPullRequest(42, "merged")) + : Effect.fail( + new GitManagerError({ + operation: "branchPullRequest", + cwd, + detail: "Offline", + }), + ), + ), ), - ), - }); - yield* Effect.gen(function* () { - const reactor = yield* fixture.start(); - expect(yield* Ref.get(fixture.commands)).toHaveLength(0); - yield* Ref.set(online, true); - yield* TestClock.adjust("1 minute"); - yield* Queue.take(fixture.reads); - yield* reactor.drain; - expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill"); - yield* TestClock.adjust("1 minute"); - yield* Queue.take(fixture.reads); - yield* reactor.drain; - expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([ - "feature", - "feature", - "feature", - ]); - }).pipe(Effect.provide(fixture.layer)); - }), - ), + }); + yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); + yield* Ref.set(online, true); + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill"); + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([ + "feature", + "feature", + "feature", + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), ); it.effect("stops retrying a settled backfill after repeated lookup failures", () => diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts index b694bacbbb33..86026632a6af 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -1,3 +1,7 @@ +import { + canonicalRepositoryKey, + sourceControlRepositorySelector, +} from "@t3tools/shared/sourceControl"; import { CommandId, type OrchestrationEvent, @@ -53,18 +57,6 @@ interface RefreshRequest { readonly backfill?: boolean; } -function canonicalRepositoryKey(key: string): string { - return key - .replace( - /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, - "dev.azure.com/$1/$2/_git/$3", - ) - .replace( - /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, - "dev.azure.com/$1/$2/_git/$3", - ); -} - export function pullRequestMatchesProject( pullRequest: GitManager.GitBranchPullRequest, project: OrchestrationProjectShell, @@ -109,7 +101,10 @@ export const make = Effect.gen(function* () { const projects = new Map(snapshot.projects.map((project) => [project.id, project])); if (request.backfill) { for (const thread of snapshot.threads) { - if (thread.settledOverride === "settled" && thread.branchPullRequest == null) { + if ( + (thread.settledOverride === "settled" || thread.settledAt !== null) && + thread.branchPullRequest == null + ) { pendingBackfill.set(thread.id, BACKFILL_ATTEMPTS); } } @@ -122,7 +117,7 @@ export const make = Effect.gen(function* () { (thread) => thread.archivedAt === null && (request.threadId === null || thread.id === request.threadId) && - (thread.settledOverride !== "settled" || + ((thread.settledOverride !== "settled" && thread.settledAt === null) || request.threadId !== null || pendingBackfill.has(thread.id)) && (thread.branch !== null || thread.branchPullRequest != null), @@ -138,7 +133,7 @@ export const make = Effect.gen(function* () { const first = group[0]!; const project = projects.get(first.projectId); if (project === undefined) return finishBackfill(group); - const repository = PullRequestService.repositoryIdentityOf(project); + const repository = sourceControlRepositorySelector(project.repositoryIdentity); if (first.branch !== null && repository === null) return finishBackfill(group); const worktreeExists = first.worktreePath !== null && (yield* fileSystem.exists(first.worktreePath)); @@ -189,6 +184,7 @@ export const make = Effect.gen(function* () { let replacement: ThreadLinkedPullRequest | undefined; if ( + thread.pullRequests.length === 0 && thread.linkedPullRequest != null && detected?.state === "open" && detectedReference !== null && @@ -334,7 +330,9 @@ export const make = Effect.gen(function* () { event.payload.session.status !== "running" && event.payload.session.status !== "starting" ) { - return worker.enqueue({ threadId: event.payload.threadId, refresh: true }); + // Checkpoint completion forces the post-turn read. Session lifecycle + // events reuse it regardless of which event reaches this worker first. + return worker.enqueue({ threadId: event.payload.threadId, refresh: false }); } break; case "thread.turn-diff-completed": diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts index 61c512e6fd91..252b99439400 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -5,6 +5,7 @@ import { ProjectId, TurnId, type OrchestrationThreadShell, + type ThreadPullRequestLink, } from "@t3tools/contracts"; import { type SettlementPullRequest, resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; @@ -18,6 +19,7 @@ const makeThread = ( modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, runtimeMode: "full-access", interactionMode: "default", + pullRequests: [], branch: "feature", worktreePath: "/repo", latestTurn: null, @@ -213,3 +215,75 @@ describe("resolveAutoSettlementAt", () => { ).toBe(true); }); }); + +function linkedRequest( + number: number, + snapshot: ThreadPullRequestLink["snapshot"], +): ThreadPullRequestLink { + return { + host: "github.com", + repository: "org/repo", + number, + url: `https://github.com/org/repo/pull/${number}`, + source: "manual", + linkedAt: NOW, + stack: null, + snapshot, + }; +} + +const terminalSnapshot = ( + state: "closed" | "merged", + terminalAt: string, + updatedAt = terminalAt, +) => ({ + state, + title: "Change", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + closedAt: terminalAt, + mergedAt: state === "merged" ? terminalAt : null, + updatedAt, + syncedAt: NOW, +}); + +describe("linked request settlement", () => { + it.each(["closed", "merged"] as const)( + "uses the latest actual %s transition despite later comments on another PR", + (state) => { + const old = linkedRequest(1, terminalSnapshot(state, "2026-08-19T00:00:00.000Z", NOW)); + const recent = linkedRequest(2, terminalSnapshot(state, "2026-08-21T00:00:00.000Z")); + expect(decide(makeThread({ pullRequests: [old, recent] }), null, { days: null })).toBe(true); + expect(decide(makeThread({ pullRequests: [recent, old] }), null, { days: null })).toBe(true); + expect(decide(makeThread({ pullRequests: [old] }), null, { days: null })).toBe(false); + }, + ); + + it("keeps unknown and open links active even after the inactivity window", () => { + const merged = linkedRequest(1, terminalSnapshot("merged", NOW)); + const unknown = linkedRequest(2, null); + const open = linkedRequest(3, { + ...terminalSnapshot("closed", NOW), + state: "open", + closedAt: null, + }); + expect(decide(makeThread({ pullRequests: [merged, unknown] }))).toBe(false); + expect(decide(makeThread({ pullRequests: [merged, open] }))).toBe(false); + expect( + decide(makeThread({ pullRequests: [merged, { ...unknown, source: "stack-dismissed" }] })), + ).toBe(true); + }); + + it("honors merge settings and ignores missing terminal timestamps", () => { + const merged = linkedRequest(1, terminalSnapshot("merged", NOW)); + expect(decide(makeThread({ pullRequests: [merged] }), null, { days: null, merge: false })).toBe( + false, + ); + const missing = linkedRequest(2, { ...terminalSnapshot("merged", NOW), mergedAt: null }); + expect(decide(makeThread({ pullRequests: [missing] }), null, { days: null })).toBe(false); + expect(decide(makeThread({ pullRequests: [missing, merged] }), null, { days: null })).toBe( + true, + ); + }); +}); diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts index 1df7855d1e04..92063745eff5 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -1,4 +1,5 @@ import type { OrchestrationThreadShell } from "@t3tools/contracts"; +import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; export interface SettlementPullRequest { readonly state: "open" | "closed" | "merged"; @@ -72,7 +73,29 @@ export function resolveAutoSettlementAt(input: { readonly autoSettleAfterDays: number | null; readonly autoSettleOnMerge: boolean; }): string | null { - const { thread, pullRequest } = input; + const { thread } = input; + let pullRequest = input.pullRequest; + const links = visibleThreadPullRequests(thread.pullRequests); + if (links.some((link) => link.snapshot === null || link.snapshot.state === "open")) return null; + if (links.length > 0) { + const terminalTimestamp = (link: (typeof links)[number]) => { + const snapshot = link.snapshot; + const value = snapshot?.state === "merged" ? snapshot.mergedAt : snapshot?.closedAt; + const timestamp = Date.parse(value ?? ""); + return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp; + }; + const latest = links.reduce((current, candidate) => + terminalTimestamp(candidate) > terminalTimestamp(current) ? candidate : current, + ); + pullRequest = + latest.snapshot === null + ? null + : { + state: latest.snapshot.state, + mergedAt: latest.snapshot.mergedAt ?? null, + closedAt: latest.snapshot.closedAt ?? null, + }; + } if (!isAutoSettlementCandidate(thread, input.now)) return null; const activityAt = latestTimestamp([ thread.latestUserMessageAt, diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index dbb3c5f9f755..0690eea2d50e 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -80,6 +80,7 @@ function makeThread( }, runtimeMode: "full-access", interactionMode: "default", + pullRequests: [], branch: null, worktreePath: null, latestTurn: null, @@ -301,6 +302,57 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( }); describe("ThreadSettlementReactor", () => { + it.effect( + "settles all-terminal links from snapshots and keeps open or unsynced links active", + () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const link = (number: number, state: "open" | "merged" | null) => ({ + host: "example.test", + repository: "owner/repository", + number, + url: `https://example.test/owner/repository/pull/${number}`, + source: "manual" as const, + linkedAt: NOW, + stack: null, + snapshot: + state === null + ? null + : { + state, + title: "Review", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: NOW, + syncedAt: NOW, + mergedAt: state === "merged" ? NOW : null, + }, + }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("merged", { pullRequests: [link(1, "merged"), link(2, "merged")] }), + makeThread("open", { pullRequests: [link(1, "merged"), link(2, "open")] }), + makeThread("unsynced", { pullRequests: [link(1, "merged"), link(2, null)] }), + ]), + settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleOnMerge: true }, + branchPullRequest: () => Effect.die("linked threads must not query the branch"), + pullRequestSummary: () => Effect.die("linked threads must use their snapshots"), + }); + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map(({ threadId }) => threadId), + [ThreadId.make("merged")], + ); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index d925df5d98f1..61fc5d4ab863 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -103,7 +103,9 @@ export const make = Effect.gen(function* () { { concurrency: 8, }, - )).filter((thread) => thread !== null); + )) + .filter((thread) => thread !== null) + .filter((thread) => !thread.pullRequests.some((link) => link.source !== "stack-dismissed")); // Use the same cwd as PR discovery so both paths share GitManager's cache. const lookupCwdByThreadId = new Map(); diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 93777c67d3e1..a3db108f2020 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -59,6 +59,7 @@ const readModel: OrchestrationReadModel = { runtimeMode: "full-access", branch: null, worktreePath: null, + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, @@ -84,6 +85,7 @@ const readModel: OrchestrationReadModel = { runtimeMode: "full-access", branch: null, worktreePath: null, + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, diff --git a/apps/server/src/orchestration/decider.active-order.test.ts b/apps/server/src/orchestration/decider.active-order.test.ts index 58a7f5c054ec..39bccba11460 100644 --- a/apps/server/src/orchestration/decider.active-order.test.ts +++ b/apps/server/src/orchestration/decider.active-order.test.ts @@ -33,6 +33,7 @@ function makeReadModel(overrides: Partial = {}): Orchestrat modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", + pullRequests: [], branch: null, worktreePath: null, latestTurn: null, diff --git a/apps/server/src/orchestration/decider.pinned.test.ts b/apps/server/src/orchestration/decider.pinned.test.ts index 4ad00ba994b4..7d6f55d8f622 100644 --- a/apps/server/src/orchestration/decider.pinned.test.ts +++ b/apps/server/src/orchestration/decider.pinned.test.ts @@ -36,6 +36,7 @@ function makeReadModel(input: { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: NOW, updatedAt: NOW, diff --git a/apps/server/src/orchestration/decider.pullRequests.test.ts b/apps/server/src/orchestration/decider.pullRequests.test.ts new file mode 100644 index 000000000000..49bfd4bea4de --- /dev/null +++ b/apps/server/src/orchestration/decider.pullRequests.test.ts @@ -0,0 +1,532 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + OrchestrationEvent, + OrchestrationCommand, + type OrchestrationReadModel, + type ThreadPullRequestLink, + type ThreadPullRequestSnapshot, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { projectEvent } from "./projector.ts"; +import { isThreadDetailEvent } from "../ws.ts"; + +const decodeCommand = Schema.decodeUnknownEffect(OrchestrationCommand); + +type PlannedEvent = Omit; + +function expectSingleEvent( + decided: PlannedEvent | ReadonlyArray, + type: Type, +): Omit, "sequence"> { + const event = Array.isArray(decided) ? decided[0] : (decided as PlannedEvent); + if (event === undefined || event.type !== type) { + throw new Error(`expected ${type}, got ${String(event?.type)}`); + } + return event as Omit, "sequence">; +} + +const NOW = "2026-01-01T00:00:00.000Z"; +const THREAD_ID = ThreadId.make("thread-1"); + +function makeLink(overrides: Partial = {}): ThreadPullRequestLink { + return { + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "manual", + linkedAt: NOW, + snapshot: null, + stack: null, + ...overrides, + }; +} + +function makeReadModel(pullRequests: ReadonlyArray): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [ + { + id: ProjectId.make("project-1"), + title: "Project", + workspaceRoot: "/repo", + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, + deletedAt: null, + repositoryIdentity: { + canonicalKey: "github.com/t3tools/t3code", + provider: "github", + displayName: "t3tools/t3code", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/t3tools/t3code.git", + }, + }, + }, + ], + threads: [ + { + id: THREAD_ID, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +const snapshot: ThreadPullRequestSnapshot = { + state: "open", + title: "Add links", + headBranch: "feat/links", + baseBranch: "main", + isDraft: false, + updatedAt: NOW, + syncedAt: NOW, +}; + +it.layer(NodeServices.layer)("pull request link decider", (it) => { + it.effect("legacy unlink cannot remove a newer cross-host link", () => + Effect.gen(function* () { + const own = makeLink(); + const foreign = makeLink({ + host: "github.enterprise.test", + url: "https://github.enterprise.test/t3tools/t3code/pull/42", + linkedAt: "2026-01-02T00:00:00Z", + }); + const command = yield* decodeCommand({ + type: "thread.meta.update", + commandId: "unlink", + threadId: THREAD_ID, + linkedPullRequest: null, + }); + const decided = yield* decideOrchestrationCommand({ + readModel: makeReadModel([own, foreign]), + command, + }); + const event = expectSingleEvent(decided, "thread.pull-request-unlinked"); + expect(event.payload.host).toBe("github.com"); + }), + ); + + it.effect("legacy replacement preserves unrelated manual links", () => + Effect.gen(function* () { + const other = makeLink({ number: 7, snapshot: { ...snapshot, state: "merged" } }); + const current = makeLink({ linkedAt: "2026-01-02T00:00:00Z" }); + let model = makeReadModel([other, current]); + const command = yield* decodeCommand({ + type: "thread.meta.update", + commandId: "replace", + threadId: THREAD_ID, + linkedPullRequest: { + projectId: "project-1", + repository: "t3tools/t3code", + number: 99, + url: "https://github.com/t3tools/t3code/pull/99", + }, + }); + const decided = yield* decideOrchestrationCommand({ readModel: model, command }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events.map((event) => event.type)).toEqual([ + "thread.pull-request-unlinked", + "thread.pull-request-linked", + ]); + for (const event of events) + model = yield* projectEvent(model, { ...event, sequence: model.snapshotSequence + 1 }); + expect(model.threads[0]!.pullRequests.map((link) => link.number)).toEqual([7, 99]); + }), + ); + it.effect("round-trips an Azure legacy link and unlinks only its organization", () => + Effect.gen(function* () { + const foreign = makeLink({ + host: "dev.azure.com", + repository: "org-b/project/_git/web", + number: 7, + url: "https://dev.azure.com/org-b/project/_git/web/pullrequest/7", + }); + let model = makeReadModel([foreign]); + model = { + ...model, + projects: model.projects.map((project) => ({ + ...project, + repositoryIdentity: { + ...project.repositoryIdentity!, + provider: "azure-devops", + canonicalKey: "ssh.dev.azure.com/v3/org-a/project/web", + displayName: "v3/org-a/project/web", + name: "web", + }, + })), + }; + const legacy = { + projectId: "project-1", + repository: "web", + number: 7, + url: "https://dev.azure.com/org-a/project/_git/web/pullrequest/7", + }; + for (const linkedPullRequest of [legacy, null]) { + const command = yield* decodeCommand({ + type: "thread.meta.update", + commandId: linkedPullRequest === null ? "unlink-azure" : "link-azure", + threadId: THREAD_ID, + linkedPullRequest, + }); + const decided = yield* decideOrchestrationCommand({ readModel: model, command }); + for (const event of Array.isArray(decided) ? decided : [decided]) { + model = yield* projectEvent(model, { ...event, sequence: model.snapshotSequence + 1 }); + } + if (linkedPullRequest !== null) { + expect(model.threads[0]!.linkedPullRequest).toEqual(legacy); + expect(model.threads[0]!.pullRequests.map((link) => link.repository)).toEqual([ + "org-b/project/_git/web", + "org-a/project/_git/web", + ]); + } + } + expect(model.threads[0]!.pullRequests).toEqual([foreign]); + expect(model.threads[0]!.linkedPullRequest).toBeNull(); + }), + ); + it.effect("legacy unlink alone does not emit an empty metadata event", () => + Effect.gen(function* () { + const command = yield* decodeCommand({ + type: "thread.meta.update", + commandId: "unlink", + threadId: THREAD_ID, + linkedPullRequest: null, + }); + const decided = yield* decideOrchestrationCommand({ + readModel: makeReadModel([makeLink()]), + command, + }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events.map((event) => event.type)).toEqual(["thread.pull-request-unlinked"]); + }), + ); + + for (const source of ["manual", "agent", "created", "stack"] as const) { + it.effect(`legacy unlink removes the visible ${source} link and preserves other requests`, () => + Effect.gen(function* () { + const other = makeLink({ number: 7, snapshot: { ...snapshot, state: "merged" } }); + const current = makeLink({ source, linkedAt: "2026-01-02T00:00:00.000Z" }); + let model = makeReadModel([other, current]); + // This is the pre-array command shape sent by older clients. + const command = yield* decodeCommand({ + type: "thread.meta.update", + commandId: "legacy-unlink", + threadId: THREAD_ID, + linkedPullRequest: null, + title: "Renamed by old client", + }); + const decided = yield* decideOrchestrationCommand({ readModel: model, command }); + const events = Array.isArray(decided) ? decided : [decided]; + for (const planned of events) { + const event = { ...planned, sequence: model.snapshotSequence + 1 }; + const encoded = yield* Schema.encodeEffect(OrchestrationEvent)(event); + const decoded = yield* Schema.decodeUnknownEffect(OrchestrationEvent)(encoded); + // Older detail-event unions must never receive the new PR discriminants. + expect(isThreadDetailEvent(decoded)).toBe(false); + model = yield* projectEvent(model, decoded); + } + const thread = model.threads[0]!; + expect(thread.title).toBe("Renamed by old client"); + expect(thread.pullRequests).toEqual( + source === "stack" ? [other, { ...current, source: "stack-dismissed" }] : [other], + ); + // The old single-link field continues to track the remaining visible request. + expect(thread.linkedPullRequest?.number).toBe(7); + }), + ); + } + + it.effect("links a pull request with a normalized key and empty host state", () => + Effect.gen(function* () { + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.link", + commandId: CommandId.make("cmd-link"), + threadId: THREAD_ID, + host: " GitHub.com ", + repository: "T3Tools/T3Code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "manual", + }, + readModel: makeReadModel([]), + }); + expect(Array.isArray(decided)).toBe(false); + const event = expectSingleEvent(decided, "thread.pull-request-linked"); + expect(event.payload.link).toEqual({ + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "manual", + linkedAt: event.payload.updatedAt, + snapshot: null, + stack: null, + }); + expect(event.payload.updatedAt).not.toBe(NOW); + }), + ); + + it.effect("rejects linking a pull request that is already linked", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.link", + commandId: CommandId.make("cmd-link-dup"), + threadId: THREAD_ID, + host: "GITHUB.COM", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "agent", + }, + readModel: makeReadModel([makeLink()]), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("re-linking a dismissed stack member un-dismisses it", () => + Effect.gen(function* () { + const dismissed = makeLink({ + source: "stack-dismissed", + snapshot, + stack: { + kind: "native", + id: "stack-1", + number: 1, + url: "https://github.com/t3tools/t3code/stack/1", + base: "main", + layers: [{ number: 42, headBranch: "feat/links", state: "open" }], + }, + }); + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.link", + commandId: CommandId.make("cmd-relink"), + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "manual", + }, + readModel: makeReadModel([dismissed]), + }); + const event = expectSingleEvent(decided, "thread.pull-request-linked"); + // Host state survives the flip; only the source changes. + expect(event.payload.link).toEqual({ ...dismissed, source: "manual" }); + }), + ); + + it.effect("rejects a stack sync re-adding a dismissed stack member", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.link", + commandId: CommandId.make("cmd-stack-readd"), + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "stack", + }, + readModel: makeReadModel([makeLink({ source: "stack-dismissed" })]), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("unlinks a manual pull request", () => + Effect.gen(function* () { + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.unlink", + commandId: CommandId.make("cmd-unlink"), + threadId: THREAD_ID, + host: "GitHub.com", + repository: "t3tools/t3code", + number: 42, + }, + readModel: makeReadModel([makeLink()]), + }); + const event = expectSingleEvent(decided, "thread.pull-request-unlinked"); + expect(event.payload).toMatchObject({ + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + }); + }), + ); + + it.effect("unlinking a stack member leaves a stack-dismissed tombstone", () => + Effect.gen(function* () { + const member = makeLink({ source: "stack", snapshot }); + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.unlink", + commandId: CommandId.make("cmd-unlink-stack"), + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + }, + readModel: makeReadModel([member]), + }); + const event = expectSingleEvent(decided, "thread.pull-request-linked"); + expect(event.payload.link).toEqual({ ...member, source: "stack-dismissed" }); + }), + ); + + for (const source of ["manual", "agent", "created"] as const) { + it.effect(`unlinking a ${source} member prevents its sibling rediscovering it`, () => + Effect.gen(function* () { + const member = makeLink({ source }); + const sibling = makeLink({ + number: 43, + source: "stack", + stack: { + kind: "native", + id: "stack-1", + number: 1, + url: "https://github.com/t3tools/t3code/stack/1", + base: "main", + layers: [ + { number: 42, headBranch: "first", state: "open" }, + { number: 43, headBranch: "second", state: "open" }, + ], + }, + }); + let model = makeReadModel([member, sibling]); + const decided = yield* decideOrchestrationCommand({ + readModel: model, + command: { + type: "thread.pull-request.unlink", + commandId: CommandId.make("remove"), + threadId: THREAD_ID, + host: member.host, + repository: member.repository, + number: member.number, + }, + }); + const event = expectSingleEvent(decided, "thread.pull-request-linked"); + model = yield* projectEvent(model, { ...event, sequence: 1 }); + expect(model.threads[0]!.pullRequests).toEqual([ + { ...member, source: "stack-dismissed" }, + sibling, + ]); + const rediscovered = yield* decideOrchestrationCommand({ + readModel: model, + command: { + type: "thread.pull-request.link", + commandId: CommandId.make("rediscovered"), + threadId: THREAD_ID, + host: member.host, + repository: member.repository, + number: member.number, + url: member.url, + source: "stack", + }, + }).pipe(Effect.result); + expect(rediscovered._tag).toBe("Failure"); + }), + ); + } + + it.effect("rejects unlinking a pull request that is not linked", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request.unlink", + commandId: CommandId.make("cmd-unlink-missing"), + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 7, + }, + readModel: makeReadModel([makeLink()]), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("rejects syncing a pull request that is not linked", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request-link.sync", + commandId: CommandId.make("cmd-sync-missing"), + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + snapshot, + stack: null, + }, + readModel: makeReadModel([]), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + + it.effect("sync emits the host snapshot for a linked pull request", () => + Effect.gen(function* () { + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.pull-request-link.sync", + commandId: CommandId.make("cmd-sync"), + threadId: THREAD_ID, + host: "GitHub.com", + repository: "t3tools/t3code", + number: 42, + snapshot, + stack: null, + }, + readModel: makeReadModel([makeLink()]), + }); + const event = expectSingleEvent(decided, "thread.pull-request-synced"); + expect(event.payload).toMatchObject({ + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + snapshot, + stack: null, + }); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.questionAttachments.test.ts b/apps/server/src/orchestration/decider.questionAttachments.test.ts index 6c3cdc62e7de..53e87d7593cc 100644 --- a/apps/server/src/orchestration/decider.questionAttachments.test.ts +++ b/apps/server/src/orchestration/decider.questionAttachments.test.ts @@ -26,6 +26,7 @@ const readModel: OrchestrationReadModel = { modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", + pullRequests: [], branch: null, worktreePath: null, latestTurn: null, diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index abc5cff37e3f..bcb2408c21e1 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -47,6 +47,7 @@ function makeReadModel( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: NOW, updatedAt: NOW, diff --git a/apps/server/src/orchestration/decider.snoozed.test.ts b/apps/server/src/orchestration/decider.snoozed.test.ts index 1012240b18a0..505bb79df034 100644 --- a/apps/server/src/orchestration/decider.snoozed.test.ts +++ b/apps/server/src/orchestration/decider.snoozed.test.ts @@ -41,6 +41,7 @@ function makeReadModel(input: { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: NOW, updatedAt: NOW, diff --git a/apps/server/src/orchestration/decider.titleRegeneration.test.ts b/apps/server/src/orchestration/decider.titleRegeneration.test.ts index b29c8ffda676..c032f33d0d01 100644 --- a/apps/server/src/orchestration/decider.titleRegeneration.test.ts +++ b/apps/server/src/orchestration/decider.titleRegeneration.test.ts @@ -26,6 +26,7 @@ const readModel: OrchestrationReadModel = { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: UPDATED_AT, updatedAt: UPDATED_AT, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 304defb80c89..c0787a18d096 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -10,8 +10,16 @@ import { type OrchestrationEvent, type OrchestrationReadModel, type OrchestrationThread, + type ThreadPullRequestKey, + type ThreadPullRequestLink, type OrchestrationThreadActivity, } from "@t3tools/contracts"; +import { + legacyLinkedPullRequestOf, + legacyThreadPullRequestKey, + normalizeThreadPullRequestKey, + threadPullRequestKeysEqual, +} from "@t3tools/shared/threadPullRequests"; import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; @@ -121,6 +129,13 @@ function hasQueuedTurnStartForThread( ); } +function findPullRequestLink( + thread: Pick, + key: ThreadPullRequestKey, +): ThreadPullRequestLink | undefined { + return thread.pullRequests.find((link) => threadPullRequestKeysEqual(link, key)); +} + function withEventBase( input: Pick & { readonly aggregateKind: OrchestrationEvent["aggregateKind"]; @@ -876,6 +891,80 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + // Old clients only see the derived single link. Unlink that request through + // the same command path as modern clients, including stack dismissal, while + // retaining other links they cannot see. Historical metadata events still replay unchanged. + const legacy = legacyLinkedPullRequestOf( + thread.pullRequests, + thread.projectId, + readModel.projects.find((project) => project.id === thread.projectId)?.repositoryIdentity, + ); + const currentPullRequest = + legacy === null + ? null + : (thread.pullRequests.find( + (link) => link.url === legacy.url && link.number === legacy.number, + ) ?? null); + if (command.linkedPullRequest != null) { + const { linkedPullRequest: linked, ...metadata } = command; + const project = readModel.projects.find((project) => project.id === thread.projectId); + let host = project?.repositoryIdentity?.canonicalKey.split("/")[0] ?? "unknown"; + try { + host = new URL(linked.url).hostname; + } catch { + // Historical clients can send links without a parseable URL. + } + const hasMetadata = Object.entries(metadata).some( + ([key, value]) => !["type", "commandId", "threadId"].includes(key) && value !== undefined, + ); + return yield* decideCommandSequence({ + readModel, + commands: [ + ...(hasMetadata ? [metadata] : []), + ...(currentPullRequest?.source === "manual" + ? [ + { + type: "thread.pull-request.unlink" as const, + commandId: command.commandId, + threadId: command.threadId, + host: currentPullRequest.host, + repository: currentPullRequest.repository, + number: currentPullRequest.number, + }, + ] + : []), + { + type: "thread.pull-request.link", + commandId: command.commandId, + threadId: command.threadId, + ...legacyThreadPullRequestKey(linked, host), + url: linked.url, + source: "manual", + }, + ], + }); + } + + if (command.linkedPullRequest === null && currentPullRequest !== null) { + const { linkedPullRequest: _linkedPullRequest, ...metadata } = command; + const hasMetadata = Object.entries(metadata).some( + ([key, value]) => !["type", "commandId", "threadId"].includes(key) && value !== undefined, + ); + return yield* decideCommandSequence({ + readModel, + commands: [ + ...(hasMetadata ? [metadata] : []), + { + type: "thread.pull-request.unlink", + commandId: command.commandId, + threadId: command.threadId, + host: currentPullRequest.host, + repository: currentPullRequest.repository, + number: currentPullRequest.number, + }, + ], + }); + } const branch = command.branch !== undefined && command.expectedBranch !== undefined && @@ -920,6 +1009,138 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.pull-request.link": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const key = normalizeThreadPullRequestKey(command); + const existing = findPullRequestLink(thread, key); + // An explicit link on a dismissed stack member un-dismisses it; any + // other duplicate is a no-op the engine would reject as zero-event. + const undismisses = + existing?.source === "stack-dismissed" && + (command.source === "manual" || command.source === "agent" || command.source === "created"); + if (existing !== undefined && !undismisses) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `pull request ${key.host}/${key.repository}#${key.number} is already linked to thread ${command.threadId}`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.pull-request-linked", + payload: { + threadId: command.threadId, + link: + existing !== undefined + ? { ...existing, url: command.url, source: command.source } + : { + ...key, + url: command.url, + source: command.source, + linkedAt: occurredAt, + snapshot: null, + stack: null, + }, + updatedAt: occurredAt, + }, + }; + } + + case "thread.pull-request.unlink": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const key = normalizeThreadPullRequestKey(command); + const existing = findPullRequestLink(thread, key); + if (existing === undefined) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `pull request ${key.host}/${key.repository}#${key.number} is not linked to thread ${command.threadId}`, + }); + } + const occurredAt = yield* nowIso; + const eventBase = yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + }); + // Any known native-stack member needs a tombstone, regardless of who linked it. + // A sibling can rediscover it even before this link has its own stack snapshot. + const belongsToStack = + existing.source === "stack" || + existing.stack !== null || + thread.pullRequests.some( + (link) => + link.host.toLowerCase() === key.host && + link.repository.toLowerCase() === key.repository && + link.stack?.layers.some((layer) => layer.number === key.number), + ); + if (belongsToStack) { + return { + ...eventBase, + type: "thread.pull-request-linked", + payload: { + threadId: command.threadId, + link: { ...existing, source: "stack-dismissed" }, + updatedAt: occurredAt, + }, + }; + } + return { + ...eventBase, + type: "thread.pull-request-unlinked", + payload: { + threadId: command.threadId, + ...key, + updatedAt: occurredAt, + }, + }; + } + + case "thread.pull-request-link.sync": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const key = normalizeThreadPullRequestKey(command); + if (findPullRequestLink(thread, key) === undefined) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `pull request ${key.host}/${key.repository}#${key.number} is not linked to thread ${command.threadId}`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.pull-request-synced", + payload: { + threadId: command.threadId, + ...key, + snapshot: command.snapshot, + stack: command.stack, + updatedAt: occurredAt, + }, + }; + } + case "thread.pull-request.sync": { const thread = yield* requireThreadNotArchived({ readModel, diff --git a/apps/server/src/orchestration/decider.userInputDismiss.test.ts b/apps/server/src/orchestration/decider.userInputDismiss.test.ts index 5c7b495bc245..a61da0ca78b3 100644 --- a/apps/server/src/orchestration/decider.userInputDismiss.test.ts +++ b/apps/server/src/orchestration/decider.userInputDismiss.test.ts @@ -49,6 +49,7 @@ function makeReadModel( modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, runtimeMode: "full-access", interactionMode: "default", + pullRequests: [], branch: null, worktreePath: null, latestTurn: null, diff --git a/apps/server/src/orchestration/projector.pullRequests.test.ts b/apps/server/src/orchestration/projector.pullRequests.test.ts new file mode 100644 index 000000000000..6876cdde4f0c --- /dev/null +++ b/apps/server/src/orchestration/projector.pullRequests.test.ts @@ -0,0 +1,417 @@ +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, + type OrchestrationReadModel, + type RepositoryIdentity, + type ThreadPullRequestLink, + type ThreadPullRequestSnapshot, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const LATER = "2026-01-02T00:00:00.000Z"; +const THREAD_ID = ThreadId.make("thread-1"); +const PROJECT_ID = ProjectId.make("project-1"); + +function makeEvent(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: THREAD_ID, + occurredAt: NOW, + commandId: CommandId.make(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +function makeLink(overrides: Partial = {}): ThreadPullRequestLink { + return { + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "manual", + linkedAt: NOW, + snapshot: null, + stack: null, + ...overrides, + }; +} + +const snapshot: ThreadPullRequestSnapshot = { + state: "merged", + title: "Add links", + headBranch: "feat/links", + baseBranch: "main", + isDraft: false, + updatedAt: LATER, + syncedAt: LATER, +}; + +const createThread = (model: OrchestrationReadModel) => + projectEvent( + model, + makeEvent({ + sequence: model.snapshotSequence + 1, + type: "thread.created", + payload: { + threadId: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: NOW, + updatedAt: NOW, + }, + }), + ); + +const createProject = (model: OrchestrationReadModel, repositoryIdentity: RepositoryIdentity) => + projectEvent(model, { + ...makeEvent({ + sequence: model.snapshotSequence + 1, + type: "project.created", + payload: { + projectId: PROJECT_ID, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, + }, + }), + aggregateKind: "project", + aggregateId: PROJECT_ID, + }).pipe( + Effect.map((next) => ({ + ...next, + projects: next.projects.map((project) => + project.id === PROJECT_ID ? { ...project, repositoryIdentity } : project, + ), + })), + ); + +it.effect("seeds threads with no pull requests", () => + Effect.gen(function* () { + const created = yield* createThread(createEmptyReadModel(NOW)); + expect(created.threads[0]?.pullRequests).toEqual([]); + expect(created.threads[0]?.linkedPullRequest ?? null).toBeNull(); + }), +); + +it.effect("projects link, sync, and unlink onto the thread", () => + Effect.gen(function* () { + const created = yield* createThread( + yield* createProject(createEmptyReadModel(NOW), { + canonicalKey: "github.com/t3tools/t3code", + provider: "github", + displayName: "t3tools/t3code", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/t3tools/t3code.git", + }, + }), + ); + const link = makeLink(); + + const linked = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.pull-request-linked", + payload: { threadId: THREAD_ID, link, updatedAt: LATER }, + }), + ); + expect(linked.threads[0]?.pullRequests).toEqual([link]); + expect(linked.threads[0]?.updatedAt).toBe(LATER); + // The legacy field is derived from the array so old clients keep working. + expect(linked.threads[0]?.linkedPullRequest).toEqual({ + projectId: PROJECT_ID, + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + }); + + // A second link for the same key replaces in place (used for un-dismiss + // and stack tombstones), never duplicates. + const relinked = yield* projectEvent( + linked, + makeEvent({ + sequence: 3, + type: "thread.pull-request-linked", + payload: { + threadId: THREAD_ID, + link: { ...link, host: "GITHUB.COM", source: "agent" }, + updatedAt: LATER, + }, + }), + ); + expect(relinked.threads[0]?.pullRequests).toHaveLength(1); + expect(relinked.threads[0]?.pullRequests[0]?.source).toBe("agent"); + + const synced = yield* projectEvent( + relinked, + makeEvent({ + sequence: 4, + type: "thread.pull-request-synced", + payload: { + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + snapshot, + stack: null, + updatedAt: LATER, + }, + }), + ); + expect(synced.threads[0]?.pullRequests[0]?.snapshot).toEqual(snapshot); + + const unlinked = yield* projectEvent( + synced, + makeEvent({ + sequence: 5, + type: "thread.pull-request-unlinked", + payload: { + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + updatedAt: LATER, + }, + }), + ); + expect(unlinked.threads[0]?.pullRequests).toEqual([]); + expect(unlinked.threads[0]?.linkedPullRequest).toBeNull(); + }), +); + +it.effect("ignores a sync for a pull request that is no longer linked", () => + Effect.gen(function* () { + const created = yield* createThread(createEmptyReadModel(NOW)); + const other = makeLink({ number: 7, url: "https://github.com/t3tools/t3code/pull/7" }); + const linked = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.pull-request-linked", + payload: { threadId: THREAD_ID, link: other, updatedAt: NOW }, + }), + ); + const synced = yield* projectEvent( + linked, + makeEvent({ + sequence: 3, + type: "thread.pull-request-synced", + payload: { + threadId: THREAD_ID, + host: "github.com", + repository: "t3tools/t3code", + number: 42, + snapshot, + stack: null, + updatedAt: LATER, + }, + }), + ); + expect(synced.threads[0]?.pullRequests).toEqual([other]); + expect(synced.threads[0]?.updatedAt).toBe(NOW); + }), +); + +it.effect("mirrors legacy meta-updated links into pullRequests using the project host", () => + Effect.gen(function* () { + const withProject = yield* createProject(createEmptyReadModel(NOW), { + canonicalKey: "GitHub.com/t3tools/t3code", + provider: "github", + displayName: "t3tools/t3code", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.com:t3tools/t3code.git", + }, + }); + const created = yield* createThread(withProject); + const agentLink = makeLink({ + number: 7, + url: "https://github.com/t3tools/t3code/pull/7", + source: "agent", + }); + const withAgentLink = yield* projectEvent( + created, + makeEvent({ + sequence: 3, + type: "thread.pull-request-linked", + payload: { threadId: THREAD_ID, link: agentLink, updatedAt: NOW }, + }), + ); + + const legacyLinked = yield* projectEvent( + withAgentLink, + makeEvent({ + sequence: 4, + type: "thread.meta-updated", + payload: { + threadId: THREAD_ID, + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "T3Tools/T3Code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + }, + updatedAt: LATER, + }, + }), + ); + expect(legacyLinked.threads[0]?.pullRequests).toEqual([ + agentLink, + { + host: "github.com", + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + source: "manual", + linkedAt: LATER, + snapshot: null, + stack: null, + }, + ]); + // Two open links read as a stack; the derived field points at the top. + expect(legacyLinked.threads[0]?.linkedPullRequest).toEqual({ + projectId: PROJECT_ID, + repository: "t3tools/t3code", + number: 42, + url: "https://github.com/t3tools/t3code/pull/42", + }); + + // Null clears only the manual link; the agent's stays. + const legacyCleared = yield* projectEvent( + legacyLinked, + makeEvent({ + sequence: 5, + type: "thread.meta-updated", + payload: { threadId: THREAD_ID, linkedPullRequest: null, updatedAt: LATER }, + }), + ); + expect(legacyCleared.threads[0]?.pullRequests).toEqual([agentLink]); + expect(legacyCleared.threads[0]?.linkedPullRequest).toEqual({ + projectId: PROJECT_ID, + repository: "t3tools/t3code", + number: 7, + url: "https://github.com/t3tools/t3code/pull/7", + }); + }), +); + +it.effect("falls back to the link URL host when the project has no repository identity", () => + Effect.gen(function* () { + const created = yield* createThread(createEmptyReadModel(NOW)); + const legacyLinked = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.meta-updated", + payload: { + threadId: THREAD_ID, + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "t3tools/t3code", + number: 42, + url: "https://GitLab.example.com/t3tools/t3code/-/merge_requests/42", + }, + updatedAt: LATER, + }, + }), + ); + expect(legacyLinked.threads[0]?.pullRequests[0]?.host).toBe("gitlab.example.com"); + }), +); + +it.effect("leaves pullRequests alone when meta-updated carries no legacy link", () => + Effect.gen(function* () { + const created = yield* createThread(createEmptyReadModel(NOW)); + const link = makeLink(); + const linked = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.pull-request-linked", + payload: { threadId: THREAD_ID, link, updatedAt: NOW }, + }), + ); + const retitled = yield* projectEvent( + linked, + makeEvent({ + sequence: 3, + type: "thread.meta-updated", + payload: { threadId: THREAD_ID, title: "Renamed", updatedAt: LATER }, + }), + ); + expect(retitled.threads[0]?.title).toBe("Renamed"); + expect(retitled.threads[0]?.pullRequests).toEqual([link]); + }), +); + +it.effect("replays Azure legacy selectors as full repository keys", () => + Effect.gen(function* () { + const withProject = yield* createProject(createEmptyReadModel(NOW), { + canonicalKey: "ssh.dev.azure.com/v3/org-a/project/web", + provider: "azure-devops", + displayName: "v3/org-a/project/web", + name: "web", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@ssh.dev.azure.com:v3/org-a/project/web", + }, + }); + const created = yield* createThread(withProject); + const legacy = { + projectId: PROJECT_ID, + repository: "web", + number: 7, + url: "https://dev.azure.com/org-a/project/_git/web/pullrequest/7", + }; + const model = yield* projectEvent( + created, + makeEvent({ + sequence: 3, + type: "thread.meta-updated", + payload: { threadId: THREAD_ID, linkedPullRequest: legacy, updatedAt: LATER }, + }), + ); + expect(model.threads[0]?.pullRequests).toEqual([ + { + host: "dev.azure.com", + repository: "org-a/project/_git/web", + number: 7, + url: legacy.url, + source: "manual", + linkedAt: LATER, + snapshot: null, + stack: null, + }, + ]); + expect(model.threads[0]?.linkedPullRequest).toEqual(legacy); + }), +); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index e973b523275f..39aaacd8739d 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -86,6 +86,7 @@ describe("orchestration projector", () => { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], branchPullRequest: null, latestTurn: null, createdAt: now, @@ -117,7 +118,31 @@ describe("orchestration projector", () => { commandId: null, }; let model = yield* projectEvent( - createEmptyReadModel(now), + { + ...createEmptyReadModel(now), + projects: [ + { + id: ProjectId.make("project-1"), + title: "T3 Code", + workspaceRoot: "/repo", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + deletedAt: null, + repositoryIdentity: { + canonicalKey: "github.com/pingdotgg/t3code", + provider: "github", + displayName: "pingdotgg/t3code", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/pingdotgg/t3code.git", + }, + }, + }, + ], + }, makeEvent({ ...eventFields, sequence: 1, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index c048247f4128..02435ea5ba44 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,4 +1,12 @@ -import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; +import type { + OrchestrationEvent, + OrchestrationProject, + OrchestrationReadModel, + ThreadId, + ThreadLinkedPullRequest, + ThreadPullRequestKey, + ThreadPullRequestLink, +} from "@t3tools/contracts"; import { isImportedAgentSessionMessageId, OrchestrationCheckpointSummary, @@ -6,6 +14,11 @@ import { OrchestrationSession, OrchestrationThread, } from "@t3tools/contracts"; +import { + legacyLinkedPullRequestOf, + legacyThreadPullRequestKey, + threadPullRequestKeysEqual, +} from "@t3tools/shared/threadPullRequests"; import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -28,6 +41,9 @@ import { ThreadSettledPayload, ThreadPinnedPayload, ThreadPinReorderedPayload, + ThreadPullRequestLinkedPayload, + ThreadPullRequestSyncedPayload, + ThreadPullRequestUnlinkedPayload, ThreadSnoozedPayload, ThreadUnpinnedPayload, ThreadUnarchivedPayload, @@ -101,6 +117,77 @@ function updateThread( return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); } +/** Patch that swaps a thread's links and re-derives the legacy single-PR field from them. */ +function pullRequestsPatch( + thread: Pick, + pullRequests: ReadonlyArray, + projects: OrchestrationReadModel["projects"], +): Pick { + return { + pullRequests, + linkedPullRequest: legacyLinkedPullRequestOf( + pullRequests, + thread.projectId, + projects.find((project) => project.id === thread.projectId)?.repositoryIdentity, + ), + }; +} + +function upsertPullRequestLink( + pullRequests: ReadonlyArray, + link: ThreadPullRequestLink, +): ReadonlyArray { + const index = pullRequests.findIndex((entry) => threadPullRequestKeysEqual(entry, link)); + return index === -1 + ? [...pullRequests, link] + : pullRequests.map((entry, entryIndex) => (entryIndex === index ? link : entry)); +} + +function removePullRequestLink( + pullRequests: ReadonlyArray, + key: ThreadPullRequestKey, +): ReadonlyArray { + return pullRequests.filter((entry) => !threadPullRequestKeysEqual(entry, key)); +} + +/** + * Host for a legacy `linkedPullRequest` being replayed into the link array. + * Legacy links never carried one; the project's canonical key + * (`//`) is the best witness, then the link URL. + */ +function legacyPullRequestHost( + project: OrchestrationProject | undefined, + linked: ThreadLinkedPullRequest, +): string { + const canonicalHost = project?.repositoryIdentity?.canonicalKey.split("/")[0]; + if (canonicalHost) return canonicalHost.toLowerCase(); + try { + return new URL(linked.url).hostname.toLowerCase(); + } catch { + return "unknown"; + } +} + +function legacyLinkToPullRequests( + thread: Pick, + project: OrchestrationProject | undefined, + linked: ThreadLinkedPullRequest | null, + linkedAt: string, +): ReadonlyArray { + // The legacy field held one user-chosen link, so null clears exactly the + // manual ones and leaves created/agent/stack links alone. + const withoutManual = thread.pullRequests.filter((entry) => entry.source !== "manual"); + if (linked === null) return withoutManual; + return upsertPullRequestLink(withoutManual, { + ...legacyThreadPullRequestKey(linked, legacyPullRequestHost(project, linked)), + url: linked.url, + source: "manual", + linkedAt, + snapshot: null, + stack: null, + }); +} + function decodeForEvent( schema: Schema.Decoder, value: unknown, @@ -336,6 +423,7 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + pullRequests: [], branchPullRequest: null, latestTurn: null, createdAt: payload.createdAt, @@ -498,30 +586,129 @@ export function projectEvent( case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( - Effect.map((payload) => ({ - ...nextBase, - threads: updateThread(nextBase.threads, payload.threadId, { - ...(payload.title !== undefined ? { title: payload.title } : {}), - ...(payload.activeOrderKey !== undefined - ? { activeOrderKey: payload.activeOrderKey } - : {}), - ...(payload.titleRegeneration !== undefined - ? { titleRegeneration: payload.titleRegeneration } - : {}), - ...(payload.modelSelection !== undefined - ? { modelSelection: payload.modelSelection } - : {}), - ...(payload.branch !== undefined ? { branch: payload.branch } : {}), - ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), - ...(payload.linkedPullRequest !== undefined - ? { linkedPullRequest: payload.linkedPullRequest } - : {}), - ...(payload.branchPullRequest !== undefined - ? { branchPullRequest: payload.branchPullRequest } - : {}), - updatedAt: payload.updatedAt, - }), - })), + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + // Legacy single-link events replay into the link array so the + // derived linkedPullRequest and pullRequests never disagree. + const legacyLinkPatch = + thread !== undefined && payload.linkedPullRequest !== undefined + ? pullRequestsPatch( + thread, + legacyLinkToPullRequests( + thread, + nextBase.projects.find((project) => project.id === thread.projectId), + payload.linkedPullRequest, + payload.updatedAt, + ), + nextBase.projects, + ) + : {}; + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.titleRegeneration !== undefined + ? { titleRegeneration: payload.titleRegeneration } + : {}), + ...(payload.modelSelection !== undefined + ? { modelSelection: payload.modelSelection } + : {}), + ...(payload.branch !== undefined ? { branch: payload.branch } : {}), + ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + ...(payload.activeOrderKey !== undefined + ? { activeOrderKey: payload.activeOrderKey } + : {}), + ...(payload.branchPullRequest !== undefined + ? { branchPullRequest: payload.branchPullRequest } + : {}), + ...legacyLinkPatch, + updatedAt: payload.updatedAt, + }), + }; + }), + ); + + case "thread.pull-request-linked": + return decodeForEvent( + ThreadPullRequestLinkedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + ...pullRequestsPatch( + thread, + upsertPullRequestLink(thread.pullRequests, payload.link), + nextBase.projects, + ), + updatedAt: payload.updatedAt, + }), + }; + }), + ); + + case "thread.pull-request-unlinked": + return decodeForEvent( + ThreadPullRequestUnlinkedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + ...pullRequestsPatch( + thread, + removePullRequestLink(thread.pullRequests, payload), + nextBase.projects, + ), + updatedAt: payload.updatedAt, + }), + }; + }), + ); + + case "thread.pull-request-synced": + return decodeForEvent( + ThreadPullRequestSyncedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + // A sync for a link the user removed in the meantime is stale; drop it. + if ( + !thread || + !thread.pullRequests.some((link) => threadPullRequestKeysEqual(link, payload)) + ) { + return nextBase; + } + const pullRequests = thread.pullRequests.map((link) => + threadPullRequestKeysEqual(link, payload) + ? { ...link, snapshot: payload.snapshot, stack: payload.stack } + : link, + ); + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + ...pullRequestsPatch(thread, pullRequests, nextBase.projects), + updatedAt: payload.updatedAt, + }), + }; + }), ); case "thread.runtime-mode-set": diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 68ceb8573b75..b7b2fdefba2f 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -15,15 +15,21 @@ import * as Statement from "effect/unstable/sql/Statement"; import { SqlitePersistenceMemory } from "./Sqlite.ts"; import { ProjectionProjectRepositoryLive } from "./ProjectionProjects.ts"; import { ProjectionThreadRepositoryLive } from "./ProjectionThreads.ts"; -import { ProjectionThreadProposedPlanRepositoryLive } from "./ProjectionThreadProposedPlans.ts"; +import * as ProjectionThreadPullRequests from "../ProjectionThreadPullRequests.ts"; import { ProjectionProjectRepository } from "../Services/ProjectionProjects.ts"; import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts"; +import { + ProjectionThreadPullRequestRepository, + type ProjectionThreadPullRequest, +} from "../ProjectionThreadPullRequests.ts"; +import { ProjectionThreadProposedPlanRepositoryLive } from "./ProjectionThreadProposedPlans.ts"; import { ProjectionThreadProposedPlanRepository } from "../Services/ProjectionThreadProposedPlans.ts"; const projectionRepositoriesLayer = it.layer( Layer.mergeAll( ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + ProjectionThreadPullRequests.layer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), ProjectionThreadProposedPlanRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), SqlitePersistenceMemory, ), @@ -529,4 +535,164 @@ projectionRepositoriesLayer("Projection repositories", (it) => { assert.deepStrictEqual(Option.getOrNull(branchCleared)?.linkedPullRequest, linkedPullRequest); }), ); + + it.effect("uses one Azure identity for repository writes, lookups, and deletion", () => + Effect.gen(function* () { + const pullRequests = yield* ProjectionThreadPullRequestRepository; + const threadId = ThreadId.make("azure-alias-link"); + const row: ProjectionThreadPullRequest = { + threadId, + host: "org.visualstudio.com", + repository: "project/_git/web", + number: 7, + url: "https://org.visualstudio.com/project/_git/web/pullrequest/7", + source: "manual", + linkedAt: "2026-09-09T00:00:00.000Z", + snapshot: null, + stack: null, + }; + yield* pullRequests.upsert(row); + yield* pullRequests.upsert({ + ...row, + host: "dev.azure.com", + repository: "org/project/_git/web", + }); + const found = yield* pullRequests.listByPullRequest({ + host: "ssh.dev.azure.com", + repository: "v3/org/project/web", + number: 7, + }); + assert.deepStrictEqual(found, [ + { ...row, host: "dev.azure.com", repository: "org/project/_git/web" }, + ]); + assert.deepStrictEqual( + yield* pullRequests.listByPullRequest({ + host: "dev.azure.com", + repository: "other/project/_git/web", + number: 7, + }), + [], + ); + yield* pullRequests.delete({ + threadId, + host: "vs-ssh.visualstudio.com", + repository: "v3/org/project/web", + number: 7, + }); + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), []); + }), + ); + + it.effect("round-trips pull request links with JSON snapshot and stack columns", () => + Effect.gen(function* () { + const pullRequests = yield* ProjectionThreadPullRequestRepository; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-pr-links"); + const otherThreadId = ThreadId.make("thread-pr-links-other"); + + const unsynced: ProjectionThreadPullRequest = { + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + source: "manual", + linkedAt: "2026-03-24T00:00:00.000Z", + snapshot: null, + stack: null, + }; + const synced: ProjectionThreadPullRequest = { + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + url: "https://github.com/pingdotgg/t3code/pull/7", + source: "stack", + linkedAt: "2026-03-23T00:00:00.000Z", + snapshot: { + state: "open", + title: "Add links", + headBranch: "feat/links", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-03-23T01:00:00.000Z", + syncedAt: "2026-03-23T02:00:00.000Z", + }, + stack: { + kind: "native", + id: "stack-1", + number: 1, + url: "https://github.com/pingdotgg/t3code/stack/1", + base: "main", + layers: [ + { number: 7, headBranch: "feat/links", state: "open" }, + { number: 42, headBranch: "feat/links-ui", state: "open" }, + ], + }, + }; + const sharedOnOtherThread: ProjectionThreadPullRequest = { + ...unsynced, + threadId: otherThreadId, + source: "agent", + linkedAt: "2026-03-25T00:00:00.000Z", + }; + + yield* pullRequests.upsert(unsynced); + yield* pullRequests.upsert(synced); + yield* pullRequests.upsert(sharedOnOtherThread); + + const rawRows = yield* sql<{ + readonly number: number; + readonly snapshotJson: string | null; + readonly stackJson: string | null; + }>` + SELECT number, snapshot_json AS "snapshotJson", stack_json AS "stackJson" + FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + ORDER BY number ASC + `; + assert.strictEqual(rawRows[0]?.number, 7); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.deepStrictEqual(JSON.parse(rawRows[0]?.snapshotJson ?? "null"), synced.snapshot); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.deepStrictEqual(JSON.parse(rawRows[0]?.stackJson ?? "null"), synced.stack); + assert.strictEqual(rawRows[1]?.snapshotJson, null); + assert.strictEqual(rawRows[1]?.stackJson, null); + + // Ordered by linked_at, then number. + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), [synced, unsynced]); + + // One pull request across threads, ordered by linked_at. + assert.deepStrictEqual( + yield* pullRequests.listByPullRequest({ + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + }), + [unsynced, sharedOnOtherThread], + ); + + // Upsert on the composite key replaces snapshot and stack in place. + const resynced = { ...unsynced, snapshot: synced.snapshot, stack: null } as const; + yield* pullRequests.upsert(resynced); + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), [synced, resynced]); + + yield* pullRequests.delete({ + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + }); + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), [resynced]); + + yield* pullRequests.deleteByThreadIdAndSource({ threadId, source: "manual" }); + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId }), []); + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId: otherThreadId }), [ + sharedOnOtherThread, + ]); + + yield* pullRequests.deleteByThreadId({ threadId: otherThreadId }); + assert.deepStrictEqual(yield* pullRequests.listByThreadId({ threadId: otherThreadId }), []); + }), + ); }); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index bc176f62cc3c..a728e6e6e22e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -61,6 +61,7 @@ import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps. import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts"; import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts"; import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts"; +import Migration0050 from "./Migrations/050_ProjectionThreadPullRequests.ts"; /** * Migration loader with all migrations defined inline. @@ -122,6 +123,7 @@ const migrationEntries = [ [47, "ProjectionProjectIcon", Migration0047], [48, "ProjectionThreadBranchPullRequest", Migration0048], [49, "ProjectionThreadsActiveOrderKey", Migration0049], + [50, "ProjectionThreadPullRequests", Migration0050], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.test.ts b/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.test.ts new file mode 100644 index 000000000000..61d9f0618e9c --- /dev/null +++ b/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.test.ts @@ -0,0 +1,181 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +interface PullRequestRow { + readonly threadId: string; + readonly host: string; + readonly repository: string; + readonly number: number; + readonly url: string; + readonly source: string; + readonly linkedAt: string; + readonly snapshotJson: string | null; + readonly stackJson: string | null; +} + +layer("050_ProjectionThreadPullRequests", (it) => { + it.effect("creates the link table and backfills legacy single links", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 49 }); + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-1', + 'Project 1', + '/tmp/project-1', + '[]', + '2026-03-01T00:00:00.000Z', + '2026-03-01T00:00:00.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + linked_pull_request_json, + created_at, + updated_at + ) + VALUES + ( + 'thread-github', + 'project-1', + 'GitHub link', + '{"instanceId":"codex","model":"gpt-5.4"}', + '{"projectId":"project-1","repository":"PingDotGG/T3Code","number":42,"url":"https://GitHub.com/pingdotgg/t3code/pull/42"}', + '2026-03-01T00:00:01.000Z', + '2026-03-02T00:00:00.000Z' + ), + ( + 'thread-bad-url', + 'project-1', + 'Unparseable URL', + '{"instanceId":"codex","model":"gpt-5.4"}', + '{"projectId":"project-1","repository":"acme/widgets","number":7,"url":"not a url"}', + '2026-03-01T00:00:02.000Z', + '2026-03-03T00:00:00.000Z' + ), + ( + 'thread-malformed', + 'project-1', + 'Malformed JSON', + '{"instanceId":"codex","model":"gpt-5.4"}', + '{"repository":"acme/widgets"}', + '2026-03-01T00:00:03.000Z', + '2026-03-04T00:00:00.000Z' + ), + ( + 'thread-unlinked', + 'project-1', + 'No link', + '{"instanceId":"codex","model":"gpt-5.4"}', + NULL, + '2026-03-01T00:00:04.000Z', + '2026-03-05T00:00:00.000Z' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 50 }); + + const rows = yield* sql` + SELECT + thread_id AS "threadId", + host, + repository, + number, + url, + source, + linked_at AS "linkedAt", + snapshot_json AS "snapshotJson", + stack_json AS "stackJson" + FROM projection_thread_pull_requests + ORDER BY thread_id ASC + `; + + assert.deepStrictEqual(rows, [ + { + threadId: "thread-bad-url", + host: "unknown", + repository: "acme/widgets", + number: 7, + url: "not a url", + source: "manual", + linkedAt: "2026-03-03T00:00:00.000Z", + snapshotJson: null, + stackJson: null, + }, + { + threadId: "thread-github", + host: "github.com", + repository: "pingdotgg/t3code", + number: 42, + url: "https://GitHub.com/pingdotgg/t3code/pull/42", + source: "manual", + linkedAt: "2026-03-02T00:00:00.000Z", + snapshotJson: null, + stackJson: null, + }, + ]); + + // The legacy column stays so a rollback keeps its data. + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + assert.ok(columns.some((column) => column.name === "linked_pull_request_json")); + + const indexes = yield* sql<{ readonly name: string }>` + PRAGMA index_list(projection_thread_pull_requests) + `; + assert.ok(indexes.some((index) => index.name === "idx_projection_thread_pull_requests_pr")); + }), + ); +}); + +it.layer(Layer.fresh(NodeSqliteClient.layerMemory()))("050 Azure legacy links", (it) => { + it.effect("keeps legacy Azure repositories distinct across organizations", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 49 }); + for (const organization of ["org-a", "org-b"]) { + yield* sql` + INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, linked_pull_request_json, created_at, updated_at) + VALUES (${organization}, ${organization}, 'Azure', '{"instanceId":"codex","model":"gpt-5.4"}', + ${encodeJson({ projectId: organization, repository: "web", number: 7, url: `https://dev.azure.com/${organization}/project/_git/web/pullrequest/7` })}, + '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z') + `; + } + yield* runMigrations({ toMigrationInclusive: 50 }); + const rows = + yield* sql`SELECT host, repository, number FROM projection_thread_pull_requests ORDER BY repository`; + assert.deepStrictEqual(rows, [ + { host: "dev.azure.com", repository: "org-a/project/_git/web", number: 7 }, + { host: "dev.azure.com", repository: "org-b/project/_git/web", number: 7 }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts b/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts new file mode 100644 index 000000000000..2a53307c8901 --- /dev/null +++ b/apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts @@ -0,0 +1,92 @@ +import { legacyThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +interface LegacyLinkedThreadRow { + readonly threadId: string; + readonly updatedAt: string; + readonly linkedPullRequestJson: string; +} + +interface LegacyLinkedPullRequest { + readonly repository: string; + readonly number: number; + readonly url: string; +} + +function parseLegacyLinkedPullRequest(json: string): LegacyLinkedPullRequest | null { + try { + const value: unknown = JSON.parse(json); + if (typeof value !== "object" || value === null) return null; + const { repository, number, url } = value as Record; + if (typeof repository !== "string" || repository.trim().length === 0) return null; + if (typeof number !== "number" || !Number.isInteger(number) || number < 1) return null; + if (typeof url !== "string" || url.trim().length === 0) return null; + return { repository, number, url }; + } catch { + return null; + } +} + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS projection_thread_pull_requests ( + thread_id TEXT NOT NULL, + host TEXT NOT NULL, + repository TEXT NOT NULL, + number INTEGER NOT NULL, + url TEXT NOT NULL, + source TEXT NOT NULL, + linked_at TEXT NOT NULL, + snapshot_json TEXT, + stack_json TEXT, + PRIMARY KEY (thread_id, host, repository, number) + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_thread_pull_requests_pr + ON projection_thread_pull_requests(host, repository, number) + `; + + const legacyRows = yield* sql` + SELECT + thread_id AS "threadId", + updated_at AS "updatedAt", + linked_pull_request_json AS "linkedPullRequestJson" + FROM projection_threads + WHERE linked_pull_request_json IS NOT NULL + `; + + for (const row of legacyRows) { + const linked = parseLegacyLinkedPullRequest(row.linkedPullRequestJson); + if (linked === null) continue; + const key = legacyThreadPullRequestKey(linked); + yield* sql` + INSERT OR IGNORE INTO projection_thread_pull_requests ( + thread_id, + host, + repository, + number, + url, + source, + linked_at, + snapshot_json, + stack_json + ) + VALUES ( + ${row.threadId}, + ${key.host}, + ${key.repository}, + ${linked.number}, + ${linked.url}, + 'manual', + ${row.updatedAt}, + NULL, + NULL + ) + `; + } +}); diff --git a/apps/server/src/persistence/ProjectionThreadPullRequests.ts b/apps/server/src/persistence/ProjectionThreadPullRequests.ts new file mode 100644 index 000000000000..9f41993bd679 --- /dev/null +++ b/apps/server/src/persistence/ProjectionThreadPullRequests.ts @@ -0,0 +1,266 @@ +import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { + IsoDateTime, + PositiveInt, + ThreadId, + ThreadPullRequestKey, + ThreadPullRequestLinkSource, + ThreadPullRequestSnapshot, + ThreadPullRequestStack, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; + +import { toPersistenceSqlError, type ProjectionRepositoryError } from "./Errors.ts"; + +import * as Layer from "effect/Layer"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +export const ProjectionThreadPullRequest = Schema.Struct({ + threadId: ThreadId, + host: TrimmedNonEmptyString, + repository: TrimmedNonEmptyString, + number: PositiveInt, + url: TrimmedNonEmptyString, + source: ThreadPullRequestLinkSource, + linkedAt: IsoDateTime, + snapshot: Schema.NullOr(ThreadPullRequestSnapshot), + stack: Schema.NullOr(ThreadPullRequestStack), +}); +export type ProjectionThreadPullRequest = typeof ProjectionThreadPullRequest.Type; + +export const ListProjectionThreadPullRequestsInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionThreadPullRequestsInput = + typeof ListProjectionThreadPullRequestsInput.Type; + +export const ListProjectionThreadPullRequestsByPullRequestInput = ThreadPullRequestKey; +export type ListProjectionThreadPullRequestsByPullRequestInput = + typeof ListProjectionThreadPullRequestsByPullRequestInput.Type; + +export const DeleteProjectionThreadPullRequestInput = Schema.Struct({ + threadId: ThreadId, + ...ThreadPullRequestKey.fields, +}); +export type DeleteProjectionThreadPullRequestInput = + typeof DeleteProjectionThreadPullRequestInput.Type; + +export const DeleteProjectionThreadPullRequestsInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadPullRequestsInput = + typeof DeleteProjectionThreadPullRequestsInput.Type; + +export const DeleteProjectionThreadPullRequestsBySourceInput = Schema.Struct({ + threadId: ThreadId, + source: ThreadPullRequestLinkSource, +}); +export type DeleteProjectionThreadPullRequestsBySourceInput = + typeof DeleteProjectionThreadPullRequestsBySourceInput.Type; + +const ProjectionThreadPullRequestDbRow = ProjectionThreadPullRequest.mapFields( + Struct.assign({ + snapshot: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestSnapshot)), + stack: Schema.NullOr(Schema.fromJsonString(ThreadPullRequestStack)), + }), +); + +export class ProjectionThreadPullRequestRepository extends Context.Service< + ProjectionThreadPullRequestRepository, + { + readonly upsert: ( + row: ProjectionThreadPullRequest, + ) => Effect.Effect; + readonly listByThreadId: ( + input: ListProjectionThreadPullRequestsInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly listByPullRequest: ( + input: ListProjectionThreadPullRequestsByPullRequestInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly delete: ( + input: DeleteProjectionThreadPullRequestInput, + ) => Effect.Effect; + readonly deleteByThreadId: ( + input: DeleteProjectionThreadPullRequestsInput, + ) => Effect.Effect; + readonly deleteByThreadIdAndSource: ( + input: DeleteProjectionThreadPullRequestsBySourceInput, + ) => Effect.Effect; + } +>()("t3/persistence/ProjectionThreadPullRequests/ProjectionThreadPullRequestRepository") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertProjectionThreadPullRequestRow = SqlSchema.void({ + Request: ProjectionThreadPullRequest, + execute: (row) => sql` + INSERT INTO projection_thread_pull_requests ( + thread_id, + host, + repository, + number, + url, + source, + linked_at, + snapshot_json, + stack_json + ) + VALUES ( + ${row.threadId}, + ${row.host}, + ${row.repository}, + ${row.number}, + ${row.url}, + ${row.source}, + ${row.linkedAt}, + ${row.snapshot === null ? null : JSON.stringify(row.snapshot)}, + ${row.stack === null ? null : JSON.stringify(row.stack)} + ) + ON CONFLICT (thread_id, host, repository, number) + DO UPDATE SET + url = excluded.url, + source = excluded.source, + linked_at = excluded.linked_at, + snapshot_json = excluded.snapshot_json, + stack_json = excluded.stack_json + `, + }); + + const listProjectionThreadPullRequestRows = SqlSchema.findAll({ + Request: ListProjectionThreadPullRequestsInput, + Result: ProjectionThreadPullRequestDbRow, + execute: ({ threadId }) => sql` + SELECT + thread_id AS "threadId", + host, + repository, + number, + url, + source, + linked_at AS "linkedAt", + snapshot_json AS "snapshot", + stack_json AS "stack" + FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + ORDER BY linked_at ASC, number ASC + `, + }); + + const listProjectionThreadPullRequestRowsByPullRequest = SqlSchema.findAll({ + Request: ListProjectionThreadPullRequestsByPullRequestInput, + Result: ProjectionThreadPullRequestDbRow, + execute: ({ host, repository, number }) => sql` + SELECT + thread_id AS "threadId", + host, + repository, + number, + url, + source, + linked_at AS "linkedAt", + snapshot_json AS "snapshot", + stack_json AS "stack" + FROM projection_thread_pull_requests + WHERE host = ${host} + AND repository = ${repository} + AND number = ${number} + ORDER BY linked_at ASC, thread_id ASC + `, + }); + + const deleteProjectionThreadPullRequestRow = SqlSchema.void({ + Request: DeleteProjectionThreadPullRequestInput, + execute: ({ threadId, host, repository, number }) => sql` + DELETE FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + AND host = ${host} + AND repository = ${repository} + AND number = ${number} + `, + }); + + const deleteProjectionThreadPullRequestRows = SqlSchema.void({ + Request: DeleteProjectionThreadPullRequestsInput, + execute: ({ threadId }) => sql` + DELETE FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + `, + }); + + const deleteProjectionThreadPullRequestRowsBySource = SqlSchema.void({ + Request: DeleteProjectionThreadPullRequestsBySourceInput, + execute: ({ threadId, source }) => sql` + DELETE FROM projection_thread_pull_requests + WHERE thread_id = ${threadId} + AND source = ${source} + `, + }); + + const upsert: ProjectionThreadPullRequestRepository["Service"]["upsert"] = (row) => + upsertProjectionThreadPullRequestRow({ ...row, ...normalizeThreadPullRequestKey(row) }).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadPullRequestRepository.upsert:query")), + ); + + const listByThreadId: ProjectionThreadPullRequestRepository["Service"]["listByThreadId"] = ( + input, + ) => + listProjectionThreadPullRequestRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadPullRequestRepository.listByThreadId:query"), + ), + ); + + const listByPullRequest: ProjectionThreadPullRequestRepository["Service"]["listByPullRequest"] = ( + input, + ) => + listProjectionThreadPullRequestRowsByPullRequest(normalizeThreadPullRequestKey(input)).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadPullRequestRepository.listByPullRequest:query"), + ), + ); + + const deleteLink: ProjectionThreadPullRequestRepository["Service"]["delete"] = (input) => + deleteProjectionThreadPullRequestRow({ + ...input, + ...normalizeThreadPullRequestKey(input), + }).pipe( + Effect.mapError(toPersistenceSqlError("ProjectionThreadPullRequestRepository.delete:query")), + ); + + const deleteByThreadId: ProjectionThreadPullRequestRepository["Service"]["deleteByThreadId"] = ( + input, + ) => + deleteProjectionThreadPullRequestRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadPullRequestRepository.deleteByThreadId:query"), + ), + ); + + const deleteByThreadIdAndSource: ProjectionThreadPullRequestRepository["Service"]["deleteByThreadIdAndSource"] = + (input) => + deleteProjectionThreadPullRequestRowsBySource(input).pipe( + Effect.mapError( + toPersistenceSqlError( + "ProjectionThreadPullRequestRepository.deleteByThreadIdAndSource:query", + ), + ), + ); + + return { + upsert, + listByThreadId, + listByPullRequest, + delete: deleteLink, + deleteByThreadId, + deleteByThreadIdAndSource, + } satisfies ProjectionThreadPullRequestRepository["Service"]; +}); + +export const layer = Layer.effect(ProjectionThreadPullRequestRepository, make); diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 38d6ad1d4331..4eb03a5cc036 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -124,6 +124,7 @@ const makeProjectedThread = (input: { modelSelection: { instanceId: sourceThread.providerInstanceId, model: "default" }, runtimeMode: "full-access", interactionMode: "default", + pullRequests: [], branch: null, worktreePath: null, latestTurn: null, diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 1c2439a9ad9a..6a7fee351bce 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -12,18 +12,39 @@ For browser work, first call \`preview_status\`. If no automation-capable previe Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable. `; +const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = ` + +## T3 Code devices + +The \`t3-code\` MCP server also exposes \`device_*\` tools for iOS Simulators and Android Emulators on this environment. For mobile verification, call \`device_list\`, then \`device_open\` so the user can watch the device in their Device panel; its result explains how to drive the device. Driving happens through the \`agent-device\` CLI, which is on PATH. Keep the host config and session flags returned by \`device_open\` on every command so concurrent devices stay independent: prefer \`agent-device snapshot -i\` refs over coordinates, and use \`device_screenshot\` when you need to see the screen. Do not call simctl, adb, xcrun, or serve-sim directly while these tools are present. If \`device_list\` reports a platform as unavailable, say so instead of trying another route. +`; + +export interface T3CodeToolAvailability { + readonly browser: boolean; + readonly device: boolean; +} + +const normalizeAvailability = ( + availability: boolean | T3CodeToolAvailability, +): T3CodeToolAvailability => + typeof availability === "boolean" ? { browser: availability, device: false } : availability; + /** - * The browser block is omitted entirely when the preview tools aren't attached. - * Describing `preview_*` tools that aren't in the turn's tool list would be + * Each block is omitted entirely when its tools aren't attached. Describing + * `preview_*` or `device_*` tools that aren't in the turn's tool list would be * worse than saying nothing: the instructions actively steer the model away - * from Playwright and agent-browser, so leaving them in would talk it out of - * the only browser automation it still has. + * from Playwright, agent-browser, and raw simctl/adb, so leaving them in would + * talk it out of the only automation it still has. */ -const browserToolInstructions = (browserToolsAvailable: boolean): string => - browserToolsAvailable ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : ""; +const browserToolInstructions = (availability: boolean | T3CodeToolAvailability): string => { + const tools = normalizeAvailability(availability); + return `${tools.browser ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : ""}${ + tools.device ? T3_CODE_DEVICE_TOOL_INSTRUCTIONS : "" + }`; +}; const codexPlanModeDeveloperInstructions = ( - browserToolsAvailable: boolean, + browserToolsAvailable: boolean | T3CodeToolAvailability, ): string => `# Plan Mode (Conversational) You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. @@ -156,7 +177,7 @@ ${browserToolInstructions(browserToolsAvailable)} `; const codexDefaultModeDeveloperInstructions = ( - browserToolsAvailable: boolean, + browserToolsAvailable: boolean | T3CodeToolAvailability, ): string => `# Collaboration Mode: Default You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active. @@ -184,7 +205,7 @@ export function buildCodexDeveloperInstructions( * it from the session's actual MCP configuration rather than re-reading the * setting, so the prompt cannot claim tools the turn doesn't have. */ - browserToolsAvailable = true, + browserToolsAvailable: boolean | T3CodeToolAvailability = true, ): string { const base = interactionMode === "plan" diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 65a8c97fe668..0082f3cbdc30 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -1,3 +1,4 @@ +import { withAgentDeviceEnvironment } from "../../mcp/McpProviderSession.ts"; import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; @@ -162,7 +163,7 @@ export const AntigravityDriver: ProviderDriver { assert.deepEqual(createInput?.options.systemPrompt, { type: "preset", preset: "claude_code", - append: - "In case you're asked: you are running in T3 Code through the Claude Code harness. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.", + append: buildRuntimeInstructions({ harness: "Claude Code" }), }); assert.equal(createInput?.options.permissionMode, "bypassPermissions"); assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); @@ -422,6 +422,29 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("lets a launch-arg permission flag win over the thread runtime mode", () => { + const harness = makeHarness({ + claudeConfig: { launchArgs: "--dangerously-skip-permissions --verbose" }, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "auto-accept-edits", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, "bypassPermissions"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); + // The honored flag is dropped from extraArgs so the CLI sees it once. + assert.deepEqual(createInput?.options.extraArgs, { verbose: null }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("loads Claude filesystem settings sources for SDK sessions", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 81a4a197e9db..981e183414f1 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -4608,7 +4608,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ) => runPromise(handleResumeDialog(request, callbackOptions)); const claudeBinaryPath = claudeSdkExecutablePath; - const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; + const { + "permission-mode": launchArgPermissionMode, + "dangerously-skip-permissions": launchArgSkipPermissions, + ...extraArgs + } = parseCliArgs(claudeSettings.launchArgs).flags; const selectedModel = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const modelSelection = selectedModel @@ -4649,7 +4653,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( auto: "auto", "full-access": "bypassPermissions", }; - const permissionMode = runtimeModeToPermission[input.runtimeMode]; + // A permission launch arg is folded into the mode T3 sends rather than + // passed through: the CLI resolves both inputs together, so argv order + // never let the user's flag win. + const permissionMode = + (launchArgPermissionMode as PermissionMode | null | undefined) ?? + (launchArgSkipPermissions === null || launchArgSkipPermissions === "true" + ? "bypassPermissions" + : runtimeModeToPermission[input.runtimeMode]); const settings = { ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), @@ -4696,7 +4707,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( canUseTool, onUserDialog, supportedDialogKinds: ["resume_return"], - env: claudeEnvironment, + env: McpProviderSession.withAgentDeviceEnvironment(claudeEnvironment, mcpSession), additionalDirectories, ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), ...(mcpSession diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 2d88e58dc1fb..b43755736ca3 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -2274,7 +2274,10 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(mcpSession ? { environment: { - ...(options?.environment ?? process.env), + ...McpProviderSession.withAgentDeviceEnvironment( + options?.environment ?? process.env, + mcpSession, + ), T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""), }, appServerArgs: [ @@ -2283,6 +2286,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( "-c", 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"', ], + mcpCapabilities: mcpSession.capabilities, } : {}), }; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 78f4b9aa8e50..bd9a6b6f34a8 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -39,7 +39,10 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { buildCodexInitializeParams } from "./CodexProvider.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { expandHomePath } from "../../pathExpansion.ts"; -import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; +import { + buildCodexDeveloperInstructions, + type T3CodeToolAvailability, +} from "../CodexDeveloperInstructions.ts"; const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); const PROVIDER = ProviderDriverKind.make("codex"); @@ -66,6 +69,16 @@ export function hasConfiguredMcpServer(appServerArgs: ReadonlyArray | un return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true; } +function configuredMcpToolAvailability( + appServerArgs: ReadonlyArray | undefined, + mcpCapabilities: ReadonlySet | undefined, +): T3CodeToolAvailability { + if (!hasConfiguredMcpServer(appServerArgs)) return { browser: false, device: false }; + // Callers predating the capability set attached the browser toolkit only. + if (mcpCapabilities === undefined) return { browser: true, device: false }; + return { browser: mcpCapabilities.has("preview"), device: mcpCapabilities.has("device") }; +} + export const CodexResumeCursorSchema = Schema.Struct({ threadId: Schema.String, }); @@ -166,6 +179,8 @@ export interface CodexSessionRuntimeOptions { readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; readonly appServerArgs?: ReadonlyArray; + /** Capabilities the session's `t3-code` MCP credential grants; drives the prompt blocks. */ + readonly mcpCapabilities?: ReadonlySet; } export interface CodexSessionRuntimeSendTurnInput { @@ -569,7 +584,7 @@ function buildCodexCollaborationMode(input: { readonly interactionMode?: ProviderInteractionMode; readonly model?: string; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; - readonly browserToolsAvailable?: boolean; + readonly browserToolsAvailable?: boolean | T3CodeToolAvailability; }): EffectCodexSchema.V2TurnStartParams__CollaborationMode | undefined { if (input.interactionMode === undefined) { return undefined; @@ -603,7 +618,7 @@ export function buildTurnStartParams(input: { readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; readonly interactionMode?: ProviderInteractionMode; /** Defaults to true so callers that predate the agent-access gate are unchanged. */ - readonly browserToolsAvailable?: boolean; + readonly browserToolsAvailable?: boolean | T3CodeToolAvailability; }): Effect.Effect< CodexTurnStartParamsWithCollaborationMode, CodexErrors.CodexAppServerProtocolParseError @@ -2349,10 +2364,13 @@ export const makeCodexSessionRuntime = ( ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), ...(input.effort ? { effort: input.effort } : {}), ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), - // Derived from the session's own MCP configuration rather than the + // Derived from the session's own credential rather than the // setting, so the prompt describes the tools this turn actually // has even if the setting changed after the session started. - browserToolsAvailable: hasConfiguredMcpServer(options.appServerArgs), + browserToolsAvailable: configuredMcpToolAvailability( + options.appServerArgs, + options.mcpCapabilities, + ), }); const rawResponse = yield* client.raw.request("turn/start", params); const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 1a77f964aac5..b19d46112fde 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -543,7 +543,14 @@ export function makeCursorAdapter( const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeCursorAcpRuntime({ cursorSettings: effectiveCursorSettings, - ...(options?.environment ? { environment: options.environment } : {}), + ...(options?.environment || mcpSession?.agentDeviceEnvironment + ? { + environment: McpProviderSession.withAgentDeviceEnvironment( + options?.environment ?? process.env, + mcpSession, + ), + } + : {}), childProcessSpawner, cwd, runtimeMode: input.runtimeMode, diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 25188adcffcc..a2f78a0d72d1 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -989,7 +989,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeGrokAcpRuntime({ grokSettings, - ...(options?.environment ? { environment: options.environment } : {}), + ...(options?.environment || mcpSession?.agentDeviceEnvironment + ? { + environment: McpProviderSession.withAgentDeviceEnvironment( + options?.environment ?? process.env, + mcpSession, + ), + } + : {}), childProcessSpawner, cwd, runtimeMode: input.runtimeMode, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 742ee9b86d6a..312a9494bebc 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -2827,19 +2827,22 @@ export function makeOpenCodeAdapter( // The runtime binds the server's lifetime to the Scope.Scope // we provide below — closing `sessionScope` kills the child // process automatically. No manual `server.close()` needed. + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const server = yield* openCodeRuntime.connectToOpenCodeServer({ binaryPath, directory, serverUrl, ...(serverPassword ? { serverPassword } : {}), - ...(options?.environment ? { environment: options.environment } : {}), + environment: McpProviderSession.withAgentDeviceEnvironment( + options?.environment ?? process.env, + mcpSession, + ), }); const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory, ...(server.serverPassword ? { serverPassword: server.serverPassword } : {}), }); - const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); if (mcpSession && !server.external) { yield* runOpenCodeSdk("mcp.add", () => client.mcp.add({ diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index e7647866d604..9a3fcb8d65f4 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4797,16 +4797,17 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => { const decodeBrowserAccessThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); describe("agent browser access", () => { - const revokedThreads: Array = []; const projectId = ProjectId.make("project-browser-access"); const startSessionWith = ( - enableAgentBrowserAccess: boolean, + access: boolean | { readonly browser: boolean; readonly device: boolean }, threadId: ThreadId, projectOverride?: boolean, ) => Effect.gen(function* () { - const issued: Array = []; + const enableAgentBrowserAccess = typeof access === "boolean" ? access : access.browser; + const enableAgentDeviceAccess = typeof access === "boolean" ? access : access.device; + const issued: Array<{ threadId: ThreadId; capabilities: ReadonlyArray }> = []; const codex = makeFakeCodexAdapter(); const providerAdapterLayer = Layer.succeed( ProviderAdapterRegistry.ProviderAdapterRegistry, @@ -4865,10 +4866,12 @@ describe("agent browser access", () => { const providerLayer = makeProviderServiceLive({ issueMcpCredential: (request) => Effect.sync(() => { - issued.push(request.threadId); + issued.push({ + threadId: request.threadId, + capabilities: [...request.capabilities].toSorted(), + }); return undefined; }), - revokeMcpCredential: (revoked) => Effect.sync(() => void revokedThreads.push(revoked)), }).pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), @@ -4876,6 +4879,7 @@ describe("agent browser access", () => { Layer.provide( ServerSettings.ServerSettingsService.layerTest({ enableAgentBrowserAccess, + enableAgentDeviceAccess, projectAgentBrowserAccessOverrides: projectOverride === undefined ? {} : { [projectId]: projectOverride }, }), @@ -4903,56 +4907,62 @@ describe("agent browser access", () => { return issued; }); - // Credential issuance is the observable that matters: it is the only place a - // credential is minted, and `/mcp` accepts nothing else, so withholding it is - // what actually denies every provider and external MCP client. - it.effect("requests no MCP credential when agent browser access is off", () => + // The capability on the credential is the observable that matters: a session + // always gets a credential (the pull request toolkit is never withheld), and + // `preview` on it is what actually grants or denies the browser tools. + it.effect("issues a credential without preview when agent browser access is off", () => Effect.gen(function* () { - const issued = yield* startSessionWith(false, asThreadId("thread-browser-off")); + const threadId = asThreadId("thread-browser-off"); + + const issued = yield* startSessionWith(false, threadId); - assert.deepEqual(issued, []); + assert.deepEqual(issued, [{ threadId, capabilities: ["pull-requests"] }]); }).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("revokes an already-issued credential when access is off", () => + it.effect("issues a credential with preview when agent browser access is on", () => Effect.gen(function* () { - const threadId = asThreadId("thread-browser-revoke"); - revokedThreads.length = 0; + const threadId = asThreadId("thread-browser-on"); - yield* startSessionWith(false, threadId); + const issued = yield* startSessionWith(true, threadId); - // Clearing the in-memory map is not enough: a token issued before the - // toggle flipped stays valid against `/mcp` for its whole liveness - // window, and later turns refresh it. - assert.deepEqual(revokedThreads, [threadId]); + assert.deepEqual(issued, [ + { threadId, capabilities: ["device", "preview", "pull-requests"] }, + ]); }).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("requests an MCP credential when agent browser access is on", () => + it.effect("drops only the preview capability when browser access alone is off", () => Effect.gen(function* () { - const threadId = asThreadId("thread-browser-on"); + const threadId = asThreadId("thread-browser-off-device-on"); - const issued = yield* startSessionWith(true, threadId); + const issued = yield* startSessionWith({ browser: false, device: true }, threadId); - assert.deepEqual(issued, [threadId]); + assert.deepEqual(issued, [{ threadId, capabilities: ["device", "pull-requests"] }]); }).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("withholds and revokes MCP credentials when the project disables browser access", () => + it.effect("issues a credential without preview when the project disables browser access", () => Effect.gen(function* () { const threadId = asThreadId("thread-project-browser-off"); - revokedThreads.length = 0; + const issued = yield* startSessionWith({ browser: true, device: false }, threadId, false); + assert.deepEqual(issued, [{ threadId, capabilities: ["pull-requests"] }]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("a project browser override leaves device access alone", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-project-browser-off-device-on"); const issued = yield* startSessionWith(true, threadId, false); - assert.deepEqual(issued, []); - assert.deepEqual(revokedThreads, [threadId]); + assert.deepEqual(issued, [{ threadId, capabilities: ["device", "pull-requests"] }]); }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("requests an MCP credential when the project overrides browser access to on", () => Effect.gen(function* () { const threadId = asThreadId("thread-project-browser-on"); - const issued = yield* startSessionWith(false, threadId, true); - assert.deepEqual(issued, [threadId]); + const issued = yield* startSessionWith({ browser: false, device: false }, threadId, true); + assert.deepEqual(issued, [{ threadId, capabilities: ["preview", "pull-requests"] }]); }).pipe(Effect.provide(NodeServices.layer)), ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 016bcd5c4a28..d04dcae7f126 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -34,6 +34,7 @@ import { type ProviderSession, } from "@t3tools/contracts"; import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { causeErrorTag } from "@t3tools/shared/observability"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings"; @@ -43,6 +44,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -52,6 +54,9 @@ import * as Stream from "effect/Stream"; import { appendUserInputAttachmentPaths } from "../userInputAttachments.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import * as ServerConfig from "../../config.ts"; +import * as DeviceService from "../../device/DeviceService.ts"; +import { ensureAgentDeviceShim } from "../../device/AgentDeviceShim.ts"; +import type * as McpInvocationContext from "../../mcp/McpInvocationContext.ts"; import { increment, providerMetricAttributes, @@ -249,8 +254,6 @@ export interface ProviderServiceLiveOptions { * test see whether a credential was requested at all. */ readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential; - /** Same seam as `issueMcpCredential`, for observing the deny path's revoke. */ - readonly revokeMcpCredential?: typeof McpSessionRegistry.revokeActiveMcpThread; } interface TurnAnalyticsMetadata { @@ -479,9 +482,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); const issueMcpCredential = options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; - const revokeMcpCredential = - options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread; const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; const runtimeEventPubSub = yield* PubSub.unbounded(); const pendingCompactions = new Map(); const timedOutNativeCompactions = new Set(); @@ -853,14 +855,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* recordCompletedTurnProperties(properties); }); /** - * Attach the `t3-code` MCP server to the session that is about to start. + * Whether the credential minted below may drive the user's browser. * - * This is the only place a credential is minted, so withholding one here is - * what disables agent browser access everywhere: every adapter already - * treats a missing session as "no MCP server", and the `/mcp` endpoint - * accepts nothing but tokens issued from this path. - */ - /** * Deny on an unreadable settings file rather than letting the read failure * escape: adding `ServerSettingsError` to `ProviderServiceError` would widen * a union every caller handles, for a branch that only decides whether one @@ -889,22 +885,66 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ); + const agentDeviceAccessEnabled = serverSettings.getSettings.pipe( + Effect.map((settings) => settings.enableAgentDeviceAccess), + Effect.catch((cause) => + Effect.logWarning( + "Could not read server settings; withholding agent device access for this session.", + { cause }, + ).pipe(Effect.as(false)), + ), + ); + + const agentAccessCapabilities = Effect.fn("ProviderService.agentAccessCapabilities")(function* ( + threadId: ThreadId, + ) { + const capabilities = new Set(["pull-requests"]); + if (yield* agentBrowserAccessEnabled(threadId)) capabilities.add("preview"); + if (yield* agentDeviceAccessEnabled) capabilities.add("device"); + return capabilities; + }); + + /** Install only the local CLI here. device_open supplies a separate config for each host. */ + const hostPlatform = yield* HostProcessPlatform; + const agentDeviceEnvironment = Effect.gen(function* () { + const devices = yield* Effect.serviceOption(DeviceService.DeviceService); + if (Option.isNone(devices)) return undefined; + const entryPath = yield* devices.value.agentCli.pipe( + Effect.catch((cause) => + Effect.logWarning("Agent device CLI unavailable", { cause }).pipe(Effect.as(null)), + ), + ); + if (!entryPath) return undefined; + const shimDir = yield* ensureAgentDeviceShim({ + entryPath, + stateDir: serverConfig.stateDir, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + Effect.orElseSucceed(() => undefined), + ); + if (!shimDir) return undefined; + return { + PATH: shimDir, + PATH_SEPARATOR: hostPlatform === "win32" ? ";" : ":", + AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1", + } satisfies Record; + }); + const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => Effect.gen(function* () { - if (!(yield* agentBrowserAccessEnabled(threadId))) { - // Revoke as well as clear. Every other prepare path reaches - // `issueActiveMcpCredential`, which revokes the thread first, so - // skipping it here would leave a previously issued bearer token valid - // against `/mcp` for the rest of its liveness window — and later turns - // would keep refreshing it. A session restart (runtime mode, cwd, - // model) re-prepares without stopping, so it relies on this. - yield* revokeMcpCredential(threadId); - yield* Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)); - return undefined; - } - const credential = yield* issueMcpCredential({ threadId, providerInstanceId }); + const capabilities = yield* agentAccessCapabilities(threadId); + const credential = yield* issueMcpCredential({ threadId, providerInstanceId, capabilities }); if (credential) { - yield* Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config)); + const deviceEnvironment = capabilities.has("device") + ? yield* agentDeviceEnvironment + : undefined; + yield* Effect.sync(() => + McpProviderSession.setMcpProviderSession({ + ...credential.config, + ...(deviceEnvironment ? { agentDeviceEnvironment: deviceEnvironment } : {}), + }), + ); } return credential; }); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 3ab2b4618a9e..2538e05e37dc 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -98,6 +98,7 @@ function makeReadModel( runtimeMode: "full-access" as const, branch: null, worktreePath: null, + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, diff --git a/apps/server/src/provider/RuntimeInstructions.test.ts b/apps/server/src/provider/RuntimeInstructions.test.ts index 320aa332c937..88fc872401f8 100644 --- a/apps/server/src/provider/RuntimeInstructions.test.ts +++ b/apps/server/src/provider/RuntimeInstructions.test.ts @@ -2,6 +2,14 @@ import { describe, expect, it } from "vite-plus/test"; import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; describe("buildRuntimeInstructions", () => { + it("requires explicit registration of every PR and stack layer", () => { + const instructions = buildRuntimeInstructions({ harness: "Codex" }); + expect(instructions).toContain("When the t3-code MCP server exposes link_pull_request"); + expect(instructions).toContain("with the full PR URL immediately after creating a PR"); + expect(instructions).toContain("For a stack, call it for every layer"); + expect(instructions).toContain("call list_thread_pull_requests and link any PR"); + }); + it("keeps known model and effort metadata on one line", () => { expect( buildRuntimeInstructions({ @@ -17,4 +25,10 @@ describe("buildRuntimeInstructions", () => { expect(instructions).toContain("through the Cursor harness."); expect(instructions).not.toContain("reasoning effort"); }); + + it("tells the agent how to nest fenced code inside a code block", () => { + expect(buildRuntimeInstructions({ harness: "Claude Code" })).toContain( + "A code block that contains triple backticks needs a longer fence (four backticks or ~~~).", + ); + }); }); diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index 102c49c78a9e..6c160fd983bb 100644 --- a/apps/server/src/provider/RuntimeInstructions.ts +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -1,3 +1,7 @@ +const PULL_REQUEST_LINKING_INSTRUCTIONS = ` +When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. +`; + /** Shared runtime context; omit model and effort when the harness manages them dynamically. */ export function buildRuntimeInstructions(runtime: { readonly harness: string; @@ -9,7 +13,7 @@ export function buildRuntimeInstructions(runtime: { const effort = toSingleLine(runtime.reasoningEffort ?? ""); const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${model}` : ""; const effortInfo = effort ? ` with ${effort} reasoning effort` : ""; - return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.`; + return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths. A code block that contains triple backticks needs a longer fence (four backticks or ~~~).\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`; } function toSingleLine(value: string): string { diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index 6e625127cb18..17b73e552006 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -35,6 +35,8 @@ export interface AntigravityAcpRuntimeInput extends Omit< | "transformSessionUpdate" | "transformStdout" > { + /** Device CLI environment supplied for this provider session. */ + readonly agentDeviceEnvironment?: Readonly>; readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly onAuthorizationUrl?: (url: string) => Effect.Effect; /** diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index f61b7c3233f6..6ef485627afb 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -1,6 +1,7 @@ import { afterEach, assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -9,14 +10,22 @@ import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + const mockedExecute = vi.fn(); +const mockedStackMemberships = vi.fn(() => + Effect.succeed(output('{"data":{}}')), +); const mockedGetPullRequest = vi.fn(); const layer = it.layer( GitHubPullRequestCli.layer.pipe( Layer.provide( Layer.mock(GitHubCli.GitHubCli)({ - execute: mockedExecute, + execute: (input) => + input.args.some((arg) => arg.includes("query PullRequestStackMemberships")) + ? mockedStackMemberships(input) + : mockedExecute(input), getPullRequest: mockedGetPullRequest, }), ), @@ -179,24 +188,42 @@ function searchQueryOfCall(index: number): string | undefined { afterEach(() => { mockedExecute.mockReset(); + mockedStackMemberships.mockReset(); mockedGetPullRequest.mockReset(); }); layer("GitHubPullRequestCli.layer", (it) => { - it.effect("reads linked pull request status through one narrow request", () => + it.effect("reads linked pull request status with the overview fields in one request", () => Effect.gen(function* () { - mockedGetPullRequest.mockReturnValueOnce( - Effect.succeed({ - number: 7, - title: "Reuse the summary", - url: "https://github.com/acme/web/pull/7", - baseRefName: "main", - headRefName: "feat/summary", - state: "merged", - closedAt: "2026-08-23T10:00:00Z", - mergedAt: "2026-08-23T10:00:00Z", - updatedAt: "2026-08-24T12:34:56.000Z", - }), + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 7, + title: "Reuse the summary", + url: "https://github.com/acme/web/pull/7", + author: { login: "octocat", name: "Octo Cat" }, + baseRefName: "main", + headRefName: "feat/summary", + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + additions: 12, + deletions: 3, + changedFiles: 2, + createdAt: "2026-08-20T00:00:00.000Z", + updatedAt: "2026-08-24T12:34:56.000Z", + reviewRequests: [], + labels: [], + statusCheckRollup: [ + { __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS", name: "ci" }, + ], + body: "", + }), + ), + ), ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; @@ -207,23 +234,252 @@ layer("GitHubPullRequestCli.layer", (it) => { number: 7, }); - assert.deepStrictEqual(summary, { + assert.deepStrictEqual( + { + number: summary.number, + state: summary.state, + headBranch: summary.headBranch, + isDraft: summary.isDraft, + author: summary.author?.login, + additions: summary.additions, + deletions: summary.deletions, + changedFiles: summary.changedFiles, + reviewDecision: summary.reviewDecision, + checksState: summary.checksState, + mergeability: summary.mergeability, + }, + { + number: 7, + state: "open", + headBranch: "feat/summary", + isDraft: false, + author: "octocat", + additions: 12, + deletions: 3, + changedFiles: 2, + reviewDecision: "approved", + checksState: "passing", + mergeability: "mergeable", + }, + ); + expect(mockedExecute).toHaveBeenCalledOnce(); + expect(mockedExecute.mock.calls[0]?.[0]?.args).toEqual([ + "pr", + "view", + "7", + "--repo", + "github.com/acme/web", + "--json", + expect.stringContaining("statusCheckRollup"), + ]); + expect(mockedGetPullRequest).not.toHaveBeenCalled(); + }), + ); + + it.effect("reads the stack a pull request is in through the stacks preview, on its host", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + id: 42, + number: 3, + url: "https://api.github.com/repos/acme/web/stacks/3", + base: { ref: "main" }, + pull_requests: [ + { + number: 6, + head: { ref: "feat/one" }, + state: "closed", + merged_at: "2026-09-02", + }, + { number: 7, head: { ref: "feat/two" }, state: "open", merged_at: null }, + ], + }, + ]), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const stack = yield* cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "ghe.example.com", + number: 7, + }); + + assert.deepStrictEqual(stack, { + id: "42", + number: 3, + url: "https://api.github.com/repos/acme/web/stacks/3", + base: "main", + layers: [ + { number: 6, headBranch: "feat/one", state: "merged" }, + { number: 7, headBranch: "feat/two", state: "open" }, + ], + }); + assert.deepStrictEqual(callAt(0).args, [ + "api", + "--hostname", + "ghe.example.com", + "repos/acme/web/stacks?pull_request=7", + ]); + }), + ); + + it.effect("fetches layer titles only when the caller asks for stack details", () => + Effect.gen(function* () { + const minimal = { + url: "https://api.github.com/repos/acme/web/stacks/3", + number: 3, + base: { ref: "main" }, + pull_requests: [ + { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null }, + ], + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify([minimal])))); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + ...minimal, + pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }], + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const stack = yield* cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + includeDetails: true, + }); + expect(stack?.layers[0]).toMatchObject({ + title: "Second layer", + headSha: "abc123", + isDraft: false, + }); + expect(callAt(1).args).toEqual([ + "api", + "--hostname", + "github.com", + "repos/acme/web/stacks/3", + ]); + }), + ); + + it.effect("reads an empty stacks listing as not stacked", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const stack = yield* cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + assert.isNull(stack); + }), + ); + + it.effect("reads a host that refuses the stacks preview as not stacked", () => + Effect.gen(function* () { + // The CLI classifies a missing preview endpoint as not found. + mockedExecute.mockReturnValueOnce( + Effect.fail( + new GitHubCli.GitHubPullRequestNotFoundError({ + command: "gh", + cwd: "/w", + cause: new Error("HTTP 404: Not Found (https://api.github.com/repos/acme/web/stacks)"), + }), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const stack = yield* cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", number: 7, - title: "Reuse the summary", - url: "https://github.com/acme/web/pull/7", - headBranch: "feat/summary", - baseBranch: "main", - state: "merged", - closedAt: "2026-08-23T10:00:00Z", - mergedAt: "2026-08-23T10:00:00Z", - updatedAt: "2026-08-24T12:34:56.000Z", }); - expect(mockedGetPullRequest).toHaveBeenCalledOnce(); - expect(mockedGetPullRequest).toHaveBeenCalledWith({ + + assert.isNull(stack); + }), + ); + + it.effect("does not read a signed-out gh as an unstacked pull request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.fail( + new GitHubCli.GitHubCliAuthenticationError({ + command: "gh", + cwd: "/w", + cause: new Error("gh auth login"), + }), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + + assert.strictEqual(error._tag, "GitHubCliAuthenticationError"); + }), + ); + + it.effect("preserves transient stack failures instead of reporting no stack", () => + Effect.gen(function* () { + const failure = new GitHubCli.GitHubCliCommandError({ + command: "gh", cwd: "/w", - reference: "https://github.com/acme/web/pull/7", + cause: new Error("HTTP 503"), }); - expect(mockedExecute).not.toHaveBeenCalled(); + mockedExecute.mockReturnValueOnce(Effect.fail(failure)); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const error = yield* Effect.flip( + cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + assert.strictEqual(error, failure); + }), + ); + + it.effect("reports a stacks answer it cannot read against the stack read", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]'))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }), + ); + + assert.strictEqual(error._tag, "GitHubPullRequestReadError"); + if (error._tag !== "GitHubPullRequestReadError") return; + assert.strictEqual(error.operation, "getPullRequestStack"); }), ); @@ -514,6 +770,127 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); + it.effect("enriches only the visible fallback rows after filtering and widening", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(pullRequests(3, 1, () => ({ isDraft: true })))), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(pullRequests(6, 1, (number) => ({ isDraft: number < 4 })))), + ); + mockedStackMemberships.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + s0: { + pullRequest: { + stack: { number: 3, size: 2, baseRefName: "main" }, + stackEntry: { position: 1 }, + }, + }, + s1: { pullRequest: { stack: null, stackEntry: null } }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 2, + filters: { draft: "hide" }, + }); + expect(batch.items.map((item) => item.number)).toEqual([4, 5]); + expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); + expect(batch.items[1]?.stack).toBeUndefined(); + expect(batch.truncated).toBe(true); + expect(batch.continues).toBe(false); + expect(mockedStackMemberships).toHaveBeenCalledTimes(1); + const query = mockedStackMemberships.mock.calls[0]?.[0].args.at(-1) ?? ""; + expect(query).toContain("pullRequest(number: 4)"); + expect(query).toContain("pullRequest(number: 5)"); + expect(query).not.toContain("pullRequest(number: 1)"); + expect(query).not.toContain("pullRequest(number: 6)"); + }), + ); + + it.effect("batches membership reads and keeps successful rows when one chunk fails", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1)))); + mockedStackMemberships.mockImplementation((input) => + input.args.at(-1)?.includes("pullRequest(number: 26)") + ? Effect.fail( + new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: "/w", + cause: new Error("HTTP 502"), + }), + ) + : Effect.succeed( + output( + encodeJson({ + data: { + s0: { + pullRequest: { + stack: { number: 3, size: 2, baseRefName: "main" }, + stackEntry: { position: 1 }, + }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 26, + }); + expect(batch.items.map((item) => item.number)).toEqual( + Array.from({ length: 26 }, (_, i) => i + 1), + ); + expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); + expect(batch.items[25]?.stack).toBeUndefined(); + expect(batch.truncated).toBe(true); + expect(batch.continues).toBe(true); + expect(mockedStackMemberships).toHaveBeenCalledTimes(2); + }), + ); + + it.effect("skips membership enrichment for empty pages and enterprise hosts", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const input = { + cwd: "/w", + repository: "acme/web", + state: "open" as const, + involvement: "all" as const, + viewer: "bilal", + limit: 2, + }; + const empty = yield* cli.listPullRequests({ ...input, host: "github.com" }); + const enterprise = yield* cli.listPullRequests({ ...input, host: "github.acme.test" }); + expect(empty.items).toEqual([]); + expect(enterprise.items.map((item) => item.number)).toEqual([7]); + expect(mockedStackMemberships).not.toHaveBeenCalled(); + }), + ); + it.effect("reads the line counts in chunks, and files them back by position", () => Effect.gen(function* () { const changeRequests = Array.from({ length: 26 }, (_, index) => ({ diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 6152df75fb73..5e1950a6eeb0 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1,3 +1,4 @@ +import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -6,6 +7,7 @@ import * as Schema from "effect/Schema"; import { resolvePullRequestAuthorFilter, type PullRequestAction, + type PullRequestStackHead, type PullRequestActor, type PullRequestInvolvement, type PullRequestListFilters, @@ -39,6 +41,7 @@ import { decodePullRequestListJson, decodePullRequestNodeIdJson, decodePullRequestSearchJson, + decodePullRequestStacksJson, decodePullRequestStatsJson, decodeReactionSubjectScopeJson, decodeRepositoryAccessJson, @@ -50,6 +53,8 @@ import { decodeReviewThreadCommentsJson, decodeReviewThreadsJson, buildPullRequestStatsGraphQlQuery, + buildPullRequestStackMembershipsGraphQlQuery, + decodePullRequestStackMembershipsJson, encodeGraphQlRequestJson, pullRequestSearchGraphQlQuery, PULL_REQUEST_SEARCH_MAX_ROWS, @@ -84,6 +89,7 @@ import { type GitHubPullRequestHead, type GitHubPullRequestListItem, type GitHubPullRequestSearchItem, + type GitHubPullRequestStack, type GitHubReviewThreadComments, type GitHubRepositoryAccess, type GitHubWorkflowRunApproval, @@ -91,7 +97,7 @@ import { type GitHubReviewThreadPage, type GitHubViewerAccess, } from "./gitHubPullRequestJson.ts"; -import type { ProviderListCursor } from "./PullRequestProvider.ts"; +import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullRequestProvider.ts"; /** * Names the read that produced unusable output, so a failure reports the call it came from @@ -332,6 +338,7 @@ export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError Effect.Effect< - { - readonly number: number; - readonly title: string; - readonly url: string; - readonly headBranch: string; - readonly baseBranch: string; - readonly state: "open" | "closed" | "merged"; - readonly isDraft?: boolean; - readonly closedAt?: string | null; - readonly mergedAt?: string | null; - readonly updatedAt: string; - }, - GitHubPullRequestCliError - >; + }) => Effect.Effect; readonly getPullRequestDetail: (input: { readonly cwd: string; @@ -494,6 +487,18 @@ export class GitHubPullRequestCli extends Context.Service< readonly isCrossRepository: true; }) => Effect.Effect, GitHubPullRequestCliError>; + /** + * The host-native stack this pull request is in, or null when it is in none — which is also + * the answer for a host that refuses the stacks preview altogether. + */ + readonly getPullRequestStack: (input: { + readonly cwd: string; + readonly includeDetails?: boolean; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + /** * How far the branch trails its base, and whether this viewer may update it. Its own read * because the comparison needs the head ref the detail answers with — a fork's branch is not @@ -628,6 +633,8 @@ export class GitHubPullRequestCli extends Context.Service< readonly host: string; readonly number: number; readonly action: PullRequestAction; + readonly stackNumber?: number; + readonly expectedStackHeads?: ReadonlyArray; readonly mergeMethod?: PullRequestMergeMethod; readonly updateMethod?: PullRequestUpdateMethod; }) => Effect.Effect; @@ -807,8 +814,8 @@ function matchesFilters( viewer: string, ): boolean { if (filters === undefined) return true; - const labels = item.labels.map((label) => label.name.trim().toLowerCase()); - const holds = (label: string) => labels.includes(label.trim().toLowerCase()); + const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase())); + const holds = (label: string) => labels.has(label.trim().toLowerCase()); return ( (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && (filters.review === undefined || @@ -1558,6 +1565,47 @@ export const make = Effect.gen(function* () { ? read(false) : Effect.succeed(batch), ), + Effect.flatMap((batch) => { + // Match the search query's host support, and enrich only rows that survived paging. + if (input.host !== "github.com" || batch.items.length === 0) return Effect.succeed(batch); + const chunks: Array> = []; + for (let start = 0; start < batch.items.length; start += STAT_ALIASES_PER_REQUEST) { + chunks.push(batch.items.slice(start, start + STAT_ALIASES_PER_REQUEST)); + } + return Effect.forEach( + chunks, + (chunk) => { + const query = buildPullRequestStackMembershipsGraphQlQuery( + input.repository, + chunk.map((item) => item.number), + ); + if (query === null) return Effect.succeed(chunk); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listPullRequestStackMemberships", + query, + decode: decodePullRequestStackMembershipsJson, + }).pipe( + Effect.map((memberships) => + chunk.map((item, index) => { + const stack = memberships.get(index); + return stack === undefined ? item : { ...item, stack }; + }), + ), + // Optional badges must not take down a listing that already read successfully. + Effect.catch(() => + Effect.logWarning("Pull request stack membership enrichment failed", { + operation: "listPullRequestStackMemberships", + host: input.host, + rows: chunk.length, + }).pipe(Effect.as(chunk)), + ), + ); + }, + { concurrency: STAT_REQUEST_CONCURRENCY }, + ).pipe(Effect.map((chunks) => ({ ...batch, items: chunks.flat() }))); + }), ); }, @@ -1582,7 +1630,7 @@ export const make = Effect.gen(function* () { operation: "searchPullRequests", // The reader's own words are in the query, so it travels over stdin rather than in argv. privateVariables: { q: query }, - query: pullRequestSearchGraphQlQuery(rows), + query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"), decode: decodePullRequestSearchJson, }).pipe( Effect.map((batch) => ({ @@ -1630,41 +1678,122 @@ export const make = Effect.gen(function* () { ).pipe(Effect.map((results) => results.flat())); }, + // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands + // the thread overview its author, diff stat, review decision and checks in the same read. getPullRequestSummary: (input) => github - .getPullRequest({ + .execute({ cwd: input.cwd, - reference: `https://${input.host}/${input.repository}/pull/${input.number}`, + args: [ + "pr", + "view", + String(input.number), + ...repositoryArgs(input), + "--json", + PULL_REQUEST_DETAIL_JSON_FIELDS, + ], }) .pipe( - Effect.flatMap((summary) => - summary.updatedAt === undefined - ? Effect.fail( - new GitHubPullRequestUpdatedAtUnavailableError({ - command: "gh", - cwd: input.cwd, - repository: input.repository, - number: input.number, - }), - ) - : Effect.succeed({ - number: summary.number, - title: summary.title, - url: summary.url, - headBranch: summary.headRefName, - baseBranch: summary.baseRefName, - state: summary.state ?? "open", - ...(summary.isDraft === true ? { isDraft: true } : {}), - closedAt: summary.closedAt ?? null, - mergedAt: summary.mergedAt ?? null, - updatedAt: summary.updatedAt, + Effect.flatMap((result) => { + const decoded = decodePullRequestDetailJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestSummary", + cause: decoded.failure, }), - ), + ); + } + const detail = decoded.success; + return Effect.succeed({ + number: detail.number, + title: detail.title, + url: detail.url, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + state: detail.state, + updatedAt: detail.updatedAt, + closedAt: detail.closedAt ?? null, + mergedAt: detail.mergedAt ?? null, + isDraft: detail.isDraft, + author: detail.author, + additions: detail.additions, + deletions: detail.deletions, + changedFiles: detail.changedFiles, + reviewDecision: detail.reviewDecision, + checksState: detail.checksState, + mergeability: detail.mergeability, + }); + }), ), getPullRequestDetail, listWorkflowRunsRequiringApproval, + getPullRequestStack: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return github + .execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + `repos/${owner}/${name}/stacks?pull_request=${input.number}`, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestStacksJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestStack", + cause: decoded.failure, + }), + ); + }), + Effect.flatMap((stack) => { + if (!input.includeDetails || stack === null) return Effect.succeed(stack); + return github + .execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + `repos/${owner}/${name}/stacks/${stack.number}`, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestStacksJson(`[${result.stdout.trim()}]`); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestStack", + cause: decoded.failure, + }), + ); + }), + ); + }), + // Hosts without the stacks preview return 404. Other failures must preserve the + // previously synced stack and let the caller retry. + Effect.catchTags({ + GitHubPullRequestNotFoundError: () => Effect.succeed(null), + }), + ); + }, + getPullRequestBaseComparison: (input) => { const { owner, name } = parseRepositorySelector(input.repository); return graphqlRead({ @@ -2057,6 +2186,10 @@ export const make = Effect.gen(function* () { }, runPullRequestAction: (input) => { + if (input.stackNumber !== undefined) + return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( + Effect.provideService(GitHubCli.GitHubCli, github), + ); if (input.action === "revert") { return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( Effect.flatMap((pullRequestId) => diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 8fd0f09dd3ea..fe5032d70d5c 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -25,6 +25,7 @@ it.effect("uses one narrow read for a linked pull request summary", () => baseBranch: "main", state: "open" as const, updatedAt: "2026-08-24T12:34:56.000Z", + author: { login: "octocat", name: null, avatarUrl: null }, }; }), }), @@ -42,6 +43,66 @@ it.effect("uses one narrow read for a linked pull request summary", () => expect(summary.state).toBe("open"); expect(summaryReads).toBe(1); + // The author's avatar comes from the login-shaped URL, not a second request. + expect(summary.author?.avatarUrl).toBe("https://github.com/octocat.png?size=80"); + }), +); + +it.effect("declares host-native stacks and passes the one the CLI reads through", () => + Effect.gen(function* () { + const stack = { + id: "42", + number: 3, + url: "https://github.com/acme/web/stacks/3", + base: "main", + layers: [ + { number: 6, headBranch: "feat/one", state: "merged" as const }, + { number: 7, headBranch: "feat/two", state: "open" as const }, + ], + }; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestStack: (input) => Effect.succeed(input.number === 7 ? stack : null), + }), + ), + ); + + expect(provider.capabilities.stacks).toBe(true); + const readStack = provider.getChangeRequestStack; + if (readStack === undefined) return yield* Effect.die("stack read was not implemented"); + const ref = { cwd: "/w", repository: "acme/web", host: "github.com" }; + expect(yield* readStack({ ...ref, number: 7 })).toEqual(stack); + expect(yield* readStack({ ...ref, number: 8 })).toBeNull(); + }), +); + +it.effect("reports a failed stack read against its own operation", () => + Effect.gen(function* () { + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestStack: () => + Effect.fail( + new GitHubPullRequestCli.GitHubPullRequestReadError({ + command: "gh", + cwd: "/w", + operation: "getPullRequestStack", + cause: new Error("unreadable"), + }), + ), + }), + ), + ); + + const readStack = provider.getChangeRequestStack; + if (readStack === undefined) return yield* Effect.die("stack read was not implemented"); + const error = yield* Effect.flip( + readStack({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }), + ); + + expect(error.operation).toBe("getChangeRequestStack"); + expect(error.reason).toBe("failed"); }), ); @@ -69,6 +130,7 @@ describe("gitHubViewerPermissions", () => { ], comment: true, resolve: true, + stackRebase: true, verdicts: ["comment", "approve", "request-changes"], requestReviewers: true, labels: true, diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index e75ef3547c04..66a247efb90e 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -47,6 +47,8 @@ const CAPABILITIES: PullRequestCapabilities = { }, reviewers: { request: true, listCandidates: true }, edit: { changeRequest: true, comment: true }, + stacks: true, + stackActions: true, labels: true, }; @@ -69,6 +71,7 @@ const CAPABILITIES: PullRequestCapabilities = { */ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions { return { + ...(access.canWrite ? { stackRebase: true } : {}), actions: [ // Arming a merge and taking the arming back are the merge, deferred: whoever may not // merge here may not leave an instruction to merge later either. @@ -292,7 +295,20 @@ export const make = Effect.gen(function* () { .pipe(Effect.mapError(fail("listChangeRequestStats"))), getChangeRequestSummary: (input) => - cli.getPullRequestSummary(input).pipe(Effect.mapError(fail("getChangeRequestSummary"))), + cli.getPullRequestSummary(input).pipe( + // `gh pr view` names the author without an avatar; the login-shaped URL every user + // has stands in, without the second request the listing spends on it. + Effect.map((summary) => ({ + ...summary, + ...(summary.author === undefined + ? {} + : { author: withAvatar(summary.author, new Map(), input.host) }), + })), + Effect.mapError(fail("getChangeRequestSummary")), + ), + + getChangeRequestStack: (input) => + cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))), getChangeRequest: (input) => Effect.all( @@ -544,6 +560,10 @@ export const make = Effect.gen(function* () { host: input.host, number: input.number, action: input.action, + ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), + ...(input.expectedStackHeads === undefined + ? {} + : { expectedStackHeads: input.expectedStackHeads }), ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), }) diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index a021db91007e..2309268bba68 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -1,7 +1,9 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import type { + PullRequestStackMembership, PullRequestAction, + PullRequestStackHead, PullRequestActor, PullRequestBaseComparison, PullRequestCapabilities, @@ -65,6 +67,7 @@ export interface PullRequestProviderFailure { /** A change request as the provider sees it, before the service attaches project context. */ export interface ProviderChangeRequest { + readonly stack?: PullRequestStackMembership; readonly number: number; readonly title: string; readonly url: string; @@ -103,6 +106,37 @@ export interface ProviderChangeRequestSummary { readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly updatedAt: string; + /** Overview fields, present where the host's single read returns them at no extra cost. */ + readonly author?: PullRequestActor | null | undefined; + readonly additions?: number | undefined; + readonly deletions?: number | undefined; + readonly changedFiles?: number | undefined; + readonly reviewDecision?: PullRequestReviewDecision | null | undefined; + readonly checksState?: PullRequestChecksState | null | undefined; + readonly mergeability?: PullRequestMergeability | undefined; +} + +/** One layer of a host-native stack, bottom to top order is the array's. */ +export interface ProviderChangeRequestStackLayer { + readonly title?: string; + readonly isDraft?: boolean; + readonly headSha?: string; + readonly number: number; + readonly headBranch: string; + readonly state: PullRequestState; +} + +/** + * A host-native stack: an ordered set of change requests the host itself merges and retargets as + * a unit. Only GitHub offers one today; the neutral shape lets the sync reactor and the UI stay + * ignorant of which host said so. + */ +export interface ProviderChangeRequestStack { + readonly id: string; + readonly number: number; + readonly url: string; + readonly base: string; + readonly layers: ReadonlyArray; } export interface ProviderChangeRequestPage { @@ -331,6 +365,14 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; + /** + * The host-native stack a change request belongs to, or null when it is not stacked. Optional + * because most hosts have no such object; the service derives chains from base branches there. + */ + readonly getChangeRequestStack?: ( + input: ProviderRepositoryRef & { readonly includeDetails?: boolean; readonly number: number }, + ) => Effect.Effect; + /** Comments, line threads, and commits, kept off the critical path for the core detail. */ readonly getChangeRequestActivity: ( input: ProviderRepositoryRef & { readonly number: number }, @@ -390,6 +432,8 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number; readonly action: PullRequestAction; + readonly stackNumber?: number; + readonly expectedStackHeads?: ReadonlyArray; /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */ readonly mergeMethod?: PullRequestMergeMethod; /** Only meaningful for `update-branch`; absent takes the host's own default. */ diff --git a/apps/server/src/pullRequest/PullRequestReadCache.test.ts b/apps/server/src/pullRequest/PullRequestReadCache.test.ts new file mode 100644 index 000000000000..f94ff3cf586d --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestReadCache.test.ts @@ -0,0 +1,78 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PullRequestOperationError } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; +import * as Persistence from "effect/unstable/persistence/Persistence"; +import * as PullRequestReadCache from "./PullRequestReadCache.ts"; + +const cacheLayer = (directory: string) => + PullRequestReadCache.make.pipe( + Effect.provide( + Persistence.layerKvs.pipe(Layer.provideMerge(KeyValueStore.layerFileSystem(directory))), + ), + ); + +it.layer(NodeServices.layer)("PR filesystem cache", (it) => { + it.effect("reuses files after restart and respects the original expiry", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); + let reads = 0; + const lookup = Effect.sync(() => String(++reads)); + const first = yield* cacheLayer(directory); + const key = "long/repository/key".repeat(100); + assert.strictEqual(yield* first.get(key, lookup), "1"); + yield* TestClock.adjust("59 seconds"); + const restarted = yield* cacheLayer(directory); + assert.strictEqual(yield* restarted.get(key, lookup), "1"); + yield* TestClock.adjust("1 second"); + assert.strictEqual(yield* restarted.get(key, lookup), "2"); + assert.strictEqual(reads, 2); + assert.strictEqual((yield* fs.readDirectory(directory)).length, 1); + }), + ); + + it.effect("clears in-flight reads before a new service can reuse them", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const cache = yield* cacheLayer(directory); + const read = yield* cache + .get( + "summary", + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as("old"), + ), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(started); + const invalidate = yield* cache.invalidate.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(read); + yield* Fiber.join(invalidate); + const restarted = yield* cacheLayer(directory); + assert.strictEqual(yield* restarted.get("summary", Effect.succeed("new")), "new"); + }), + ); + + it.effect("does not persist failed GitHub reads", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); + const cache = yield* cacheLayer(directory); + const error = new PullRequestOperationError({ operation: "summary", detail: "unavailable" }); + yield* cache.get("summary", Effect.fail(error)).pipe(Effect.flip); + const restarted = yield* cacheLayer(directory); + assert.strictEqual(yield* restarted.get("summary", Effect.succeed("recovered")), "recovered"); + }), + ); +}); diff --git a/apps/server/src/pullRequest/PullRequestReadCache.ts b/apps/server/src/pullRequest/PullRequestReadCache.ts new file mode 100644 index 000000000000..62d1cffc3c83 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestReadCache.ts @@ -0,0 +1,125 @@ +import * as Cache from "effect/Cache"; +import * as Clock from "effect/Clock"; +import * as Equal from "effect/Equal"; +import * as Hash from "effect/Hash"; +import { PullRequestOperationError, PullRequestUnavailableError } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Encoding from "effect/Encoding"; +import * as Option from "effect/Option"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; +import * as Persistable from "effect/unstable/persistence/Persistable"; +import * as PersistedCache from "effect/unstable/persistence/PersistedCache"; +import * as Persistence from "effect/unstable/persistence/Persistence"; +import { ServerConfig } from "../config.ts"; + +const CONCURRENT_READS = 512; +type ReadError = PullRequestOperationError | PullRequestUnavailableError; + +class Read extends Persistable.Class<{ + payload: { key: string; lookup: Effect.Effect }; +}>()("PullRequestRead", { + primaryKey: ({ key }) => key, + success: Schema.Struct({ payload: Schema.String, expiresAt: Schema.Finite }), + error: Schema.Union([PullRequestOperationError, PullRequestUnavailableError]), +}) { + [Equal.symbol](that: unknown): boolean { + return that instanceof Read && that.key === this.key; + } + [Hash.symbol](): number { + return Hash.string(this.key); + } +} + +export class PullRequestReadCache extends Context.Service< + PullRequestReadCache, + { + readonly get: ( + key: string, + lookup: Effect.Effect, + ) => Effect.Effect; + readonly invalidate: Effect.Effect; + } +>()("t3/pullRequest/PullRequestReadCache") {} + +export const make = Effect.gen(function* () { + const backing = yield* KeyValueStore.KeyValueStore; + const crypto = yield* Crypto.Crypto; + const clock = yield* Clock.Clock; + let enabled = true; + const lock = yield* Semaphore.make(CONCURRENT_READS); + const timeToLive: Persistable.TimeToLiveFn = (exit) => + Exit.isSuccess(exit) + ? Duration.millis(Math.max(0, exit.value.expiresAt - clock.currentTimeMillisUnsafe())) + : Duration.zero; + const cache = yield* PersistedCache.make( + (request: Read) => + request.lookup.pipe( + Effect.map((payload) => ({ payload, expiresAt: clock.currentTimeMillisUnsafe() + 60_000 })), + ), + { + storeId: "pr-v2", + timeToLive, + inMemoryTTL: timeToLive, + inMemoryCapacity: CONCURRENT_READS, + }, + ); + return PullRequestReadCache.of({ + get: Effect.fn("PullRequestReadCache.get")(function* (key, lookup) { + if (!enabled) return yield* lookup; + const digest = yield* crypto + .digest("SHA-256", new TextEncoder().encode(key)) + .pipe(Effect.option); + if (Option.isNone(digest)) return yield* lookup; + const read = yield* Effect.cached(lookup); + return yield* cache + .get(new Read({ key: Encoding.encodeHex(digest.value), lookup: read })) + .pipe( + Effect.map((result) => result.payload), + Effect.catchTags({ + PersistenceError: () => read, + SchemaError: () => read, + }), + Effect.uninterruptible, + lock.withPermits(1), + ); + }), + // Let existing reads finish before clearing, so they cannot repopulate stale entries. + invalidate: Cache.invalidateAll(cache.inMemory).pipe( + Effect.andThen(backing.clear), + Effect.catch(() => { + enabled = false; + return Effect.logWarning("PR cache disabled after clearing failed"); + }), + lock.withPermits(CONCURRENT_READS), + ), + }); +}); + +export const layer = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig; + const path = yield* Path.Path; + return Layer.effect(PullRequestReadCache, make).pipe( + Layer.provide(Persistence.layerKvs), + Layer.provide( + KeyValueStore.layerFileSystem( + path.join(config.providerStatusCacheDir, "pull-requests"), + ).pipe( + Layer.catch(() => + Layer.effectDiscard( + Effect.logWarning("PR cache directory unavailable; using memory cache"), + ).pipe(Layer.provideMerge(KeyValueStore.layerMemory)), + ), + ), + ), + ); + }), +); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index eb2f913c9206..3117c0072a78 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,3 +1,6 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; +import * as Persistence from "effect/unstable/persistence/Persistence"; import { assert, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -24,6 +27,7 @@ import { } from "./PullRequestProvider.ts"; import { PullRequestProviderRegistry, fromProviders } from "./PullRequestProviderRegistry.ts"; import * as PullRequestService from "./PullRequestService.ts"; +import * as PullRequestReadCache from "./PullRequestReadCache.ts"; function project(input: { readonly id: string; @@ -32,6 +36,7 @@ function project(input: { readonly repository?: string; readonly provider?: string; readonly host?: string; + readonly remoteUrl?: string; }): OrchestrationProjectShell { // The host defaults from the provider, so a fixture only names one when the point of the // test is two hosts of the same kind. @@ -47,7 +52,7 @@ function project(input: { locator: { source: "git-remote" as const, remoteName: "origin", - remoteUrl: `https://${host}/${input.repository}.git`, + remoteUrl: input.remoteUrl ?? `https://${host}/${input.repository}.git`, }, provider: input.provider ?? "github", displayName: input.repository, @@ -197,6 +202,11 @@ function makeService(input: { }), }), SourceControlRateLimit.layer, + Layer.effect(PullRequestReadCache.PullRequestReadCache, PullRequestReadCache.make).pipe( + Layer.provide(Persistence.layerKvs), + Layer.provide(KeyValueStore.layerMemory), + Layer.provide(NodeServices.layer), + ), ), ), ); @@ -470,7 +480,7 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows" // Keyed by the selector Azure is actually asked with, which is the repository's own name. assert.deepStrictEqual(result.nextCursors, { - "dev.azure.com web": "2026-07-02T00:00:00Z|4|7", + "dev.azure.com dev.azure.com/acme/web": "2026-07-02T00:00:00Z|4|7", }); }), ); @@ -1563,6 +1573,289 @@ it.effect("refuses a repository that does not belong to the requested project", }), ); +it.effect("caches stack membership separately from action details", () => + Effect.gen(function* () { + const reads: Array = []; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequestStack: (input) => + Effect.sync(() => { + reads.push(input.includeDetails === true); + return { + id: "9", + number: 3, + url: "https://github.com/acme/web/stacks/3", + base: "main", + layers: [ + { + number: 7, + headBranch: "a", + state: "open" as const, + ...(input.includeDetails ? { title: "First layer", headSha: "abc" } : {}), + }, + ], + }; + }), + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 7 }; + yield* service.stack(reference, { includeDetails: false }); + yield* service.stack(reference, { includeDetails: false }); + const detail = yield* service.stack(reference); + yield* service.stack(reference); + assert.deepStrictEqual(reads, [false, true]); + assert.strictEqual(detail?.layers[0]?.headSha, "abc"); + yield* service.invalidate({ reference }); + yield* service.stack(reference, { includeDetails: false }); + yield* service.stack(reference); + assert.deepStrictEqual(reads, [false, true, false, true]); + }), +); + +it.effect("reads a host-native stack through the provider and null where it has none", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequestStack: () => + Effect.succeed({ + id: "9", + number: 3, + url: "https://github.com/acme/web/stacks/3", + base: "main", + layers: [ + { number: 7, headBranch: "a", state: "open" as const }, + { number: 8, headBranch: "b", state: "open" as const }, + ], + }), + }), + ], + }); + + const stack = yield* service.stack({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 7, + }); + assert.deepStrictEqual( + stack?.layers.map((layer) => layer.number), + [7, 8], + ); + + const withoutStacks = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [fakeProvider("github")], + }); + assert.isNull( + yield* withoutStacks.stack({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 7, + }), + ); + }), +); + +it.effect("routes a hosted reference to another repository through a project on that host", () => + Effect.gen(function* () { + const seen: Array<{ cwd: string; repository: string; host: string }> = []; + const service = yield* makeService({ + projects: [ + project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push({ cwd: input.cwd, repository: input.repository, host: input.host }); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), + }), + ], + }); + + const summary = yield* service.summary( + { projectId: "frontend" as ProjectId, host: "github.com", repository: "acme/api", number: 7 }, + { recoverTransientFailure: false }, + ); + + assert.strictEqual(summary.number, 7); + assert.deepStrictEqual(seen, [{ cwd: "/web", repository: "acme/api", host: "github.com" }]); + }), +); + +it.effect("routes Azure reads and writes through the requested organization's checkout", () => + Effect.gen(function* () { + const seen: string[] = []; + const service = yield* makeService({ + projects: ["org-a", "org-b"].map((organization) => + project({ + id: organization, + title: organization, + workspaceRoot: `/${organization}`, + repository: `${organization}/project/_git/web`, + provider: "azure-devops", + host: "dev.azure.com", + }), + ), + providers: [ + fakeProvider("azure-devops", { + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push(`read ${input.cwd} ${input.repository}`); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), + runAction: (input) => + Effect.sync(() => { + seen.push(`write ${input.cwd} ${input.repository}`); + }), + }), + ], + }); + const reference = { + projectId: "org-a" as ProjectId, + host: "dev.azure.com", + repository: "org-b/project/_git/web", + number: 7, + }; + yield* service.summary(reference, { recoverTransientFailure: false }); + yield* service.runAction({ ...reference, action: "merge" }); + assert.deepStrictEqual(seen, ["read /org-b web", "write /org-b web", "read /org-b web"]); + }), +); + +for (const checkout of [ + { + host: "ssh.dev.azure.com", + repository: "v3/org-b/project/web", + remoteUrl: "git@ssh.dev.azure.com:v3/org-b/project/web", + }, + { + host: "vs-ssh.visualstudio.com", + repository: "v3/org-b/project/web", + remoteUrl: "git@vs-ssh.visualstudio.com:v3/org-b/project/web", + }, + { + host: "org-b.visualstudio.com", + repository: "DefaultCollection/project/_git/web", + remoteUrl: "https://org-b.visualstudio.com/DefaultCollection/project/_git/web", + }, +]) { + it.effect(`routes Azure URL reads and writes through a ${checkout.host} checkout`, () => + Effect.gen(function* () { + const seen: string[] = []; + const target = project({ + id: "target", + title: "target", + workspaceRoot: "/target", + provider: "azure-devops", + ...checkout, + }); + const service = yield* makeService({ + projects: [ + ...["org-a/project/_git/web", "org-b/other-project/_git/web"].map((repository) => + project({ + id: repository, + title: repository, + workspaceRoot: `/${repository}`, + provider: "azure-devops", + host: "dev.azure.com", + repository, + }), + ), + target, + ], + providers: [ + fakeProvider("azure-devops", { + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push(`read ${input.cwd} ${input.repository}`); + return changeRequest(7, "2026-07-02T00:00:00Z"); + }), + runAction: (input) => + Effect.sync(() => { + seen.push(`write ${input.cwd} ${input.repository}`); + }), + }), + ], + }); + const reference = { + projectId: "org-a/project/_git/web" as ProjectId, + host: "dev.azure.com", + repository: "org-b/project/_git/web", + number: 7, + }; + yield* service.summary(reference, { recoverTransientFailure: false }); + yield* service.runAction({ ...reference, action: "merge" }); + assert.deepStrictEqual(seen, ["read /target web", "write /target web", "read /target web"]); + }), + ); +} + +it.effect("refuses Azure cross-organization reads and writes without its checkout", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "org-a", + title: "org-a", + workspaceRoot: "/org-a", + repository: "org-a/project/_git/web", + provider: "azure-devops", + host: "dev.azure.com", + }), + ], + providers: [ + fakeProvider("azure-devops", { + getChangeRequestSummary: () => Effect.die("must not read the wrong organization"), + runAction: () => Effect.die("must not modify the wrong organization"), + }), + ], + }); + const reference = { + projectId: "org-a" as ProjectId, + host: "dev.azure.com", + repository: "org-b/project/_git/web", + number: 7, + }; + const readError = yield* Effect.flip( + service.summary(reference, { recoverTransientFailure: false }), + ); + const writeError = yield* Effect.flip(service.runAction({ ...reference, action: "close" })); + assert.strictEqual(readError._tag, "PullRequestUnavailableError"); + assert.strictEqual(writeError._tag, "PullRequestUnavailableError"); + }), +); + +it.effect("refuses a hosted reference when nothing is checked out from that host", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "frontend", title: "web", workspaceRoot: "/web", repository: "acme/web" }), + ], + providers: [fakeProvider("github")], + }); + + const error = yield* service + .summary( + { + projectId: "frontend" as ProjectId, + host: "gitlab.com", + repository: "acme/api", + number: 7, + }, + { recoverTransientFailure: false }, + ) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "PullRequestUnavailableError"); + }), +); + it.effect("refuses a diff on a host that cannot produce one", () => Effect.gen(function* () { const service = yield* makeService({ @@ -3377,6 +3670,48 @@ it.effect("does not ask the host again for a linked summary it already holds", ( }), ); +it.effect( + "opening detail preserves enriched linked summaries and updates draft and diff fields", + () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + getChangeRequestSummary: () => + Effect.succeed({ + ...changeRequest(1, "2026-07-02T00:00:00Z"), + isDraft: true, + reviewDecision: "approved", + checksState: "passing", + }), + getChangeRequest: () => + Effect.succeed({ + ...hostedChangeRequest("body", 14), + deletions: 3, + changedFiles: 5, + mergeability: "conflicting", + }), + }), + ], + }); + yield* service.summary(reference); + const detail = yield* service.detail(reference); + const summary = yield* service.summary(reference); + assert.strictEqual(summary.isDraft, false); + assert.deepStrictEqual(summary.author, detail.author); + assert.strictEqual(summary.additions, 14); + assert.strictEqual(summary.deletions, 3); + assert.strictEqual(summary.changedFiles, 5); + assert.strictEqual(summary.mergeability, "conflicting"); + assert.strictEqual(summary.reviewDecision, "approved"); + assert.strictEqual(summary.checksState, "passing"); + }), +); + it.effect("reuses an observed merged state for strict settlement reads", () => Effect.gen(function* () { const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; @@ -3587,43 +3922,6 @@ it.effect("carries an armed auto-merge through to the detail, and silence as sil }), ); -it("names an Azure DevOps repository by its own name, not its project path", () => { - // `az repos pr list --repository` takes a name and detects the organisation and project from - // the checkout; the recorded `org/project/_git/repo` path is refused, and the repository then - // reads as unavailable on the page. - const selector = PullRequestService.repositoryIdentityOf({ - repositoryIdentity: { - provider: "azure-devops", - displayName: "contoso/payments/_git/checkout", - owner: "contoso", - name: "checkout", - }, - } as never); - assert.strictEqual(selector, "checkout"); -}); - -it("falls back to the path's last segment where an Azure identity has no name", () => { - const selector = PullRequestService.repositoryIdentityOf({ - repositoryIdentity: { - provider: "azure-devops", - displayName: "contoso/payments/_git/checkout", - }, - } as never); - assert.strictEqual(selector, "checkout"); -}); - -it("keeps a GitLab identity's whole path, because a nested group is part of the name", () => { - const selector = PullRequestService.repositoryIdentityOf({ - repositoryIdentity: { - provider: "gitlab", - displayName: "group/subgroup/service", - owner: "group", - name: "service", - }, - } as never); - assert.strictEqual(selector, "group/subgroup/service"); -}); - it.effect("narrows the rows of a host that ignored the filters it was handed", () => Effect.gen(function* () { const service = yield* makeService({ @@ -3750,6 +4048,91 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho }), ); +it.effect("authorizes stack rebases independently of whether the selected layer is behind", () => + Effect.gen(function* () { + let taken = 0; + let summaryReads = 0; + let mutationFails = false; + let stackRebase = true; + let stackActions = true; + const capabilities = { + diff: true, + comment: true, + actions: ["update-branch"] as const, + mergeMethods: ["merge"] as const, + updateMethods: ["rebase"] as const, + get stackActions() { + return stackActions; + }, + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities, + getViewerPermissions: () => + Effect.succeed({ + actions: [], + stackRebase, + comment: true, + resolve: false, + verdicts: [], + requestReviewers: false, + }), + getChangeRequestSummary: () => + Effect.sync(() => { + summaryReads++; + return changeRequest(8, "2026-07-01T00:00:00Z"); + }), + runAction: () => + Effect.gen(function* () { + taken++; + if (mutationFails) return yield* requestFailed; + }), + }), + ], + }); + const input = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 3, + action: "update-branch" as const, + updateMethod: "rebase" as const, + stackNumber: 50, + expectedStackHeads: [{ number: 3, headSha: "ccc" }], + }; + yield* service.runAction(input); + assert.strictEqual(taken, 1); + const unrelated = { ...input, number: 8 }; + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 1); + stackRebase = false; + assert.strictEqual( + (yield* Effect.flip(service.runAction(input)))._tag, + "PullRequestOperationError", + ); + stackRebase = true; + stackActions = false; + assert.strictEqual( + (yield* Effect.flip(service.runAction(input)))._tag, + "PullRequestOperationError", + ); + assert.strictEqual(taken, 1); + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 1); + stackActions = true; + mutationFails = true; + yield* Effect.flip(service.runAction(input)); + assert.strictEqual(taken, 2); + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 2); + }), +); + it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () => Effect.gen(function* () { let taken: string | null = null; @@ -4137,3 +4520,40 @@ it.effect("names the signed-in account in the detail, and says nothing where the assert.strictEqual(unnamed.viewer, undefined); }), ); + +it.effect("keeps Azure continuation cursors separate for repositories with the same name", () => + Effect.gen(function* () { + const seen: string[] = []; + const service = yield* makeService({ + projects: ["org-a", "org-b"].map((organization) => + project({ + id: organization, + title: organization, + workspaceRoot: `/${organization}`, + repository: `${organization}/project/_git/web`, + provider: "azure-devops", + host: "dev.azure.com", + }), + ), + providers: [ + fakeProvider("azure-devops", { + listChangeRequests: (input) => + Effect.sync(() => { + seen.push(input.cwd); + return { + items: [changeRequest(7, "2026-07-02T00:00:00Z")], + truncated: true, + continues: true, + }; + }), + }), + ], + }); + const first = yield* service.list({ state: "open" }); + assert.lengthOf(Object.keys(first.nextCursors), 2); + const key = Object.keys(first.nextCursors).find((key) => key.includes("org-b"))!; + seen.length = 0; + yield* service.list({ state: "open", cursors: { [key]: first.nextCursors[key]! } }); + assert.deepStrictEqual(seen, ["/org-b"]); + }), +); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 2229a4f652c0..37716da44005 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1,3 +1,7 @@ +import { + canonicalRepositoryKey, + sourceControlRepositorySelector, +} from "@t3tools/shared/sourceControl"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -7,6 +11,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -46,7 +51,8 @@ import { type PullRequestLabelCandidateList, type PullRequestLabelChangeInput, type PullRequestSubmitReviewInput, - type PullRequestSummary, + PullRequestStack, + PullRequestSummary, type PullRequestThreadReplyInput, type PullRequestThreadResolutionInput, type PullRequestThreadCommentsInput, @@ -66,6 +72,7 @@ import { type PullRequestProviderApi, PullRequestProviderError, } from "./PullRequestProvider.ts"; +import * as PullRequestReadCache from "./PullRequestReadCache.ts"; import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts"; export interface PullRequestMergeEvent extends PullRequestRef { @@ -109,7 +116,6 @@ const REPOSITORY_SEARCH_CHUNK = 100; * `invalidate` rather than a flag on the read, so an ordinary read can never opt out. */ const LIST_CACHE_TTL = Duration.seconds(30); -const SUMMARY_CACHE_TTL = Duration.seconds(60); const DETAIL_CACHE_TTL = Duration.seconds(15); const DIFF_CACHE_TTL = Duration.seconds(60); /** A commit is content-addressed, so its own diff cannot change under its key. */ @@ -144,6 +150,14 @@ export class PullRequestService extends Context.Service< input: PullRequestRef, options?: { readonly recoverTransientFailure?: boolean }, ) => Effect.Effect; + /** + * The host-native stack the pull request belongs to, or null when it is not in one or the + * host keeps no such object. Cached like a summary; a stack changes about as often. + */ + readonly stack: ( + input: PullRequestRef, + options?: { readonly includeDetails?: boolean }, + ) => Effect.Effect; readonly subscribeMerges: Effect.Effect< Stream.Stream, never, @@ -241,6 +255,7 @@ const LABEL_CHANGE_REFUSAL = "You need triage access on this repository to chang /** A project this page can read: its remote is on a host with an implementation. */ interface SupportedProject { + readonly cursorKey: string; readonly project: OrchestrationProjectShell; readonly api: PullRequestProviderApi; readonly repository: string; @@ -453,7 +468,7 @@ function withRateLimitBackoff( call: (...args: Args) => Effect.Effect, ) => wrap(operation, call, true); - return { + const wrapped = { kind: api.kind, capabilities: api.capabilities, getViewer: wrap("getViewer", api.getViewer), @@ -474,6 +489,9 @@ function withRateLimitBackoff( : { getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary), }), + ...(api.getChangeRequestStack === undefined + ? {} + : { getChangeRequestStack: wrap("getChangeRequestStack", api.getChangeRequestStack) }), getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity), ...(api.getReviewThreadComments === undefined ? {} @@ -506,30 +524,9 @@ function withRateLimitBackoff( setReaction: interactive("setReaction", api.setReaction), setThreadResolution: interactive("setThreadResolution", api.setThreadResolution), }; -} - -/** - * The provider-native repository selector. `displayName` is the full path below the host, which - * is what nested GitLab groups need; owner/name is the two-segment fallback for identities - * recorded before that field existed. - * - * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and - * takes the organisation and project from the checkout it detects — so the recorded - * `org/project/_git/repo` path is refused outright and the whole repository reads as - * unavailable. Its name is the last segment, which is what this hands over. - * - * One function because everything downstream is keyed by what it answers: the rows' own - * `repository`, the per-repository cursors, and the detail and diff reads a row leads to. - */ -export function repositoryIdentityOf(project: OrchestrationProjectShell): string | null { - const identity = project.repositoryIdentity; - if (!identity) return null; - if (identity.provider === "azure-devops") { - const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git"); - return identity.name || segments.at(-1) || null; - } - if (identity.displayName) return identity.displayName; - return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; + // Optional provider methods must be forwarded too; returning the interface alone permits omissions. + return wrapped satisfies PullRequestProviderApi & + Record, never>; } export const make = Effect.gen(function* () { @@ -539,6 +536,7 @@ export const make = Effect.gen(function* () { const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const rateLimits = yield* SourceControlRateLimit.SourceControlRateLimit; + const readCache = yield* PullRequestReadCache.PullRequestReadCache; const refineUnknownProjectKinds = ( projects: ReadonlyArray, @@ -554,7 +552,11 @@ export const make = Effect.gen(function* () { for (const project of projects) { if (filter.projectId !== undefined && project.id !== filter.projectId) continue; const identity = project.repositoryIdentity; - if (identity?.provider !== "unknown" || repositoryIdentityOf(project) === null) continue; + if ( + identity?.provider !== "unknown" || + sourceControlRepositorySelector(project.repositoryIdentity) === null + ) + continue; const host = pullRequestHostOf(identity, "unknown"); // A legacy identity has no canonical host until its provider is refined, so it must reach // the refinement before a host filter can decide whether it belongs in the result. @@ -628,7 +630,7 @@ export const make = Effect.gen(function* () { if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue; const identity = project.repositoryIdentity; let kind = identity?.provider as SourceControlProviderKind | undefined; - const repository = repositoryIdentityOf(project); + const repository = sourceControlRepositorySelector(project.repositoryIdentity); if (!identity || kind === undefined || repository === null) continue; // Worktrees of one repository are separate projects; reading the remote once keeps // the page from repeating every change request per local checkout. The host is part @@ -647,7 +649,10 @@ export const make = Effect.gen(function* () { if (roots === undefined) viewerRoots.set(host, [project.workspaceRoot]); else if (!roots.includes(project.workspaceRoot)) roots.push(project.workspaceRoot); } - const key = listCursorKey(host, repository); + const key = listCursorKey( + host, + kind === "azure-devops" ? identity.canonicalKey : repository, + ); if (seen.has(key)) continue; seen.add(key); if (api === null) { @@ -657,6 +662,7 @@ export const make = Effect.gen(function* () { continue; } supported.push({ + cursorKey: key, project, api: withRateLimitBackoff(api, host, rateLimits), repository, @@ -667,16 +673,30 @@ export const make = Effect.gen(function* () { }), ); + /** + * The project whose checkout and credentials serve a reference. The project's own + * repository is the default; a reference that names a `host` may instead point at any + * repository on that host. Prefer its own checkout; providers with explicit repository + * targeting can fall back to another checkout on the host. Azure derives its organization + * from the checkout, so it requires a matching repository. + */ const requireProject = (ref: PullRequestRef): Effect.Effect => listWorkspaceProjects({ projectId: ref.projectId }).pipe( Effect.flatMap(({ supported }): Effect.Effect => { - const match = supported[0]; - if (!match) { - return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" })); + const own = supported[0]; + const repository = ref.repository.trim(); + const host = ref.host?.trim().toLowerCase(); + if (own !== undefined && own.repository.toLowerCase() === repository.toLowerCase()) { + // Hostless references only ever meant the project's own repository, and a hosted one + // naming it still is; either way the project serves itself. + if (host === undefined || host === own.host) return Effect.succeed(own); } - // The repository travels through the client, so it is checked against the project's - // own remote rather than being handed to a provider verbatim. - if (match.repository.toLowerCase() !== ref.repository.trim().toLowerCase()) { + if (host === undefined) { + if (own === undefined) { + return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" })); + } + // The repository travels through the client, so it is checked against the project's + // own remote rather than being handed to a provider verbatim. return Effect.fail( new PullRequestOperationError({ operation: "resolveRepository", @@ -684,7 +704,39 @@ export const make = Effect.gen(function* () { }), ); } - return Effect.succeed(match); + const repositoryKey = canonicalRepositoryKey(`${host}/${repository}`.toLowerCase()); + // Azure SSH and legacy clone hosts differ from the browser URL's host. Compare + // the complete repository identity before narrowing those checkouts by host. + return listWorkspaceProjects( + repositoryKey.startsWith("dev.azure.com/") ? {} : { host }, + ).pipe( + Effect.flatMap(({ supported }) => { + const onHost = supported.filter((candidate) => candidate.host === host); + const route = + supported.find( + (candidate) => + candidate.api.kind === "azure-devops" && + candidate.project.repositoryIdentity != null && + canonicalRepositoryKey( + candidate.project.repositoryIdentity.canonicalKey.toLowerCase(), + ) === repositoryKey, + ) ?? + onHost.find( + (candidate) => + candidate.api.kind !== "azure-devops" && + candidate.repository.toLowerCase() === repository.toLowerCase(), + ) ?? + onHost.find((candidate) => candidate.api.kind !== "azure-devops"); + if (route === undefined) { + return Effect.fail( + new PullRequestUnavailableError({ reason: "provider-unsupported" }), + ); + } + return Effect.succeed( + route.api.kind === "azure-devops" ? route : { ...route, repository }, + ); + }), + ); }), ); @@ -829,8 +881,8 @@ export const make = Effect.gen(function* () { viewer: string, ): boolean => { if (filters === undefined) return true; - const labels = item.labels.map((label) => label.name.trim().toLowerCase()); - const holds = (label: string) => labels.includes(label.trim().toLowerCase()); + const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase())); + const holds = (label: string) => labels.has(label.trim().toLowerCase()); return ( (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && // Judged on the provider row rather than the entry, because the two absences mean @@ -858,6 +910,7 @@ export const make = Effect.gen(function* () { }): PullRequestListEntry => { const viewer = input.viewer.toLowerCase(); return { + ...(input.item.stack === undefined ? {} : { stack: input.item.stack }), provider: input.project.api.kind, host: input.project.host, projectId: input.project.project.id, @@ -949,9 +1002,7 @@ export const make = Effect.gen(function* () { const selected = continuation === null ? projects - : projects.filter(({ host, repository }) => - continuation.has(listCursorKey(host, repository)), - ); + : projects.filter(({ cursorKey }) => continuation.has(cursorKey)); const readable = selected.filter(({ host }) => viewers[host] !== undefined); // A host that could not be read still has projects, and they are absent from the list. // Reporting them keeps "N repositories were unavailable" honest instead of dropping them. @@ -993,7 +1044,7 @@ export const make = Effect.gen(function* () { const limit = input.limit ?? DEFAULT_REPOSITORY_LIST_LIMIT; const cursorOf = (project: SupportedProject): ListCursor | undefined => - continuation?.get(listCursorKey(project.host, project.repository)); + continuation?.get(project.cursorKey); /** * One repository asked on its own. What every host without a search across repositories @@ -1002,7 +1053,7 @@ export const make = Effect.gen(function* () { const readRepository = (project: SupportedProject): Effect.Effect => { { const viewer = viewers[project.host]!; - const key = listCursorKey(project.host, project.repository); + const key = project.cursorKey; const cursor = cursorOf(project); return project.api .listChangeRequests({ @@ -1161,7 +1212,7 @@ export const make = Effect.gen(function* () { !cursorHere.seenAt.includes(item.number), ); return Effect.succeed({ - key: listCursorKey(project.host, project.repository), + key: project.cursorKey, entries: items .filter((item) => matchesRowFilters(item, input.filters, viewer)) .map((item) => toEntry({ project, item, viewer })), @@ -1254,17 +1305,69 @@ export const make = Effect.gen(function* () { title: changeRequest.title, url: changeRequest.url, state: changeRequest.state, - ...(changeRequest.isDraft === true ? { isDraft: true } : {}), headBranch: changeRequest.headBranch, baseBranch: changeRequest.baseBranch, closedAt: changeRequest.closedAt ?? null, mergedAt: changeRequest.mergedAt ?? null, updatedAt: changeRequest.updatedAt, + ...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }), + ...(changeRequest.author === undefined ? {} : { author: changeRequest.author }), + ...(changeRequest.additions === undefined + ? {} + : { additions: changeRequest.additions }), + ...(changeRequest.deletions === undefined + ? {} + : { deletions: changeRequest.deletions }), + ...(changeRequest.changedFiles === undefined + ? {} + : { changedFiles: changeRequest.changedFiles }), + ...(changeRequest.reviewDecision === undefined + ? {} + : { reviewDecision: changeRequest.reviewDecision }), + ...(changeRequest.checksState === undefined + ? {} + : { checksState: changeRequest.checksState }), + ...(changeRequest.mergeability === undefined + ? {} + : { mergeability: changeRequest.mergeability }), })), ); }), ); + const stackUncached: PullRequestService["Service"]["stack"] = (input, options) => + requireProject(input).pipe( + Effect.flatMap((project) => { + const read = project.api.getChangeRequestStack; + if (read === undefined) return Effect.succeed(null); + return read({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + includeDetails: options?.includeDetails !== false, + }).pipe( + Effect.mapError(toPullRequestError("stack")), + Effect.map((stack): PullRequestStack | null => + stack === null + ? null + : { + id: stack.id, + number: stack.number, + url: stack.url, + base: stack.base, + layers: stack.layers.map((layer) => ({ + ...layer, + number: layer.number, + headBranch: layer.headBranch, + state: layer.state, + })), + }, + ), + ); + }), + ); + const detailUncached: PullRequestService["Service"]["detail"] = (input) => requireProject(input).pipe( Effect.flatMap((project) => @@ -1434,6 +1537,20 @@ export const make = Effect.gen(function* () { const runAction = (input: PullRequestActionInput): Effect.Effect => requireProject(input).pipe( Effect.flatMap((project): Effect.Effect => { + if ( + input.stackNumber !== undefined && + (project.api.capabilities.stackActions !== true || + !["merge", "update-branch"].includes(input.action) || + input.expectedStackHeads === undefined || + (input.action === "update-branch" && input.updateMethod !== "rebase")) + ) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: "This stack action is not supported or has no expected head revision.", + }), + ); + } // The surface hides what a host cannot do, and this refuses it as well: a request that // reached here anyway must not be handed to a provider that never claimed the action. if (!project.api.capabilities.actions.includes(input.action)) { @@ -1476,7 +1593,10 @@ export const make = Effect.gen(function* () { // above do not. return viewerPermissionsOf(project, input, "runAction").pipe( Effect.flatMap((viewer): Effect.Effect => { - if (!viewer.actions.includes(input.action)) { + const stackRebase = input.stackNumber !== undefined && input.action === "update-branch"; + if ( + stackRebase ? viewer.stackRebase !== true : !viewer.actions.includes(input.action) + ) { return Effect.fail( new PullRequestOperationError({ operation: "runAction", @@ -1485,6 +1605,7 @@ export const make = Effect.gen(function* () { ); } if ( + !stackRebase && input.updateMethod !== undefined && !(viewer.updateMethods ?? []).includes(input.updateMethod) ) { @@ -1502,12 +1623,23 @@ export const make = Effect.gen(function* () { host: project.host, number: input.number, action: input.action, + ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), + ...(input.expectedStackHeads === undefined + ? {} + : { expectedStackHeads: input.expectedStackHeads }), ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), }) .pipe( + // Once the authorized provider action starts, a failure may leave partial + // remote updates. Validation and permission failures above changed nothing. + Effect.ensuring(input.stackNumber === undefined ? Effect.void : refreshAfterTurn), Effect.mapError(toPullRequestError("runAction")), - Effect.as(project.repository), + Effect.as( + project.api.kind === "azure-devops" + ? input.repository.trim() + : project.repository, + ), ); }), ); @@ -2133,11 +2265,35 @@ export const make = Effect.gen(function* () { let turnRefreshEpoch = 0; const refEpochs = new Map(); const REF_EPOCH_CAPACITY = 2_048; - const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`; + const refScope = (ref: PullRequestRef) => + `${ref.projectId} ${ref.host?.toLowerCase() ?? ""} ${ref.repository.toLowerCase()} ${ref.number}`; const refEpoch = (ref: PullRequestRef) => Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0); + // Keys carry the reference back out of the cache loader, so the slot layout is shared with + // `refOfCacheKey` rather than read positionally at every loader. const refCacheKey = (ref: PullRequestRef) => - JSON.stringify([refEpoch(ref), ref.projectId, ref.repository, ref.number]); + JSON.stringify([ + refEpoch(ref), + ref.projectId, + ref.host?.toLowerCase() ?? null, + ref.repository.toLowerCase(), + ref.number, + ]); + const refOfCacheKey = (key: string): PullRequestRef => { + const [, projectId, host, repository, number] = JSON.parse(key) as [ + number, + string, + string | null, + string, + number, + ]; + return { + projectId, + ...(host === null ? {} : { host }), + repository, + number, + } as PullRequestRef; + }; // Counts belong to a PR, not a filtered page. Background reads and filter changes reuse // them; explicit refreshes, mutations, and turns strand old and in-flight results. const statsCacheKey = (key: string) => JSON.stringify([listingsEpoch, key]); @@ -2179,19 +2335,49 @@ export const make = Effect.gen(function* () { }; }; - const summaryCache = yield* Cache.makeWith( - (key: string) => { - const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; - return summaryUncached({ projectId, repository, number } as PullRequestRef); - }, - { - capacity: DETAIL_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero), - }, - ); + const persistedRead = Effect.fn("PullRequestService.persistedRead")(function* ( + input: PullRequestRef, + operation: string, + codec: Schema.Codec, + read: Effect.Effect, + ) { + const project = yield* requireProject(input); + const key = [ + operation, + project.api.kind, + project.host.toLowerCase(), + project.repository.toLowerCase(), + project.project.id, + project.project.workspaceRoot, + String(input.number), + ] + .map(encodeURIComponent) + .join(":"); + const lookup = yield* Effect.cached(read); + const encodedRead = lookup.pipe( + Effect.flatMap((value) => + Schema.encodeEffect(codec)(value).pipe( + Effect.mapError( + (cause) => + new PullRequestOperationError({ + operation: "cache", + detail: "Could not encode PR cache data.", + cause, + }), + ), + ), + ), + ); + const payload = yield* readCache.get(key, encodedRead); + const decoded = yield* Schema.decodeUnknownEffect(codec)(payload).pipe(Effect.option); + return Option.isSome(decoded) ? decoded.value : yield* lookup; + }); + const summaryCodec = Schema.fromJsonString(PullRequestSummary); + const stackCodec = Schema.fromJsonString(Schema.NullOr(PullRequestStack)); + const summary: PullRequestService["Service"]["summary"] = (input, options) => { const key = refCacheKey(input); - const cached = Cache.get(summaryCache, key); + const cached = persistedRead(input, "summary", summaryCodec, summaryUncached(input)); const held = lastGoodSummary.peek(key); return held !== undefined && (options?.recoverTransientFailure !== false || held.state === "merged") @@ -2203,6 +2389,14 @@ export const make = Effect.gen(function* () { ); }; + const stack: PullRequestService["Service"]["stack"] = (input, options) => + persistedRead( + input, + `stack:${options?.includeDetails !== false}`, + stackCodec, + stackUncached(input, options), + ); + // Keys serialize positionally and parse back in the lookup, so the cache is the only holder // of in-flight state: concurrent identical reads coalesce on the key into one host request. // The continuation cursors are part of the key, entries sorted so one continuation is one @@ -2283,8 +2477,7 @@ export const make = Effect.gen(function* () { const detailCache = yield* Cache.makeWith( (key: string) => { const statsKey = statsCacheKey(key); - const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; - return detailUncached({ projectId, repository, number } as PullRequestRef).pipe( + return detailUncached(refOfCacheKey(key)).pipe( Effect.tap( Effect.fn("PullRequestService.recordDetailStats")(function* (value: PullRequestDetail) { recordStats( @@ -2307,7 +2500,12 @@ export const make = Effect.gen(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), }, ); - const summaryFromDetail = (detail: PullRequestDetail): PullRequestSummary => ({ + const summaryFromDetail = ( + detail: PullRequestDetail, + previous: PullRequestSummary | undefined, + ): PullRequestSummary => ({ + // Detail does not carry review/check summaries. Keep the last summary observation. + ...previous, provider: detail.provider, projectId: detail.projectId, repository: detail.repository, @@ -2315,7 +2513,12 @@ export const make = Effect.gen(function* () { title: detail.title, url: detail.url, state: detail.state, - ...(detail.isDraft === true ? { isDraft: true } : {}), + isDraft: detail.isDraft, + author: detail.author, + additions: detail.additions, + deletions: detail.deletions, + changedFiles: detail.changedFiles, + mergeability: detail.mergeability, headBranch: detail.headBranch, baseBranch: detail.baseBranch, closedAt: detail.closedAt, @@ -2338,7 +2541,7 @@ export const make = Effect.gen(function* () { key, Cache.get(detailCache, key).pipe( Effect.tap((value) => { - const summary = summaryFromDetail(value); + const summary = summaryFromDetail(value, lastGoodSummary.peek(key)); return shouldReplaceHeldSummary(key, summary) ? lastGoodSummary.record(key, summary) : Effect.void; @@ -2350,8 +2553,7 @@ export const make = Effect.gen(function* () { const activityCache = yield* Cache.makeWith( (key: string) => { - const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; - return activityUncached({ projectId, repository, number } as PullRequestRef); + return activityUncached(refOfCacheKey(key)); }, { capacity: DETAIL_CACHE_CAPACITY, @@ -2365,9 +2567,10 @@ export const make = Effect.gen(function* () { const diffCache = yield* Cache.makeWith( (key: string) => { - const [, projectId, repository, number, cursor, commit] = JSON.parse(key) as [ + const [, projectId, host, repository, number, cursor, commit] = JSON.parse(key) as [ number, string, + string | null, string, number, string | null, @@ -2375,6 +2578,7 @@ export const make = Effect.gen(function* () { ]; return diffUncached({ projectId, + ...(host === null ? {} : { host }), repository, number, ...(cursor === null ? {} : { cursor }), @@ -2385,7 +2589,7 @@ export const make = Effect.gen(function* () { capacity: DIFF_CACHE_CAPACITY, timeToLive: (exit, key) => { if (!Exit.isSuccess(exit)) return Duration.zero; - const commit = (JSON.parse(key) as ReadonlyArray)[5]; + const commit = (JSON.parse(key) as ReadonlyArray)[6]; return commit === null ? DIFF_CACHE_TTL : COMMIT_DIFF_CACHE_TTL; }, }, @@ -2394,7 +2598,8 @@ export const make = Effect.gen(function* () { const key = JSON.stringify([ refEpoch(input), input.projectId, - input.repository, + input.host?.toLowerCase() ?? null, + input.repository.toLowerCase(), input.number, input.cursor ?? null, input.commit ?? null, @@ -2465,7 +2670,7 @@ export const make = Effect.gen(function* () { const invalidate: PullRequestService["Service"]["invalidate"] = (input) => { const reference = input.reference; if (reference !== undefined) { - return Effect.sync(() => bumpRefEpoch(reference)); + return readCache.invalidate.pipe(Effect.andThen(Effect.sync(() => bumpRefEpoch(reference)))); } return Effect.sync(() => { listingsEpoch = ++epochCounter; @@ -2475,7 +2680,9 @@ export const make = Effect.gen(function* () { const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = Effect.suspend(() => { turnRefreshEpoch = listingsEpoch = ++epochCounter; - return SubscriptionRef.set(pullRequestRefreshes, turnRefreshEpoch); + return readCache.invalidate.pipe( + Effect.andThen(SubscriptionRef.set(pullRequestRefreshes, turnRefreshEpoch)), + ); }); // A mutation's own client re-reads right after it, and every other client's next read must @@ -2486,7 +2693,9 @@ export const make = Effect.gen(function* () { method: (input: I) => Effect.Effect, ): ((input: I) => Effect.Effect) => (input) => - method(input).pipe( + readCache.invalidate.pipe( + Effect.andThen(method(input)), + Effect.ensuring(readCache.invalidate), Effect.tap(() => Effect.sync(() => { bumpRefEpoch(input); @@ -2497,7 +2706,8 @@ export const make = Effect.gen(function* () { const runActionAndInvalidate: PullRequestService["Service"]["runAction"] = Effect.fn( "PullRequestService.runActionAndInvalidate", )(function* (input) { - const repository = yield* runAction(input); + yield* readCache.invalidate; + const repository = yield* runAction(input).pipe(Effect.ensuring(readCache.invalidate)); bumpRefEpoch({ ...input, repository }); listingsEpoch = ++epochCounter; if (input.action === "merge") { @@ -2523,6 +2733,7 @@ export const make = Effect.gen(function* () { list, listStats, summary, + stack, subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), ), diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index f6f5957f5875..f9f6c4ce3d26 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; import { buildReviewSubmissionJson, + buildPullRequestStackMembershipsGraphQlQuery, + decodePullRequestStackMembershipsJson, buildReviewerRequestJson, decodeBaseComparisonJson, decodePullRequestActivityJson, @@ -11,6 +13,7 @@ import { decodePullRequestListJson, decodePullRequestNodeIdJson, decodePullRequestSearchJson, + decodePullRequestStacksJson, decodeLabelCandidatesJson, decodeRepositoryAccessJson, decodeReviewerCandidatesJson, @@ -20,6 +23,7 @@ import { decodeWorkflowRunApprovalsJson, reviewThreadConversation, REVIEW_THREADS_GRAPHQL_QUERY, + pullRequestSearchGraphQlQuery, } from "./gitHubPullRequestJson.ts"; function listJson(entries: ReadonlyArray>): string { @@ -164,6 +168,17 @@ describe("pull request search decoding", () => { }); } + it("keeps stack membership beside search results without extra per-PR reads", () => { + const raw = JSON.parse(searchJson(["SUCCESS", null])); + raw.data.search.nodes[0].stack = { number: 3, size: 2, baseRefName: "main" }; + raw.data.search.nodes[0].stackEntry = { position: 1 }; + const batch = expectSuccess(decodePullRequestSearchJson(JSON.stringify(raw))); + expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, position: 1, base: "main" }); + expect(batch.items[1]?.stack).toBeUndefined(); + expect(pullRequestSearchGraphQlQuery(20, true)).toContain("stackEntry"); + expect(pullRequestSearchGraphQlQuery(20)).not.toContain("stackEntry"); + }); + it("maps the rollup enum the search answers with onto the same three words", () => { // The search asks GitHub for the verdict rather than the checks behind it, so this path sees // one enum where the listing sees an array. @@ -1500,3 +1515,142 @@ describe("how far a branch trails its base", () => { expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false); }); }); + +describe("host-native stack decoding", () => { + /** A stack as the preview lists it, bottom to top, with the fields it answers today. */ + function stack(overrides: Record = {}) { + return { + id: 42, + number: 3, + node_id: "STK_kwDO", + url: "https://api.github.com/repos/acme/web/stacks/3", + base: { ref: "main", sha: "abc" }, + open: true, + created_at: "2026-09-01T00:00:00Z", + pull_requests: [ + { + number: 10, + head: { ref: "feat/one" }, + state: "closed", + merged_at: "2026-09-02T00:00:00Z", + }, + { number: 11, head: { ref: "feat/two" }, state: "open", merged_at: null }, + { number: 12, head: { ref: "feat/three" }, state: "closed", merged_at: null }, + ], + ...overrides, + }; + } + + /** The one stack a listing answered with, which these reads all expect to find. */ + function expectStack(overrides: Record = {}) { + const decoded = expectSuccess(decodePullRequestStacksJson(JSON.stringify([stack(overrides)]))); + if (decoded === null) throw new Error("expected a stack"); + return decoded; + } + + it("reads the first stack, bottom to top, with merged_at outranking state", () => { + expect(expectStack()).toEqual({ + id: "42", + number: 3, + url: "https://api.github.com/repos/acme/web/stacks/3", + base: "main", + layers: [ + { number: 10, headBranch: "feat/one", state: "merged" }, + { number: 11, headBranch: "feat/two", state: "open" }, + { number: 12, headBranch: "feat/three", state: "closed" }, + ], + }); + }); + + it("retains the detailed layer titles, draft state and expected revision", () => { + expect( + expectStack({ + pull_requests: [ + { + number: 11, + title: "Second layer", + draft: true, + head: { ref: "feat/two", sha: "abc123" }, + state: "open", + merged_at: null, + }, + ], + }).layers, + ).toEqual([ + { + number: 11, + title: "Second layer", + isDraft: true, + headSha: "abc123", + headBranch: "feat/two", + state: "open", + }, + ]); + }); + + it("accepts a base named as a bare branch, which is what the preview started out sending", () => { + expect(expectStack({ base: "develop" }).base).toBe("develop"); + }); + + it("prefers the page a person opens over the API URL, where the host reports one", () => { + expect(expectStack({ html_url: "https://github.com/acme/web/stacks/3" }).url).toBe( + "https://github.com/acme/web/stacks/3", + ); + }); + + it("falls back to the node id, then the number, for a stack without an id", () => { + expect(expectStack({ id: undefined }).id).toBe("STK_kwDO"); + expect(expectStack({ id: null, node_id: null }).id).toBe("3"); + }); + + it("reads an empty listing as not stacked", () => { + expect(expectSuccess(decodePullRequestStacksJson("[]"))).toBeNull(); + }); + + it("refuses a stack without a number or without its pull requests", () => { + expect( + Result.isSuccess(decodePullRequestStacksJson(JSON.stringify([stack({ number: undefined })]))), + ).toBe(false); + expect( + Result.isSuccess( + decodePullRequestStacksJson(JSON.stringify([stack({ pull_requests: undefined })])), + ), + ).toBe(false); + expect(Result.isSuccess(decodePullRequestStacksJson("{"))).toBe(false); + }); +}); + +describe("pull request stack membership batches", () => { + it("maps aliases while skipping missing pull requests and incomplete memberships", () => { + const memberships = expectSuccess( + decodePullRequestStackMembershipsJson( + JSON.stringify({ + data: { + s0: { + pullRequest: { + stack: { number: 3, size: 2, baseRefName: "main" }, + stackEntry: { position: 1 }, + }, + }, + s1: null, + s2: { pullRequest: null }, + s3: { pullRequest: { stack: null, stackEntry: null } }, + s4: { pullRequest: { stack: { number: 3, size: 2, baseRefName: "main" } } }, + }, + }), + ), + ); + expect([...memberships]).toEqual([[0, { number: 3, size: 2, base: "main", position: 1 }]]); + }); + + it("refuses malformed responses and unsafe query selectors", () => { + expect(Result.isFailure(decodePullRequestStackMembershipsJson('{"errors":[]}'))).toBe(true); + expect(buildPullRequestStackMembershipsGraphQlQuery('acme/web") { x } #', [1])).toBeNull(); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [0])).toBeNull(); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [1.5])).toBeNull(); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [])).toBeNull(); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [7, 8])).toContain( + "pullRequest(number: 8)", + ); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 7887a81617d6..42fa0ba0e04f 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -3,6 +3,7 @@ import * as Exit from "effect/Exit"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { + PullRequestStackMembership, PullRequestActor, PullRequestCheck, PullRequestCheckStatus, @@ -111,12 +112,22 @@ const RawListItemSchema = Schema.Struct({ statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))), }); +const RawStackMembershipSchema = Schema.Struct({ + stack: Schema.optional( + Schema.NullOr( + Schema.Struct({ number: Schema.Int, size: Schema.Int, baseRefName: Schema.String }), + ), + ), + stackEntry: Schema.optional(Schema.NullOr(Schema.Struct({ position: Schema.Int }))), +}); + /** * A search's own answer, which is the listing's row one connection deeper: `gh pr list --json` * flattens reviewers and labels, and GraphQL does not. Everything below the row is optional * because a node that is not a pull request decodes as an empty object, which is skipped. */ const RawSearchItemSchema = Schema.Struct({ + ...RawStackMembershipSchema.fields, number: Schema.Int, title: Schema.String, url: Schema.String, @@ -221,6 +232,13 @@ const RawStatsSchema = Schema.Struct({ ), }); +const RawStackMembershipsSchema = Schema.Struct({ + data: Schema.Record( + Schema.String, + Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(RawStackMembershipSchema) })), + ), +}); + /** How many of a reaction's people the hover names before it counts the rest. */ const REACTORS_PER_GROUP = 10; @@ -648,12 +666,13 @@ export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; * than twenty labels shows twenty, and one that has asked more than twenty people for a review * is already past what a row can say. */ -export function pullRequestSearchGraphQlQuery(rows: number): string { +export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = false): string { return `query($q: String!) { search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS)}) { pageInfo { hasNextPage } nodes { ... on PullRequest { + ${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""} number title url @@ -1019,6 +1038,7 @@ export const REPOSITORY_ACCESS_JSON_FIELDS = "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission"; export interface GitHubPullRequestListItem { + readonly stack?: PullRequestStackMembership; /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */ readonly authorId: string | null; readonly number: number; @@ -1526,6 +1546,7 @@ export function decodePullRequestSearchJson( const node = decodedNode.value; const repository = trimmed(node.repository?.nameWithOwner); if (repository === null) continue; + const stack = toStackMembership(node); items.push({ ...toListItem({ ...node, @@ -1541,6 +1562,7 @@ export function decodePullRequestSearchJson( return state === null ? [] : [{ state }]; }), }), + ...(stack === undefined ? {} : { stack }), repository, }); } @@ -1551,6 +1573,19 @@ export function decodePullRequestSearchJson( }); } +function toStackMembership( + raw: Schema.Schema.Type, +): PullRequestStackMembership | undefined { + return raw.stack && raw.stackEntry + ? { + number: raw.stack.number, + size: raw.stack.size, + base: raw.stack.baseRefName, + position: raw.stackEntry.position, + } + : undefined; +} + /** What a repository selector may hold before it is written into a GraphQL document unquoted. */ const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/; @@ -1581,6 +1616,42 @@ export function buildPullRequestStatsGraphQlQuery( return `query {\n${selections.join("\n")}\n}`; } +/** Stack membership for the visible rows of a per-repository listing. */ +export function buildPullRequestStackMembershipsGraphQlQuery( + repository: string, + numbers: ReadonlyArray, +): string | null { + if (numbers.length === 0) return null; + const [owner, name, ...rest] = repository.trim().split("/"); + if (rest.length > 0 || owner === undefined || name === undefined) return null; + if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; + const selections: string[] = []; + for (const [index, number] of numbers.entries()) { + if (!Number.isSafeInteger(number) || number <= 0) return null; + selections.push( + ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${number}) { stack { number size baseRefName } stackEntry { position } } }`, + ); + } + return `query PullRequestStackMemberships {\n${selections.join("\n")}\n}`; +} + +const decodeStackMemberships = decodeJsonResult(RawStackMembershipsSchema); + +export function decodePullRequestStackMembershipsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeStackMemberships(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const memberships = new Map(); + for (const [alias, value] of Object.entries(decoded.success.data)) { + const index = /^s(\d+)$/.exec(alias)?.[1]; + if (index === undefined || value?.pullRequest == null) continue; + const stack = toStackMembership(value.pullRequest); + if (stack !== undefined) memberships.set(Number(index), stack); + } + return Result.succeed(memberships); +} + /** * The counts by the position they were asked in. A repository or a pull request GitHub answered * nothing for is simply absent, which leaves the row with whatever it already had. @@ -2445,3 +2516,77 @@ export function decodePullRequestFilesJson( omittedFileStats, }); } + +/** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ +const RawStackPullRequestSchema = Schema.Struct({ + title: Schema.optional(Schema.String), + draft: Schema.optional(Schema.Boolean), + number: Schema.Int, + head: Schema.Struct({ ref: Schema.String, sha: Schema.optional(Schema.String) }), + state: Schema.optional(Schema.NullOr(Schema.String)), + merged_at: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** + * A stack as `GET /repos/{owner}/{repo}/stacks` answers it, in a public preview whose shape may + * still move. Only what a stack is made of is required — where it lives, what it stands on, and + * its pull requests — and `base` is accepted both as the ref object the preview sends today and + * as the bare branch name it started out as. + */ +const RawStackSchema = Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.Union([Schema.Int, Schema.String]))), + number: Schema.Int, + node_id: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.String, + html_url: Schema.optional(Schema.NullOr(Schema.String)), + base: Schema.Union([Schema.String, Schema.Struct({ ref: Schema.String })]), + pull_requests: Schema.Array(RawStackPullRequestSchema), +}); + +const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema)); + +export interface GitHubPullRequestStackLayer { + readonly title?: string; + readonly isDraft?: boolean; + readonly headSha?: string; + readonly number: number; + readonly headBranch: string; + readonly state: PullRequestState; +} + +export interface GitHubPullRequestStack { + readonly id: string; + readonly number: number; + readonly url: string; + readonly base: string; + /** Bottom to top, which is the order GitHub lists them in. */ + readonly layers: ReadonlyArray; +} + +/** + * The first stack of a `?pull_request=` listing, or null for an empty one: a pull request is in + * at most one stack, so the array is GitHub's way of saying "none" rather than a page. + */ +export function decodePullRequestStacksJson( + raw: string, +): Result.Result { + const decoded = decodeStacks(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const stack = decoded.success[0]; + if (stack === undefined) return Result.succeed(null); + return Result.succeed({ + id: stack.id == null ? (trimmed(stack.node_id) ?? String(stack.number)) : String(stack.id), + number: stack.number, + // The page a person opens where the preview reports one; the API URL is what it always has. + url: trimmed(stack.html_url) ?? stack.url, + base: typeof stack.base === "string" ? stack.base : stack.base.ref, + layers: stack.pull_requests.map((pullRequest) => ({ + ...(pullRequest.title === undefined ? {} : { title: pullRequest.title }), + ...(pullRequest.draft === undefined ? {} : { isDraft: pullRequest.draft }), + ...(pullRequest.head.sha === undefined ? {} : { headSha: pullRequest.head.sha }), + number: pullRequest.number, + headBranch: pullRequest.head.ref, + state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }), + })), + }); +} diff --git a/apps/server/src/pullRequest/githubStackActions.test.ts b/apps/server/src/pullRequest/githubStackActions.test.ts new file mode 100644 index 000000000000..4ed254514ee0 --- /dev/null +++ b/apps/server/src/pullRequest/githubStackActions.test.ts @@ -0,0 +1,413 @@ +import { expect, it } from "@effect/vitest"; +import * as Layer from "effect/Layer"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; + +const runGitHubStackAction = ( + execute: GitHubCli.GitHubCli["Service"]["execute"], + input: Parameters[0], +) => runStackAction(input).pipe(Effect.provide(Layer.mock(GitHubCli.GitHubCli)({ execute }))); + +const stack = [ + { + number: 50, + url: "https://api.github.com/repos/acme/web/stacks/50", + base: { ref: "main" }, + pull_requests: [ + { + number: 1, + title: "Base", + head: { ref: "base", sha: "aaa" }, + state: "closed", + merged_at: "2026-01-01T00:00:00Z", + }, + { + number: 2, + title: "Middle", + head: { ref: "middle", sha: "bbb" }, + state: "open", + draft: false, + }, + { number: 3, title: "Top", head: { ref: "top", sha: "ccc" }, state: "open", draft: false }, + ], + }, +]; +const input = { + cwd: "/repo", + repository: "acme/web", + host: "github.com", + number: 3, + stackNumber: 50, + expectedStackHeads: [ + { number: 2, headSha: "bbb" }, + { number: 3, headSha: "ccc" }, + ], + action: "merge" as const, +}; +const access = { + data: { + repository: { + pr2: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, + pr3: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, + }, + }, +}; + +const branch = (number: number, headRefOid: string, behindBy = 1, processed: string[] = []) => ({ + data: { + processed: processed.map((headRefOid) => ({ headRefOid })), + repository: { + pullRequest: { id: `PR_${number}`, headRefOid, baseRef: { compare: { behindBy } } }, + }, + }, +}); +const rebased = { + data: { updatePullRequestBranch: { pullRequest: { headRefOid: "rebased-sha" } } }, +}; +const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased]; + +function fake(responses: readonly unknown[]) { + const calls: ReadonlyArray[] = []; + const execute: GitHubCli.GitHubCli["Service"]["execute"] = (request) => + Effect.sync(() => { + calls.push(request.args); + const value = responses[calls.length - 1]; + if (value === undefined) throw new Error("Unexpected GitHub request"); + return { + exitCode: ChildProcessSpawner.ExitCode(0), + // @effect-diagnostics-next-line preferSchemaOverJson:off + stdout: JSON.stringify(value), + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + }; + }); + return { execute, calls }; +} + +it.effect("submits one atomic merge with the reviewed head and respects the merge queue", () => + Effect.gen(function* () { + const api = fake([stack, { status: "enqueued", details: {} }]); + yield* runGitHubStackAction(api.execute, { ...input, mergeMethod: "squash" }); + expect(api.calls).toHaveLength(2); + expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); + expect(api.calls[1]).toContain("sha=ccc"); + expect(api.calls[1]).toContain("merge_action=default"); + expect(api.calls[1]).toContain("merge_method=squash"); + }), +); + +it.effect("merges through the selected layer without including later draft layers", () => + Effect.gen(function* () { + const fiveLayers = [ + { + ...stack[0], + pull_requests: Array.from({ length: 5 }, (_, index) => ({ + number: index + 1, + head: { ref: `layer-${index + 1}`, sha: `sha-${index + 1}` }, + state: "open", + draft: index >= 3, + })), + }, + ]; + const api = fake([fiveLayers, { status: "merged", details: {} }]); + yield* runGitHubStackAction(api.execute, { + ...input, + number: 3, + expectedStackHeads: [1, 2, 3].map((number) => ({ number, headSha: `sha-${number}` })), + }); + expect(api.calls).toHaveLength(2); + expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); + expect(api.calls[1]).toContain("sha=sha-3"); + expect(api.calls[1]).toContain("merge_action=default"); + }), +); + +it.effect("rejects stale reviewed heads below a selected middle layer", () => + Effect.gen(function* () { + const api = fake([stack]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + number: 2, + expectedStackHeads: [{ number: 2, headSha: "old-head" }], + }).pipe(Effect.result); + expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); + expect(api.calls).toHaveLength(1); + }), +); + +it.effect("does not merge from an already merged layer", () => + Effect.gen(function* () { + const api = fake([stack]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + number: 1, + expectedStackHeads: [], + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackUnsupportedError" }, + }); + expect(api.calls).toHaveLength(1); + }), +); + +it.effect("polls an accepted merge and reports a later rule rejection", () => + Effect.gen(function* () { + const api = fake([ + stack, + { status: "pending", details: { uuid: "operation" } }, + { status: "failed", details: { message: "Required checks have not passed" } }, + ]); + const fiber = yield* runGitHubStackAction(api.execute, input).pipe( + Effect.result, + Effect.forkChild, + ); + yield* TestClock.adjust("1 second"); + const result = yield* Fiber.join(fiber); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackMergeRejectedError" }, + }); + expect(api.calls[2]).toContain("repos/acme/web/pulls/3/merge-async/operation"); + }), +); + +it.effect("retains stack identity and a rejection response without a message", () => + Effect.gen(function* () { + const rejection = { status: "failed", details: {} }; + const api = fake([stack, rejection]); + const result = yield* runGitHubStackAction(api.execute, input).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "GitHubStackMergeRejectedError", + repository: input.repository, + number: input.number, + stackNumber: input.stackNumber, + cause: rejection, + }, + }); + }), +); + +it.effect("refuses a changed stack before performing any mutation", () => + Effect.gen(function* () { + const api = fake([stack]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + expectedStackHeads: [ + { number: 2, headSha: "old" }, + { number: 3, headSha: "ccc" }, + ], + }).pipe(Effect.result); + expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); + expect(api.calls).toHaveLength(1); + }), +); + +it.effect("rebases unmerged layers bottom to top without local git commands", () => + Effect.gen(function* () { + const api = fake([stack, access, ...rebaseResponses]); + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); + const mutations = api.calls.filter((args) => + args.some((arg) => arg.startsWith("query=mutation")), + ); + expect(mutations).toHaveLength(2); + expect(mutations[0]).toContain("id=PR_2"); + expect(mutations[0]).toContain("sha=bbb"); + expect(mutations[1]).toContain("id=PR_3"); + expect(mutations[1]).toContain("sha=ccc"); + expect(api.calls.every((args) => args[0] === "api")).toBe(true); + }), +); + +it.effect("does not update later layers after a rebase failure", () => + Effect.gen(function* () { + const api = fake([stack, access, branch(2, "bbb")]); + const execute: typeof api.execute = (request) => + !request.args.some((arg) => arg.startsWith("query=mutation")) + ? api.execute(request) + : Effect.fail( + new GitHubCli.GitHubCliAuthenticationError({ + command: "gh", + cwd: "/repo", + cause: new Error("denied"), + }), + ); + const result = yield* runGitHubStackAction(execute, { ...input, action: "update-branch" }).pipe( + Effect.result, + ); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackRebaseFailedError", number: 2, completed: 0 }, + }); + }), +); + +it.effect("refuses the entire rebase before mutation when a later fork denies write access", () => + Effect.gen(function* () { + const api = fake([ + stack, + { + data: { + repository: { + ...access.data.repository, + pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false }, + }, + }, + }, + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackPermissionError" }, + }); + expect(api.calls).toHaveLength(2); + expect(api.calls.every((args) => args[0] === "api")).toBe(true); + }), +); + +it.effect("allows a fork that explicitly permits maintainer updates", () => + Effect.gen(function* () { + const api = fake([ + stack, + { + data: { + repository: { + ...access.data.repository, + pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: true }, + }, + }, + }, + ...rebaseResponses, + ]); + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); + expect(api.calls.at(-1)).toContain("id=PR_3"); + }), +); + +it.effect("bounds polling and reports a still-running merge without claiming success", () => + Effect.gen(function* () { + const api = fake([ + stack, + ...Array.from({ length: 40 }, () => ({ status: "pending", details: { uuid: "operation" } })), + ]); + const fiber = yield* runGitHubStackAction(api.execute, input).pipe( + Effect.result, + Effect.forkChild, + ); + yield* TestClock.adjust("6 minutes"); + expect(yield* Fiber.join(fiber)).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackMergePendingError" }, + }); + expect(api.calls.length).toBeLessThan(40); + }), +); + +it.effect("rejects a push after preflight without rebasing the new revision", () => + Effect.gen(function* () { + const api = fake([stack, access, branch(2, "new-head")]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackChangedError", number: 2, completed: 0 }, + }); + expect(api.calls).toHaveLength(3); + }), +); + +it.effect("skips current layers without submitting a rebase mutation", () => + Effect.gen(function* () { + const api = fake([stack, access, branch(2, "bbb", 0), branch(3, "ccc", 0, ["bbb"])]); + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); + expect(api.calls.some((args) => args.some((arg) => arg.startsWith("query=mutation")))).toBe( + false, + ); + }), +); + +it.effect("keeps earlier progress and stops after a later layer fails", () => + Effect.gen(function* () { + const api = fake([ + stack, + access, + branch(2, "bbb"), + rebased, + branch(3, "ccc", 1, ["rebased-sha"]), + { data: { updatePullRequestBranch: null } }, + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackRebaseFailedError", number: 3, completed: 1 }, + }); + }), +); + +it.effect("reports partial progress when a later head changes during the rebase", () => + Effect.gen(function* () { + const api = fake([ + stack, + access, + branch(2, "bbb"), + rebased, + branch(3, "concurrent-head", 1, ["rebased-sha"]), + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackChangedError", number: 3, completed: 1 }, + }); + if (result._tag === "Failure") { + expect(result.failure.message).toContain("Earlier updates remain on GitHub"); + } + expect( + api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), + ).toHaveLength(1); + }), +); + +it.effect.each([false, true])("rejects a push to a processed layer, rebased=%s", (rebasedParent) => + Effect.gen(function* () { + const api = fake([ + stack, + access, + branch(2, "bbb", rebasedParent ? 1 : 0), + ...(rebasedParent ? [rebased] : []), + branch(3, "ccc", 1, ["concurrent-parent-head"]), + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackChangedError", number: 2, completed: 1 }, + }); + expect(api.calls.at(-1)?.some((arg) => arg.includes('processed:nodes(ids:["PR_2"])'))).toBe( + true, + ); + expect( + api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), + ).toHaveLength(rebasedParent ? 1 : 0); + }), +); diff --git a/apps/server/src/pullRequest/githubStackActions.ts b/apps/server/src/pullRequest/githubStackActions.ts new file mode 100644 index 000000000000..6e72836e6e40 --- /dev/null +++ b/apps/server/src/pullRequest/githubStackActions.ts @@ -0,0 +1,418 @@ +import type { + PullRequestAction, + PullRequestMergeMethod, + PullRequestStackHead, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; + +const stackErrorIdentity = { + repository: Schema.String, + number: Schema.Int, + stackNumber: Schema.Int, +}; + +export class GitHubStackChangedError extends Schema.TaggedError()( + "GitHubStackChangedError", + { ...stackErrorIdentity, completed: Schema.Int }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return this.completed > 0 + ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.` + : "The stack changed. Refresh it before trying again."; + } +} + +export class GitHubStackUnsupportedError extends Schema.TaggedError()( + "GitHubStackUnsupportedError", + stackErrorIdentity, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "This operation is not supported for this stack."; + } +} + +export class GitHubStackResponseInvalidError extends Schema.TaggedError()( + "GitHubStackResponseInvalidError", + { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "GitHub returned an unreadable stack operation response."; + } +} + +export class GitHubStackMergeRejectedError extends Schema.TaggedError()( + "GitHubStackMergeRejectedError", + { ...stackErrorIdentity, cause: Schema.Defect() }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements."; + } +} + +export class GitHubStackMergePendingError extends Schema.TaggedError()( + "GitHubStackMergePendingError", + stackErrorIdentity, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "The merge is still running on GitHub. Check its status there before submitting another request."; + } +} + +export class GitHubStackPermissionError extends Schema.TaggedError()( + "GitHubStackPermissionError", + stackErrorIdentity, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying."; + } +} + +export class GitHubStackRebaseFailedError extends Schema.TaggedError()( + "GitHubStackRebaseFailedError", + { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`; + } +} + +export type GitHubStackActionError = + | GitHubStackChangedError + | GitHubStackUnsupportedError + | GitHubStackResponseInvalidError + | GitHubStackMergeRejectedError + | GitHubStackMergePendingError + | GitHubStackPermissionError + | GitHubStackRebaseFailedError; + +const MergeResponse = Schema.Struct({ + status: Schema.Literals(["pending", "merged", "enqueued", "failed"]), + details: Schema.Struct({ + uuid: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + }), +}); + +const decodeBranchAccess = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr( + Schema.Struct({ + headRepository: Schema.NullOr( + Schema.Struct({ viewerPermission: Schema.NullOr(Schema.String) }), + ), + maintainerCanModify: Schema.Boolean, + }), + ), + ), + ), + }), + }), + ), +); + +const decodeRebaseBranch = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + processed: Schema.optional( + Schema.Array(Schema.NullOr(Schema.Struct({ headRefOid: Schema.String }))), + ), + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + id: Schema.String, + headRefOid: Schema.String, + baseRef: Schema.Struct({ compare: Schema.Struct({ behindBy: Schema.Int }) }), + }), + }), + }), + }), + ), +); +const decodeRebaseResponse = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + updatePullRequestBranch: Schema.Struct({ + pullRequest: Schema.Struct({ headRefOid: Schema.String }), + }), + }), + }), + ), +); + +const encodeNodeIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); + +const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse)); + +/** Remote-only updates: a stack rebase never switches or rewrites the environment's checkout. */ +export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* (input: { + cwd: string; + repository: string; + host: string; + number: number; + stackNumber: number; + expectedStackHeads?: ReadonlyArray; + action: PullRequestAction; + mergeMethod?: PullRequestMergeMethod; +}) { + const github = yield* GitHubCli.GitHubCli; + const identity = { + repository: input.repository, + number: input.number, + stackNumber: input.stackNumber, + }; + if (input.action !== "merge" && input.action !== "update-branch") + return yield* new GitHubStackUnsupportedError({ ...identity }); + const endpoint = `repos/${input.repository}`; + const read = yield* github.execute({ + cwd: input.cwd, + args: ["api", "--hostname", input.host, `${endpoint}/stacks?pull_request=${input.number}`], + }); + const decoded = decodePullRequestStacksJson(read.stdout); + if (Result.isFailure(decoded)) + return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure }); + const stack = decoded.success; + const targetIndex = stack?.layers.findIndex((layer) => layer.number === input.number) ?? -1; + const target = stack?.layers[targetIndex]; + if ( + stack?.number !== input.stackNumber || + target === undefined || + (input.action === "update-branch" && targetIndex !== stack.layers.length - 1) + ) { + return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); + } + const affectedLayers = + input.action === "merge" ? stack.layers.slice(0, targetIndex + 1) : stack.layers; + const open = affectedLayers.filter((layer) => layer.state !== "merged"); + if (input.action === "merge" && target.state !== "open") + return yield* new GitHubStackUnsupportedError({ ...identity }); + if ( + !input.expectedStackHeads || + input.expectedStackHeads.length !== open.length || + new Set(input.expectedStackHeads.map((layer) => layer.number)).size !== open.length || + open.some( + (layer) => + !layer.headSha || + !input.expectedStackHeads?.some( + (expected) => expected.number === layer.number && expected.headSha === layer.headSha, + ), + ) + ) { + return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); + } + if (open.length === 0 || open.some((layer) => layer.state !== "open")) + return yield* new GitHubStackUnsupportedError({ ...identity }); + if (input.action === "update-branch") { + const [owner, name] = input.repository.split("/"); + const permissions = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "graphql", + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + "-f", + `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open + .map( + (layer) => + `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`, + ) + .join(" ")}}}`, + ], + }); + const access = yield* decodeBranchAccess(permissions.stdout).pipe( + Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), + ); + // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent + // will make it stale. Check branch write access separately before touching any layer. + if ( + open.some((layer) => { + const pr = access.data.repository?.[`pr${layer.number}`]; + return ( + !pr?.headRepository || + (!pr.maintainerCanModify && + !["ADMIN", "MAINTAIN", "WRITE"].includes(pr.headRepository.viewerPermission ?? "")) + ); + }) + ) + return yield* new GitHubStackPermissionError({ ...identity }); + const processed: Array<{ id: string; number: number; headSha: string }> = []; + for (const [index, layer] of open.entries()) { + yield* Effect.gen(function* () { + const read = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "graphql", + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + "-F", + `number=${layer.number}`, + "-f", + `sha=${layer.headSha}`, + "-f", + `query=query($owner:String!,$name:String!,$number:Int!,$sha:String!){${ + processed.length === 0 + ? "" + : `processed:nodes(ids:${encodeNodeIds(processed.map((head) => head.id))}){... on PullRequest{headRefOid}}` + } repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`, + ], + }); + const { + data: { + processed: observed, + repository: { pullRequest: pr }, + }, + } = yield* decodeRebaseBranch(read.stdout); + // A push to an earlier layer must not silently become the next layer's new base. + const changed = processed.find( + (head, index) => observed?.[index]?.headRefOid !== head.headSha, + ); + if (changed !== undefined) + return yield* new GitHubStackChangedError({ + ...identity, + number: changed.number, + completed: index, + }); + if (pr.headRefOid !== layer.headSha) + return yield* new GitHubStackChangedError({ + ...identity, + number: layer.number, + completed: index, + }); + if (pr.baseRef.compare.behindBy === 0) { + processed.push({ id: pr.id, number: layer.number, headSha: pr.headRefOid }); + return; + } + // Pass the reviewed revision to GitHub, including when a push races this read. + const updated = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "graphql", + "-f", + `id=${pr.id}`, + "-f", + `sha=${layer.headSha}`, + "-f", + "query=mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}", + ], + }); + const response = yield* decodeRebaseResponse(updated.stdout); + processed.push({ + id: pr.id, + number: layer.number, + headSha: response.data.updatePullRequestBranch.pullRequest.headRefOid, + }); + }).pipe( + Effect.mapError((cause) => + cause._tag === "GitHubStackChangedError" + ? cause + : new GitHubStackRebaseFailedError({ + ...identity, + number: layer.number, + completed: index, + cause, + }), + ), + ); + } + return; + } + if (open.some((layer) => layer.isDraft)) + return yield* new GitHubStackUnsupportedError({ ...identity }); + const decode = (raw: string) => + decodeMergeResponse(raw).pipe( + Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), + ); + const request = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "--method", + "PUT", + `${endpoint}/pulls/${input.number}/merge-async`, + "-f", + `merge_method=${input.mergeMethod ?? "merge"}`, + "-f", + "merge_action=default", + "-f", + `sha=${target.headSha}`, + ], + }); + let result = yield* decode(request.stdout); + const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000; + for ( + let attempt = 0; + result.status === "pending" && (yield* Clock.currentTimeMillis) < deadline; + attempt++ + ) { + const uuid = result.details.uuid; + if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity }); + yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000)); + const poll = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`, + ], + }); + result = yield* decode(poll.stdout); + } + if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity }); + if (result.status === "failed") + return yield* new GitHubStackMergeRejectedError({ ...identity, cause: result }); +}); diff --git a/apps/server/src/pullRequest/linkedThreads.test.ts b/apps/server/src/pullRequest/linkedThreads.test.ts new file mode 100644 index 000000000000..08237d0e3ef4 --- /dev/null +++ b/apps/server/src/pullRequest/linkedThreads.test.ts @@ -0,0 +1,117 @@ +import { assert, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { listLinkedPullRequestThreads } from "./linkedThreads.ts"; + +it.effect( + "finds active and archived threads for exactly one pull request, excluding deleted and dismissed links", + () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-09-01T00:00:00.000Z"; + const archivedAt = "2026-09-03T00:00:00.000Z"; + yield* sql` + INSERT INTO projection_projects (project_id, title, workspace_root, scripts_json, created_at, updated_at) + VALUES ('project-1', 'Project', '/tmp/project', '[]', ${createdAt}, ${createdAt}) + `; + const fixtures = [ + { + id: "azure", + host: "dev.azure.com", + repository: "org/project/_git/web", + number: 7, + source: "manual", + }, + { + id: "other-org", + host: "dev.azure.com", + repository: "other/project/_git/web", + number: 7, + source: "manual", + }, + { id: "active", host: "github.com", repository: "acme/web", number: 7, source: "manual" }, + { + id: "archived", + host: "github.com", + repository: "acme/web", + number: 7, + source: "created", + }, + { id: "deleted", host: "github.com", repository: "acme/web", number: 7, source: "manual" }, + { + id: "dismissed", + host: "github.com", + repository: "acme/web", + number: 7, + source: "stack-dismissed", + }, + { + id: "other-host", + host: "github.example.com", + repository: "acme/web", + number: 7, + source: "manual", + }, + { + id: "other-repository", + host: "github.com", + repository: "acme/api", + number: 7, + source: "manual", + }, + { + id: "other-number", + host: "github.com", + repository: "acme/web", + number: 8, + source: "manual", + }, + ]; + for (const fixture of fixtures) { + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, created_at, updated_at, archived_at, deleted_at + ) VALUES ( + ${fixture.id}, 'project-1', ${fixture.id}, '{"instanceId":"codex","model":"gpt-5.4"}', + ${createdAt}, ${fixture.id === "archived" ? archivedAt : createdAt}, + ${fixture.id === "archived" ? archivedAt : null}, + ${fixture.id === "deleted" ? archivedAt : null} + ) + `; + yield* sql` + INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at) + VALUES (${fixture.id}, ${fixture.host}, ${fixture.repository}, ${fixture.number}, + 'https://github.com/acme/web/pull/7', ${fixture.source}, ${createdAt}) + `; + } + + expect( + (yield* listLinkedPullRequestThreads({ + host: "org.visualstudio.com", + repository: "project/_git/web", + number: 7, + })).threads.map((thread) => thread.id), + ).toEqual(["azure"]); + const result = yield* listLinkedPullRequestThreads({ + host: "GitHub.Com", + repository: "ACME/WEB", + number: 7, + }); + expect(result).toEqual({ + threads: [ + { id: "archived", projectId: "project-1", title: "archived", archivedAt }, + { id: "active", projectId: "project-1", title: "active", archivedAt: null }, + ], + }); + assert.deepStrictEqual( + yield* listLinkedPullRequestThreads({ + host: "github.com", + repository: "acme/web", + number: 99, + }), + { threads: [] }, + ); + }).pipe(Effect.provide(SqlitePersistenceMemory)), +); diff --git a/apps/server/src/pullRequest/linkedThreads.ts b/apps/server/src/pullRequest/linkedThreads.ts new file mode 100644 index 000000000000..26c751f0b817 --- /dev/null +++ b/apps/server/src/pullRequest/linkedThreads.ts @@ -0,0 +1,39 @@ +import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { + PullRequestLinkedThreadsResult, + PullRequestOperationError, + type ThreadPullRequestKey, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +const decodeLinkedThreads = Schema.decodeUnknownEffect(PullRequestLinkedThreadsResult); + +export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThreads")( + function* (input: ThreadPullRequestKey) { + const key = normalizeThreadPullRequestKey(input); + const sql = yield* SqlClient.SqlClient; + const threads = yield* sql` + SELECT t.thread_id AS id, t.project_id AS "projectId", t.title, + t.archived_at AS "archivedAt" + FROM projection_thread_pull_requests AS link + JOIN projection_threads AS t ON t.thread_id = link.thread_id + WHERE link.host = ${key.host.toLowerCase()} + AND link.repository = ${key.repository.toLowerCase()} + AND link.number = ${key.number} + AND link.source != 'stack-dismissed' + AND t.deleted_at IS NULL + ORDER BY t.updated_at DESC, t.thread_id ASC + `; + return yield* decodeLinkedThreads({ threads }); + }, + Effect.mapError( + (cause) => + new PullRequestOperationError({ + operation: "linkedThreads", + detail: "Could not load linked threads.", + cause, + }), + ), +); diff --git a/apps/server/src/pullRequest/pullRequestSyncKey.test.ts b/apps/server/src/pullRequest/pullRequestSyncKey.test.ts new file mode 100644 index 000000000000..2b21e20d15fc --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestSyncKey.test.ts @@ -0,0 +1,46 @@ +import { ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { pullRequestSyncKey } from "./pullRequestSyncKey.ts"; + +describe("pullRequestSyncKey", () => { + const reference = { projectId: ProjectId.make("project"), repository: "web", number: 7 }; + it.each([ + "dev.azure.com/org/project/_git/web", + "ssh.dev.azure.com/v3/org/project/web", + "vs-ssh.visualstudio.com/v3/org/project/web", + "org.visualstudio.com/defaultcollection/project/_git/web", + ])("resolves hostless and checkout-host Azure references for %s", (canonicalKey) => { + const identity = { + canonicalKey, + provider: "azure-devops", + name: "web", + displayName: canonicalKey.split("/").slice(1).join("/"), + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: `https://${canonicalKey}`, + }, + }; + const expected = { host: "dev.azure.com", repository: "org/project/_git/web", number: 7 }; + expect(pullRequestSyncKey(reference, identity)).toEqual(expected); + expect( + pullRequestSyncKey({ ...reference, host: canonicalKey.split("/")[0]! }, identity), + ).toEqual(expected); + expect(pullRequestSyncKey({ ...reference, repository: "other" }, identity)).toBeNull(); + expect(pullRequestSyncKey({ ...reference, host: "unrelated.test" }, identity)).toBeNull(); + }); + it("normalizes complete hosted aliases without requiring a checkout", () => { + expect( + pullRequestSyncKey({ + ...reference, + host: "org.visualstudio.com", + repository: "project/_git/web", + }), + ).toEqual({ host: "dev.azure.com", repository: "org/project/_git/web", number: 7 }); + expect( + pullRequestSyncKey({ ...reference, host: "github.com", repository: "acme/web" }), + ).toEqual({ host: "github.com", repository: "acme/web", number: 7 }); + expect(pullRequestSyncKey(reference)).toBeNull(); + }); +}); diff --git a/apps/server/src/pullRequest/pullRequestSyncKey.ts b/apps/server/src/pullRequest/pullRequestSyncKey.ts new file mode 100644 index 000000000000..334d48762f69 --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestSyncKey.ts @@ -0,0 +1,44 @@ +import { + pullRequestHostOf, + type PullRequestRef, + type RepositoryIdentity, + type SourceControlProviderKind, + type ThreadPullRequestKey, +} from "@t3tools/contracts"; +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; +import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; + +/** Convert checkout-scoped references to the host-level identity used by linked threads. */ +export function pullRequestSyncKey( + reference: PullRequestRef, + identity?: RepositoryIdentity | null, +): ThreadPullRequestKey | null { + if (identity?.provider === "azure-devops" && !reference.repository.includes("/")) { + if ( + reference.repository.toLowerCase() !== + sourceControlRepositorySelector(identity)?.toLowerCase() || + (reference.host !== undefined && + reference.host.toLowerCase() !== pullRequestHostOf(identity, "azure-devops")) + ) + return null; + const [host, ...repository] = identity.canonicalKey.split("/"); + if (!host || repository.length === 0) return null; + return normalizeThreadPullRequestKey({ + host, + repository: repository.join("/"), + number: reference.number, + }); + } + const host = + reference.host ?? + (identity + ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind) + : undefined); + return host === undefined + ? null + : normalizeThreadPullRequestKey({ + host, + repository: reference.repository, + number: reference.number, + }); +} diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 59049500729d..1e7cdc3f00e6 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -334,6 +334,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: now, updatedAt: now, @@ -477,6 +478,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: { turnId: "turn-1" as TurnId, state: "running", @@ -668,6 +670,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: { turnId: "turn-1" as TurnId, state: "running", diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2f32b6524d7b..afb4adfef79c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5,6 +5,7 @@ import * as NodeCrypto from "node:crypto"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { + type DeviceServiceState, AuthAccessTokenType, AuthStandardClientScopes, AuthEnvironmentBootstrapTokenType, @@ -97,6 +98,7 @@ const encodeTestJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unk import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; +import * as DeviceService from "./device/DeviceService.ts"; import { HTTP_ROUTER_CONFIG, makeRoutesLayer } from "./server.ts"; import { isThreadDetailEvent, @@ -117,6 +119,7 @@ import { } from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; +import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEventStore.ts"; @@ -331,6 +334,7 @@ const makeDefaultOrchestrationReadModel = () => { runtimeMode: "full-access" as const, branch: null, worktreePath: null, + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, @@ -361,6 +365,7 @@ const makeDefaultOrchestrationThreadShell = ( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: now, updatedAt: now, @@ -384,7 +389,7 @@ const browserOtlpTracingLayer = Layer.mergeAll( const makeAuthTestLayer = () => EnvironmentAuth.layer.pipe( - Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(SqlitePersistenceMemory), Layer.provide(ServerSecretStore.layer), Layer.provide( Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({ @@ -802,6 +807,11 @@ const buildAppUnderTest = (options?: { listBindings: () => Effect.succeed([]), ...options?.layers?.providerSessionDirectory, }), + Layer.mock(DeviceService.DeviceService)({ + state: Effect.succeed(EMPTY_DEVICE_STATE), + currentReadiness: () => Effect.succeed(null), + sessionsForThread: () => Effect.succeed([]), + }), ), ), Layer.provide( @@ -956,6 +966,11 @@ const buildAppUnderTest = (options?: { drainThrough: () => Effect.void, ...options?.layers?.threadDeletionReactor, }), + Layer.mock(PullRequestSyncReactor.PullRequestSyncReactor)({ + start: () => Effect.void, + drain: Effect.void, + requestSync: () => Effect.void, + }), ), ), Layer.provide( @@ -1652,6 +1667,18 @@ const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe( ), ); +const EMPTY_DEVICE_STATE: DeviceServiceState = { + hosts: [], + hostStatus: "disabled", + hostStatuses: {}, + devices: [], + sessions: [], + onboardingCompleted: false, + agentAccessEnabled: false, + hubBasePath: DeviceService.DEVICE_HUB_ROUTE_PREFIX, + revision: 0, +}; + it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("parks HTTP ingress until command readiness", () => Effect.gen(function* () { @@ -8104,6 +8131,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { runtimeMode: "full-access" as const, branch: null, worktreePath: null, + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index fd8ee4a4f699..e3b42a2637a1 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -57,6 +57,8 @@ import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; +import * as DeviceService from "./device/DeviceService.ts"; +import { deviceHubProxyRouteLayer } from "./device/DeviceHubProxy.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as ProcessRunner from "./processRunner.ts"; @@ -71,6 +73,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; +import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; @@ -92,6 +95,7 @@ import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; +import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; import * as SourceControlRateLimit from "./sourceControl/SourceControlRateLimit.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; @@ -280,6 +284,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), Layer.provideMerge(ThreadSettlementReactor.layer), + Layer.provideMerge(PullRequestSyncReactor.layer), Layer.provideMerge(ThreadPullRequestReactor.layer), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), @@ -316,6 +321,7 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay const PullRequestServiceLive = PullRequestService.layer.pipe( Layer.provide(PullRequestProviderRegistry.layer), + Layer.provide(PullRequestReadCache.layer), Layer.provide(SourceControlProviderRegistryLayerLive), Layer.provide(SourceControlRateLimit.layer), ); @@ -382,6 +388,12 @@ const PreviewLayerLive = Layer.empty.pipe( Layer.provideMerge(PortScannerLayerLive), ); +const DeviceLayerLive = DeviceService.layer.pipe( + Layer.provide(ServerSettingsLayerLive), + Layer.provide(ProcessRunner.layer), + Layer.provide(NetService.layer), +); + const WorkspaceEntriesLayerLive = WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer)); const WorkspaceFileSystemLayerLive = WorkspaceFileSystem.layer.pipe( @@ -465,7 +477,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), - Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), + Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive, DeviceLayerLive)), Layer.provideMerge(PersistenceLayerLive), // Both read a user-owned file out of the state directory and stream changes // to clients; neither depends on the other. @@ -549,6 +561,7 @@ export const makeRoutesLayer = Layer.mergeAll( otlpTracesProxyRouteLayer, assetRouteLayer, attachmentUploadRouteLayer, + deviceHubProxyRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 740be1330817..f59a753d9c72 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -70,6 +70,7 @@ import { type TerminalError, type TerminalEvent, type TerminalMetadataStreamEvent, + type PullRequestRef, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -113,6 +114,7 @@ import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; +import * as DeviceService from "./device/DeviceService.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; @@ -124,6 +126,7 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; @@ -142,6 +145,10 @@ import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; +import { listLinkedPullRequestThreads } from "./pullRequest/linkedThreads.ts"; +import { pullRequestSyncKey } from "./pullRequest/pullRequestSyncKey.ts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -476,7 +483,18 @@ const makeWsRpcLayer = ( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; const crypto = yield* Crypto.Crypto; + const sql = yield* SqlClient.SqlClient; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + /** A reference's host-level link key; the project's own host where the ref names none. */ + const resolvePullRequestSyncKey = (reference: PullRequestRef) => + reference.host !== undefined && reference.repository.includes("/") + ? Effect.succeed(pullRequestSyncKey(reference)) + : projectionSnapshotQuery.getProjectShellById(reference.projectId).pipe( + Effect.map((project) => + pullRequestSyncKey(reference, Option.getOrUndefined(project)?.repositoryIdentity), + ), + Effect.orElseSucceed(() => null), + ); const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const threadDeletionReactor = yield* ThreadDeletionReactor; const analytics = yield* AnalyticsService.AnalyticsService; @@ -519,6 +537,7 @@ const makeWsRpcLayer = ( const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; const terminalManager = yield* TerminalManager.TerminalManager; const previewManager = yield* PreviewManager.PreviewManager; + const deviceService = yield* DeviceService.DeviceService; const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerService = yield* ProviderService.ProviderService; @@ -604,6 +623,7 @@ const makeWsRpcLayer = ( const sourceControlRepositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; const pullRequests = yield* PullRequestService.PullRequestService; + const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; @@ -2133,6 +2153,24 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.pullRequestsSummary, pullRequests.summary(input), { "rpc.aggregate": "pull-requests", }), + [WS_METHODS.pullRequestsStack]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsStack, pullRequests.stack(input), { + "rpc.aggregate": "pull-requests", + }), + [WS_METHODS.pullRequestsLinkedThreads]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsLinkedThreads, + resolvePullRequestSyncKey(input).pipe( + Effect.flatMap((key) => + key === null + ? Effect.succeed({ threads: [] }) + : listLinkedPullRequestThreads(key).pipe( + Effect.provideService(SqlClient.SqlClient, sql), + ), + ), + ), + { "rpc.aggregate": "pull-requests" }, + ), [WS_METHODS.pullRequestsDetail]: (input) => observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), { "rpc.aggregate": "pull-requests", @@ -2156,9 +2194,21 @@ const makeWsRpcLayer = ( { "rpc.aggregate": "pull-requests" }, ), [WS_METHODS.pullRequestsRunAction]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsRunAction, pullRequests.runAction(input), { - "rpc.aggregate": "pull-requests", - }), + observeRpcEffect( + WS_METHODS.pullRequestsRunAction, + pullRequests + .runAction(input) + .pipe( + Effect.tap(() => + resolvePullRequestSyncKey(input).pipe( + Effect.flatMap((key) => + key === null ? Effect.void : pullRequestSync.requestSync(key), + ), + ), + ), + ), + { "rpc.aggregate": "pull-requests" }, + ), [WS_METHODS.pullRequestsUpdate]: (input) => observeRpcEffect(WS_METHODS.pullRequestsUpdate, pullRequests.update(input), { "rpc.aggregate": "pull-requests", @@ -2196,9 +2246,23 @@ const makeWsRpcLayer = ( "rpc.aggregate": "pull-requests", }), [WS_METHODS.pullRequestsInvalidate]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsInvalidate, pullRequests.invalidate(input), { - "rpc.aggregate": "pull-requests", - }), + observeRpcEffect( + WS_METHODS.pullRequestsInvalidate, + pullRequests.invalidate(input).pipe( + // A reader asking for fresh host state also wants the thread badges it feeds to + // catch up, including a merged link the sweep would otherwise never revisit. + Effect.andThen( + input.reference === undefined + ? Effect.void + : resolvePullRequestSyncKey(input.reference).pipe( + Effect.flatMap((key) => + key === null ? Effect.void : pullRequestSync.requestSync(key), + ), + ), + ), + ), + { "rpc.aggregate": "pull-requests" }, + ), [WS_METHODS.pullRequestsSubscribeRefreshes]: () => observeRpcStream( WS_METHODS.pullRequestsSubscribeRefreshes, @@ -2507,8 +2571,25 @@ const makeWsRpcLayer = ( .pipe( Effect.matchCauseEffect({ onFailure: (cause) => Queue.failCause(queue, cause), - onSuccess: () => - refreshGitStatus(input.cwd).pipe( + onSuccess: (result) => + (input.threadId === undefined + ? Effect.void + : linkCreatedPullRequest({ + threadId: input.threadId, + result, + commandId: serverCommandId("pr-created-link"), + }).pipe( + Effect.provideService( + OrchestrationEngine.OrchestrationEngineService, + orchestrationEngine, + ), + Effect.provideService( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + projectionSnapshotQuery, + ), + ) + ).pipe( + Effect.andThen(refreshGitStatus(input.cwd)), Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), ), }), @@ -2685,6 +2766,44 @@ const makeWsRpcLayer = ( observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, { "rpc.aggregate": "preview", }), + [WS_METHODS.deviceConfigure]: (input) => + observeRpcEffect(WS_METHODS.deviceConfigure, deviceService.configure(input), { + "rpc.aggregate": "device", + }), + [WS_METHODS.deviceTestHost]: (input) => + observeRpcEffect(WS_METHODS.deviceTestHost, deviceService.testHost(input), { + "rpc.aggregate": "device", + }), + [WS_METHODS.deviceList]: (_input) => + observeRpcEffect(WS_METHODS.deviceList, deviceService.list, { + "rpc.aggregate": "device", + }), + [WS_METHODS.deviceOpen]: (input) => + observeRpcEffect(WS_METHODS.deviceOpen, deviceService.open(input), { + "rpc.aggregate": "device", + }), + [WS_METHODS.deviceClose]: (input) => + observeRpcEffect(WS_METHODS.deviceClose, deviceService.close(input), { + "rpc.aggregate": "device", + }), + [WS_METHODS.deviceShutdown]: (input) => + observeRpcEffect(WS_METHODS.deviceShutdown, deviceService.shutdown(input), { + "rpc.aggregate": "device", + }), + [WS_METHODS.deviceDetail]: (input) => + observeRpcEffect(WS_METHODS.deviceDetail, deviceService.detail(input), { + "rpc.aggregate": "device", + }), + [WS_METHODS.deviceAction]: (input) => + observeRpcEffect(WS_METHODS.deviceAction, deviceService.action(input), { + "rpc.aggregate": "device", + }), + [WS_METHODS.subscribeDeviceState]: (_input) => + observeRpcStream( + WS_METHODS.subscribeDeviceState, + DeviceService.stateStream(deviceService), + { "rpc.aggregate": "device" }, + ), [WS_METHODS.subscribeDiscoveredLocalServers]: (input) => observeRpcStream( WS_METHODS.subscribeDiscoveredLocalServers, @@ -2932,6 +3051,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( ), }); const pullRequests = yield* PullRequestService.PullRequestService; + const sql = yield* SqlClient.SqlClient; return HttpRouter.add( "GET", "/ws", @@ -2966,6 +3086,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( previewAutomationBroker, ).pipe( Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)), Layer.provide(AgentSessionScanner.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), diff --git a/apps/web/package.json b/apps/web/package.json index c2ce73b42b11..16579e730f1b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,8 @@ "class-variance-authority": "^0.7.1", "culori": "^4.0.2", "effect": "catalog:", + "hast-util-to-html": "^9.0.5", + "hast-util-to-jsx-runtime": "^2.3.6", "heic-to": "^1.5.2", "jose": "catalog:", "jsonc-parser": "3.3.1", @@ -59,6 +61,7 @@ "@types/babel__core": "^7.20.5", "@types/compression": "^1.8.1", "@types/culori": "^4.0.1", + "@types/mdast": "^4.0.4", "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", "@types/react-test-renderer": "19.1.0", @@ -68,6 +71,7 @@ "compression": "^1.8.1", "react-test-renderer": "19.2.6", "tailwindcss": "^4.0.0", + "unified": "^11.0.5", "vite": "catalog:", "vite-plus": "catalog:" } diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 2a953132992c..7bf122c46817 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -1,9 +1,18 @@ +import type { EnvironmentId, PullRequestLinkedThreadsResult } from "@t3tools/contracts"; + +export interface CommandPaletteLinkedThreads { + readonly environmentId: EnvironmentId; + readonly threads: PullRequestLinkedThreadsResult["threads"]; +} + // Tiny event bus allowing components to programmatically open the command palette // without owning its React state. const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; export interface CommandPaletteOpenDetail { readonly open?: "add-project" | "new-thread-in"; + readonly query?: string; + readonly linkedThreads?: CommandPaletteLinkedThreads; } export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index e9b5360cbc45..4bb5f0aefd89 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,3 +1,6 @@ +import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; +import { useRightPanelStore } from "../rightPanelStore"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { @@ -47,7 +50,12 @@ import { sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; -import { ChangeRequestStatusIcon, prStatusIndicator } from "./ThreadStatusIndicators"; +import { + ThreadPullRequestBadgeControl, + prStatusIndicator, + resolveThreadPullRequestBadge, + useLinkedThreadPullRequest, +} from "./ThreadStatusIndicators"; import { Button } from "./ui/button"; import { Switch } from "./ui/switch"; import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; @@ -612,7 +620,7 @@ export function BranchToolbarBranchSelector({ startFromOrigin, }); - // PR pill shown next to the branch selector when the active branch has one. + // Branch status is the fallback when this thread has no linked pull requests. const branchPrBranch = resolveBranchToolbarPrBranch({ activeThreadBranch, resolvedActiveBranch, @@ -621,12 +629,27 @@ export function BranchToolbarBranchSelector({ branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch ? (branchStatusQuery.data.pr ?? null) : null; - const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider); - // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's - // state-description tooltip. - const branchPrTooltip = branchPr - ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state})` - : ""; + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId); + const linkedStatus = useLinkedThreadPullRequest( + environmentId, + serverThread?.linkedPullRequest, + true, + serverThread?.pullRequests, + serverThread?.branchPullRequest, + ); + const currentLinkedPr = supportsMultiplePullRequests + ? resolveThreadCurrentPullRequestLink(serverThread?.pullRequests ?? []) + : null; + const prBadge = supportsMultiplePullRequests + ? resolveThreadPullRequestBadge(serverThread?.pullRequests) + : null; + const displayedPr = linkedStatus?.pr ?? (currentLinkedPr === null ? branchPr : null); + const displayedPrStatus = prStatusIndicator( + displayedPr, + linkedStatus?.sourceControlProvider ?? branchStatusQuery.data?.sourceControlProvider, + ); + const prNumber = currentLinkedPr?.number ?? displayedPr?.number; + const prUrl = currentLinkedPr?.url ?? displayedPr?.url; const openPrLink = useOpenPrLink(threadRef); function renderPickerItem(itemValue: string, index: number) { @@ -730,41 +753,17 @@ export function BranchToolbarBranchSelector({ className={cn("flex min-w-0 items-center gap-1", className)} data-composer-context-control > - {branchPr && branchPrStatus ? ( - - openPrLink(event, branchPrStatus.url)} - className={cn( - "inline-flex shrink-0 items-center gap-0.5 rounded px-1 py-0.5 text-[11px] font-medium tabular-nums transition-colors hover:bg-muted/60", - branchPrStatus.colorClass, - )} - /> - } - > - - - - #{branchPr.number} - - - - {branchPrTooltip} - - ) : null} + useRightPanelStore.getState().open(threadRef, "pull-requests")} + onOpenPullRequest={(event) => { + if (prUrl) openPrLink(event, prUrl); + }} + /> {/* Context menu lives on the wrapper: the disabled Button has pointer-events-none, so the trigger itself never sees right-clicks while refs are loading or a branch action is pending. */} diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 2c4a1fa7af6e..adc3ddb77444 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -43,6 +43,7 @@ vi.mock("../state/session", async (importOriginal) => ({ vi.mock("../state/entities", () => ({ readThreadShell: () => null, useProjects: () => [], + useServerConfigs: () => new Map(), })); vi.mock("../remoteOpen", () => ({ useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), @@ -52,8 +53,7 @@ vi.mock("../editorPreferences", () => ({ usePreferredEditor: () => [null, vi.fn()], })); vi.mock("~/lib/openPullRequestLink", () => ({ - findProjectForChangeRequest: () => undefined, - matchesLinkedPullRequestUrl: () => false, + findProjectOnChangeRequestHost: () => undefined, parseChangeRequestUrl: () => null, useOpenChangeRequestLink: () => vi.fn(), })); @@ -110,13 +110,36 @@ describe("ChatMarkdown favicon privacy", () => { }); describe("ChatMarkdown streaming", () => { + it("does not retokenize completed lines when streaming finishes", async () => { + const highlighter = await getSyntaxHighlighterPromise("typescript"); + const highlight = vi.spyOn(highlighter, "codeToHast"); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let renderer: ReactTestRenderer | undefined; + const text = "```typescript\nconst completed = 1;\nconst current = 2;"; + try { + await act(async () => { + renderer = create(); + }); + expect(highlight).toHaveBeenCalled(); + highlight.mockClear(); + await act(async () => { + renderer!.update(); + }); + expect(highlight.mock.calls.every(([code]) => !code.includes("const completed"))).toBe(true); + } finally { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + } + }); + it("recovers highlighting after a failed fence changes without resetting its controls", async () => { const highlighter = await getSyntaxHighlighterPromise("text"); - const codeToHtml = highlighter.codeToHtml.bind(highlighter); + const codeToHast = highlighter.codeToHast.bind(highlighter); let fail = true; - vi.spyOn(highlighter, "codeToHtml").mockImplementation((...args) => { + vi.spyOn(highlighter, "codeToHast").mockImplementation((...args) => { if (fail) throw new Error("Temporary highlighter failure"); - return codeToHtml(...args); + return codeToHast(...args); }); vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -156,7 +179,7 @@ describe("ChatMarkdown streaming", () => { it("preserves code controls and details without highlighting an unchanged fence again", async () => { const highlighter = await getSyntaxHighlighterPromise("text"); - const highlight = vi.spyOn(highlighter, "codeToHtml"); + const highlight = vi.spyOn(highlighter, "codeToHast"); const writeText = vi.fn(async (_text: string) => {}); vi.stubGlobal("navigator", { clipboard: { writeText } }); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 02c61d8e8207..0dd85e9147dc 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,3 +1,4 @@ +import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { useAtomValue } from "@effect/atom-react"; import { CheckIcon, @@ -26,7 +27,7 @@ import type { EnvironmentId, ScopedThreadRef, ServerProviderSkill, - ThreadLinkedPullRequest, + ThreadPullRequestKey, } from "@t3tools/contracts"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { @@ -68,6 +69,8 @@ import React, { } from "react"; import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; import ReactMarkdown from "react-markdown"; +import { toHtml } from "hast-util-to-html"; +import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; import { defaultUrlTransform } from "react-markdown"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; @@ -121,6 +124,8 @@ import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; import { GitHubIcon } from "./Icons"; +import { createIncrementalHighlightedDocument } from "../lib/incrementalHighlighting"; +import { HighlightedCodeLines } from "./chat/HighlightedCodeLines"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; @@ -155,7 +160,6 @@ import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { projectEnvironment } from "../state/projects"; -import { threadEnvironment } from "../state/threads"; import { claimWorkspaceBasenameLookup, needsWorkspaceBasenameLookup, @@ -164,7 +168,6 @@ import { } from "../workspaceBasenameLookup"; import { findProjectForChangeRequest, - matchesLinkedPullRequestUrl, parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, useOpenChangeRequestLink, @@ -1010,9 +1013,14 @@ function SuspenseShikiCodeBlock({ themeName, isStreaming, }: SuspenseShikiCodeBlockProps) { + const [hasStreamed, setHasStreamed] = useState(isStreaming); + if (isStreaming && !hasStreamed) setHasStreamed(true); const language = extractFenceLanguage(className); const cacheKey = createHighlightCacheKey(code, language, themeName); - const cachedHighlightedHtml = !isStreaming ? highlightedCodeCache.get(cacheKey) : null; + // Once lines are mounted individually, keep that renderer when streaming + // finishes so switching to cached HTML cannot clear an existing selection. + const cachedHighlightedHtml = + !isStreaming && !hasStreamed ? highlightedCodeCache.get(cacheKey) : null; if (cachedHighlightedHtml != null) { return ( @@ -1030,6 +1038,7 @@ function SuspenseShikiCodeBlock({ themeName={themeName} cacheKey={cacheKey} isStreaming={isStreaming} + preserveLines={isStreaming || hasStreamed} /> ); } @@ -1040,6 +1049,7 @@ interface UncachedShikiCodeBlockProps { themeName: DiffThemeName; cacheKey: string; isStreaming: boolean; + preserveLines: boolean; } function UncachedShikiCodeBlock({ @@ -1048,11 +1058,20 @@ function UncachedShikiCodeBlock({ themeName, cacheKey, isStreaming, + preserveLines, }: UncachedShikiCodeBlockProps) { const highlighter = use(getSyntaxHighlighterPromise(language)); - const highlightedHtml = useMemo(() => { + const incrementalHighlight = useMemo( + () => + preserveLines ? createIncrementalHighlightedDocument(highlighter, language, themeName) : null, + [highlighter, preserveLines, language, themeName], + ); + const highlighted = useMemo(() => { try { - return highlighter.codeToHtml(code, { lang: language, theme: themeName }); + if (incrementalHighlight) return incrementalHighlight(code); + return preserveLines + ? highlighter.codeToHast(code, { lang: language, theme: themeName }) + : highlighter.codeToHtml(code, { lang: language, theme: themeName }); } catch (error) { // Log highlighting failures for debugging while falling back to plain text console.warn( @@ -1060,22 +1079,29 @@ function UncachedShikiCodeBlock({ error instanceof Error ? error.message : error, ); // If highlighting fails for this language, render as plain text - return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); + return preserveLines + ? highlighter.codeToHast(code, { lang: "text", theme: themeName }) + : highlighter.codeToHtml(code, { lang: "text", theme: themeName }); } - }, [code, highlighter, language, themeName]); + }, [code, highlighter, incrementalHighlight, language, preserveLines, themeName]); useEffect(() => { if (!isStreaming) { + const highlightedHtml = typeof highlighted === "string" ? highlighted : toHtml(highlighted); highlightedCodeCache.set( cacheKey, highlightedHtml, estimateHighlightedSize(highlightedHtml, code), ); } - }, [cacheKey, code, highlightedHtml, isStreaming]); + }, [cacheKey, code, highlighted, isStreaming]); - return ( -
+ return typeof highlighted === "string" ? ( +
+ ) : ( +
+ +
); } @@ -2185,9 +2211,7 @@ function useChatMarkdownState({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); - const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { - reportFailure: false, - }); + const pullRequestLinking = usePullRequestLinking(threadRef?.environmentId); const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null; const remoteOpen = useRemoteOpenResolution(environmentId); const canUseShellActions = canUseMarkdownFileShellActions( @@ -2239,9 +2263,6 @@ function useChatMarkdownState({ [createAssetUrl, cwd, expandMedia, preparedConnection, threadRef], ); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); - const threadServerConfig = useAtomValue( - serverEnvironment.configValueAtom(threadRef?.environmentId ?? environmentId), - ); const projects = useProjects(); const availableEditors = serverConfig?.availableEditors ?? []; const [preferredEditor] = usePreferredEditor(availableEditors); @@ -2331,52 +2352,33 @@ function useChatMarkdownState({ // makes a persisted "app" apply once settings hydrate after launch. const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget); const resolveThreadPullRequest = useCallback( - (href: string): ThreadLinkedPullRequest | null => { + (href: string): (ThreadPullRequestKey & { readonly url: string }) | null => { if ( threadRef === undefined || readThreadShell(threadRef) === null || - threadServerConfig?.environment.capabilities.threadPullRequestLinking !== true - ) { + !pullRequestLinking.canLink(href) + ) return null; - } const parsed = parseChangeRequestUrl(href); - if (parsed === null) return null; - const project = findProjectForChangeRequest( - projects.filter((candidate) => candidate.environmentId === threadRef.environmentId), - parsed, - ); - if (project === undefined) return null; - return { - projectId: project.id, - repository: project.repositoryIdentity?.displayName ?? parsed.repository, - number: parsed.number, - url: href, - }; + return parsed === null ? null : { ...parsed, url: href }; }, - [projects, threadRef, threadServerConfig], + [pullRequestLinking, threadRef], + ); + const linkedThreadPullRequestFor = useCallback( + (href: string) => { + if (threadRef === undefined || !pullRequestLinking.isLinked(readThreadShell(threadRef), href)) + return null; + const parsed = parseChangeRequestUrl(href); + return parsed === null ? null : { ...parsed, url: href }; + }, + [pullRequestLinking, threadRef], ); const updateThreadPullRequestLink = useCallback( async (href: string, linked: boolean) => { - if (threadRef === undefined) return; - const linkedPullRequest = linked ? resolveThreadPullRequest(href) : null; - if (linked && linkedPullRequest === null) { - throw new Error("The pull request is not available in this environment."); - } - if (!linked) { - const currentPullRequest = readThreadShell(threadRef)?.linkedPullRequest; - if (currentPullRequest == null || !matchesLinkedPullRequestUrl(currentPullRequest, href)) { - return; - } - } - const result = await updateThreadMetadata({ - environmentId: threadRef.environmentId, - input: { threadId: threadRef.threadId, linkedPullRequest }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - throw squashAtomCommandFailure(result); - } + if (threadRef === undefined || (!linked && linkedThreadPullRequestFor(href) === null)) return; + await pullRequestLinking.changeLink(threadRef, href, linked); }, - [resolveThreadPullRequest, threadRef, updateThreadMetadata], + [linkedThreadPullRequestFor, pullRequestLinking, threadRef], ); const openExternalLinkInPreview = useCallback( (url: string) => { @@ -2588,6 +2590,7 @@ function useChatMarkdownState({ openExternalLinkInPreview, openMarkdownMedia, projects, + linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, serverConfig, @@ -2614,6 +2617,7 @@ function useChatMarkdownState({ openExternalLinkInPreview, openMarkdownMedia, projects, + linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, serverConfig, @@ -2732,6 +2736,7 @@ const CHAT_MARKDOWN_COMPONENTS = { linkTargetPreference, openExternalLinkInPreview, projects, + linkedThreadPullRequestFor, resolveThreadPullRequest, serverConfig, updateThreadPullRequestLink, @@ -2866,13 +2871,10 @@ const CHAT_MARKDOWN_COMPONENTS = { event.stopPropagation(); const api = readLocalApi(); if (!api) return; - const pullRequest = resolveThreadPullRequest(href); - const currentPullRequest = - threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest; const threadLinkAction = - currentPullRequest != null && matchesLinkedPullRequestUrl(currentPullRequest, href) + linkedThreadPullRequestFor(href) !== null ? "unlink-from-thread" - : pullRequest === null + : resolveThreadPullRequest(href) === null ? undefined : "link-to-thread"; void showExternalLinkContextMenu({ @@ -3129,12 +3131,17 @@ function ChatMarkdown({ localMediaPreview, setLocalMediaPreview, } = useChatMarkdownState({ text, ...props }); + const incrementalParsing = + props.isStreaming === true && + extraRemarkPlugins.length === 0 && + /(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text); const remarkPlugins = useMemo( () => [ ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), ...extraRemarkPlugins, + ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []), ], - [extraRemarkPlugins, lineBreaks], + [extraRemarkPlugins, incrementalParsing, lineBreaks], ); // react-markdown converts unparsed HTML nodes to text when skipHtml is false. diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx index 17d9c04b06cc..344eca250ce9 100644 --- a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -32,6 +32,7 @@ vi.mock("../state/session", async (importOriginal) => ({ vi.mock("../state/entities", () => ({ readThreadShell: () => null, useProjects: () => [], + useServerConfigs: () => new Map(), })); vi.mock("../remoteOpen", () => ({ useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), @@ -41,8 +42,7 @@ vi.mock("../editorPreferences", () => ({ usePreferredEditor: () => [null, vi.fn()], })); vi.mock("~/lib/openPullRequestLink", () => ({ - findProjectForChangeRequest: () => undefined, - matchesLinkedPullRequestUrl: () => false, + findProjectOnChangeRequestHost: () => undefined, parseChangeRequestUrl: () => null, useOpenChangeRequestLink: () => vi.fn(), })); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 821044f2e774..973dd5749027 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -59,6 +59,14 @@ import { resolveSendEnvMode, threadShellHasStarted, resolveDraftHeroState, + isPaintOnlyThreadTimeline, + peekHeldThreadTimeline, + peekRememberedThreadTimeline, + rememberReadyThreadTimeline, + resetHeldThreadTimeline, + resolveThreadSwitchTimeline, + threadKeysShareEnvironment, + timelineHasEphemeralPreviewUrls, scheduleEnvironmentReconnectWarning, startNewThreadForProject, codexArtifactTemplatePromptToAppend, @@ -565,6 +573,179 @@ describe("draft hero submission transition", () => { }); }); +describe("resolveThreadSwitchTimeline", () => { + afterEach(() => { + resetHeldThreadTimeline(); + }); + + const held = { threadKey: "env-1:thread-a", entries: ["a1", "a2"] }; + + it("keeps the previous thread's entries while the next thread is loading", () => { + expect( + resolveThreadSwitchTimeline({ + loading: true, + activeThreadKey: "env-1:thread-b", + nextEntries: [], + lastReady: held, + }), + ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); + }); + + it("shows the new thread once its detail is ready", () => { + expect( + resolveThreadSwitchTimeline({ + loading: false, + activeThreadKey: "env-1:thread-b", + nextEntries: ["b1"], + lastReady: held, + }), + ).toEqual({ entries: ["b1"], displayThreadKey: "env-1:thread-b" }); + }); + + it("does not invent a timeline on the first open of a thread", () => { + expect( + resolveThreadSwitchTimeline({ + loading: true, + activeThreadKey: "env-1:thread-a", + nextEntries: [], + lastReady: null, + }), + ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" }); + }); + + it("keeps the held thread workspace cwd with the snapshot", () => { + rememberReadyThreadTimeline({ + ...held, + markdownCwd: "/repo/a", + workspaceRoot: "/repo/a", + }); + expect(peekHeldThreadTimeline()).toEqual({ + ...held, + markdownCwd: "/repo/a", + workspaceRoot: "/repo/a", + }); + }); + + it("survives a ChatView remount by remembering the last ready timeline", () => { + rememberReadyThreadTimeline(held); + expect(peekHeldThreadTimeline()).toEqual(held); + expect( + resolveThreadSwitchTimeline({ + loading: true, + activeThreadKey: "env-1:thread-b", + nextEntries: [], + }), + ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); + }); + + it("paints a remembered destination instead of the last-viewed thread", () => { + rememberReadyThreadTimeline(held); + rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["b1", "b2"] }); + expect(peekRememberedThreadTimeline("env-1:thread-a")).toEqual(["a1", "a2"]); + expect( + resolveThreadSwitchTimeline({ + loading: true, + activeThreadKey: "env-1:thread-a", + nextEntries: [], + }), + ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); + }); + + it("prefers live entries over a remembered snapshot", () => { + rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["stale-b"] }); + expect( + resolveThreadSwitchTimeline({ + loading: false, + activeThreadKey: "env-1:thread-b", + nextEntries: ["fresh-b"], + }), + ).toEqual({ entries: ["fresh-b"], displayThreadKey: "env-1:thread-b" }); + }); + + it("does not keep a remembered snapshot on a resolved empty thread", () => { + rememberReadyThreadTimeline(held); + expect( + resolveThreadSwitchTimeline({ + loading: false, + activeThreadKey: "env-1:thread-a", + nextEntries: [], + }), + ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" }); + }); + + it("does not hold another environment's timeline across a jump", () => { + expect(threadKeysShareEnvironment("env-1:thread-a", "env-2:thread-b")).toBe(false); + expect( + resolveThreadSwitchTimeline({ + loading: true, + activeThreadKey: "env-2:thread-b", + nextEntries: [], + lastReady: held, + }), + ).toEqual({ entries: [], displayThreadKey: "env-2:thread-b" }); + }); + + it("treats a foreign held timeline as paint-only", () => { + expect(isPaintOnlyThreadTimeline("env-1:thread-a", "env-1:thread-b")).toBe(true); + expect(isPaintOnlyThreadTimeline("env-1:thread-b", "env-1:thread-b")).toBe(false); + }); + + it("does not remember a timeline that still has handoff blob previews", () => { + expect( + timelineHasEphemeralPreviewUrls([ + { + kind: "message", + message: { + id: MessageId.make("preview-message"), + role: "user", + text: "Preview", + turnId: null, + streaming: false, + createdAt: "2026-09-10T12:00:00.000Z", + updatedAt: "2026-09-10T12:00:00.000Z", + attachments: [ + { + type: "image", + id: "preview", + name: "preview.png", + mimeType: "image/png", + sizeBytes: 1, + previewUrl: "blob:handoff", + }, + ], + }, + }, + ]), + ).toBe(true); + expect( + timelineHasEphemeralPreviewUrls([ + { + kind: "message", + message: { + id: MessageId.make("preview-message"), + role: "user", + text: "Preview", + turnId: null, + streaming: false, + createdAt: "2026-09-10T12:00:00.000Z", + updatedAt: "2026-09-10T12:00:00.000Z", + attachments: [ + { + type: "image", + id: "preview", + name: "preview.png", + mimeType: "image/png", + sizeBytes: 1, + previewUrl: "https://cdn.example/a.png", + }, + ], + }, + }, + ]), + ).toBe(false); + }); +}); + describe("shouldReleaseTimelineAnchorForToolActivity", () => { const activeTurnId = TurnId.make("active-turn"); const anchorMessageId = MessageId.make("anchored-message"); @@ -726,6 +907,7 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], activities: [], checkpoints: [], + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, @@ -843,6 +1025,7 @@ describe("buildLoadingThreadFromShell", () => { snoozedUntil: null, snoozedAt: null, session: null, + pullRequests: [], latestUserMessageAt: now, hasPendingApprovals: false, hasPendingUserInput: false, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 950a9c73fa91..772a0f3cf2fa 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -18,6 +18,7 @@ import { type ThreadLinkedPullRequest, type TurnId, } from "@t3tools/contracts"; +import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { squashAtomCommandFailure, @@ -262,6 +263,135 @@ export function resolveDraftHeroState(input: { ); } +/** + * Keep painted timelines on screen across thread jumps. Remounting LegendList + * (or handing it an empty first paint) punches a hole through the chat pane — + * white in light mode — so cmd+1/2/3 spam flashes even when the destination + * is already cached. + * + * Stored at module scope because ChatView remounts when the thread route + * changes (same pattern as the thread-error banner session dismissals). + * Remember more than the last thread so jumping back to cmd+1 does not show + * cmd+3's messages, and so a cached destination can paint on the first frame. + */ +export type HeldThreadTimeline = { + threadKey: string | null; + entries: T; + markdownCwd?: string | null; + workspaceRoot?: string | null; +}; + +const MAX_REMEMBERED_THREAD_TIMELINES = 16; + +let rememberedThreadTimelines = new Map>(); +let rememberedThreadTimelineOrder: string[] = []; +let lastReadyThreadKey: string | null = null; + +function rememberThreadTimelineEntries(held: HeldThreadTimeline): void { + if (held.threadKey === null) { + return; + } + rememberedThreadTimelines.set(held.threadKey, held); + rememberedThreadTimelineOrder = [ + ...rememberedThreadTimelineOrder.filter((key) => key !== held.threadKey), + held.threadKey, + ]; + while (rememberedThreadTimelineOrder.length > MAX_REMEMBERED_THREAD_TIMELINES) { + const evicted = rememberedThreadTimelineOrder.shift(); + if (evicted !== undefined) { + rememberedThreadTimelines.delete(evicted); + } + } + lastReadyThreadKey = held.threadKey; +} + +export function rememberReadyThreadTimeline( + held: HeldThreadTimeline, +): void { + if (held.threadKey === null || held.entries.length === 0) { + return; + } + rememberThreadTimelineEntries(held); +} + +export function peekRememberedThreadTimeline( + threadKey: string | null, +): T | null { + if (threadKey === null) { + return null; + } + return (rememberedThreadTimelines.get(threadKey)?.entries as T | undefined) ?? null; +} + +export function peekHeldThreadTimeline< + T extends readonly unknown[], +>(): HeldThreadTimeline | null { + if (lastReadyThreadKey === null) { + return null; + } + const held = rememberedThreadTimelines.get(lastReadyThreadKey); + if (held === undefined || held.entries.length === 0) { + return null; + } + return held as HeldThreadTimeline; +} + +export function resetHeldThreadTimeline(): void { + rememberedThreadTimelines = new Map(); + rememberedThreadTimelineOrder = []; + lastReadyThreadKey = null; +} + +export function threadKeysShareEnvironment(left: string | null, right: string | null): boolean { + if (left === null || right === null) { + return false; + } + const leftRef = parseScopedThreadKey(left); + const rightRef = parseScopedThreadKey(right); + return leftRef !== null && rightRef !== null && leftRef.environmentId === rightRef.environmentId; +} + +/** True while we still paint another thread's last snapshot. */ +export function isPaintOnlyThreadTimeline( + displayThreadKey: string | null, + activeThreadKey: string | null, +): boolean { + return ( + displayThreadKey !== null && activeThreadKey !== null && displayThreadKey !== activeThreadKey + ); +} + +export function resolveThreadSwitchTimeline(input: { + loading: boolean; + activeThreadKey: string | null; + nextEntries: T; + rememberedForActive?: T | null; + lastReady?: HeldThreadTimeline | null; +}): { entries: T; displayThreadKey: string | null } { + if (input.nextEntries.length > 0) { + return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey }; + } + + const rememberedForActive = + input.rememberedForActive ?? peekRememberedThreadTimeline(input.activeThreadKey); + if (input.loading && rememberedForActive !== null && rememberedForActive.length > 0) { + return { entries: rememberedForActive, displayThreadKey: input.activeThreadKey }; + } + + const lastReady = input.lastReady ?? peekHeldThreadTimeline(); + if ( + input.loading && + lastReady !== null && + lastReady.threadKey !== null && + lastReady.threadKey !== input.activeThreadKey && + lastReady.entries.length > 0 && + threadKeysShareEnvironment(lastReady.threadKey, input.activeThreadKey) + ) { + return { entries: lastReady.entries, displayThreadKey: lastReady.threadKey }; + } + return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey }; +} + export function resolveDraftPromotionNavigationTarget(input: { serverThreadRef: ScopedThreadRef | null; serverThread: Pick | null | undefined; @@ -353,6 +483,7 @@ export function buildLocalDraftThread( branch: draftThread.branch, worktreePath: draftThread.worktreePath, checkpoints: [], + pullRequests: [], activities: [], proposedPlans: [], }; @@ -611,6 +742,17 @@ export function revokeUserMessagePreviewUrls(message: ChatMessage): void { } } +export function timelineHasEphemeralPreviewUrls( + entries: ReadonlyArray & { message?: ChatMessage }>, +): boolean { + return entries.some( + (entry) => + entry.kind === "message" && + entry.message !== undefined && + collectUserMessageBlobPreviewUrls(entry.message).length > 0, + ); +} + export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] { if (message.role !== "user" || !message.attachments) { return []; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0fbef88c81e1..51b9c5eabc48 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -203,6 +203,12 @@ import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; import { RightPanelTabs } from "./RightPanelTabs"; import { AgentsPanel } from "./AgentsPanel"; +import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; +import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; +import { useDeviceState } from "~/state/device"; +import { DeviceSetup } from "./device/DeviceSetup"; +import { Dialog } from "./ui/dialog"; +import { WizardPopup } from "./ui/wizard"; import { deriveAgentPanelModel, foldSubagentActivities, @@ -409,6 +415,12 @@ import { resolveComposerInteractionMode, resolveComposerProviderSelection, resolveDraftHeroState, + isPaintOnlyThreadTimeline, + peekHeldThreadTimeline, + peekRememberedThreadTimeline, + rememberReadyThreadTimeline, + resolveThreadSwitchTimeline, + timelineHasEphemeralPreviewUrls, observeProactivePanelUserChoice, resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, @@ -554,6 +566,9 @@ const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), ); const DiffPanel = lazy(() => import("./DiffPanel")); +const DevicePanel = lazy(() => + import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })), +); const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ @@ -577,6 +592,7 @@ const TYPE_TO_FOCUS_INTERACTIVE_SELECTOR = [ '[role="tab"]', ].join(","); const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ + '[role="dialog"][aria-modal="true"]', '[data-slot="alert-dialog-popup"]:is([data-open],[data-ending-style])', '[data-slot="command-dialog-popup"]:is([data-open],[data-ending-style])', '[data-slot="dialog-popup"]:is([data-open],[data-ending-style])', @@ -1369,6 +1385,10 @@ function chatActionErrorMessage(error: unknown): string { } const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; +const EMPTY_HELD_TURN_DIFF_SUMMARIES: readonly never[] = []; +const noopHeldTurnDiff = (_turnId: TurnId, _filePath?: string) => {}; +const noopHeldRevert = (_targetTurnCount: number) => {}; +const noopHeldAttachment = (_attachment: ChatFileAttachment) => {}; /** * Drops the send-time anchored end space. That space is what holds a sent @@ -3229,6 +3249,18 @@ export default function ChatView(props: ChatViewProps) { timelineMessages, workLogEntries, ]); + const displayedTimeline = resolveThreadSwitchTimeline({ + loading: timelineEntries.length === 0 && threadSyncPhase !== null, + activeThreadKey, + nextEntries: timelineEntries, + rememberedForActive: peekRememberedThreadTimeline(activeThreadKey), + }); + const displayedTimelineKey = displayedTimeline.displayThreadKey ?? routeThreadKey; + const paintOnlyDisplayedTimeline = isPaintOnlyThreadTimeline( + displayedTimeline.displayThreadKey, + activeThreadKey, + ); + const displayedThreadRef = parseScopedThreadKey(displayedTimelineKey); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; @@ -3327,6 +3359,24 @@ export default function ChatView(props: ChatViewProps) { const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; + useLayoutEffect(() => { + if ( + threadDetailLoading || + timelineEntries.length === 0 || + timelineHasEphemeralPreviewUrls(timelineEntries) + ) { + return; + } + rememberReadyThreadTimeline({ + threadKey: activeThreadKey, + entries: timelineEntries, + markdownCwd: gitCwd, + workspaceRoot: activeWorkspaceRoot ?? null, + }); + }, [activeThreadKey, activeWorkspaceRoot, gitCwd, threadDetailLoading, timelineEntries]); + const heldPaintContext = paintOnlyDisplayedTimeline + ? peekHeldThreadTimeline() + : null; const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Git status arrives after the composer paints. A checkout seen earlier in @@ -4127,6 +4177,71 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef) return; useRightPanelStore.getState().open(activeThreadRef, "agents"); }, [activeThreadRef]); + const supportsThreadPullRequests = + serverConfig?.environment.capabilities.threadPullRequests === true; + const addPullRequestsSurface = useCallback(() => { + if (!activeThreadRef || !supportsThreadPullRequests) return; + useRightPanelStore.getState().open(activeThreadRef, "pull-requests"); + }, [activeThreadRef, supportsThreadPullRequests]); + const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( + activeThreadRef?.environmentId ?? null, + ); + const [deviceSetupThread, setDeviceSetupThread] = useState(null); + const addDeviceSurface = useCallback(() => { + if (!activeThreadRef) return; + if (!deviceState.onboardingCompleted || deviceState.hostStatus === "disabled") { + setDeviceSetupThread(activeThreadRef); + return; + } + useRightPanelStore.getState().open(activeThreadRef, "device"); + }, [activeThreadRef, deviceState.onboardingCompleted, deviceState.hostStatus]); + // Reconcile new server sessions into separate tabs, including sessions opened + // by an agent or another client. The first snapshot is a baseline: persisted + // tabs restore themselves, and existing sessions must not resurrect closed tabs. + const previousDeviceSessions = useRef(new Map>()); + useEffect(() => { + if (!activeThreadRef || !deviceStateLoaded) return; + const threadKey = `${activeThreadRef.environmentId}:${activeThreadRef.threadId}`; + const sessions = deviceState.sessions.filter( + (session) => session.threadId === activeThreadRef.threadId, + ); + const key = (session: (typeof sessions)[number]) => `${session.hostId}:${session.deviceId}`; + const previous = previousDeviceSessions.current.get(threadKey); + previousDeviceSessions.current.set(threadKey, new Set(sessions.map(key))); + if (!previous || shouldUseRightPanelSheet) return; + for (const session of sessions) { + if (previous?.has(key(session))) continue; + const existing = useRightPanelStore + .getState() + .byThreadKey[scopedThreadKey(activeThreadRef)]?.surfaces.some( + (surface) => + surface.kind === "device" && + surface.target?.hostId === session.hostId && + surface.target.deviceId === session.deviceId, + ); + if (existing) continue; + const device = deviceState.devices.find( + (entry) => entry.hostId === session.hostId && entry.id === session.deviceId, + ); + if (!device) continue; + useRightPanelStore.getState().openDevice( + activeThreadRef, + { + hostId: session.hostId, + deviceId: session.deviceId, + platform: device.platform, + name: device.name, + }, + true, + ); + } + }, [ + activeThreadRef, + deviceStateLoaded, + shouldUseRightPanelSheet, + deviceState.sessions, + deviceState.devices, + ]); const openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; @@ -4284,10 +4399,21 @@ export default function ChatView(props: ChatViewProps) { supportsPullRequests, threadDetailLoading, ]); + const closePreviewPanel = useCallback(() => { + if (activeThreadRef) { + if (activeRightPanelSurface?.kind === "preview" && activeRightPanelSurface.resourceId) { + usePreviewMiniPlayerStore + .getState() + .open(activeThreadRef, activeRightPanelSurface.resourceId); + } + setMaximizedRightPanelThreadKey(null); + useRightPanelStore.getState().close(activeThreadRef); + } + }, [activeRightPanelSurface, activeThreadRef]); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; if (previewPanelOpen) { - useRightPanelStore.getState().close(activeThreadRef); + closePreviewPanel(); return; } const activeTabId = activePreviewState.activeTabId; @@ -4296,13 +4422,13 @@ export default function ChatView(props: ChatViewProps) { } else { createBrowserSurface(); } - }, [activePreviewState.activeTabId, activeThreadRef, createBrowserSurface, previewPanelOpen]); - const closePreviewPanel = useCallback(() => { - if (activeThreadRef) { - setMaximizedRightPanelThreadKey(null); - useRightPanelStore.getState().close(activeThreadRef); - } - }, [activeThreadRef]); + }, [ + activePreviewState.activeTabId, + activeThreadRef, + closePreviewPanel, + createBrowserSurface, + previewPanelOpen, + ]); const addTerminalSurface = useCallback(() => { if (!activeThreadRef || !activeThreadId || !activeProject) return; const cwd = gitCwd ?? activeProject.workspaceRoot; @@ -4824,6 +4950,21 @@ export default function ChatView(props: ChatViewProps) { void legendListRef.current?.scrollToEnd?.({ animated }); }); }, []); + const displayedTimelineKeyRef = useRef(displayedTimeline.displayThreadKey); + useLayoutEffect(() => { + const displayKey = displayedTimeline.displayThreadKey; + if (displayKey === null || displayKey !== activeThreadKey) { + displayedTimelineKeyRef.current = displayKey; + return; + } + if (displayedTimelineKeyRef.current === displayKey) { + return; + } + displayedTimelineKeyRef.current = displayKey; + // Keep the list mounted across jumps; pin the newly displayed thread to + // its end the way a remount used to via initialScrollAtEnd. + scrollToEnd(); + }, [activeThreadKey, displayedTimeline.displayThreadKey, scrollToEnd]); useLayoutEffect(() => { if (timelineScrollModeRef.current !== "anchoring-new-turn") { return; @@ -5375,9 +5516,16 @@ export default function ChatView(props: ChatViewProps) { : resolveThreadReferenceCopyTarget({ threadId: activeThreadId, openPanelPullRequestUrl, + pullRequests: activeThreadMetadata?.pullRequests, linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null, }), - [activeThreadId, isServerThread, linkedThreadPullRequest?.url, openPanelPullRequestUrl], + [ + activeThreadId, + isServerThread, + activeThreadMetadata?.pullRequests, + linkedThreadPullRequest?.url, + openPanelPullRequestUrl, + ], ); const copyActiveThreadReference = useCallback(() => { const target = activeThreadReferenceCopyTarget; @@ -5788,15 +5936,13 @@ export default function ChatView(props: ChatViewProps) { pendingApprovals.length > 0 || pendingUserInputs.length > 0 || showPlanFollowUpPrompt; - const compactDisabled = compactThreadUnavailable || composerHasUnsentContent; + const compactDisabled = compactThreadUnavailable; const compactDisabledReason = compactDisabled - ? composerHasUnsentContent - ? "Send or clear your draft before compacting" - : !activeProject - ? "Choose a project before compacting" - : !manualCompactionProviderAvailable - ? "Compaction is unavailable for this provider" - : "Compacting is unavailable right now" + ? !activeProject + ? "Choose a project before compacting" + : !manualCompactionProviderAvailable + ? "Compaction is unavailable for this provider" + : "Compacting is unavailable right now" : null; const resumeCompactionBannerItem = useMemo(() => { if ( @@ -6386,6 +6532,78 @@ export default function ChatView(props: ChatViewProps) { ], ); + const onCompactContext = async () => { + if (compactDisabled || !activeThread || !clientSettingsHydrated || sendInFlightRef.current) { + return; + } + const context = composerRef.current?.getSendContext(); + if (!context?.providerAvailable) return; + + // Compaction is a standalone command; the draft and its attachments stay local. + const threadId = activeThread.id; + const messageId = newMessageId(); + const createdAt = new Date().toISOString(); + sendInFlightRef.current = true; + beginLocalDispatch(); + setThreadError(threadId, null); + setOptimisticUserMessages((messages) => [ + ...messages, + { + id: messageId, + role: "user", + text: "/compact", + turnId: null, + createdAt, + updatedAt: createdAt, + streaming: false, + }, + ]); + scrollToEnd(); + try { + const settingsResult = await persistThreadSettingsForNextTurn({ + threadId, + createdAt, + modelSelection: context.selectedModelSelection, + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), + runtimeMode, + interactionMode: context.interactionMode, + }); + const result = + settingsResult._tag === "Failure" + ? settingsResult + : await startThreadTurn({ + environmentId, + input: { + threadId, + message: { messageId, role: "user", text: "/compact", attachments: [] }, + modelSelection: context.selectedModelSelection, + runtimeMode, + interactionMode: context.interactionMode, + createdAt, + }, + }); + if (result._tag === "Failure") { + setOptimisticUserMessages((messages) => + messages.filter((message) => message.id !== messageId), + ); + resetLocalDispatch(); + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + threadId, + error instanceof Error ? error.message : "Failed to compact context.", + ); + } + } else { + clearUsageLimitsFor(routeThreadKey); + } + } finally { + sendInFlightRef.current = false; + } + }; + const onSend = async ( e?: { preventDefault: () => void }, submissionIntent: ComposerSubmissionIntent = "foreground", @@ -8037,11 +8255,21 @@ export default function ChatView(props: ChatViewProps) { // reader's feet. A link the agent wrote can open any other one here, and that one has to be // checkable out like it is anywhere else. { + if (activeThreadRef) + useRightPanelStore.getState().openPullRequest(activeThreadRef, { + projectId: reference.projectId, + repository: reference.repository, + number: reference.number, + ...(reference.host ? { host: reference.host } : {}), + }); + }} threadRef={activeThreadRef} reference={{ projectId: renderedRightPanelSurface.projectId as ProjectId, + ...(renderedRightPanelSurface.host ? { host: renderedRightPanelSurface.host } : {}), repository: renderedRightPanelSurface.repository, number: renderedRightPanelSurface.number, }} @@ -8062,13 +8290,34 @@ export default function ChatView(props: ChatViewProps) { : "page" } composerDraftTarget={composerDraftTarget} + onBack={ + activeThreadRef !== null && supportsThreadPullRequests + ? addPullRequestsSurface + : undefined + } /> + ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( + ) : renderedRightPanelSurface?.kind === "agents" ? ( + ) : renderedRightPanelSurface?.kind === "device" ? ( + + { + closeRightPanelSurface(renderedRightPanelSurface); + useRightPanelStore.getState().show(activeThreadRef); + }} + /> + ) : (renderedRightPanelSurface?.kind === "files" || renderedRightPanelSurface?.kind === "file") && ((activeProject && activeWorkspaceRoot) || @@ -8124,6 +8373,29 @@ export default function ChatView(props: ChatViewProps) { return (
+ { + if (!open) setDeviceSetupThread(null); + }} + > + + {activeThreadRef ? ( + { + useRightPanelStore.getState().open(activeThreadRef, "device"); + setDeviceSetupThread(null); + }} + /> + ) : null} + + {rightPanelControlsAtRoot ? panelLayoutControls : null}
{/* Messages Wrapper */} -
+
{/* Messages — LegendList handles virtualization and scrolling internally */} {/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -8428,6 +8724,7 @@ export default function ChatView(props: ChatViewProps) { onPageScrollKeyDown={onComposerPageScrollKeyDown} onPageScrollKeyUp={onComposerPageScrollKeyUp} onPageScrollRelease={onComposerPageScrollRelease} + onCompactContext={onCompactContext} onSend={onSend} onInterrupt={onInterrupt} onImplementPlanInNewThread={onImplementPlanInNewThread} @@ -8610,6 +8907,10 @@ export default function ChatView(props: ChatViewProps) { terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} + onRenameDevice={(surfaceId, title) => { + if (activeThreadRef) + useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); + }} onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} @@ -8620,13 +8921,17 @@ export default function ChatView(props: ChatViewProps) { onAddDiff={addDiffSurface} onAddFiles={addFilesSurface} onAddPullRequest={addPullRequestSurface} + onAddPullRequests={addPullRequestsSurface} onAddAgents={addAgentsSurface} + onAddDevice={addDeviceSurface} browserAvailable={isPreviewSupportedInRuntime()} terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} + pullRequestsAvailable={isServerThread && supportsThreadPullRequests} agentsAvailable + deviceAvailable={activeThreadRef !== null} liveAgentCount={agentPanelModel.liveCount} > {rightPanelContent} @@ -8660,6 +8965,10 @@ export default function ChatView(props: ChatViewProps) { terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} onCloseSurface={closeRightPanelSurface} + onRenameDevice={(surfaceId, title) => { + if (activeThreadRef) + useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); + }} onCloseOtherSurfaces={closeOtherRightPanelSurfaces} onCloseSurfacesToRight={closeRightPanelSurfacesToRight} onCloseAllSurfaces={closeAllRightPanelSurfaces} @@ -8670,13 +8979,17 @@ export default function ChatView(props: ChatViewProps) { onAddDiff={addDiffSurface} onAddFiles={addFilesSurface} onAddPullRequest={addPullRequestSurface} + onAddPullRequests={addPullRequestsSurface} onAddAgents={addAgentsSurface} + onAddDevice={addDeviceSurface} browserAvailable={isPreviewSupportedInRuntime()} terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} + pullRequestsAvailable={isServerThread && supportsThreadPullRequests} agentsAvailable + deviceAvailable={activeThreadRef !== null} liveAgentCount={agentPanelModel.liveCount} > {rightPanelContent} @@ -8684,6 +8997,7 @@ export default function ChatView(props: ChatViewProps) { ) : null} + {expandedImage && ( { + it("keeps archived relations searchable and routes them through the PR environment", async () => { + const environmentId = EnvironmentId.make("remote"); + const id = ThreadId.make("archived-thread"); + const runThread = vi.fn(async () => {}); + const query = "https://github.com/acme/web/pull/42"; + const linkedThreads = { + environmentId, + threads: [ + { + id, + projectId: ProjectId.make("project"), + title: "Completed work", + archivedAt: "2026-09-01T00:00:00.000Z", + }, + ], + }; + const state = reduceCommandPaletteUiState( + { open: false, mode: "command", openIntent: null }, + { + _tag: "OpenSearch", + query, + linkedThreads, + }, + ); + expect(state.openIntent).toEqual({ kind: "search", query, linkedThreads }); + const items = buildLinkedThreadActionItems({ ...linkedThreads, query, icon: null, runThread }); + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query, + isInSubmenu: false, + projectSearchItems: [], + settingsSearchItems: [], + threadSearchItems: items, + }); + expect(groups.flatMap((group) => group.items)).toEqual(items); + expect(items[0]?.description).toBe("Archived thread"); + await items[0]?.run(); + expect(runThread).toHaveBeenCalledWith({ environmentId, id }); + }); +}); + +describe("buildCommandPaletteProjectMetadata", () => { + const localEnvironmentId = EnvironmentId.make("environment-local"); + const remoteEnvironmentId = EnvironmentId.make("environment-build-box"); + const locations = new Map([ + [localEnvironmentId, { kind: "local" as const, label: "Local", machine: "laptop" as const }], + [ + remoteEnvironmentId, + { kind: "remote" as const, label: "Build box", machine: "server" as const }, + ], + ]); + + it("makes every member environment and path searchable", () => { + const metadata = buildCommandPaletteProjectMetadata({ + projects: [ + { + environmentId: localEnvironmentId, + title: "T3 Code", + workspaceRoot: "/Users/theo/Projects/t3code", + }, + { + environmentId: remoteEnvironmentId, + title: "t3code", + workspaceRoot: "/srv/t3code", + }, + ], + locationByEnvironmentId: locations, + }); + + expect(metadata.searchTerms).toEqual([ + "T3 Code", + "/Users/theo/Projects/t3code", + "Local", + "t3code", + "/srv/t3code", + "Build box", + ]); + expect(metadata.environmentLabels).toEqual(["Local", "Build box"]); + + const [filteredGroup] = filterCommandPaletteGroups({ + activeGroups: [], + query: "build box", + isInSubmenu: false, + projectSearchItems: [ + { + kind: "action", + value: "project:t3code", + title: "T3 Code", + searchTerms: metadata.searchTerms, + icon: null, + run: async () => undefined, + }, + ], + threadSearchItems: [], + }); + expect(filteredGroup?.items).toHaveLength(1); + }); + + it("deduplicates grouped checkouts by environment", () => { + const metadata = buildCommandPaletteProjectMetadata({ + projects: [ + { + environmentId: remoteEnvironmentId, + title: "T3 Code", + workspaceRoot: "/srv/t3code", + }, + { + environmentId: remoteEnvironmentId, + title: "T3 Code worktree", + workspaceRoot: "/srv/t3code-feature", + }, + ], + locationByEnvironmentId: locations, + }); + + expect(metadata.environmentLabels).toEqual(["Build box"]); + }); + + it("deduplicates distinct environments with the same label", () => { + const secondRemoteEnvironmentId = EnvironmentId.make("environment-build-box-2"); + const metadata = buildCommandPaletteProjectMetadata({ + projects: [ + { + environmentId: remoteEnvironmentId, + title: "T3 Code", + workspaceRoot: "/srv/t3code", + }, + { + environmentId: secondRemoteEnvironmentId, + title: "T3 Code mirror", + workspaceRoot: "/srv/mirror/t3code", + }, + ], + locationByEnvironmentId: new Map([ + [remoteEnvironmentId, { label: "Build box" }], + [secondRemoteEnvironmentId, { label: "Build box" }], + ]), + }); + + expect(metadata.environmentLabels).toEqual(["Build box"]); + }); + + it("uses a human-readable fallback when presentation data is unavailable", () => { + const metadata = buildCommandPaletteProjectMetadata({ + projects: [ + { + environmentId: remoteEnvironmentId, + title: "T3 Code", + workspaceRoot: "/srv/t3code", + }, + ], + locationByEnvironmentId: new Map(), + }); + + expect(metadata.searchTerms).toContain("Remote"); + expect(metadata.environmentLabels).toEqual(["Remote"]); + }); +}); + describe("reduceCommandPaletteUiState", () => { const closedState = { open: false, mode: "command", openIntent: null } as const; @@ -47,6 +209,32 @@ describe("reduceCommandPaletteUiState", () => { ); }); + it("opens PR search from another overlay and replaces an earlier search", () => { + const first = reduceCommandPaletteUiState( + { open: true, mode: "files", openIntent: null }, + { + _tag: "OpenSearch", + query: "https://github.com/acme/web/pull/7", + }, + ); + expect(first).toEqual({ + open: true, + mode: "command", + openIntent: { kind: "search", query: "https://github.com/acme/web/pull/7" }, + }); + const second = reduceCommandPaletteUiState(first, { + _tag: "OpenSearch", + query: "https://github.com/acme/web/pull/8", + }); + expect(second.openIntent).toEqual({ + kind: "search", + query: "https://github.com/acme/web/pull/8", + }); + expect( + reduceCommandPaletteUiState(second, { _tag: "SetOpen", open: false }).openIntent, + ).toBeNull(); + }); + it("routes open intents to command mode", () => { const filesOpen = reduceCommandPaletteUiState(closedState, { _tag: "ToggleMode", @@ -149,6 +337,7 @@ function makeThread(overrides: Partial = {}): Thread { branch: null, worktreePath: null, checkpoints: [], + pullRequests: [], activities: [], ...overrides, }; @@ -507,3 +696,45 @@ describe("filterPinnedBrowseEntries", () => { }); }); }); + +it.each([ + "#10839", + "10839", + "pingdotgg/t3code#10839", + "https://github.com/pingdotgg/t3code/pull/10839", +])("finds linked threads from PR query %s", (query) => { + const items = buildThreadActionItems({ + threads: [ + makeThread({ + title: "Implementation", + pullRequests: [ + { + host: "github.com", + repository: "pingdotgg/t3code", + number: 10839, + url: "https://github.com/pingdotgg/t3code/pull/10839", + source: "manual", + linkedAt: "2026-09-08T00:00:00Z", + snapshot: null, + stack: null, + }, + ], + }), + makeThread({ id: ThreadId.make("unrelated"), title: "Other work" }), + ], + projectTitleById: new Map(), + sortOrder: "updated_at", + icon: null, + runThread: async () => undefined, + }); + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query, + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: items, + }); + expect(groups.flatMap((group) => group.items.map((item) => item.title))).toEqual([ + "Implementation", + ]); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index e91de65d5ca9..57c1711de158 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,4 +1,7 @@ +import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; +import type { CommandPaletteLinkedThreads } from "../commandPaletteBus"; import { + type EnvironmentId, type FilesystemBrowseEntry, type KeybindingCommand, THREAD_JUMP_KEYBINDING_COMMANDS, @@ -17,6 +20,25 @@ export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; +/** A PR's relations include archived threads that normal palette search omits. */ +export function buildLinkedThreadActionItems( + input: CommandPaletteLinkedThreads & { + query: string; + icon: ReactNode; + runThread: (thread: Pick) => Promise; + }, +): CommandPaletteActionItem[] { + return input.threads.map((thread) => ({ + kind: "action", + value: `thread:${input.environmentId}:${thread.id}`, + title: thread.title || "Untitled thread", + description: thread.archivedAt === null ? "Linked thread" : "Archived thread", + searchTerms: [input.query, thread.title], + icon: input.icon, + run: () => input.runThread({ environmentId: input.environmentId, id: thread.id }), + })); +} + export function browseInputEndPaddingClass(input: { readonly willCreateProjectPath: boolean; readonly hasHighlightedBrowseItem: boolean; @@ -38,9 +60,13 @@ export function browseInputEndPaddingClass(input: { */ export type SearchOverlayMode = "command" | "files" | "content"; -export interface CommandPaletteOpenIntent { - readonly kind: "add-project" | "new-thread-in"; -} +export type CommandPaletteOpenIntent = + | { readonly kind: "add-project" | "new-thread-in" } + | { + readonly kind: "search"; + readonly query: string; + readonly linkedThreads?: CommandPaletteLinkedThreads; + }; export interface CommandPaletteUiState { readonly open: boolean; @@ -51,6 +77,11 @@ export interface CommandPaletteUiState { export type CommandPaletteUiAction = | { readonly _tag: "SetOpen"; readonly open: boolean } | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode } + | { + readonly _tag: "OpenSearch"; + readonly query: string; + readonly linkedThreads?: CommandPaletteLinkedThreads; + } | { readonly _tag: "OpenAddProject" } | { readonly _tag: "OpenNewThreadIn" } | { readonly _tag: "ClearOpenIntent" }; @@ -68,6 +99,16 @@ export function reduceCommandPaletteUiState( return state.open && state.mode === action.mode ? { ...state, open: false, openIntent: null } : { open: true, mode: action.mode, openIntent: null }; + case "OpenSearch": + return { + open: true, + mode: "command", + openIntent: { + kind: "search", + query: action.query, + ...(action.linkedThreads ? { linkedThreads: action.linkedThreads } : {}), + }, + }; case "OpenAddProject": return { open: true, mode: "command", openIntent: { kind: "add-project" } }; case "OpenNewThreadIn": @@ -145,6 +186,22 @@ export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-b // every other surface uses the real title, so overriding it desyncs the icon. export type CommandPaletteProject = Project & { readonly displayName: string }; +export function buildCommandPaletteProjectMetadata(input: { + readonly projects: ReadonlyArray>; + readonly locationByEnvironmentId: ReadonlyMap; +}) { + const searchTerms: string[] = []; + const environmentLabels = new Set(); + + for (const project of input.projects) { + const label = input.locationByEnvironmentId.get(project.environmentId)?.label ?? "Remote"; + searchTerms.push(project.title, project.workspaceRoot, label); + environmentLabels.add(label); + } + + return { searchTerms, environmentLabels: [...environmentLabels] }; +} + export function buildProjectActionItems(input: { projects: ReadonlyArray; valuePrefix: string; @@ -186,6 +243,7 @@ export type BuildThreadActionItemsThread = Pick< | "title" | "worktreePath" > & { + pullRequests?: SidebarThreadSummary["pullRequests"]; updatedAt: string; latestUserMessageAt?: string | null; }; @@ -240,6 +298,7 @@ export function buildThreadActionItems; } +function ProjectSearchDescription(props: { + readonly environmentLabels: ReadonlyArray; + readonly grouped: boolean; + readonly location: { + readonly kind: "local" | "remote"; + readonly label: string; + readonly machine: EnvironmentMachineKind; + }; + readonly workspaceRoot: string; +}) { + if (!props.grouped) { + return ( + + + {props.location.kind === "remote" ? ( + + ) : null} + {props.location.label} + + + {props.workspaceRoot} + + ); + } + + return {props.environmentLabels.join(" · ")}; +} + function getEnvironmentBrowsePlatform(os: string | null | undefined): string { if (os === "windows") { return "Win32"; @@ -478,6 +516,12 @@ export function CommandPalette({ children }: { children: ReactNode }) { openNewThreadIn(); } else if (detail.open === "add-project") { openAddProject(); + } else if (detail.query !== undefined) { + dispatch({ + _tag: "OpenSearch", + query: detail.query, + ...(detail.linkedThreads ? { linkedThreads: detail.linkedThreads } : {}), + }); } else { setOpen(true); } @@ -569,7 +613,10 @@ function OpenCommandPaletteDialog(props: { const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; - const [query, setQuery] = useState(""); + const [query, setQuery] = useState(openIntent?.kind === "search" ? openIntent.query : ""); + const [linkedThreadSearch, setLinkedThreadSearch] = useState( + openIntent?.kind === "search" ? openIntent : null, + ); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); @@ -605,12 +652,16 @@ function OpenCommandPaletteDialog(props: { ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null; const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(referenceThreadRef); + const activeThreadServerConfig = useServerConfigs().get( + activeThread?.environmentId ?? ("" as EnvironmentId), + ); const activeThreadReferenceCopyTarget = referenceThreadRef === null || (pathname === "/pull-requests" && !openPanelPullRequestUrl) ? null : resolveThreadReferenceCopyTarget({ threadId: referenceThreadRef.threadId, openPanelPullRequestUrl, + pullRequests: activeThread?.pullRequests, linkedPullRequestUrl: activeThread?.linkedPullRequest?.url ?? activeThread?.branchPullRequest?.url ?? null, }); @@ -688,6 +739,7 @@ function OpenCommandPaletteDialog(props: { ); const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); + const cloneLookupGeneration = useRef(0); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); const projectGroupingSettings = useMemo( @@ -1060,15 +1112,43 @@ function OpenCommandPaletteDialog(props: { projects: pickerProjects, valuePrefix: "project", searchTerms: (project) => { - const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const members = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`) + ?.memberProjects ?? [project]; + return buildCommandPaletteProjectMetadata({ + projects: members, + locationByEnvironmentId: projectEnvironmentLocationById, + }).searchTerms; + }, + renderDescription: (project) => { + const members = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`) + ?.memberProjects ?? [project]; + const metadata = buildCommandPaletteProjectMetadata({ + projects: members, + locationByEnvironmentId: projectEnvironmentLocationById, + }); + const location = projectEnvironmentLocationById.get(project.environmentId) ?? { + kind: "remote" as const, + label: "Remote", + machine: "server" as const, + }; return ( - group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? [] + 1} + location={location} + workspaceRoot={project.workspaceRoot} + /> ); }, icon: projectFavicon, runProject: openProjectFromSearch, }), - [openProjectFromSearch, pickerProjects, projectGroupByTargetKey], + [ + openProjectFromSearch, + pickerProjects, + projectEnvironmentLocationById, + projectGroupByTargetKey, + ], ); const projectThreadItems = useMemo( @@ -1157,6 +1237,9 @@ function OpenCommandPaletteDialog(props: { { + if (openIntent?.kind !== "search") return; + browseNavigation.invalidate(); + cloneLookupGeneration.current += 1; + setIsRemoteProjectLookingUp(false); + setAddProjectCloneFlow(null); + setViewStack([]); + setLinkedThreadSearch(openIntent); + setQuery(openIntent.query); + clearOpenIntent(); + }, [browseNavigation, clearOpenIntent, openIntent]); + useLayoutEffect(() => { if (openIntent?.kind !== "add-project") { return; @@ -1591,6 +1687,35 @@ function OpenCommandPaletteDialog(props: { }); } + if ( + activeThread !== null && + threadPullRequestLinkMode(activeThreadServerConfig?.environment.capabilities) !== "unsupported" + ) { + const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + actionItems.push({ + kind: "action", + value: "action:link-pull-request", + searchTerms: ["link", "pull request", "pr", "attach", "stack"], + title: "Link pull request to thread", + icon: , + run: async () => { + openLinkPullRequestDialog(threadRef); + }, + }); + if (activeThreadServerConfig?.environment.capabilities.threadPullRequests === true) { + actionItems.push({ + kind: "action", + value: "action:open-thread-pull-requests", + searchTerms: ["pull requests", "linked", "stack", "prs"], + title: "Show linked pull requests", + icon: , + run: async () => { + useRightPanelStore.getState().open(threadRef, "pull-requests"); + }, + }); + } + } + actionItems.push({ kind: "action", value: "action:open-file-picker", @@ -1768,7 +1893,20 @@ function OpenCommandPaletteDialog(props: { isInSubmenu: currentView !== null, projectSearchItems: projectSearchItems, settingsSearchItems, - threadSearchItems: allThreadItems, + threadSearchItems: + linkedThreadSearch?.linkedThreads && deferredQuery === linkedThreadSearch.query + ? buildLinkedThreadActionItems({ + ...linkedThreadSearch.linkedThreads, + query: linkedThreadSearch.query, + icon: , + runThread: async (thread) => { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), + }); + }, + }) + : allThreadItems, }); const handleAddProjectForEnvironment = useCallback( @@ -1974,6 +2112,7 @@ function OpenCommandPaletteDialog(props: { } setIsRemoteProjectLookingUp(true); + const lookupGeneration = ++cloneLookupGeneration.current; const lookupResult = await lookupRepository({ environmentId: addProjectCloneFlow.environmentId, input: { @@ -1981,6 +2120,7 @@ function OpenCommandPaletteDialog(props: { repository: rawRepository, }, }); + if (lookupGeneration !== cloneLookupGeneration.current) return; setIsRemoteProjectLookingUp(false); if (lookupResult._tag === "Failure") { if (!isAtomCommandInterrupted(lookupResult)) { diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index 66a49ebf8084..6ca4ff1b4983 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -13,7 +13,7 @@ function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { "flex items-center justify-between gap-2", mode === "embedded" ? "px-2" : "px-4", shouldUseDragRegion - ? "drag-region h-[52px] border-b border-border wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]" + ? "drag-region h-[var(--workspace-topbar-height)] border-b border-border wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]" : "flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-transparent", ); } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index c575f270f0bc..aa3767a3155a 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1351,6 +1351,9 @@ export default function GitActionsControl({ ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), + // A pull request the action opens is linked to the thread it ran beside. Drafts + // have no server thread yet, so there is nothing to link to. + ...(activeServerThread ? { threadId: activeServerThread.id } : {}), onProgress: applyProgressEvent, }); @@ -1875,9 +1878,9 @@ export default function GitActionsControl({ Excluded ) : ( <> - +{file.insertions} + +{file.insertions} / - -{file.deletions} + -{file.deletions} )} @@ -1888,11 +1891,11 @@ export default function GitActionsControl({
- + +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)} / - + -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)}
diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 199d0ba834d0..b13040152e18 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -2,6 +2,32 @@ import React, { type SVGProps, useId } from "react"; import { cn } from "~/lib/utils"; export type Icon = React.FC>; +// Apple brand mark from Simple Icons (CC0). +export const AppleIcon: Icon = (props) => ( + +); + +export const AndroidIcon: Icon = (props) => ( + +); + export const LinuxIcon: Icon = ({ className, ...props }) => ( diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index c0e16c7cce71..81fd6047f13e 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1,3 +1,6 @@ +import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; +import { GitPullRequestIcon } from "lucide-react"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { Spinner } from "~/components/ui/spinner"; import { ArchiveIcon, @@ -465,10 +468,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }); const linkedPullRequestStatus = useLinkedThreadPullRequest( thread.environmentId, - thread.linkedPullRequest ?? thread.branchPullRequest, + thread.linkedPullRequest, leaseLiveStatus, + thread.pullRequests, + thread.branchPullRequest, ); const pr = linkedPullRequestStatus?.pr ?? null; + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); + const currentLinkedPr = supportsMultiplePullRequests + ? resolveThreadCurrentPullRequestLink(thread.pullRequests) + : null; const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; @@ -579,17 +588,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ); const handlePrClick = useCallback( (event: React.MouseEvent) => { - if (!prStatus) return; + const url = prStatus?.url ?? currentLinkedPr?.url; + if (!url) return; const openedInRightPanel = openPrLink( event, - prStatus.url, + url, openPullRequestsInRightPanel ? threadRef : undefined, ); if (openedInRightPanel && openPullRequestsInRightPanel && !isActive) { navigateToThread(threadRef); } }, - [isActive, navigateToThread, openPrLink, openPullRequestsInRightPanel, prStatus, threadRef], + [ + isActive, + navigateToThread, + openPrLink, + openPullRequestsInRightPanel, + prStatus, + currentLinkedPr, + threadRef, + ], ); const handleRenameInputRef = useCallback( (element: HTMLInputElement | null) => { @@ -729,6 +747,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} + {!pr && currentLinkedPr ? ( + event.stopPropagation()} + onClick={handlePrClick} + className="text-muted-foreground" + aria-label={`PR #${currentLinkedPr.number}, status pending`} + > + + + ) : null} {threadStatus && } {renamingThreadKey === threadKey ? (
content
, @@ -278,3 +284,47 @@ describe("tabMuteMenuItem", () => { }); }); }); + +describe("pull request tab snapshots", () => { + const environmentId = EnvironmentId.make("local"); + const link: ThreadPullRequestLink = { + host: "github.com", + repository: "acme/api", + number: 7, + url: "https://github.com/acme/api/pull/7", + source: "manual", + linkedAt: "2026-01-01T00:00:00Z", + stack: null, + snapshot: null, + }; + it("keeps unknown linked state authoritative and scopes matches to environment and host", () => { + const threads = [{ environmentId, pullRequests: [link] }]; + expect(resolvePullRequestTabLink(threads, environmentId, "github.com", link)).toBe(link); + expect( + resolvePullRequestTabLink(threads, EnvironmentId.make("remote"), "github.com", link), + ).toBeUndefined(); + expect( + resolvePullRequestTabLink(threads, environmentId, "github.enterprise.test", link), + ).toBeUndefined(); + }); + it("uses the newest snapshot when several threads link the same PR", () => { + const snapshot = { + state: "merged" as const, + title: "API", + headBranch: "api", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-02-01T00:00:00Z", + }; + const newer = { ...link, snapshot }; + expect( + resolvePullRequestTabLink( + [{ environmentId, pullRequests: [link, newer] }], + environmentId, + "github.com", + link, + ), + ).toBe(newer); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index e9dab0c9d2b4..c791a632b79e 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,3 +1,10 @@ +import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities"; +import { + threadPullRequestKeysEqual, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; import type { ContextMenuItem, EnvironmentId, @@ -8,12 +15,14 @@ import type { import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { Bot, + Smartphone, ChevronDown, ChevronLeft, ChevronRight, FileDiff, Files, GitPullRequest, + GitPullRequestArrow, Globe2, Plus, TerminalSquare, @@ -37,6 +46,7 @@ import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { Button } from "~/components/ui/button"; +import { AndroidIcon, AppleIcon } from "~/components/Icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { Kbd } from "~/components/ui/kbd"; import { @@ -88,6 +98,7 @@ interface RightPanelTabsProps { previewRuntimeTabId?: ((tabId: string) => string) | undefined; terminalLabelsById: ReadonlyMap; onActivate: (surface: RightPanelSurface) => void; + onRenameDevice?: (surfaceId: string, title: string) => void; onCloseSurface: (surface: RightPanelSurface) => void; onCloseOtherSurfaces: (surface: RightPanelSurface) => void; onCloseSurfacesToRight: (surface: RightPanelSurface) => void; @@ -104,13 +115,17 @@ interface RightPanelTabsProps { onAddDiff: () => void; onAddFiles: () => void; onAddPullRequest: () => void; + onAddPullRequests: () => void; onAddAgents: () => void; + onAddDevice: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; + pullRequestsAvailable: boolean; agentsAvailable: boolean; + deviceAvailable: boolean; pullRequestStatusSeeds?: Readonly>; /** Running + waiting subagents; badges the Agents card in the empty state. */ liveAgentCount: number; @@ -139,7 +154,9 @@ const SURFACE_DISABLED_REASONS = { files: "Files are only available when a project is open.", diff: "Diff is only available for server threads in Git repositories.", pullRequest: "This thread's branch has no pull request yet.", + pullRequests: "Linked pull requests are only available for server threads.", agents: "Agents are only available from a thread.", + device: "Devices are only available from a thread.", } as const; /** Overlays that must win over the launcher's letter shortcuts. */ @@ -154,17 +171,20 @@ const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ '[data-slot="autocomplete-popup"]', ].join(","); -/** One-line unavailability hints for the empty-state cards. */ +/** One-line unavailability hints for the empty-state rows. */ const SURFACE_UNAVAILABLE_HINTS = { browser: "Only available in the desktop app.", terminal: "Available when a project is open.", files: "Available when a project is open.", diff: "Available for Git repositories.", pullRequest: "No pull request on this branch yet.", + pullRequests: "Available for server threads.", agents: "Available from a thread.", + device: "Available from a thread.", } as const; type TabContextMenuAction = + | "rename" | "copy-path" | "toggle-mute" | "close" @@ -284,7 +304,7 @@ function SurfaceMenuItem(props: { } /** - * Card launcher shown when the right panel has no surfaces. Keyboard-first + * List launcher shown when the right panel has no surfaces. Keyboard-first * without palette chrome: a surface's letter opens it directly from anywhere * outside a typing context, and arrows plus Enter work while the launcher is * focused. The highlight only appears on hover or arrow use. Unavailable @@ -298,13 +318,17 @@ function RightPanelEmptyState(props: { onAddDiff: () => void; onAddFiles: () => void; onAddPullRequest: () => void; + onAddPullRequests: () => void; onAddAgents: () => void; + onAddDevice: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; + pullRequestsAvailable: boolean; agentsAvailable: boolean; + deviceAvailable: boolean; liveAgentCount: number; }) { // -1 means no highlight: it only appears on hover or arrow use. @@ -313,7 +337,6 @@ function RightPanelEmptyState(props: { const actions = [ { label: "Browser", - description: "Open a local app or URL.", icon: Globe2, shortcut: "B", available: props.browserAvailable, @@ -323,7 +346,6 @@ function RightPanelEmptyState(props: { }, { label: "Terminal", - description: "Start a shell in this workspace.", icon: TerminalSquare, shortcut: "T", available: props.terminalAvailable, @@ -333,7 +355,6 @@ function RightPanelEmptyState(props: { }, { label: "Files", - description: "Browse and read workspace files.", icon: Files, shortcut: "F", available: props.filesAvailable, @@ -343,7 +364,6 @@ function RightPanelEmptyState(props: { }, { label: "Diff", - description: "Review changes in this thread.", icon: FileDiff, shortcut: "D", available: props.diffAvailable, @@ -353,7 +373,6 @@ function RightPanelEmptyState(props: { }, { label: "Pull request", - description: "Open this branch's pull request.", icon: GitPullRequest, shortcut: "P", available: props.pullRequestAvailable, @@ -361,9 +380,17 @@ function RightPanelEmptyState(props: { onClick: props.onAddPullRequest, badgeCount: 0, }, + { + label: "Linked pull requests", + icon: GitPullRequestArrow, + shortcut: "L", + available: props.pullRequestsAvailable, + disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, + onClick: props.onAddPullRequests, + badgeCount: 0, + }, { label: "Agents", - description: "Follow subagents and workflows.", icon: Bot, shortcut: "A", available: props.agentsAvailable, @@ -371,6 +398,16 @@ function RightPanelEmptyState(props: { onClick: props.onAddAgents, badgeCount: props.liveAgentCount, }, + { + label: "Device", + description: "Watch an iOS Simulator or Android Emulator.", + icon: Smartphone, + shortcut: "M", + available: props.deviceAvailable, + disabledReason: SURFACE_UNAVAILABLE_HINTS.device, + onClick: props.onAddDevice, + badgeCount: 0, + }, ] as const; type SurfaceAction = (typeof actions)[number]; @@ -420,9 +457,8 @@ function RightPanelEmptyState(props: { return; } if (event.key === "Enter") { - // A focused card button owns its own activation; only open from the - // highlight when the container itself has focus. - if (event.target instanceof HTMLElement && event.target.closest("button")) return; + // Only activate the highlight when the launcher itself has focus. + if (event.target !== event.currentTarget) return; const action = availableActions[highlightIndex]; if (!action) return; event.preventDefault(); @@ -456,10 +492,6 @@ function RightPanelEmptyState(props: { ); }; - const cardShellClass = - "rounded-lg border border-border/80 bg-card dark:border-transparent dark:shadow-none dark:inset-ring-1 dark:inset-ring-white/5"; - const highlightedCardClass = "bg-accent/60 dark:inset-ring-white/20"; - return (
-
-
-

Open a surface

-

- Choose what to show in the right panel. -

-
-
+
+

Open a surface

+
{actions.map((action) => action.available ? ( - // The card is itself a button, so the profile chooser sits beside + // The row is itself a button, so the profile chooser sits beside // it in a wrapper rather than inside it. Hover lives on the - // wrapper: the chooser overlays the card, and a pointer moving - // onto it must not read as leaving the card. + // wrapper: the chooser overlays the row, and a pointer moving + // onto it must not read as leaving the row.
- {action.shortcut} - - {actionIcon(action)} - {action.label} - - - {action.description} + {actionIcon(action, "size-4")} + 1 && "pr-7", + )} + > + {action.label} + {action.shortcut} {/* - Same choice the tab bar's "+" menu offers: the card opens the + Same choice the tab bar's "+" menu offers: the row opens the default profile, the chevron picks another. Only worth showing once there is something to choose between. */} @@ -530,7 +555,7 @@ function RightPanelEmptyState(props: { render={
) : ( -
- {action.shortcut} - - {actionIcon(action)} - {action.label} - - - {action.disabledReason} - -
+ reason={action.disabledReason} + trigger={ +
+ {actionIcon(action, "size-4")} + {action.label} + {action.shortcut} +
+ } + /> ), )}
@@ -602,8 +626,12 @@ function surfaceTitle( ); case "pull-request": return `#${surface.number}`; + case "pull-requests": + return "Pull requests"; case "agents": return "Agents"; + case "device": + return surface.title ?? surface.target?.name ?? "Device"; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; @@ -683,9 +711,48 @@ function SurfaceIcon({ seed={pullRequestStatusSeeds?.[surface.id]} /> ); + case "pull-requests": + return ; case "agents": return ; + case "device": + return surface.target?.platform === "ios" ? ( + + ) : surface.target?.platform === "android" ? ( + + ) : ( + + ); + } +} + +export function resolvePullRequestTabLink( + threads: readonly Pick[], + environmentId: EnvironmentId | null, + host: string | null, + reference: { repository: string; number: number }, +) { + if (environmentId === null || host === null) return undefined; + let newest: EnvironmentThreadShell["pullRequests"][number] | undefined; + for (const thread of threads) { + if (thread.environmentId !== environmentId) continue; + for (const link of visibleThreadPullRequests(thread.pullRequests)) { + if ( + !threadPullRequestKeysEqual(link, { + host, + repository: reference.repository, + number: reference.number, + }) + ) + continue; + if ( + newest === undefined || + (link.snapshot?.syncedAt ?? "") > (newest.snapshot?.syncedAt ?? "") + ) + newest = link; + } } + return newest; } function PullRequestSurfaceIcon({ @@ -699,13 +766,36 @@ function PullRequestSurfaceIcon({ }) { const resolvedEnvironmentId = (surface.environmentId as EnvironmentId | undefined) ?? environmentId; - const detail = useEnvironmentQuery( + const projects = useProjects(); + const threads = useThreadShells(); + const project = projects.find( + (entry) => entry.environmentId === resolvedEnvironmentId && entry.id === surface.projectId, + ); + const identity = project?.repositoryIdentity; + const host = + surface.host ?? + (identity?.provider + ? pullRequestHostOf(identity, identity.provider as SourceControlProviderKind) + : null); + const configs = useServerConfigs(); + const capabilities = resolvedEnvironmentId === null + ? undefined + : configs.get(resolvedEnvironmentId)?.environment.capabilities; + const linkedSnapshot = + capabilities?.threadPullRequests === true + ? (resolvePullRequestTabLink(threads, resolvedEnvironmentId, host, surface)?.snapshot ?? null) + : null; + const detail = useEnvironmentQuery( + resolvedEnvironmentId === null || capabilities?.pullRequests !== true || linkedSnapshot !== null ? null : pullRequestEnvironment.detail({ environmentId: resolvedEnvironmentId, input: { projectId: surface.projectId as ProjectId, + ...(capabilities?.threadPullRequests === true && surface.host !== undefined + ? { host: surface.host } + : {}), repository: surface.repository, number: surface.number, }, @@ -714,11 +804,15 @@ function PullRequestSurfaceIcon({ // Only state and draft reach the tab. A list seed cannot know mergeability, so feeding the // full detail would flip an open tab to the conflict glyph the moment its read lands. const status = - detail === null ? (seed ?? null) : { state: detail.state, isDraft: detail.isDraft }; + linkedSnapshot !== null + ? linkedSnapshot + : detail === null + ? (seed ?? null) + : { state: detail.state, isDraft: detail.isDraft }; if (status === null) { return ; } - const presentation = resolvePullRequestState(status); + const presentation = resolvePullRequestState({ state: status.state, isDraft: status.isDraft }); return ; } @@ -727,6 +821,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const browserProfiles = useBrowserDefaults().profiles; const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); + const [renamingDevice, setRenamingDevice] = useState(null); const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); const [tabScrollState, setTabScrollState] = useState({ hasOverflow: false, @@ -806,6 +901,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.pullRequest, onClick: props.onAddPullRequest, }, + { + label: "Linked pull requests", + icon: GitPullRequestArrow, + shortcut: "L", + available: props.pullRequestsAvailable, + disabledReason: SURFACE_DISABLED_REASONS.pullRequests, + onClick: props.onAddPullRequests, + }, { label: "Agents", icon: Bot, @@ -814,6 +917,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.agents, onClick: props.onAddAgents, }, + { + label: "Device", + icon: Smartphone, + shortcut: "M", + available: props.deviceAvailable, + disabledReason: SURFACE_DISABLED_REASONS.device, + onClick: props.onAddDevice, + }, ] as const; const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { @@ -837,6 +948,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) { if (surfaceIndex < 0) return; const items: ContextMenuItem[] = []; + if (surface.kind === "device" && props.onRenameDevice) + items.push({ id: "rename", label: "Rename" }); if (surface.kind === "file" && surface.attachment === undefined) { items.push({ id: "copy-path", label: "Copy path" }); } @@ -880,6 +993,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY }); switch (action) { + case "rename": + setRenamingDevice(surface.id); + break; case "copy-path": if (surface.kind === "file" && surface.attachment === undefined) { props.onCopyFilePath(surface.relativePath); @@ -1081,20 +1197,48 @@ export function RightPanelTabs(props: RightPanelTabsProps) { {audio === "muted" ? "Unmute tab" : "Mute tab"} )} - - props.onActivate(surface)} - > - {title} - - } + {renamingDevice === surface.id ? ( + { + element?.focus(); + element?.select(); + }} + onBlur={(event) => { + props.onRenameDevice?.(surface.id, event.currentTarget.value); + setRenamingDevice(null); + }} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + event.currentTarget.value = title; + event.currentTarget.blur(); + } + }} /> - {title} - + ) : ( + + { + if (surface.kind === "device" && props.onRenameDevice) + setRenamingDevice(surface.id); + }} + className="cursor-pointer flex min-w-0 items-center" + onClick={() => props.onActivate(surface)} + > + {title} + + } + /> + {title} + + )}
); })} @@ -1250,13 +1394,17 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddDiff={props.onAddDiff} onAddFiles={props.onAddFiles} onAddPullRequest={props.onAddPullRequest} + onAddPullRequests={props.onAddPullRequests} onAddAgents={props.onAddAgents} + onAddDevice={props.onAddDevice} browserAvailable={props.browserAvailable} terminalAvailable={props.terminalAvailable} diffAvailable={props.diffAvailable} filesAvailable={props.filesAvailable} pullRequestAvailable={props.pullRequestAvailable} + pullRequestsAvailable={props.pullRequestsAvailable} agentsAvailable={props.agentsAvailable} + deviceAvailable={props.deviceAvailable} liveAgentCount={props.liveAgentCount} /> ) : ( diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 4a0584821a9b..33157e7b4b4b 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -27,7 +27,7 @@ import { resolveSidebarThreadStatus, resolveThreadStatusPill, resolveWorkingStartedAt, - searchSidebarThreadsByTitle, + searchSidebarThreads, formatWorkingDurationLabel, shouldClearThreadSelectionOnMouseDown, shouldRecedeSidebarThread, @@ -802,7 +802,7 @@ describe("resolveSidebarThreadStatus", () => { }); }); -describe("searchSidebarThreadsByTitle", () => { +describe("searchSidebarThreads", () => { const threads = [ { id: "thread-1", title: "Fix workspace search", project: "Alpha" }, { id: "thread-2", title: "Review providers", project: "Workspace" }, @@ -810,15 +810,15 @@ describe("searchSidebarThreadsByTitle", () => { ]; it("matches thread titles case-insensitively and preserves their order", () => { - expect(searchSidebarThreadsByTitle(threads, "work")).toEqual([threads[0], threads[2]]); + expect(searchSidebarThreads(threads, "work")).toEqual([threads[0], threads[2]]); }); it("does not match project metadata", () => { - expect(searchSidebarThreadsByTitle(threads, "workspace")).toEqual([threads[0]]); + expect(searchSidebarThreads(threads, "workspace")).toEqual([threads[0]]); }); it("returns no results for an empty query", () => { - expect(searchSidebarThreadsByTitle(threads, " ")).toEqual([]); + expect(searchSidebarThreads(threads, " ")).toEqual([]); }); }); @@ -2133,6 +2133,7 @@ function makeThread(overrides: Partial = {}): Thread { branch: null, worktreePath: null, checkpoints: [], + pullRequests: [], activities: [], ...overrides, }; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 9b59675e2eb1..0150eb473d51 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,3 +1,4 @@ +import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import * as React from "react"; import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; import { @@ -875,17 +876,20 @@ export { pinOrderKeyBetween, planPinnedReorder } from "@t3tools/client-runtime/s export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; /** - * Search the already-ordered sidebar thread collection by title only. + * Search the already-ordered sidebar thread collection by title or linked PR. * Keeping the input order means lifecycle ordering (active, snoozed, settled) * remains stable while the user narrows the list. */ -export function searchSidebarThreadsByTitle( - threads: readonly T[], - query: string, -): T[] { +export function searchSidebarThreads< + T extends { readonly title: string } & Parameters[0], +>(threads: readonly T[], query: string): T[] { const normalizedQuery = query.trim().toLowerCase(); if (normalizedQuery.length === 0) return []; - return threads.filter((thread) => thread.title.toLowerCase().includes(normalizedQuery)); + return threads.filter((thread) => + [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) => + term.toLowerCase().includes(normalizedQuery), + ), + ); } export function filterSidebarProjectScopeItems(input: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ebc1078e672b..c269b73ee761 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,5 @@ +import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { @@ -70,6 +72,7 @@ import { } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; +import { useRightPanelStore } from "../rightPanelStore"; import { isAtomCommandInterrupted, settlePromise, @@ -159,7 +162,7 @@ import { resolveSidebarDropVerb, type SidebarDropVerb, resolveSidebarThreadStatus, - searchSidebarThreadsByTitle, + searchSidebarThreads, shouldCreateNewThreadInCurrentProject, shouldRecedeSidebarThread, resolveWorkingStartedAt, @@ -185,9 +188,11 @@ import { import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; import { createSidebarListMotion } from "./Sidebar.motion"; import { + ThreadPullRequestBadgeControl, + ThreadPullRequestsMiniList, ThreadWorktreeIndicator, prStatusIndicator, - settledPrHoverColorClass, + resolveThreadPullRequestBadge, terminalStatusFromRunningIds, type TerminalStatusIndicator, useLinkedThreadPullRequest, @@ -330,6 +335,7 @@ function SidebarThreadTooltip({ terminalProcessCount: number; }) { const driverKind = providerEntry?.driverKind ?? null; + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); return ( ) : null}
+ {supportsMultiplePullRequests && thread.pullRequests.length > 0 ? ( +
+ +
+ ) : null}
); @@ -1047,8 +1058,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const gitCwd = thread.worktreePath ?? props.project?.workspaceRoot ?? null; const linkedPullRequestStatus = useLinkedThreadPullRequest( thread.environmentId, - thread.linkedPullRequest ?? thread.branchPullRequest, + thread.linkedPullRequest, leaseLiveStatus, + thread.pullRequests, + thread.branchPullRequest, ); const gitStatus = useEnvironmentQuery( leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null @@ -1063,6 +1076,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { gitStatus.data, ); const pr = linkedPullRequestStatus?.pr ?? null; + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); + const currentLinkedPr = supportsMultiplePullRequests + ? resolveThreadCurrentPullRequestLink(thread.pullRequests) + : null; // Same semantics as the legacy sidebar (never-visited counts as read): // switching sidebars must not light up every historical thread as unread. @@ -1156,7 +1173,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { currentGitBranch: visibleGitStatus?.refName ?? null, }); const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); - const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined; const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; @@ -1339,17 +1355,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [showSnoozeButton]); const handlePrClick = useCallback( (event: ReactMouseEvent) => { - if (!pr?.url) return; + const url = pr?.url ?? currentLinkedPr?.url; + if (!url) return; const openedInRightPanel = openPrLink( event, - pr.url, + url, openPullRequestsInRightPanel ? threadRef : undefined, ); if (openedInRightPanel && openPullRequestsInRightPanel && !props.isActive) { onThreadActivate(threadRef); } }, - [onThreadActivate, openPrLink, openPullRequestsInRightPanel, pr, props.isActive, threadRef], + [ + onThreadActivate, + openPrLink, + openPullRequestsInRightPanel, + pr, + currentLinkedPr, + props.isActive, + threadRef, + ], ); // All sidebar rows share one surface model. Live threads used to look @@ -1358,6 +1383,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // content; surface is reserved for interaction (hover, multi-select, route). const rowSurfaceClassName = cn( "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none", + variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70", props.isActive ? "bg-sidebar-row-active text-sidebar-foreground" : isSelected @@ -1440,7 +1466,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { : "text-foreground/90", ) : cn( - "truncate group-hover/sidebar-row:text-foreground", + "truncate group-focus-within/sidebar-row:text-foreground group-hover/sidebar-row:text-foreground", shouldRecede ? "text-secondary-label/70" : props.isActive || isWoke @@ -1456,28 +1482,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ); - // A real link so cmd/ctrl+click and middle-click open the host in the - // browser. A plain click still opens T3's pull request view. + // Stacks show their layer count; multiple unrelated links show their total count. + // Plain clicks open T3; individual PR links also support opening the host in a new tab. + const prBadgeShape = supportsMultiplePullRequests + ? resolveThreadPullRequestBadge(thread.pullRequests) + : null; + const handlePrStackClick = useCallback(() => { + useRightPanelStore.getState().open(threadRef, "pull-requests"); + if (!props.isActive) onThreadActivate(threadRef); + }, [onThreadActivate, props.isActive, threadRef]); const prBadge = - prStatus && pr ? ( -
event.stopPropagation()} - onClick={handlePrClick} - className={cn( - // Sidebar chrome follows the interface font; tabular digits keep the - // number from reflowing as PR states stream in. - "shrink-0 text-xs tabular-nums hover:underline", - variant === "slim" && variantAction === "unsettle" - ? cn("text-secondary-label transition-colors", settledPrHoverClass) - : prStatus.colorClass, - )} - aria-label={prStatus.tooltip} - > - #{pr.number} - + prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? ( + ) : null; const terminalStatusIcon = terminalStatus ? ( {props.project ? : null} @@ -1891,8 +1915,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {prBadge} {diff ? ( - +{diff.insertions}{" "} - −{diff.deletions} + +{diff.insertions}{" "} + −{diff.deletions} ) : null} searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery), + () => searchSidebarThreads(searchableThreads, threadSearchQuery), [searchableThreads, threadSearchQuery], ); const threadSearchResultOrderKey = threadSearchResults @@ -4304,7 +4328,7 @@ export default function Sidebar() { setActiveSearchResultIndex(0); }} onKeyDown={handleThreadSearchKeyDown} - placeholder="Search" + placeholder="Search threads or PRs" aria-label="Search threads" role="combobox" aria-autocomplete="list" diff --git a/apps/web/src/components/ThreadCommandSubtitle.tsx b/apps/web/src/components/ThreadCommandSubtitle.tsx index 32c6c0373438..275105a79099 100644 --- a/apps/web/src/components/ThreadCommandSubtitle.tsx +++ b/apps/web/src/components/ThreadCommandSubtitle.tsx @@ -36,6 +36,7 @@ function WorkspaceIcon(props: { variant: ThreadCommandSubtitleVariant; isWorktre export function ThreadCommandSubtitle(props: { project: ProjectFaviconProject | null; projectTitle: string | null; + environmentLabel?: string | null; branch: string | null; worktreePath: string | null; isCurrent: boolean; @@ -69,6 +70,12 @@ export function ThreadCommandSubtitle(props: { ) : null} {projectLabel} + {props.environmentLabel ? ( + <> + + {props.environmentLabel} + + ) : null} ) : null} diff --git a/apps/web/src/components/ThreadStatusIndicators.test.tsx b/apps/web/src/components/ThreadStatusIndicators.test.tsx index 868bd2cd99c0..2ad3ef64448e 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.test.tsx @@ -1,8 +1,8 @@ -import { ThreadId } from "@t3tools/contracts"; +import { ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { ThreadWorktreeIndicator } from "./ThreadStatusIndicators"; +import { ThreadWorktreeIndicator, linkedPullRequestSnapshotStatus } from "./ThreadStatusIndicators"; describe("ThreadWorktreeIndicator", () => { it("renders the worktree folder and branch in an accessible label", () => { @@ -37,3 +37,46 @@ describe("ThreadWorktreeIndicator", () => { expect(markup).toBe(""); }); }); + +describe("linked pull request snapshots", () => { + const link: ThreadPullRequestLink = { + host: "gitlab.example.com", + repository: "acme/web", + number: 42, + url: "https://gitlab.example.com/acme/web/-/merge_requests/42", + source: "manual", + linkedAt: "2026-01-01T00:00:00Z", + stack: null, + snapshot: null, + }; + it("keeps unsynced links unknown", () => { + expect(linkedPullRequestSnapshotStatus(link)).toBeNull(); + }); + it("uses the snapshot state and branches with the linked identity", () => { + const result = linkedPullRequestSnapshotStatus({ + ...link, + snapshot: { + state: "merged", + title: "Change", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-01-02T00:00:00Z", + syncedAt: "2026-01-03T00:00:00Z", + }, + }); + expect(result).toEqual({ + pr: { + number: 42, + url: link.url, + title: "Change", + state: "merged", + isDraft: false, + headRef: "feature", + baseRef: "main", + updatedAt: "2026-01-02T00:00:00Z", + }, + sourceControlProvider: { kind: "gitlab", name: "gitlab", baseUrl: "" }, + }); + }); +}); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index afe56608eda5..2a69eaeddc9d 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,25 +1,37 @@ +import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; import { - type EnvironmentId, resolveEnvironmentMachineKind, + type EnvironmentId, type ThreadLinkedPullRequest, + type ThreadPullRequestLink, type VcsStatusResult, } from "@t3tools/contracts"; -import { FolderGit2Icon, TerminalIcon } from "lucide-react"; -import { useMemo } from "react"; +import { + resolveThreadCurrentPullRequestLink, + resolveThreadPullRequestChains, + visibleThreadPullRequests, + type ThreadPullRequestBadge, +} from "@t3tools/shared/threadPullRequests"; +import { FolderGit2Icon, GitPullRequestArrowIcon, LayersIcon, TerminalIcon } from "lucide-react"; +import { useMemo, type MouseEvent } from "react"; +import { buttonVariants, InlineButton } from "./ui/button"; +import { cn } from "../lib/utils"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { parseChangeRequestUrl } from "../lib/openPullRequestLink"; import { useEnvironmentQuery } from "../state/query"; import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useUiStateStore } from "../uiStateStore"; import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; -import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; import type { SidebarThreadSummary } from "../types"; import { formatWorktreePathForDisplay } from "../worktreeCleanup"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { pullRequestListLines } from "./pullRequest/pullRequestListLines"; +import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; export interface PrStatusIndicator { label: string; @@ -43,42 +55,232 @@ export interface LinkedThreadPullRequestStatus { readonly sourceControlProvider: NonNullable; } -/** Keep cached summaries visible when an offscreen row stops live queries. */ +/** Linked badges use persisted snapshots; only branch and legacy fallbacks lease summary reads. */ export function useLinkedThreadPullRequest( environmentId: EnvironmentId | null, linkedPullRequest: ThreadLinkedPullRequest | null | undefined, enabled = true, + pullRequests?: ReadonlyArray, + branchPullRequest?: ThreadLinkedPullRequest | null, ): LinkedThreadPullRequestStatus | null { + const supportsLinks = useSupportsMultiplePullRequests(environmentId); + const current = useMemo( + () => (supportsLinks ? resolveThreadCurrentPullRequestLink(pullRequests ?? []) : null), + [pullRequests, supportsLinks], + ); + const fallback = + current === null ? ((!supportsLinks ? linkedPullRequest : null) ?? branchPullRequest) : null; + const host = fallback == null ? undefined : parseChangeRequestUrl(fallback.url)?.host; + const reference = + fallback == null ? null : { ...fallback, ...(host === undefined ? {} : { host }) }; const queried = useEnvironmentQuery( - !enabled || environmentId === null || linkedPullRequest == null + !enabled || environmentId === null || reference === null ? null - : linkedPullRequestDetailAtom({ - environmentId, - input: { - projectId: linkedPullRequest.projectId, - repository: linkedPullRequest.repository, - number: linkedPullRequest.number, - }, - }), + : linkedPullRequestDetailAtom({ environmentId, input: reference }), ).data; - const detail = useSharedPullRequestSummary(environmentId, linkedPullRequest ?? null, queried); + const detail = useSharedPullRequestSummary(environmentId, reference, queried); + + return useMemo(() => { + if (current !== null) return linkedPullRequestSnapshotStatus(current); + return detail === null + ? null + : { + pr: pullRequestDetailToVcsStatus(detail), + sourceControlProvider: { kind: detail.provider, name: detail.provider, baseUrl: "" }, + }; + }, [current, detail]); +} + +export function linkedPullRequestSnapshotStatus( + link: ThreadPullRequestLink, +): LinkedThreadPullRequestStatus | null { + const snapshot = link.snapshot; + if (snapshot === null) return null; + const kind = link.url.includes("/-/merge_requests/") + ? "gitlab" + : link.url.includes("/pullrequest/") + ? "azure-devops" + : link.url.includes("/pull-requests/") + ? "bitbucket" + : "github"; + return { + pr: { + number: link.number, + url: link.url, + title: snapshot.title, + state: snapshot.state, + isDraft: snapshot.isDraft, + headRef: snapshot.headBranch, + baseRef: snapshot.baseBranch, + ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }), + }, + sourceControlProvider: { kind, name: kind, baseUrl: "" }, + }; +} - return useMemo( +export { + resolveThreadPullRequestBadge, + type ThreadPullRequestBadge, +} from "@t3tools/shared/threadPullRequests"; + +/** The glyph a row's badge wears: the layers icon for a stack, the pull-request one otherwise. */ +function ThreadPullRequestBadgeIcon({ + icon, + className, +}: { + icon: "stack" | "pull-request"; + className?: string | undefined; +}) { + const Icon = icon === "stack" ? LayersIcon : GitPullRequestArrowIcon; + return ; +} + +/** The complete linked-PR control shared by the sidebar and composer footer. */ +export function ThreadPullRequestBadgeControl({ + variant, + badge, + number, + url, + status, + onOpenStack, + onOpenPullRequest, +}: { + variant: "underline" | "ghost"; + badge: ThreadPullRequestBadge | null; + number?: number | undefined; + url?: string | undefined; + status: PrStatusIndicator | null; + onOpenStack: () => void; + onOpenPullRequest: (event: MouseEvent) => void; +}) { + const isStack = badge?.kind === "stack"; + const linkedCount = badge?.kind === "pull-request" && badge.others > 0 ? badge.others + 1 : null; + if (!isStack && (number === undefined || url === undefined)) return null; + const label = isStack + ? `Stack of ${badge.layers} pull requests, ${badge.state}` + : `${status?.tooltip ?? `PR #${number}, status pending`}${ + badge?.kind === "pull-request" && badge.others > 0 + ? `, and ${badge.others} more linked; overall ${badge.state}` + : "" + }`; + const className = cn( + variant === "ghost" + ? buttonVariants({ variant: "ghost", size: "xs" }) + : "inline-flex shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap border-b border-transparent hover:border-current focus-visible:outline-2 focus-visible:outline-ring", + "text-xs tabular-nums", + variant === "ghost" && + "font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]", + badge !== null && (isStack || linkedCount !== null) + ? PR_STATE_COLOR_CLASS[badge.state] + : (status?.colorClass ?? "text-muted-foreground"), + ); + const content = ( + <> + + {isStack ? badge.layers : linkedCount !== null ? `+${linkedCount}` : number} + + ); + return ( + + event.stopPropagation()} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + onOpenStack(); + }} + /> + ) : ( + event.stopPropagation()} + onClick={onOpenPullRequest} + /> + ) + } + > + {content} + + {label} + + ); +} + +/** + * A miniature of the pull-requests panel for the thread tooltip: same order, same indentation, + * so the hover answers "what is in here" without opening the surface. + */ +export function ThreadPullRequestsMiniList({ + pullRequests, +}: { + pullRequests: ReadonlyArray; +}) { + const lines = useMemo( () => - detail === null - ? null - : { - pr: pullRequestDetailToVcsStatus(detail), - sourceControlProvider: { - kind: detail.provider, - name: detail.provider, - baseUrl: "", - }, - }, - [detail], + pullRequestListLines(resolveThreadPullRequestChains(visibleThreadPullRequests(pullRequests))), + [pullRequests], + ); + if (lines.length === 0) return null; + return ( +
    + {lines.map((line) => { + const snapshot = line.link.snapshot; + const presentation = + snapshot === null + ? null + : resolvePullRequestState({ state: snapshot.state, isDraft: snapshot.isDraft }); + return ( +
  • + {presentation ? ( + + ) : ( + + )} + #{line.link.number} + + {snapshot?.title ?? line.link.repository} + + {line.stack ? ( + + {line.stack.kind === "native" ? "stack" : "chain"} · {line.stack.size} + + ) : null} +
  • + ); + })} +
); } +/** The ink each pull-request state wears in the sidebar, shared by the number and stack badges. */ +const PR_STATE_COLOR_CLASS: Record = { + open: "text-emerald-600 dark:text-emerald-300/90", + merged: "text-violet-600 dark:text-violet-300/90", + closed: "text-red-600 dark:text-red-300/90", + draft: "text-zinc-500 dark:text-zinc-400/80", +}; + export function settledPrHoverColorClass( state: NonNullable["state"], isDraft = false, @@ -282,7 +484,10 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar ); const pullRequest = useLinkedThreadPullRequest( thread.environmentId, - thread.linkedPullRequest ?? thread.branchPullRequest, + thread.linkedPullRequest, + true, + thread.pullRequests, + thread.branchPullRequest, ); const pr = pullRequest?.pr ?? null; const prStatus = prStatusIndicator(pr, pullRequest?.sourceControlProvider); @@ -293,7 +498,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar }, }); - if (!prStatus && !threadStatus) { + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); + const pendingLink = + pr === null && supportsMultiplePullRequests + ? resolveThreadCurrentPullRequestLink(thread.pullRequests) + : null; + if (!prStatus && !threadStatus && !pendingLink) { return null; } @@ -316,6 +526,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar ) : null} + {pendingLink ? ( + + ) : null} {threadStatus ? : null}
); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7b2e65f8bf3e..7ecb5e2dc635 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1354,6 +1354,7 @@ export interface ChatComposerProps { onPageScrollRelease: () => void; // Callbacks + onCompactContext: () => void; onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -1460,6 +1461,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPageScrollKeyDown, onPageScrollKeyUp, onPageScrollRelease, + onCompactContext, onSend, onInterrupt, onImplementPlanInNewThread, @@ -1995,7 +1997,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /** * Count of pasted images still being compressed, per thread. Reserved * against the attachment limit so concurrent pastes can't overshoot it, - * and checked before sending or compacting so an image cannot move into + * and checked before sending so an image cannot move into * the next draft. */ const pendingImageCompressionsRef = useRef>(new Map()); @@ -3097,42 +3099,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) { return; } - // The compact buttons cannot see the compression counter (it lives in - // a ref), so they render enabled during a paste; toast instead of - // silently ignoring the click. - if ((pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0) { - toastManager.add({ - type: "info", - title: "Still compressing a pasted image.", - description: "Compact again once its thumbnail appears.", - }); - return; - } - - promptRef.current = "/compact"; - setComposerDraftPrompt(composerDraftTarget, "/compact"); - submitComposer(); - // A blocked dispatch (busy send ref, provider preflight rejection) - // would leave the injected "/compact" behind as if the user typed it. - // Clearing here is safe even when the send did dispatch: the send - // snapshots its prompt synchronously and clears the draft itself. - if (promptRef.current === "/compact") { - promptRef.current = ""; - setComposerDraftPrompt(composerDraftTarget, ""); - } + onCompactContext(); }, [ activePendingApproval, activeThreadId, compactDisabled, - composerDraftTarget, isConnecting, isSendBusy, noProviderAvailable, + onCompactContext, pendingUserInputs.length, phase, - promptRef, - setComposerDraftPrompt, - submitComposer, ]); const expandMobileComposer = useCallback(() => { if (composerBlurFrameRef.current !== null) { @@ -4146,7 +4123,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null} -
; } return (
@@ -837,7 +870,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ref={listRef} data={rows} - extraData={rows.length} + extraData={`${listIdentityKey}:${rows.length}`} keyExtractor={keyExtractor} getItemType={getItemType} renderItem={renderItem} @@ -911,13 +944,6 @@ function getItemType(item: MessagesTimelineRow) { return item.kind === "message" ? `message:${item.message.role}` : item.kind; } -interface TimelineMinimapItem { - readonly id: string; - readonly rowIndex: number; - readonly userText: string | null; - readonly assistantText: string | null; -} - interface TimelinePositionState { readonly contentLength?: number; readonly scroll?: number; @@ -926,51 +952,6 @@ interface TimelinePositionState { readonly sizeAtIndex?: (index: number) => number | undefined; } -function deriveTimelineMinimapItems( - rows: ReadonlyArray, -): TimelineMinimapItem[] { - const items: TimelineMinimapItem[] = []; - for (let index = 0; index < rows.length; index += 1) { - const row = rows[index]; - if (row?.kind !== "message" || row.message.role !== "user") { - continue; - } - - items.push({ - id: row.id, - rowIndex: index, - userText: compactMinimapPreview(row.message.text), - assistantText: compactMinimapPreview(resolveFinalAssistantTextForTurn(rows, index)), - }); - } - return items; -} - -function resolveFinalAssistantTextForTurn( - rows: ReadonlyArray, - userRowIndex: number, -) { - let finalAssistantText: string | null = null; - for (let index = userRowIndex + 1; index < rows.length; index += 1) { - const row = rows[index]; - if (row?.kind !== "message") { - continue; - } - if (row.message.role === "user") { - break; - } - if (row.message.role === "assistant") { - finalAssistantText = row.message.text ?? null; - } - } - return finalAssistantText; -} - -function compactMinimapPreview(text: string | null | undefined) { - const compact = text?.replace(/\s+/g, " ").trim() ?? ""; - return compact.length > 0 ? compact : null; -} - function resolveTimelineRowTop(state: TimelinePositionState, rowIndex: number) { const top = state.positionAtIndex?.(rowIndex); return typeof top === "number" && Number.isFinite(top) ? top : null; @@ -1004,7 +985,13 @@ function TimelineMinimap({ const resolvedActiveIndex = activeIndex !== null && activeIndex < items.length ? activeIndex : null; - const activeItem = resolvedActiveIndex === null ? null : (items[resolvedActiveIndex] ?? null); + const activeItem = useMemo( + () => + resolveTimelineMinimapPreview( + resolvedActiveIndex === null ? null : (items[resolvedActiveIndex] ?? null), + ), + [items, resolvedActiveIndex], + ); const activeTopPercent = resolvedActiveIndex === null ? 0 @@ -1581,7 +1568,7 @@ function UserTimelineRow({ row }: { row: Extract
-
+
}> @@ -1733,7 +1720,7 @@ function AssistantMessageMeta({ "flex items-center gap-2 text-xs tabular-nums transition-opacity duration-200", alwaysVisible ? "opacity-100" - : "opacity-0 focus-within:opacity-100 group-hover/assistant:opacity-100", + : "opacity-0 pointer-coarse:opacity-100 focus-within:opacity-100 group-hover/assistant:opacity-100", className, )} > @@ -2113,7 +2100,7 @@ function LiveActivityRow({ active = false, shimmer = false, }: { - label: string; + label: ReactNode; iconName?: WorkEntryIconName; toolIcon?: ToolActivityIcon | undefined; failed?: boolean; @@ -2153,7 +2140,7 @@ function LiveActivityContent({ active = false, highlighted = false, }: { - label: string; + label: ReactNode; iconName: WorkEntryIconName | undefined; toolIcon?: ToolActivityIcon | undefined; failed?: boolean; @@ -2211,7 +2198,25 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract ctx.onToggleWorkGroup(row.groupId, row.id)} > + {label} + + {getQuestionAnswerPreview(row.entry.questionAnswer)} + + + ) : ( + label + ) + } iconName={workEntryIconName(row.entry)} toolIcon={row.entry.toolIcon ?? row.entry.toolSource?.icon} failed={failed} @@ -2225,6 +2230,11 @@ function toolGroupSummaryIconName( kind: Extract["summaryKind"], ): WorkEntryIconName { switch (kind) { + case "pull-request": + case "link-pr": + case "unlink-pr": + case "list-prs": + return "pull-request"; case "read": return "eye"; case "edit": @@ -2233,6 +2243,8 @@ function toolGroupSummaryIconName( return "terminal"; case "browser": return "browser"; + case "device": + return "device"; case "search": return "globe"; case "code-search": @@ -2751,17 +2763,23 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte /** Returns a structurally-shared copy of `rows`: for each row whose content * hasn't changed since last call, the previous object reference is reused. */ -function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { +function useStableRows(rows: MessagesTimelineRow[], identity: string): MessagesTimelineRow[] { const prevState = useRef({ byId: new Map(), result: [], }); + const prevIdentity = useRef(identity); return useMemo(() => { - const nextState = computeStableMessagesTimelineRows(rows, prevState.current); + const previous = + prevIdentity.current === identity + ? prevState.current + : { byId: new Map(), result: [] }; + prevIdentity.current = identity; + const nextState = computeStableMessagesTimelineRows(rows, previous); prevState.current = nextState; return nextState.result; - }, [rows]); + }, [identity, rows]); } // --------------------------------------------------------------------------- @@ -2794,6 +2812,7 @@ type WorkEntryIconName = | "check" | "circle-alert" | "computer" + | "device" | "eye" | "globe" | "hammer" @@ -2801,6 +2820,7 @@ type WorkEntryIconName = | "search" | "square-pen" | "terminal" + | "pull-request" | "t3-code" | "wrench" | "x" @@ -3005,6 +3025,8 @@ function ToolActivityImageIcon(props: { function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) { switch (name) { + case "pull-request": + return ; case "bot": return ; case "brain": @@ -3013,6 +3035,8 @@ function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className return ; case "computer": return ; + case "device": + return ; case "t3-code": return ; case "check": @@ -3133,6 +3157,7 @@ const toolCallExpandedBodyClassName = function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { if ( + workEntry.questionAnswer || workEntry.sourceActivityKind === "user-input.requested" || workEntry.sourceActivityKind === "user-input.resolved" ) { @@ -3163,6 +3188,18 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation(); +/** + * Click handler for expanded row labels, which turn text selection back on. + * Only a click that ends a real selection is withheld from the row toggle, so + * an ordinary click on the label still bubbles and collapses the row it opened. + */ +const stopRowToggleWhileSelectingText = (e: MouseEvent) => { + const selection = e.currentTarget.ownerDocument.getSelection(); + if (selection && !selection.isCollapsed) { + e.stopPropagation(); + } +}; + /** * A1 spawn CTA: one anchored row per workflow run (or per-turn direct-spawn * batch). Live status is derived from the shared agent panel model at render @@ -3298,9 +3335,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { showWarningIndicator || showDestructiveRowStyle ? undefined : (workEntry.toolIcon ?? workEntry.toolSource?.icon); - const previewText = workEntry.questionAnswer - ? "Question answer submitted" - : (displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot)); + const previewText = displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot); + const answerPreview = workEntry.questionAnswer + ? getQuestionAnswerPreview(workEntry.questionAnswer) + : null; const viewedImagePath = workEntryViewedImagePath(workEntry); const viewedImage = viewedImagePath && threadRef @@ -3309,13 +3347,13 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workspaceRoot, }) : null; - const commandMatchesVisibleLabel = workEntry.command?.trim() === previewText.trim(); const canExpand = + Boolean(workEntry.questionAnswer) || (showFailedIndicator && previewText.trim().length > 0) || (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) || Boolean( - (!commandMatchesVisibleLabel && - (workEntryRawCommand(workEntry) || workEntry.command?.trim())) || + workEntryRawCommand(workEntry) || + workEntry.command?.trim() || workEntry.detail?.trim() || workEntry.changedFiles?.length || viewedImage, @@ -3348,9 +3386,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { : workLogEntryIsToolLike(workEntry) ? "text-secondary-label" : "text-foreground/80"; + const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": "); const accessibleDisplayText = showFailedIndicator - ? `${previewText}, tool call failed` - : previewText; + ? `${accessiblePreview}, tool call failed` + : accessiblePreview; const rowToggleProps = canExpand ? { role: "button" as const, @@ -3395,17 +3434,29 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {

{previewText} + {answerPreview ? ( + + {answerPreview} + + ) : null}

{showFailedIndicator && @@ -3446,10 +3497,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { />
) : null} - {workEntry.questionAnswer ? ( + {expanded && workEntry.questionAnswer ? ( ) : null} - {expanded && canExpand && expandedBody ? ( + {expanded && canExpand && expandedBody && !workEntry.questionAnswer ? (
{[ ...new Set([ + ...Object.keys(answer.questionTextById ?? {}), ...Object.keys(answer.answers), ...Object.keys(answer.attachmentsByQuestionId), ]), ].map((questionId) => (
{answer.questionTextById?.[questionId] ? ( -

{answer.questionTextById[questionId]}

+

+ {answer.questionTextById[questionId]} +

+ ) : null} + {getQuestionAnswerText(answer.answers[questionId]) ? ( +

+ {getQuestionAnswerText(answer.answers[questionId])} +

) : null} -

- {[answer.answers[questionId]] - .flat() - .filter((value): value is string => typeof value === "string") - .join(", ")} -

{(answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => { const url = urls[attachments.indexOf(attachment)]; diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 932754db1eca..fb2cdb2f1280 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -42,7 +42,6 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { activeProviderIconClassName?: string; instanceIndicatorBackground?: string; size?: ComposerControlSize; - compact?: boolean; isComposerOwned?: boolean; disabled?: boolean; terminalOpen?: boolean; @@ -171,8 +170,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { size={size} data-chat-provider-model-picker="true" className={cn( - "min-w-0 justify-between whitespace-nowrap", - props.compact ? "max-w-42 shrink-0" : "max-w-48 shrink sm:max-w-56", + "min-w-0 shrink justify-between whitespace-nowrap", + !props.isComposerOwned && "max-w-48 sm:max-w-56", props.triggerClassName, )} disabled={props.disabled} diff --git a/apps/web/src/components/chat/ZoomableImage.tsx b/apps/web/src/components/chat/ZoomableImage.tsx new file mode 100644 index 000000000000..944c5204ed4d --- /dev/null +++ b/apps/web/src/components/chat/ZoomableImage.tsx @@ -0,0 +1,239 @@ +import { + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, + type Ref, +} from "react"; + +const MAX_ZOOM = 8; + +export interface ZoomableImageHandle { + pan: (key: string) => boolean; +} + +/** Zooms around the pointer and keeps the whole image accessible by dragging or scrolling. */ +export function ZoomableImage({ + src, + name, + onError, + ref, +}: { + src: string; + name: string; + onError: () => void; + ref?: Ref; +}) { + const viewportRef = useRef(null); + const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 }); + const [windowSize, setWindowSize] = useState(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); + const [zoom, setZoom] = useState(1); + const zoomRef = useRef(1); + const anchorRef = useRef<{ x: number; y: number; clientX: number; clientY: number } | null>(null); + const dragRef = useRef<{ + pointerId: number; + x: number; + y: number; + left: number; + top: number; + } | null>(null); + const suppressClickRef = useRef(false); + const [dragging, setDragging] = useState(false); + const maxHeight = Math.max(1, Math.min(windowSize.height * 0.86, windowSize.height - 80)); + const fit = Math.min( + 1, + (windowSize.width * 0.92) / (naturalSize.width || 1), + maxHeight / (naturalSize.height || 1), + ); + const width = naturalSize.width * fit * zoom; + const height = naturalSize.height * fit * zoom; + + useImperativeHandle( + ref, + () => ({ + pan(key) { + const viewport = viewportRef.current; + if (!viewport || zoomRef.current <= 1) return false; + switch (key) { + case "ArrowLeft": + viewport.scrollLeft -= 40; + break; + case "ArrowRight": + viewport.scrollLeft += 40; + break; + case "ArrowUp": + viewport.scrollTop -= 40; + break; + case "ArrowDown": + viewport.scrollTop += 40; + break; + default: + return false; + } + return true; + }, + }), + [], + ); + + const changeZoom = useCallback((next: number, point?: { x: number; y: number }) => { + const viewport = viewportRef.current; + const previous = zoomRef.current; + const clamped = Math.min(MAX_ZOOM, Math.max(1, next)); + if (!viewport || previous === clamped) return; + const bounds = viewport.getBoundingClientRect(); + const x = point ? point.x - bounds.left : viewport.clientWidth / 2; + const y = point ? point.y - bounds.top : viewport.clientHeight / 2; + anchorRef.current = { + x: (viewport.scrollLeft + x) / previous, + y: (viewport.scrollTop + y) / previous, + clientX: bounds.left + x, + clientY: bounds.top + y, + }; + zoomRef.current = clamped; + setZoom(clamped); + }, []); + + useLayoutEffect(() => { + const viewport = viewportRef.current; + const anchor = anchorRef.current; + if (!viewport || !anchor) return; + const bounds = viewport.getBoundingClientRect(); + viewport.scrollLeft = anchor.x * zoom - (anchor.clientX - bounds.left); + viewport.scrollTop = anchor.y * zoom - (anchor.clientY - bounds.top); + anchorRef.current = null; + }, [zoom]); + + useEffect(() => { + const resize = () => { + setWindowSize({ width: window.innerWidth, height: window.innerHeight }); + changeZoom(1); + }; + window.addEventListener("resize", resize); + return () => window.removeEventListener("resize", resize); + }, [changeZoom]); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + const wheel = (event: WheelEvent) => { + if (event.deltaY === 0) return; + event.preventDefault(); + const delta = + event.deltaY * + (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1); + changeZoom(zoomRef.current * Math.exp(-delta * (event.ctrlKey ? 0.01 : 0.002)), { + x: event.clientX, + y: event.clientY, + }); + }; + viewport.addEventListener("wheel", wheel, { passive: false }); + return () => viewport.removeEventListener("wheel", wheel); + }, [changeZoom]); + + return ( +
+
1 ? (dragging ? "grabbing" : "grab") : "zoom-in", + }} + onClick={(event) => { + // Pointer capture also produces a click after dragging; leave the image zoomed. + if (suppressClickRef.current || event.detail > 1) return; + changeZoom(zoomRef.current > 1 ? 1 : 2, { x: event.clientX, y: event.clientY }); + }} + onKeyDown={(event) => { + if (event.ctrlKey || event.metaKey || event.altKey) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + if (!event.repeat) changeZoom(zoomRef.current > 1 ? 1 : 2); + } else if (event.key === "+" || event.key === "=") { + event.preventDefault(); + changeZoom(zoomRef.current * 1.5); + } else if (event.key === "-") { + event.preventDefault(); + changeZoom(zoomRef.current / 1.5); + } else if (event.key === "0") { + event.preventDefault(); + changeZoom(1); + } + }} + onPointerDown={(event) => { + if (dragRef.current) return; + suppressClickRef.current = false; + if (event.pointerType !== "mouse" || event.button !== 0 || zoomRef.current <= 1) return; + const viewport = event.currentTarget; + const bounds = viewport.getBoundingClientRect(); + if ( + event.clientX - bounds.left >= viewport.clientWidth || + event.clientY - bounds.top >= viewport.clientHeight + ) + return; + dragRef.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + left: viewport.scrollLeft, + top: viewport.scrollTop, + }; + viewport.setPointerCapture(event.pointerId); + setDragging(true); + }} + onPointerMove={(event) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) > 4) { + suppressClickRef.current = true; + } + event.currentTarget.scrollLeft = drag.left - (event.clientX - drag.x); + event.currentTarget.scrollTop = drag.top - (event.clientY - drag.y); + }} + onPointerUp={(event) => { + if (dragRef.current?.pointerId !== event.pointerId) return; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + dragRef.current = null; + setDragging(false); + }} + onLostPointerCapture={(event) => { + if (dragRef.current?.pointerId !== event.pointerId) return; + dragRef.current = null; + setDragging(false); + }} + > + {name} { + setNaturalSize({ + width: event.currentTarget.naturalWidth, + height: event.currentTarget.naturalHeight, + }); + }} + onError={onError} + /> +
+ + {Math.round(zoom * 100)}% zoom + +
+ ); +} diff --git a/apps/web/src/components/chat/timelineMinimapItems.test.ts b/apps/web/src/components/chat/timelineMinimapItems.test.ts new file mode 100644 index 000000000000..f7c40aa71206 --- /dev/null +++ b/apps/web/src/components/chat/timelineMinimapItems.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vite-plus/test"; +import { MessageId } from "@t3tools/contracts"; +import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; +import { deriveTimelineMinimapItems, resolveTimelineMinimapPreview } from "./timelineMinimapItems"; +import type { ChatMessage } from "../../types"; + +function rows( + entries: ReadonlyArray, +): MessagesTimelineRow[] { + const messages: ChatMessage[] = entries.map(([role, text], index) => ({ + id: MessageId.make(`message-${index}`), + role, + text, + streaming: false, + turnId: null, + createdAt: new Date(index * 1000).toISOString(), + updatedAt: new Date(index * 1000).toISOString(), + })); + return messages.map((message) => ({ + kind: "message", + id: message.id, + createdAt: message.createdAt, + message, + durationStart: message.createdAt, + showAssistantMeta: false, + showAssistantCopyButton: false, + assistantCopyStreaming: false, + })); +} + +describe("timeline minimap previews", () => { + it("previews the last assistant response before the next prompt and retains jump targets", () => { + const source = rows([ + ["user", " Inspect\n this "], + ["assistant", "Working"], + ["assistant", " Done\t now "], + ["user", "Next"], + ["assistant", "Second answer"], + ]); + const items = deriveTimelineMinimapItems(source); + expect(items).toHaveLength(2); + expect(resolveTimelineMinimapPreview(items[0]!)).toEqual({ + ...items[0], + userText: "Inspect this", + assistantText: "Done now", + }); + expect(source[items[0]!.rowIndex]!.id).toBe(items[0]!.id); + expect(resolveTimelineMinimapPreview(items[1]!)?.assistantText).toBe("Second answer"); + expect(items[0]?.assistantText).toBe(" Done\t now "); + }); + + it("handles an unanswered prompt, empty responses, and a closed preview", () => { + const items = deriveTimelineMinimapItems( + rows([ + ["user", "First"], + ["assistant", " \n\t"], + ["user", "Next"], + ]), + ); + expect(items.map((item) => resolveTimelineMinimapPreview(item)?.assistantText)).toEqual([ + null, + null, + ]); + expect(resolveTimelineMinimapPreview(null)).toBeNull(); + }); + + it("shows fresh streaming text without changing the jump target", () => { + const first = deriveTimelineMinimapItems( + rows([ + ["user", "Explain"], + ["assistant", "First"], + ]), + )[0]!; + const next = { ...first, assistantText: "First\n second" }; + expect(resolveTimelineMinimapPreview(next)).toEqual({ + ...first, + assistantText: "First second", + }); + expect(resolveTimelineMinimapPreview(first)?.assistantText).toBe("First"); + }); +}); diff --git a/apps/web/src/components/chat/timelineMinimapItems.ts b/apps/web/src/components/chat/timelineMinimapItems.ts new file mode 100644 index 000000000000..0a37c686e780 --- /dev/null +++ b/apps/web/src/components/chat/timelineMinimapItems.ts @@ -0,0 +1,66 @@ +import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; + +export interface TimelineMinimapItem { + readonly id: string; + readonly rowIndex: number; + readonly userText: string | null; + readonly assistantText: string | null; +} + +/** Keep full source text untouched until a minimap preview is opened. */ +export function deriveTimelineMinimapItems( + rows: ReadonlyArray, +): TimelineMinimapItem[] { + const items: TimelineMinimapItem[] = []; + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]; + if (row?.kind !== "message" || row.message.role !== "user") { + continue; + } + + items.push({ + id: row.id, + rowIndex: index, + userText: row.message.text, + assistantText: resolveFinalAssistantTextForTurn(rows, index), + }); + } + return items; +} + +function resolveFinalAssistantTextForTurn( + rows: ReadonlyArray, + userRowIndex: number, +) { + let finalAssistantText: string | null = null; + for (let index = userRowIndex + 1; index < rows.length; index += 1) { + const row = rows[index]; + if (row?.kind !== "message") { + continue; + } + if (row.message.role === "user") { + break; + } + if (row.message.role === "assistant") { + finalAssistantText = row.message.text ?? null; + } + } + return finalAssistantText; +} + +function compactMinimapPreview(text: string | null | undefined) { + const compact = text?.replace(/\s+/g, " ").trim() ?? ""; + return compact.length > 0 ? compact : null; +} + +export function resolveTimelineMinimapPreview( + item: TimelineMinimapItem | null, +): TimelineMinimapItem | null { + return item === null + ? null + : { + ...item, + userText: compactMinimapPreview(item.userText), + assistantText: compactMinimapPreview(item.assistantText), + }; +} diff --git a/apps/web/src/components/device/DeviceHostAvailability.tsx b/apps/web/src/components/device/DeviceHostAvailability.tsx new file mode 100644 index 000000000000..03a0b175dae8 --- /dev/null +++ b/apps/web/src/components/device/DeviceHostAvailability.tsx @@ -0,0 +1,27 @@ +import type { DevicePlatformAvailability } from "@t3tools/contracts"; +import { Check, Minus } from "lucide-react"; +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; + +export function DeviceHostAvailability({ + platforms, +}: { + platforms: ReadonlyArray; +}) { + return ( +
+ {platforms.map((platform) => ( + + }> + {platform.available ? : } + {platform.platform === "ios" ? "iOS" : "Android"}{" "} + {platform.available ? "available" : "unavailable"} + + + {platform.reason ?? + (platform.platform === "ios" ? "iOS available" : "Android available")} + + + ))} +
+ ); +} diff --git a/apps/web/src/components/device/DeviceLoadingView.tsx b/apps/web/src/components/device/DeviceLoadingView.tsx new file mode 100644 index 000000000000..70e848c3ce7e --- /dev/null +++ b/apps/web/src/components/device/DeviceLoadingView.tsx @@ -0,0 +1,47 @@ +import { Smartphone } from "lucide-react"; + +import { Spinner } from "~/components/ui/spinner"; + +export function DeviceLoadingView(props: { + readonly name: string; + readonly description?: string; + readonly stage: "opening" | "stream"; + readonly message: string; + readonly error?: boolean; +}) { + return ( +
+
+
+ +
+
+

{props.name}

+ {props.description ? ( +

{props.description}

+ ) : null} +
+
+ {!props.error ? : null} + {props.message} +
+ {!props.error ? ( +
+ + +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx new file mode 100644 index 000000000000..5b312f7fc4f4 --- /dev/null +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -0,0 +1,425 @@ +import type { + DevicePlatform, + DeviceServiceState, + DeviceSummary, + ScopedThreadRef, +} from "@t3tools/contracts"; +import { + ChevronLeft, + Home, + Power, + RotateCcw, + SlidersHorizontal, + Smartphone, + Square, + X, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore"; +import { Button } from "~/components/ui/button"; +import { DiscoveryList, DiscoveryListRow } from "~/components/ui/discovery-list"; +import { Dialog } from "~/components/ui/dialog"; +import { WizardPopup } from "~/components/ui/wizard"; +import { Spinner } from "~/components/ui/spinner"; +import { Toggle } from "~/components/ui/toggle"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { cn } from "~/lib/utils"; +import { deviceEnvironment, useDeviceHubAccess, useDeviceState } from "~/state/device"; +import { formatEnvironmentQueryError } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { DeviceStreamView, type DeviceStreamHandle } from "./DeviceStreamView"; +import { DeviceLoadingView } from "./DeviceLoadingView"; +import { DeviceSetup } from "./DeviceSetup"; +import { DeviceToolsPanel } from "./DeviceToolsPanel"; +import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell"; + +const platformLabel = (platform: DevicePlatform) => + platform === "ios" ? "iOS Simulators" : "Android Emulators"; + +const deviceKey = (device: Pick) => + `${device.hostId}\u0000${device.id}`; + +/** Each surface owns one host/device; only the visible surface streams. */ +export function DevicePanel(props: { + readonly mode: PreviewPanelMode; + readonly threadRef: ScopedThreadRef; + readonly surface: Extract; + readonly visible: boolean; + readonly onDismissSetup: () => void; +}) { + const { environmentId, threadId } = props.threadRef; + const { state, loaded } = useDeviceState(environmentId); + const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); + const open = useAtomCommand(deviceEnvironment.open); + const close = useAtomCommand(deviceEnvironment.close); + const [operationError, setOperationError] = useState(null); + const [pendingDevice, setPendingDevice] = useState(null); + const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null; + const [handle, setHandle] = useState(null); + const [toolsOpen, setToolsOpen] = useState(false); + const [axOverlay, setAxOverlay] = useState(false); + const access = useDeviceHubAccess(environmentId); + + const hostDisabled = state.hostStatus === "disabled"; + + // Opening setup never grants permission to install or start helpers. + useEffect(() => { + if (!props.visible || !loaded || hostDisabled) return; + void list({ environmentId, input: {} }); + }, [environmentId, list, loaded, props.visible, hostDisabled]); + + const sessions = useMemo( + () => state.sessions.filter((session) => session.threadId === threadId), + [state.sessions, threadId], + ); + const activeSession = props.surface.target + ? sessions.find( + (session) => + session.deviceId === props.surface.target?.deviceId && + session.hostId === props.surface.target.hostId, + ) + : undefined; + const activeDevice = activeSession + ? state.devices.find( + (device) => device.hostId === activeSession.hostId && device.id === activeSession.deviceId, + ) + : undefined; + + const grouped = useMemo(() => groupDevices(state), [state]); + + const selectDevice = async (value: string) => { + const device = state.devices.find((candidate) => deviceKey(candidate) === value); + if (!device) return; + setOperationError(null); + setPendingDevice(device); + try { + const result = await open({ + environmentId, + input: { + threadId, + hostId: device.hostId, + deviceId: device.id, + platform: device.platform, + }, + }); + if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); + else + useRightPanelStore.getState().openDevice(props.threadRef, { + hostId: result.value.hostId, + deviceId: result.value.deviceId, + platform: device.platform, + name: device.name, + }); + } finally { + setPendingDevice(null); + } + }; + + const closeActive = (powerOff: boolean) => { + if (!powerOff) { + useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); + return; + } + if (!activeSession) return; + setOperationError(null); + void close({ + environmentId, + input: { + threadId, + hostId: activeSession.hostId, + deviceId: activeSession.deviceId, + shutdown: powerOff, + }, + }).then((result) => { + if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); + else useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); + }); + }; + + const bootingDevices = + state.bootingDevices?.filter((device) => device.threadId === threadId) ?? []; + const hostReady = Object.values(state.hostStatuses).some((host) => host.status === "ready"); + const hostBusy = + !hostReady && + Object.values(state.hostStatuses).some( + (host) => host.status === "installing" || host.status === "starting", + ); + const unavailablePlatforms = state.hosts.flatMap((host) => + host.platforms + .filter((platform) => !platform.available) + .map((platform) => ({ ...platform, hostId: host.id, hostLabel: host.label })), + ); + + if (loaded && (!state.onboardingCompleted || hostDisabled)) { + return ( + { + if (!isOpen) props.onDismissSetup(); + }} + > + + + + + ); + } + + return ( + +
+ + {props.surface.target + ? `${state.hosts.find((host) => host.id === props.surface.target?.hostId)?.label ?? "Device host"} · ${activeDevice?.version ?? props.surface.target.platform}` + : (pendingDevice?.name ?? "Choose a device")} + + {activeDevice ? ( + <> + handle?.pressButton("home")} + disabled={!handle?.inputConnected} + > + + + {activeDevice.platform === "android" ? ( + <> + handle?.pressButton("back")} + disabled={!handle?.inputConnected} + > + + + handle?.pressButton("recents")} + disabled={!handle?.inputConnected} + > + + + + ) : ( + handle?.rotate()} + disabled={!handle?.inputConnected} + > + + + )} + setToolsOpen(Boolean(pressed))} + > + + + closeActive(true)}> + + + closeActive(false)}> + + + + ) : null} +
+ {hostReady && state.hostStatusDetail ? ( +
+ {state.hostStatusDetail} +
+ ) : null} + {bootingDevices.length > 0 ? ( +
+ Starting {bootingDevices.map((device) => device.name).join(", ")}… This can take a minute. +
+ ) : null} + {operationError ? ( +
+

{operationError}

+ +
+ ) : null} +
+ {activeDevice && activeSession ? ( + <> +
+ host.id === activeDevice.hostId)?.label ?? "Device host"} · ${activeDevice.version}`} + deviceId={activeDevice.id} + hostId={activeDevice.hostId} + visible={props.visible} + axOverlay={axOverlay} + onHandle={setHandle} + /> +
+ {toolsOpen ? ( + setToolsOpen(false)} + className="absolute inset-y-0 right-0 z-10 w-full max-w-72 border-l shadow-lg @[560px]:static @[560px]:w-72 @[560px]:shrink-0 @[560px]:shadow-none" + /> + ) : null} + + ) : pendingDevice || hostBusy || !loaded ? ( + host.id === pendingDevice.hostId)?.label ?? "Device host"} · ${pendingDevice.version}` + : "" + } + stage="opening" + message={ + pendingDevice + ? pendingDevice.booted + ? "Opening device…" + : "Starting device…" + : state.hostStatus === "installing" + ? "Installing device support…" + : "Finding devices…" + } + /> + ) : ( +
+
+ {grouped.length === 0 ? ( + <> + +

+ {state.hostStatus === "failed" + ? (state.hostStatusDetail ?? "The device hub failed to start.") + : "No simulators or emulators were found on this environment."} +

+ + ) : null} + {hostReady && grouped.length > 0 ? ( +
+ {grouped.map((group) => ( +
+
+ +

{platformLabel(group.platform)}

+
+ + {group.devices.map((device) => ( + + + + } + title={device.name} + description={`${state.hosts.find((host) => host.id === device.hostId)?.label} · ${device.version} · ${device.booted ? "Running" : "Stopped"}`} + disabled={pendingDeviceKey !== null} + aria-label={`${device.booted ? "Open" : "Start"} ${device.name}`} + onClick={() => void selectDevice(deviceKey(device))} + action={ + pendingDeviceKey === deviceKey(device) ? ( + + ) : ( + + {device.booted ? "Open" : "Start"} + + ) + } + /> + ))} + +
+ ))} +
+ ) : null} + {hostReady && + !state.devices.some((device) => device.platform === "android") && + !unavailablePlatforms.some((platform) => platform.platform === "android") ? ( +

+ No Android virtual devices found. Create one in Android Studio's Device Manager, + then refresh. +

+ ) : null} + {loaded && !hostBusy ? ( + + ) : null} +
+
+ )} +
+
+ ); +} + +function DeviceButton(props: { + readonly label: string; + readonly onClick: () => void; + readonly disabled?: boolean; + readonly children: React.ReactNode; +}) { + return ( + + + } + > + {props.children} + + {props.label} + + ); +} + +function groupDevices(state: DeviceServiceState) { + const groups: Array<{ platform: DevicePlatform; devices: DeviceSummary[] }> = []; + for (const platform of ["ios", "android"] as const) { + const devices = state.devices + .filter((device) => device.platform === platform) + .toSorted((a, b) => Number(b.booted) - Number(a.booted) || a.name.localeCompare(b.name)); + if (devices.length > 0) groups.push({ platform, devices }); + } + return groups; +} diff --git a/apps/web/src/components/device/DeviceSetup.tsx b/apps/web/src/components/device/DeviceSetup.tsx new file mode 100644 index 000000000000..67bf14181b21 --- /dev/null +++ b/apps/web/src/components/device/DeviceSetup.tsx @@ -0,0 +1,307 @@ +import type { DevicePlatform, DeviceServiceState, EnvironmentId } from "@t3tools/contracts"; +import { Check, CircleAlert } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { DialogClose } from "~/components/ui/dialog"; +import { WizardHeader, WizardPanel, WizardSteps, WizardFooter } from "~/components/ui/wizard"; +import { Spinner } from "~/components/ui/spinner"; +import { Switch } from "~/components/ui/switch"; +import { deviceEnvironment } from "~/state/device"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { cn } from "~/lib/utils"; + +const platformName = (platform: DevicePlatform) => (platform === "ios" ? "iOS" : "Android"); + +export const deviceHubDescription = + "Enable this environment to open simulators and emulators, whether they run here or on a remote device host."; +export const agentDeviceDescription = + "Allow new agent sessions in this environment to start and control local and remote devices, with required tools set up automatically."; + +export function platformSetupStatus(state: DeviceServiceState, platform: DevicePlatform) { + const availability = state.hosts + .flatMap((host) => host.platforms) + .find((candidate) => candidate.platform === platform); + if (!availability?.available) { + return { + ready: false, + message: availability?.reason ?? `${platformName(platform)} support was not detected.`, + }; + } + if ( + state.hostStatus === "ready" && + !state.devices.some((device) => device.platform === platform) + ) { + return { + ready: false, + message: + platform === "ios" + ? "Xcode is installed, but no iOS Simulator is available. Install a runtime in Xcode Settings → Components." + : "The Android SDK is installed, but no virtual device exists. Create one in Android Studio → Device Manager.", + }; + } + return { + ready: true, + message: + platform === "ios" + ? "Xcode and iOS Simulator are available." + : "The Android SDK and Emulator are available.", + }; +} + +export function DeviceSetup(props: { + readonly environmentId: EnvironmentId; + readonly state: DeviceServiceState; + readonly onComplete?: () => void; +}) { + const configure = useAtomCommand(deviceEnvironment.configure); + const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); + const [pending, setPending] = useState<"hub" | "check" | "agent" | "complete" | null>(null); + const [step, setStep] = useState(0); + const enabled = props.state.hostStatus !== "disabled"; + const busy = props.state.hostStatus === "installing" || props.state.hostStatus === "starting"; + + const update = async ( + kind: NonNullable, + input: { enabled?: boolean; agentAccessEnabled?: boolean; onboardingCompleted?: boolean }, + ) => { + setPending(kind); + try { + const result = await configure({ environmentId: props.environmentId, input }); + if (kind === "complete" && result._tag === "Success") props.onComplete?.(); + } finally { + setPending(null); + } + }; + + return ( + <> + + busy || pending !== null || requested > step} + /> + + + + {step === 0 ? ( +
+

Enable the device hub

+
+

{deviceHubDescription}

+ + void update("hub", { + enabled: Boolean(checked), + ...(checked ? {} : { agentAccessEnabled: false }), + }) + } + /> +
+ +
+ ) : null} + + {step === 1 ? ( +
+

Check simulator support

+ { + setPending("check"); + void list({ environmentId: props.environmentId, input: {} }).finally(() => + setPending(null), + ); + }} + /> +
+ ) : null} + + {step === 2 ? ( +
+

Allow agent control

+
+

{agentDeviceDescription}

+ + void update("agent", { agentAccessEnabled: Boolean(checked) }) + } + /> +
+ +

+ Leave this off to keep manual device controls without giving agents access. +

+
+ ) : null} + {props.state.hostStatus === "failed" && props.state.hostStatusDetail ? ( +

+ {props.state.hostStatusDetail} +

+ ) : null} +
+ + + {step === 0 ? ( + }>Cancel + ) : ( + + )} + {step < 2 ? ( + + ) : ( + + )} + + + ); +} + +export function DeviceHubSetupStatus({ + state, + pending, + compact = false, +}: { + readonly state: DeviceServiceState; + readonly pending: boolean; + readonly compact?: boolean; +}) { + if (!pending && state.hostStatus !== "ready") return null; + return ( +

+ {pending ? : } + {pending + ? state.hostStatus === "installing" + ? compact + ? "Installing…" + : "Installing device hub…" + : state.hostStatus === "starting" + ? compact + ? "Starting…" + : "Starting device hub…" + : compact + ? "Updating…" + : "Updating device hub…" + : "Device hub is ready."} +

+ ); +} + +function DevicePlatformSetup(props: { + readonly state: DeviceServiceState; + readonly checking: boolean; + readonly disabled: boolean; + readonly onCheck: () => void; +}) { + return ( +
+ + +

+ You can use either platform. Fixing a missing platform does not block the other one. +

+ +
+ ); +} + +export function AgentDeviceSetupStatus(props: { + readonly state: DeviceServiceState; + readonly pending: boolean; + readonly compact?: boolean; +}) { + if (props.pending) { + const label = + props.state.hostStatus === "installing" + ? props.compact + ? "Installing…" + : "Installing agent tools…" + : props.state.hostStatus === "starting" + ? props.compact + ? "Starting…" + : "Starting agent tools…" + : props.compact + ? "Updating…" + : "Updating agent access…"; + return ( +

+ + {label} +

+ ); + } + if ( + props.state.agentAccessEnabled && + props.state.hostStatus === "ready" && + props.state.hosts.some((host) => host.agentDeviceInstalled) + ) { + return ( +

+ + Agent tools are ready. +

+ ); + } + return null; +} + +export function PlatformStatus(props: { + readonly platform: string; + readonly status: { readonly ready: boolean; readonly message: string }; + readonly compact?: boolean; +}) { + const Icon = props.status.ready ? Check : CircleAlert; + return ( +
+ +
+

{props.platform}

+

+ {props.compact && props.status.ready ? "Ready" : props.status.message} +

+
+
+ ); +} diff --git a/apps/web/src/components/device/DeviceStreamView.test.tsx b/apps/web/src/components/device/DeviceStreamView.test.tsx new file mode 100644 index 000000000000..9c8a1197926a --- /dev/null +++ b/apps/web/src/components/device/DeviceStreamView.test.tsx @@ -0,0 +1,59 @@ +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { EnvironmentId } from "@t3tools/contracts"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +vi.mock("~/state/device", () => ({ + useDeviceHubAccess: () => access, + refreshDeviceHubAccess: vi.fn(), +})); +const access = { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true }; +vi.mock("./deviceStream", () => ({ + createDeviceStreamClient: ( + _target: unknown, + _canvas: unknown, + events: { onMjpegFallback: (url: string) => void }, + ) => ({ + start: () => events.onMjpegFallback("http://test/stream.mjpeg"), + stop: vi.fn(), + }), +})); +import { DeviceStreamView } from "./DeviceStreamView"; +let renderer: ReactTestRenderer | undefined; +afterEach(async () => { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +it("removes MJPEG requests while hidden and reconnects when shown", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + const view = (visible: boolean) => ( + + ); + await act(async () => { + renderer = create(view(true), { + createNodeMock: () => ({ + style: { setProperty() {} }, + getBoundingClientRect: () => ({ width: 400, height: 800 }), + }), + }); + }); + expect(renderer!.root.findAllByType("img")).toHaveLength(1); + await act(async () => renderer!.update(view(false))); + expect(renderer!.root.findAllByType("img")).toHaveLength(0); + await act(async () => renderer!.update(view(true))); + expect(renderer!.root.findAllByType("img")).toHaveLength(1); +}); diff --git a/apps/web/src/components/device/DeviceStreamView.tsx b/apps/web/src/components/device/DeviceStreamView.tsx new file mode 100644 index 000000000000..994ee1d6d1ec --- /dev/null +++ b/apps/web/src/components/device/DeviceStreamView.tsx @@ -0,0 +1,332 @@ +import type { DevicePlatform, EnvironmentId } from "@t3tools/contracts"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { cn } from "~/lib/utils"; +import { refreshDeviceHubAccess, useDeviceHubAccess } from "~/state/device"; +import { DeviceLoadingView } from "./DeviceLoadingView"; +import { type DeviceAxElement, fetchDeviceAxTree } from "./deviceHubApi"; +import { + createDeviceStreamClient, + type DeviceHardwareButton, + type DeviceScreenSize, + type DeviceStreamClient, + type DeviceStreamStatus, +} from "./deviceStream"; + +const AX_POLL_INTERVAL_MS = 2_000; + +export interface DeviceStreamHandle { + readonly pressButton: (button: DeviceHardwareButton) => void; + readonly rotate: () => void; + /** False while the input socket is down; controls should disable. */ + readonly inputConnected: boolean; +} + +/** + * The live device screen. Pointer events map onto normalized coordinates in + * the displayed frame and go to the device; keyboard input is forwarded while + * the surface is focused. `visible=false` tears the stream down so a hidden + * panel decodes nothing. + */ +export function DeviceStreamView(props: { + readonly environmentId: EnvironmentId; + readonly platform: DevicePlatform; + readonly deviceId: string; + readonly deviceName?: string; + readonly deviceDescription?: string; + readonly visible: boolean; + readonly hostId: string; + /** Draw accessibility element frames over the screen. */ + readonly axOverlay?: boolean; + readonly onHandle?: (handle: DeviceStreamHandle | null) => void; + readonly onScreen?: (screen: DeviceScreenSize | null) => void; +}) { + const access = useDeviceHubAccess(props.environmentId, props.hostId); + const canvasRef = useRef(null); + const clientRef = useRef(null); + const [status, setStatus] = useState("connecting"); + const [detail, setDetail] = useState(undefined); + const [screen, setScreen] = useState(null); + const [mjpegUrl, setMjpegUrl] = useState(null); + const [mjpegGeneration, setMjpegGeneration] = useState(0); + const [inputState, setInputState] = useState<{ connected: boolean; detail?: string }>({ + connected: false, + }); + const { onHandle, onScreen } = props; + + useEffect(() => { + const canvas = canvasRef.current; + if (!access || !canvas || !props.visible) { + setStatus("connecting"); + onHandle?.(null); + return; + } + const client = createDeviceStreamClient( + { platform: props.platform, deviceId: props.deviceId, access }, + canvas, + { + onStatus: (next, nextDetail) => { + setStatus(next); + setDetail(nextDetail); + }, + onScreen: (next) => { + setScreen(next); + onScreen?.(next); + }, + onUnauthorized: () => { + // A fresh ticket re-runs this effect through the access dependency. + refreshDeviceHubAccess(props.environmentId); + }, + onMjpegFallback: (url) => { + setMjpegUrl(url); + setMjpegGeneration((generation) => generation + 1); + }, + onInputConnected: (connected, detail) => { + setInputState({ connected, ...(detail ? { detail } : {}) }); + onHandle?.({ + pressButton: client.pressButton, + rotate: client.rotate, + inputConnected: connected, + }); + }, + }, + ); + clientRef.current = client; + setMjpegUrl(null); + setInputState({ connected: false }); + client.start(); + onHandle?.({ pressButton: client.pressButton, rotate: client.rotate, inputConnected: false }); + return () => { + client.stop(); + clientRef.current = null; + onHandle?.(null); + onScreen?.(null); + setScreen(null); + }; + }, [ + access, + onHandle, + onScreen, + props.deviceId, + props.environmentId, + props.platform, + props.visible, + ]); + + // Displayed aspect ratio (width / height) of the device as the user sees it. + const aspect = useMemo(() => { + if (!screen) return props.platform === "ios" ? 9 / 19.5 : 9 / 20; + const landscape = + screen.orientation === "landscape_left" || screen.orientation === "landscape_right"; + const w = landscape + ? Math.max(screen.width, screen.height) + : Math.min(screen.width, screen.height); + const h = landscape + ? Math.min(screen.width, screen.height) + : Math.max(screen.width, screen.height); + return w / h; + }, [props.platform, screen]); + + // The frame is the largest box at `aspect` that fits the container, so a + // narrow panel shows a shorter phone rather than a squeezed one. CSS + // `aspect-ratio` alone cannot do this: with the height pinned to 100% the + // width clamp wins and distorts the drawn frame. + const hostRef = useRef(null); + const [host, setHost] = useState({ width: 0, height: 0 }); + useEffect(() => { + const element = hostRef.current; + if (!element) return; + const update = () => { + const rect = element.getBoundingClientRect(); + setHost((current) => + current.width === rect.width && current.height === rect.height + ? current + : { width: rect.width, height: rect.height }, + ); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(element); + return () => observer.disconnect(); + }, []); + const frame = useMemo(() => { + if (host.width === 0 || host.height === 0) return { width: 0, height: 0 }; + const byHeight = { width: host.height * aspect, height: host.height }; + return byHeight.width <= host.width + ? byHeight + : { width: host.width, height: host.width / aspect }; + }, [aspect, host]); + + // serve-sim streams the raw framebuffer; rotate the display for a device + // that reports landscape while its frames stay portrait. + const rotation = useMemo(() => { + if (props.platform !== "ios" || !screen || screen.width > screen.height) return 0; + switch (screen.orientation) { + case "landscape_left": + return 90; + case "landscape_right": + return -90; + case "portrait_upside_down": + return 180; + default: + return 0; + } + }, [props.platform, screen]); + + // A sideways rotation draws the raw portrait frame into a landscape box: + // the media element takes the transposed size and is rotated about the + // box's center. + const sideways = rotation === 90 || rotation === -90; + const mediaStyle: React.CSSProperties = sideways + ? { + width: frame.height, + height: frame.width, + left: (frame.width - frame.height) / 2, + top: (frame.height - frame.width) / 2, + transform: `rotate(${rotation}deg)`, + } + : { + width: frame.width, + height: frame.height, + ...(rotation ? { transform: `rotate(${rotation}deg)` } : {}), + }; + + // The accessibility tree is polled while the overlay is on; each poll is + // one JSON fetch, so there is nothing to repaint between polls. + const [axElements, setAxElements] = useState>([]); + useEffect(() => { + if (!props.axOverlay || !access || !props.visible) return; + const target = { access, platform: props.platform, deviceId: props.deviceId }; + let controller: AbortController | null = null; + let timer: ReturnType | null = null; + let stopped = false; + const poll = async () => { + controller = new AbortController(); + try { + const tree = await fetchDeviceAxTree(target, controller.signal); + if (!stopped) setAxElements(tree.elements); + } catch { + // Keep the last good tree; the next poll retries. + } + if (!stopped) timer = setTimeout(() => void poll(), AX_POLL_INTERVAL_MS); + }; + void poll(); + return () => { + stopped = true; + controller?.abort(); + if (timer) clearTimeout(timer); + setAxElements([]); + }; + }, [access, props.axOverlay, props.deviceId, props.platform, props.visible]); + + const pointerActive = useRef(false); + const normalizedPoint = (event: React.PointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + const x = (event.clientX - rect.left) / rect.width; + const y = (event.clientY - rect.top) / rect.height; + return { x: Math.min(1, Math.max(0, x)), y: Math.min(1, Math.max(0, y)) }; + }; + + return ( +
{ + if (event.metaKey && !["r", "R"].includes(event.key)) return; + event.preventDefault(); + clientRef.current?.sendKey(event.nativeEvent, "down"); + }} + onKeyUp={(event) => { + clientRef.current?.sendKey(event.nativeEvent, "up"); + }} + > +
{ + event.currentTarget.setPointerCapture(event.pointerId); + (event.currentTarget.parentElement as HTMLElement | null)?.focus(); + pointerActive.current = true; + const { x, y } = normalizedPoint(event); + clientRef.current?.sendTouch("begin", x, y); + }} + onPointerMove={(event) => { + if (!pointerActive.current) return; + const { x, y } = normalizedPoint(event); + clientRef.current?.sendTouch("move", x, y); + }} + onPointerUp={(event) => { + if (!pointerActive.current) return; + pointerActive.current = false; + const { x, y } = normalizedPoint(event); + clientRef.current?.sendTouch("end", x, y); + }} + onPointerCancel={(event) => { + if (!pointerActive.current) return; + pointerActive.current = false; + const { x, y } = normalizedPoint(event); + clientRef.current?.sendTouch("end", x, y); + }} + > + + {props.visible && access && mjpegUrl ? ( + + ) : null} + {axElements.length > 0 ? ( +
+ {axElements.map((element) => ( +
+ {element.label ? ( + + {element.label} + + ) : null} +
+ ))} +
+ ) : null} +
+ {status === "streaming" && !inputState.connected ? ( +
+ + Input disconnected{inputState.detail ? ` (${inputState.detail})` : ""}, reconnecting… + +
+ ) : null} + {status !== "streaming" ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/device/DeviceToolsPanel.tsx b/apps/web/src/components/device/DeviceToolsPanel.tsx new file mode 100644 index 000000000000..7f3984257ecd --- /dev/null +++ b/apps/web/src/components/device/DeviceToolsPanel.tsx @@ -0,0 +1,722 @@ +import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess"; +import type { + DeviceActionInput, + DeviceDetail, + DevicePermission, + DeviceSummary, + DeviceTextSize, + EnvironmentId, +} from "@t3tools/contracts"; +import { ChevronDown, X } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "~/components/ui/collapsible"; +import { Input } from "~/components/ui/input"; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { Spinner } from "~/components/ui/spinner"; +import { Switch } from "~/components/ui/switch"; +import { Toggle, ToggleGroup } from "~/components/ui/toggle-group"; +import { cn } from "~/lib/utils"; +import { deviceEnvironment } from "~/state/device"; +import { formatEnvironmentQueryError } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { + type DeviceEventLogEntry, + type DeviceForegroundInfo, + subscribeDeviceEventLog, + subscribeDeviceForeground, +} from "./deviceHubApi"; + +type ActionBody = DeviceActionInput extends infer A + ? A extends { readonly type: string } + ? Omit + : never + : never; + +const TEXT_SIZES: ReadonlyArray<{ value: DeviceTextSize; label: string }> = [ + { value: "small", label: "Small" }, + { value: "default", label: "Default" }, + { value: "large", label: "Large" }, + { value: "extra-large", label: "Extra large" }, +]; + +const COLOR_FILTERS = [ + { value: "none", label: "None" }, + { value: "grayscale", label: "Grayscale" }, + { value: "red-green", label: "Red / green (protanopia)" }, + { value: "green-red", label: "Green / red (deuteranopia)" }, + { value: "blue-yellow", label: "Blue / yellow (tritanopia)" }, +] as const; + +const ORIENTATIONS = [ + { value: "portrait", label: "Portrait" }, + { value: "landscape_left", label: "Landscape left" }, + { value: "portrait_upside_down", label: "Upside down" }, + { value: "landscape_right", label: "Landscape right" }, +] as const; + +const IOS_PERMISSIONS: ReadonlyArray<{ value: DevicePermission; label: string }> = [ + { value: "camera", label: "Camera" }, + { value: "microphone", label: "Microphone" }, + { value: "photos", label: "Photos" }, + { value: "contacts", label: "Contacts" }, + { value: "calendar", label: "Calendar" }, + { value: "reminders", label: "Reminders" }, + { value: "location", label: "Location" }, + { value: "notifications", label: "Notifications" }, + { value: "motion", label: "Motion" }, + { value: "media-library", label: "Media library" }, + { value: "faceid", label: "Face ID" }, +]; + +const ANDROID_PERMISSIONS: ReadonlyArray<{ value: DevicePermission; label: string }> = [ + { value: "camera", label: "Camera" }, + { value: "microphone", label: "Microphone" }, + { value: "photos", label: "Photos" }, + { value: "contacts", label: "Contacts" }, + { value: "calendar", label: "Calendar" }, + { value: "location", label: "Location" }, + { value: "notifications", label: "Notifications" }, + { value: "motion", label: "Physical activity" }, +]; + +const LOCATION_PRESETS = [ + { label: "San Francisco", latitude: 37.7749, longitude: -122.4194 }, + { label: "New York", latitude: 40.7128, longitude: -74.006 }, + { label: "London", latitude: 51.5074, longitude: -0.1278 }, + { label: "Stockholm", latitude: 59.3293, longitude: 18.0686 }, + { label: "Tokyo", latitude: 35.6762, longitude: 139.6503 }, +] as const; + +/** + * The Tools drawer for one open device: current settings read from the device, + * one control per supported action, and the read-only feeds the hub exposes. + * Every change is a `device.action` round trip; the returned detail replaces + * local state so the controls never show a value the device did not confirm. + */ +export function DeviceToolsPanel(props: { + readonly environmentId: EnvironmentId; + readonly device: DeviceSummary; + readonly access: DeviceHubAccess | null; + readonly axOverlay: boolean; + readonly onAxOverlayChange: (enabled: boolean) => void; + readonly onClose: () => void; + readonly className?: string; +}) { + const { environmentId, device } = props; + const readDetail = useAtomCommand(deviceEnvironment.detail, { reportFailure: false }); + const runAction = useAtomCommand(deviceEnvironment.action, { reportFailure: false }); + const [detail, setDetail] = useState(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [foreground, setForeground] = useState(undefined); + const isIos = device.platform === "ios"; + + const target = useMemo( + () => ({ hostId: device.hostId, deviceId: device.id }), + [device.hostId, device.id], + ); + + // The panel is keyed by device, so a mount is always a fresh device. + useEffect(() => { + let cancelled = false; + void readDetail({ environmentId, input: target }).then((result) => { + if (cancelled) return; + if (result._tag === "Success") setDetail(result.value); + else setError(formatEnvironmentQueryError(result.cause)); + }); + return () => { + cancelled = true; + }; + }, [environmentId, readDetail, target]); + + useEffect(() => { + if (!props.access) return; + return subscribeDeviceForeground( + { access: props.access, platform: device.platform, deviceId: device.id }, + setForeground, + ); + }, [device.id, device.platform, props.access]); + + const act = useCallback( + async (body: ActionBody) => { + setPending(true); + setError(null); + try { + const result = await runAction({ + environmentId, + input: { ...target, ...body } as DeviceActionInput, + }); + if (result._tag === "Success") setDetail(result.value); + else setError(formatEnvironmentQueryError(result.cause)); + } finally { + setPending(false); + } + }, + [environmentId, runAction, target], + ); + + const settings = detail?.settings; + const foregroundApp = foreground === undefined ? (detail?.foregroundApp ?? null) : foreground; + const disabled = pending || detail === null; + + return ( +
+
+ Tools + {pending ? : null} + +
+
+ {error ? ( +

{error}

+ ) : null} + {detail === null && !error ? ( +
+ Reading device settings… +
+ ) : null} + +
+ + {foregroundApp?.id ?? "—"} + + {foregroundApp ? ( +
+ + +
+ ) : null} + act({ type: "openUrl", url })} + /> + act({ type: "launchApp", appId })} + /> +
+ +
+ + { + const next = value[0]; + if (next === "light" || next === "dark") + void act({ type: "setAppearance", value: next }); + }} + > + Light + Dark + + + + act({ type: "setTextSize", value })} + /> + + {isIos ? ( + <> + + { + const next = value[0]; + if (next === "clear" || next === "tinted") { + void act({ type: "setLiquidGlass", value: next }); + } + }} + > + Clear + Tinted + + + + act({ type: "setColorFilter", value })} + /> + + + ) : ( + + act({ type: "setOrientation", value })} + /> + + )} + act({ type: "setToggle", setting: "reduceMotion", value })} + /> + {isIos ? ( + <> + act({ type: "setToggle", setting: "increaseContrast", value })} + /> + + act({ type: "setToggle", setting: "reduceTransparency", value }) + } + /> + act({ type: "setToggle", setting: "showBorders", value })} + /> + act({ type: "setToggle", setting: "voiceOver", value })} + /> + + ) : ( + act({ type: "setToggle", setting: "networkEnabled", value })} + /> + )} +
+ +
+ { + props.onAxOverlayChange(value); + return Promise.resolve(); + }} + /> +
+ + act({ type: "setLocation", latitude, longitude })} + onClear={() => act({ type: "clearLocation" })} + /> + + + act({ type: "setPermission", appId, permission, decision }) + } + /> + + {isIos ? ( +
+ + foregroundApp + ? act({ type: "sendPush", appId: foregroundApp.id, payload }) + : Promise.resolve() + } + /> + {!foregroundApp ? ( +

Open an app first.

+ ) : null} +
+ ) : null} + + {isIos && props.access ? : null} +
+
+ ); +} + +function Section(props: { readonly title: string; readonly children: React.ReactNode }) { + return ( +
+

+ {props.title} +

+ {props.children} +
+ ); +} + +function Row(props: { readonly label: string; readonly children: React.ReactNode }) { + return ( +
+ {props.label} +
{props.children}
+
+ ); +} + +function SwitchRow(props: { + readonly label: string; + readonly checked: boolean | undefined; + readonly disabled: boolean; + readonly onChange: (value: boolean) => Promise; +}) { + return ( + + void props.onChange(checked)} + /> + + ); +} + +function ChoiceSelect(props: { + readonly ariaLabel: string; + readonly value: V | null; + readonly options: ReadonlyArray<{ readonly value: V; readonly label: string }>; + readonly disabled: boolean; + readonly placeholder?: string; + readonly onChange: (value: V) => Promise; +}) { + const current = props.options.find((option) => option.value === props.value); + return ( + + ); +} + +function SubmitRow(props: { + readonly placeholder: string; + readonly action: string; + readonly disabled: boolean; + readonly onSubmit: (value: string) => Promise; +}) { + const [value, setValue] = useState(""); + const submit = () => { + const trimmed = value.trim(); + if (!trimmed) return; + void props.onSubmit(trimmed).then(() => setValue("")); + }; + return ( +
{ + event.preventDefault(); + submit(); + }} + > + setValue(event.target.value)} + /> + +
+ ); +} + +function LocationSection(props: { + readonly disabled: boolean; + readonly canClear: boolean; + readonly onSet: (latitude: number, longitude: number) => Promise; + readonly onClear: () => Promise; +}) { + const [latitude, setLatitude] = useState(""); + const [longitude, setLongitude] = useState(""); + const parsed = { latitude: Number(latitude), longitude: Number(longitude) }; + const valid = + latitude.trim() !== "" && + longitude.trim() !== "" && + Math.abs(parsed.latitude) <= 90 && + Math.abs(parsed.longitude) <= 180; + return ( +
+
+ setLatitude(event.target.value)} + /> + setLongitude(event.target.value)} + /> +
+
+ + value={null} + disabled={props.disabled} + onValueChange={(value) => { + const preset = LOCATION_PRESETS.find((candidate) => candidate.label === value); + if (!preset) return; + setLatitude(String(preset.latitude)); + setLongitude(String(preset.longitude)); + void props.onSet(preset.latitude, preset.longitude); + }} + > + + + Preset… + + + + {LOCATION_PRESETS.map((preset) => ( + + {preset.label} + + ))} + + + + {props.canClear ? ( + + ) : null} +
+
+ ); +} + +function PermissionsSection(props: { + readonly permissions: ReadonlyArray<{ value: DevicePermission; label: string }>; + readonly canReset: boolean; + readonly defaultAppId: string; + readonly disabled: boolean; + readonly onDecide: ( + appId: string, + permission: DevicePermission, + decision: "grant" | "revoke" | "reset", + ) => Promise; +}) { + const [appId, setAppId] = useState(""); + const [permission, setPermission] = useState("camera"); + const resolvedAppId = appId.trim() || props.defaultAppId; + const decide = (decision: "grant" | "revoke" | "reset") => + void props.onDecide(resolvedAppId, permission, decision); + return ( +
+ setAppId(event.target.value)} + /> +
+ { + setPermission(value); + return Promise.resolve(); + }} + /> + + + {props.canReset ? ( + + ) : null} +
+
+ ); +} + +const EVENT_LOG_LIMIT = 100; + +function EventLogSection(props: { + readonly access: DeviceHubAccess; + readonly device: DeviceSummary; +}) { + const [open, setOpen] = useState(false); + const [entries, setEntries] = useState>([]); + + useEffect(() => { + if (!open) return; + const unsubscribe = subscribeDeviceEventLog( + { access: props.access, platform: props.device.platform, deviceId: props.device.id }, + (incoming, reset) => { + setEntries((current) => { + const merged = reset ? [...incoming] : [...current, ...incoming]; + return merged.length > EVENT_LOG_LIMIT ? merged.slice(-EVENT_LOG_LIMIT) : merged; + }); + }, + ); + return () => { + unsubscribe(); + setEntries([]); + }; + }, [open, props.access, props.device.id, props.device.platform]); + + return ( + + + Event log + + + +
    + {entries.length === 0 ? ( +
  1. No events yet.
  2. + ) : ( + entries.map((entry) => ( +
  3. + + {entry.timestamp.slice(11, 19)} + + {entry.summary} +
  4. + )) + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/device/deviceHubApi.test.ts b/apps/web/src/components/device/deviceHubApi.test.ts new file mode 100644 index 000000000000..52575f4a4224 --- /dev/null +++ b/apps/web/src/components/device/deviceHubApi.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { subscribeDeviceForeground } from "./deviceHubApi"; + +describe("foreground app events", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("clears the last app when it exits and ignores malformed events", () => { + let source: FakeEventSource; + class FakeEventSource { + onmessage: ((event: { data: string }) => void) | null = null; + addEventListener(_type: string, listener: (event: { data: string }) => void) { + this.onmessage = listener; + } + close = vi.fn(); + constructor() { + source = this; + } + } + vi.stubGlobal("EventSource", FakeEventSource); + const onChange = vi.fn(); + const stop = subscribeDeviceForeground( + { + platform: "ios", + deviceId: "test", + access: { httpBase: "http://test", wsBase: "ws://test", query: {}, credentials: true }, + }, + onChange, + ); + const emit = (data: unknown) => source.onmessage?.({ data: JSON.stringify(data) }); + emit({ bundleId: "com.example.app", pid: 123 }); + emit({ bundleId: null }); + emit({ bundleId: "com.example.other" }); + emit({ bundleId: "" }); + emit({ other: "not app state" }); + expect(onChange.mock.calls.map(([app]) => app)).toEqual([ + { id: "com.example.app", pid: 123 }, + null, + { id: "com.example.other" }, + null, + ]); + stop(); + expect(source!.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/components/device/deviceHubApi.ts b/apps/web/src/components/device/deviceHubApi.ts new file mode 100644 index 000000000000..dbcf4fa0015c --- /dev/null +++ b/apps/web/src/components/device/deviceHubApi.ts @@ -0,0 +1,248 @@ +import { withDeviceHubQuery } from "@t3tools/client-runtime/state/deviceHubAccess"; +import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess"; +import type { DevicePlatform } from "@t3tools/contracts"; + +/** + * Read-only hub endpoints the Tools drawer consumes directly: the accessibility + * tree, the foreground app, and the event log. Everything that changes device + * state goes through the `device.action` RPC instead, so this file never POSTs. + */ + +export interface DeviceAxElement { + readonly id: string; + readonly label: string; + readonly role: string; + /** Normalized to the displayed screen: 0..1 on both axes. */ + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface DeviceAxTree { + readonly elements: ReadonlyArray; + readonly errors: ReadonlyArray; +} + +export interface DeviceEventLogEntry { + readonly id: number; + readonly timestamp: string; + readonly kind: string; + readonly summary: string; +} + +export interface DeviceForegroundInfo { + readonly id: string; + readonly label?: string; + readonly pid?: number; + readonly isReactNative?: boolean; +} + +interface Target { + readonly access: DeviceHubAccess; + readonly platform: DevicePlatform; + readonly deviceId: string; +} + +const vendorBase = (target: Target) => + `${target.access.httpBase}${target.platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"}`; + +const hubUrl = (target: Target, path: string, params?: Record) => { + const search = params ? `?${new URLSearchParams(params).toString()}` : ""; + return withDeviceHubQuery(`${vendorBase(target)}${path}${search}`, target.access); +}; + +const fetchJson = async (target: Target, url: string, signal?: AbortSignal): Promise => { + const response = await fetch(url, { + cache: "no-store", + credentials: target.access.credentials ? "include" : "same-origin", + ...(signal ? { signal } : {}), + }); + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); + return response.json(); +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const numberOr = (value: unknown, fallback: number) => + typeof value === "number" && Number.isFinite(value) ? value : fallback; + +const AX_ELEMENT_LIMIT = 500; + +/** + * serve-sim's helper returns the native nested tree; the root node is the + * application covering the whole screen. Flatten it the way serve-sim's own + * overlay does: skip nodes with the root's frame, cap the count. + */ +const flattenIosAxTree = (roots: ReadonlyArray): ReadonlyArray => { + const first = roots[0]; + const rootFrame = isRecord(first) && isRecord(first.frame) ? first.frame : null; + const screenWidth = Math.max(1, numberOr(rootFrame?.width, 1)); + const screenHeight = Math.max(1, numberOr(rootFrame?.height, 1)); + const elements: DeviceAxElement[] = []; + const visit = (node: unknown, path: string) => { + if (elements.length >= AX_ELEMENT_LIMIT || !isRecord(node) || !isRecord(node.frame)) return; + const frame = node.frame; + const width = numberOr(frame.width, 0); + const height = numberOr(frame.height, 0); + const coversScreen = + Math.abs(width - screenWidth) < 0.5 && Math.abs(height - screenHeight) < 0.5; + if (!coversScreen && width > 0 && height > 0) { + elements.push({ + id: typeof node.AXUniqueId === "string" ? node.AXUniqueId : path, + label: typeof node.AXLabel === "string" ? node.AXLabel : "", + role: typeof node.type === "string" ? node.type : "", + x: numberOr(frame.x, 0) / screenWidth, + y: numberOr(frame.y, 0) / screenHeight, + width: width / screenWidth, + height: height / screenHeight, + }); + } + const children = Array.isArray(node.children) ? node.children : []; + children.forEach((child, index) => visit(child, `${path}.${index}`)); + }; + roots.forEach((root, index) => visit(root, String(index))); + return elements; +}; + +export async function fetchDeviceAxTree( + target: Target, + signal?: AbortSignal, +): Promise { + if (target.platform === "ios") { + const payload = await fetchJson( + target, + hubUrl(target, `/helper/${encodeURIComponent(target.deviceId)}/ax`), + signal, + ); + if (!Array.isArray(payload)) { + const error = isRecord(payload) && typeof payload.error === "string" ? payload.error : null; + return { elements: [], errors: [error ?? "Unexpected accessibility payload."] }; + } + return { elements: flattenIosAxTree(payload), errors: [] }; + } + const payload = await fetchJson( + target, + hubUrl(target, "/api/accessibility", { device: target.deviceId }), + signal, + ); + if (!isRecord(payload) || !Array.isArray(payload.nodes)) { + const error = isRecord(payload) && typeof payload.error === "string" ? payload.error : null; + return { elements: [], errors: [error ?? "Unexpected accessibility payload."] }; + } + // uiautomator reports pixel bounds; the first node is the full window. + const nodes = payload.nodes.filter( + (node): node is Record => isRecord(node) && isRecord(node.bounds), + ); + const root = nodes[0]?.bounds as Record | undefined; + const screenWidth = Math.max(1, numberOr(root?.right, 1)); + const screenHeight = Math.max(1, numberOr(root?.bottom, 1)); + // Layout containers span the whole window and would tint the entire + // screen; only nodes a user could point at are worth drawing. + const elements = nodes.slice(1).flatMap((node): DeviceAxElement[] => { + const bounds = node.bounds as Record; + const left = numberOr(bounds.left, 0); + const top = numberOr(bounds.top, 0); + const width = (numberOr(bounds.right, left) - left) / screenWidth; + const height = (numberOr(bounds.bottom, top) - top) / screenHeight; + const text = typeof node.text === "string" ? node.text : ""; + const description = typeof node.contentDescription === "string" ? node.contentDescription : ""; + const label = text || description; + if (width >= 0.95 && height >= 0.9) return []; + if (!label && node.clickable !== true) return []; + const className = typeof node.className === "string" ? node.className : ""; + return [ + { + id: String(node.id ?? ""), + label, + role: className.split(".").at(-1) ?? "", + x: left / screenWidth, + y: top / screenHeight, + width, + height, + }, + ]; + }); + return { elements, errors: [] }; +} + +const openEventSource = ( + target: Target, + url: string, + onMessage: (data: unknown) => void, +): (() => void) => { + const source = new EventSource(url, { withCredentials: target.access.credentials }); + source.addEventListener("message", (event) => { + try { + onMessage(JSON.parse(String(event.data))); + } catch { + // Keep-alive comments and malformed frames carry nothing to render. + } + }); + return () => source.close(); +}; + +/** iOS only: the frontmost app, pushed by serve-sim whenever it changes. */ +export function subscribeDeviceForeground( + target: Target, + onChange: (app: DeviceForegroundInfo | null) => void, +): () => void { + if (target.platform !== "ios") return () => {}; + return openEventSource( + target, + hubUrl(target, "/appstate", { device: target.deviceId }), + (data) => { + if (!isRecord(data)) return; + if (data.bundleId === null || data.bundleId === "") { + onChange(null); + return; + } + if (typeof data.bundleId !== "string") return; + onChange({ + id: data.bundleId, + ...(typeof data.pid === "number" ? { pid: data.pid } : {}), + ...(typeof data.isReactNative === "boolean" ? { isReactNative: data.isReactNative } : {}), + }); + }, + ); +} + +const toEventLogEntry = (raw: unknown): DeviceEventLogEntry | null => { + if (!isRecord(raw) || typeof raw.id !== "number") return null; + return { + id: raw.id, + timestamp: typeof raw.timestamp === "string" ? raw.timestamp : "", + kind: typeof raw.kind === "string" ? raw.kind : "", + summary: + typeof raw.summary === "string" ? raw.summary : typeof raw.msg === "string" ? raw.msg : "", + }; +}; + +/** + * iOS only: serve-sim's event log, seeded with recent history and then pushed + * live. Android's session recorder only tracks replayable gestures, which the + * user already sees themselves, so it is not surfaced. + */ +export function subscribeDeviceEventLog( + target: Target, + onEvents: (entries: ReadonlyArray, reset: boolean) => void, +): () => void { + if (target.platform !== "ios") return () => {}; + return openEventSource( + target, + hubUrl(target, "/api/event-log/events", { device: target.deviceId, limit: "100" }), + (data) => { + if (!isRecord(data)) return; + if (Array.isArray(data.events)) { + onEvents( + data.events.flatMap((raw) => toEventLogEntry(raw) ?? []), + true, + ); + return; + } + const entry = toEventLogEntry(data.event); + if (entry) onEvents([entry], false); + }, + ); +} diff --git a/apps/web/src/components/device/deviceStream.test.ts b/apps/web/src/components/device/deviceStream.test.ts new file mode 100644 index 000000000000..362bd44fedba --- /dev/null +++ b/apps/web/src/components/device/deviceStream.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + createDeviceStreamClient, + AvccDemuxer, + avcCodecString, + parseSemuPacket, + scanAccessUnit, +} from "./deviceStream"; + +const envelope = (tag: number, payload: number[]) => { + const length = 1 + payload.length; + return [ + (length >>> 24) & 0xff, + (length >>> 16) & 0xff, + (length >>> 8) & 0xff, + length & 0xff, + tag, + ...payload, + ]; +}; + +describe("AvccDemuxer", () => { + it("reassembles envelopes split across reads", () => { + const demuxer = new AvccDemuxer(); + const bytes = new Uint8Array([ + ...envelope(1, [1, 0x64, 0x00, 0x1f]), + ...envelope(2, [9, 9, 9]), + ...envelope(0x7f, [0]), + ...envelope(3, [4]), + ]); + const first = demuxer.push(bytes.subarray(0, 7)); + const rest = demuxer.push(bytes.subarray(7)); + const chunks = [...first, ...rest]; + expect(chunks.map((chunk) => chunk.type)).toEqual(["description", "keyframe", "delta"]); + expect(Array.from(chunks[0]!.payload)).toEqual([1, 0x64, 0x00, 0x1f]); + expect(Array.from(chunks[1]!.payload)).toEqual([9, 9, 9]); + }); + + it("derives the WebCodecs codec string from the avcC record", () => { + expect(avcCodecString(new Uint8Array([1, 0x64, 0x00, 0x1f]))).toBe("avc1.64001f"); + expect(avcCodecString(new Uint8Array([1]))).toBe("avc1.42E01E"); + }); +}); + +describe("serve-emu frames", () => { + it("strips the SEMU header and reads the keyframe flag and timestamp", () => { + const buffer = new ArrayBuffer(16 + 3); + const view = new DataView(buffer); + view.setUint32(0, 0x53454d55); + view.setUint8(4, 1); + view.setUint8(5, 1); + view.setBigUint64(8, 123456n); + new Uint8Array(buffer).set([7, 8, 9], 16); + const packet = parseSemuPacket(buffer); + expect(packet.isKey).toBe(true); + expect(packet.timestamp).toBe(123456); + expect(Array.from(packet.data)).toEqual([7, 8, 9]); + }); + + it("treats a frame without the header as raw data", () => { + const packet = parseSemuPacket(new Uint8Array([0, 0, 1, 0x65]).buffer); + expect(packet.isKey).toBeNull(); + expect(packet.data.length).toBe(4); + }); + + it("finds the SPS and IDR NAL units in an Annex-B access unit", () => { + const unit = new Uint8Array([0, 0, 0, 1, 0x67, 0x64, 0x00, 0x1f, 0, 0, 1, 0x65, 0xaa]); + const scanned = scanAccessUnit(unit); + expect(scanned.isKey).toBe(true); + expect(scanned.sps && avcCodecString(scanned.sps)).toBe("avc1.64001f"); + expect(scanAccessUnit(new Uint8Array([0, 0, 1, 0x41, 0x00])).isKey).toBe(false); + }); +}); + +describe("iOS input startup", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + const setup = () => { + vi.useFakeTimers(); + const sockets: FakeSocket[] = []; + class FakeSocket { + static OPEN = 1; + readyState = 1; + binaryType = ""; + onopen: (() => void) | null = null; + onmessage: ((event: { data: ArrayBuffer }) => void) | null = null; + onclose: ((event: { code: number; reason: string }) => void) | null = null; + send = vi.fn(); + close = vi.fn(); + constructor() { + sockets.push(this); + } + } + vi.stubGlobal("WebSocket", FakeSocket); + const signals: AbortSignal[] = []; + vi.stubGlobal( + "fetch", + vi.fn((_url, init: RequestInit) => { + const signal = init.signal!; + signals.push(signal); + return Promise.resolve( + new Response( + new ReadableStream({ + start(controller) { + signal.addEventListener("abort", () => controller.error(new Error("aborted"))); + }, + }), + ), + ); + }), + ); + const client = createDeviceStreamClient( + { + platform: "ios", + deviceId: "test-device", + access: { + httpBase: "http://test/api/device-hub", + wsBase: "ws://test/api/device-hub", + credentials: true, + query: {}, + }, + }, + { getContext: () => null } as unknown as HTMLCanvasElement, + { + onStatus: vi.fn(), + onScreen: vi.fn(), + onUnauthorized: vi.fn(), + onMjpegFallback: vi.fn(), + onInputConnected: vi.fn(), + }, + ); + return { client, sockets, signals }; + }; + + it("connects input when the MJPEG prime never produces a frame", async () => { + const { client, sockets, signals } = setup(); + client.start(); + await vi.advanceTimersByTimeAsync(2_000); + expect(signals[0]?.aborted).toBe(true); + expect(sockets).toHaveLength(1); + client.stop(); + }); + + it("aborts priming immediately when hidden without opening a socket later", async () => { + const { client, sockets, signals } = setup(); + client.start(); + await vi.advanceTimersByTimeAsync(0); + client.stop(); + expect(signals[0]?.aborted).toBe(true); + await vi.advanceTimersByTimeAsync(2_000); + expect(sockets).toHaveLength(0); + }); + + it.each([ + ["landscape_left", 0.7, 1 - 0.2], + ["landscape_right", 1 - 0.7, 0.2], + ])("maps %s touches back to the raw iOS framebuffer", async (orientation, x, y) => { + const { client, sockets } = setup(); + client.start(); + await vi.advanceTimersByTimeAsync(2_000); + const socket = sockets[0]!; + const json = new TextEncoder().encode(JSON.stringify({ width: 400, height: 800, orientation })); + const packet = new Uint8Array(1 + json.length); + packet[0] = 0x82; + packet.set(json, 1); + socket.onmessage?.({ data: packet.buffer }); + client.sendTouch("begin", 0.2, 0.7); + const sent = socket.send.mock.calls[0]![0] as Uint8Array; + expect(JSON.parse(new TextDecoder().decode(sent.subarray(1)))).toEqual({ + type: "begin", + x, + y, + }); + client.stop(); + }); +}); diff --git a/apps/web/src/components/device/deviceStream.ts b/apps/web/src/components/device/deviceStream.ts new file mode 100644 index 000000000000..d6ae632202dd --- /dev/null +++ b/apps/web/src/components/device/deviceStream.ts @@ -0,0 +1,697 @@ +/** + * Framework-free client for expo-device-hub's per-device streams, reached + * through the T3 proxy. One class handles both platforms because the hub + * vendors two servers with different wire formats: + * + * - iOS (serve-sim): video is an HTTP `stream.avcc` body of length-prefixed + * envelopes (`u32be length, u8 tag, payload`; tag 1 avcC description, + * 2 keyframe, 3 delta, 4 JPEG seed) decoded with WebCodecs; input goes over + * `helper/ws?device=` as `[tag][json]` packets. When WebCodecs is + * unavailable (plain-http remote origins) the MJPEG endpoint is used as an + * `` source instead. + * - Android (serve-emu): one WebSocket at `ws?device=&frame-meta=1` + * carries H.264 access units prefixed with a 16-byte "SEMU" header + * (magic, version, key flag, pts) and accepts JSON gestures upstream. + * + * The decoder only runs while frames arrive and the viewer is attached; a + * hidden panel calls `stop()` so an idle device costs nothing on the GPU. + */ +import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess"; +import { withDeviceHubQuery } from "@t3tools/client-runtime/state/deviceHubAccess"; +import type { DevicePlatform } from "@t3tools/contracts"; + +export type DeviceStreamStatus = "connecting" | "streaming" | "error"; + +export interface DeviceScreenSize { + readonly width: number; + readonly height: number; + readonly orientation: "portrait" | "portrait_upside_down" | "landscape_left" | "landscape_right"; +} + +export interface DeviceStreamEvents { + readonly onStatus: (status: DeviceStreamStatus, detail?: string) => void; + readonly onScreen: (screen: DeviceScreenSize) => void; + /** The proxy rejected the credential; the owner should refresh access and reconnect. */ + readonly onUnauthorized: () => void; + /** + * H.264 cannot be decoded here (no WebCodecs, or the simulator's profile is + * unsupported); the owner should show this MJPEG URL in an `` instead of + * the canvas. + */ + readonly onMjpegFallback: (url: string) => void; + /** Whether touches and keys can currently reach the device. */ + readonly onInputConnected: (connected: boolean, detail?: string) => void; +} + +export interface DeviceStreamTarget { + readonly platform: DevicePlatform; + readonly deviceId: string; + readonly access: DeviceHubAccess; +} + +export type DeviceHardwareButton = "home" | "back" | "recents" | "power" | "appSwitcher"; + +const RETRY_DELAY_MS = 1_000; +const FRAME_DURATION_US = 16_667; +const SEMU_MAGIC = 0x53454d55; +const SEMU_HEADER_BYTES = 16; +const SEMU_FLAG_KEY = 1; +const SOFT_DECODE_QUEUE = 8; + +// serve-sim binary WS message tags (browser -> helper). +const IOS_MSG_TOUCH = 0x03; +const IOS_MSG_BUTTON = 0x04; +const IOS_MSG_KEY = 0x06; +const IOS_MSG_ORIENTATION = 0x07; +const IOS_MSG_HARDWARE_KEYBOARD = 0x0d; +// helper -> browser. +const IOS_TAG_SCREEN_CONFIG = 0x82; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const isWebCodecsSupported = (): boolean => + typeof globalThis !== "undefined" && + "VideoDecoder" in globalThis && + "EncodedVideoChunk" in globalThis; + +function taggedJson(tag: number, payload: unknown): Uint8Array { + const json = encoder.encode(JSON.stringify(payload)); + const out = new Uint8Array(1 + json.length); + out[0] = tag; + out.set(json, 1); + return out; +} + +/** Build the WebCodecs `avc1.PPCCLL` string from an avcC record or an SPS NAL. */ +export function avcCodecString(bytes: Uint8Array): string { + if (bytes.length < 4) return "avc1.42E01E"; + const hex = (byte: number) => byte.toString(16).padStart(2, "0"); + return `avc1.${hex(bytes[1]!)}${hex(bytes[2]!)}${hex(bytes[3]!)}`; +} + +/** Split serve-emu's SEMU-framed message into metadata and the Annex-B payload. */ +export function parseSemuPacket(raw: ArrayBuffer): { + readonly data: Uint8Array; + readonly isKey: boolean | null; + readonly timestamp: number | null; +} { + const bytes = new Uint8Array(raw); + if (bytes.byteLength > SEMU_HEADER_BYTES) { + const view = new DataView(raw, 0, SEMU_HEADER_BYTES); + if (view.getUint32(0, false) === SEMU_MAGIC && view.getUint8(4) === 1) { + const pts = view.getBigUint64(8, false); + return { + data: bytes.subarray(SEMU_HEADER_BYTES), + isKey: (view.getUint8(5) & SEMU_FLAG_KEY) !== 0, + timestamp: pts <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(pts) : null, + }; + } + } + return { data: bytes, isKey: null, timestamp: null }; +} + +const isVideoSessionMessage = (text: string) => { + try { + const message = JSON.parse(text) as { type?: unknown }; + return message.type === "video-session"; + } catch { + return false; + } +}; + +/** Walk an Annex-B access unit for its keyframe flag and SPS bytes. */ +export function scanAccessUnit(buf: Uint8Array): { isKey: boolean; sps: Uint8Array | null } { + let isKey = false; + let sps: Uint8Array | null = null; + const len = buf.length; + let i = 0; + while (i + 2 < len) { + if (buf[i] === 0 && buf[i + 1] === 0) { + let codeLen = 0; + if (buf[i + 2] === 1) codeLen = 3; + else if (i + 3 < len && buf[i + 2] === 0 && buf[i + 3] === 1) codeLen = 4; + if (codeLen) { + const nalType = buf[i + codeLen]! & 0x1f; + if (nalType === 7 && !sps) sps = buf.subarray(i + codeLen); + if (nalType === 5) isKey = true; + i += codeLen + 1; + continue; + } + } + i++; + } + return { isKey, sps }; +} + +export type AvccChunk = { + readonly type: "description" | "keyframe" | "delta" | "seed"; + readonly payload: Uint8Array; +}; + +const AVCC_TAGS: Record = { + 1: "description", + 2: "keyframe", + 3: "delta", + 4: "seed", +}; + +/** Turns a fragmented AVCC byte stream into complete envelopes. */ +export class AvccDemuxer { + private buffer = new Uint8Array(64 * 1024); + private length = 0; + + push(bytes: Uint8Array): AvccChunk[] { + if (this.length + bytes.length > this.buffer.length) { + let capacity = this.buffer.length; + while (capacity < this.length + bytes.length) capacity *= 2; + const grown = new Uint8Array(capacity); + grown.set(this.buffer.subarray(0, this.length)); + this.buffer = grown; + } + this.buffer.set(bytes, this.length); + this.length += bytes.length; + + const chunks: AvccChunk[] = []; + let offset = 0; + while (this.length - offset >= 4) { + const view = new DataView(this.buffer.buffer, this.buffer.byteOffset + offset, 4); + const frameLength = view.getUint32(0, false); + if (this.length - offset - 4 < frameLength) break; + if (frameLength >= 1) { + const type = AVCC_TAGS[this.buffer[offset + 4]!]; + if (type) { + chunks.push({ type, payload: this.buffer.slice(offset + 5, offset + 4 + frameLength) }); + } + } + offset += 4 + frameLength; + } + if (offset > 0) { + this.buffer.copyWithin(0, offset, this.length); + this.length -= offset; + } + return chunks; + } + + reset(): void { + this.length = 0; + } +} + +export interface DeviceStreamClient { + readonly start: () => void; + readonly stop: () => void; + /** Normalized 0..1 coordinates in the displayed frame. */ + readonly sendTouch: (phase: "begin" | "move" | "end", x: number, y: number) => void; + readonly sendKey: (event: KeyboardEvent, phase: "down" | "up") => void; + readonly pressButton: (button: DeviceHardwareButton) => void; + readonly rotate: () => void; +} + +const HID_USAGE_BY_CODE: Readonly> = { + Enter: 0x28, + Escape: 0x29, + Backspace: 0x2a, + Tab: 0x2b, + Space: 0x2c, + Minus: 0x2d, + Equal: 0x2e, + BracketLeft: 0x2f, + BracketRight: 0x30, + Backslash: 0x31, + Semicolon: 0x33, + Quote: 0x34, + Backquote: 0x35, + Comma: 0x36, + Period: 0x37, + Slash: 0x38, + Delete: 0x4c, + ArrowRight: 0x4f, + ArrowLeft: 0x50, + ArrowDown: 0x51, + ArrowUp: 0x52, + ControlLeft: 0xe0, + ShiftLeft: 0xe1, + AltLeft: 0xe2, + MetaLeft: 0xe3, + ControlRight: 0xe4, + ShiftRight: 0xe5, + AltRight: 0xe6, + MetaRight: 0xe7, +}; + +function hidUsageForCode(code: string): number | null { + if (/^Key[A-Z]$/.test(code)) return 0x04 + (code.charCodeAt(3) - 65); + if (/^Digit[1-9]$/.test(code)) return 0x1e + (code.charCodeAt(5) - 49); + if (code === "Digit0") return 0x27; + return HID_USAGE_BY_CODE[code] ?? null; +} + +const ANDROID_KEYCODE_BY_KEY: Readonly> = { + ArrowUp: 19, + ArrowDown: 20, + ArrowLeft: 21, + ArrowRight: 22, + Tab: 61, + Enter: 66, + Backspace: 67, + Delete: 112, + Home: 122, + End: 123, + PageUp: 92, + PageDown: 93, +}; + +const IOS_ORIENTATIONS: ReadonlyArray = [ + "portrait", + "landscape_left", + "portrait_upside_down", + "landscape_right", +]; + +export function createDeviceStreamClient( + target: DeviceStreamTarget, + canvas: HTMLCanvasElement, + events: DeviceStreamEvents, +): DeviceStreamClient { + const { access, platform, deviceId } = target; + const vendor = platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"; + const device = encodeURIComponent(deviceId); + const httpUrl = (path: string) => + withDeviceHubQuery(`${access.httpBase}${vendor}${path}`, access); + const wsUrl = (path: string) => withDeviceHubQuery(`${access.wsBase}${vendor}${path}`, access); + const useWebCodecs = isWebCodecsSupported(); + + let stopped = true; + let socket: WebSocket | null = null; + let controller: AbortController | null = null; + const retryTimers = new Map<"video" | "input", ReturnType>(); + let primeController: AbortController | null = null; + let videoDecoder: VideoDecoder | null = null; + let timestamp = 0; + let awaitingKeyframe = true; + let screen: DeviceScreenSize | null = null; + let firstFrame = false; + let configuring = false; + let mjpeg = false; + + const mjpegUrl = () => httpUrl(`/helper/${device}/stream.mjpeg`); + + const fallBackToMjpeg = () => { + if (stopped || mjpeg) return; + mjpeg = true; + closeDecoder(); + events.onMjpegFallback(mjpegUrl()); + setStatus("streaming"); + }; + + const setStatus = (status: DeviceStreamStatus, detail?: string) => { + if (!stopped) events.onStatus(status, detail); + }; + + const paint = (source: CanvasImageSource, width: number, height: number) => { + if (stopped) return; + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + if (platform === "android") { + screen = { width, height, orientation: width > height ? "landscape_left" : "portrait" }; + events.onScreen(screen); + } + } + canvas.getContext("2d")?.drawImage(source, 0, 0, width, height); + if (!firstFrame) { + firstFrame = true; + setStatus("streaming"); + } + }; + + const closeDecoder = () => { + try { + videoDecoder?.close(); + } catch { + // Already closed. + } + videoDecoder = null; + awaitingKeyframe = true; + }; + + const makeDecoder = () => + new VideoDecoder({ + output: (frame) => { + try { + paint(frame, frame.displayWidth, frame.displayHeight); + } finally { + frame.close(); + } + }, + error: () => { + closeDecoder(); + requestKeyframe(); + }, + }); + + /** + * Resolves false when this browser cannot decode the stream's profile + * (simulators encode High 5.1, which headless and some hardware decoders + * reject). iOS then falls back to MJPEG; Android has no MJPEG. + */ + const configureDecoder = async (config: VideoDecoderConfig): Promise => { + const full: VideoDecoderConfig = { ...config, optimizeForLatency: true }; + const support = await VideoDecoder.isConfigSupported(full).catch(() => ({ supported: false })); + if (stopped) return false; + if (!support.supported) { + setStatus("error", `This browser cannot decode ${config.codec}.`); + return false; + } + if (!videoDecoder || videoDecoder.state === "closed") videoDecoder = makeDecoder(); + try { + videoDecoder.configure(full); + return true; + } catch (cause) { + setStatus("error", `Video decoder: ${(cause as Error).message}`); + return false; + } + }; + + const decode = (isKey: boolean, data: Uint8Array, pts?: number | null) => { + if (!videoDecoder || videoDecoder.state !== "configured") return; + if (awaitingKeyframe) { + if (!isKey) return; + awaitingKeyframe = false; + } + if (videoDecoder.decodeQueueSize > SOFT_DECODE_QUEUE) { + closeDecoder(); + requestKeyframe(); + return; + } + try { + videoDecoder.decode( + new EncodedVideoChunk({ + type: isKey ? "key" : "delta", + timestamp: pts ?? timestamp, + data, + }), + ); + timestamp += FRAME_DURATION_US; + } catch { + closeDecoder(); + requestKeyframe(); + } + }; + + const requestKeyframe = () => { + if (platform === "android" && socket?.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: "reset-video", ack: false })); + } + }; + + const scheduleRetry = (channel: "video" | "input", run: () => void) => { + if (stopped || retryTimers.has(channel)) return; + retryTimers.set( + channel, + setTimeout(() => { + retryTimers.delete(channel); + run(); + }, RETRY_DELAY_MS), + ); + }; + + const handleUnauthorized = () => { + stop(); + events.onUnauthorized(); + }; + + // iOS video: fetch the AVCC body and demux into the decoder. + const readIosVideo = async () => { + const demuxer = new AvccDemuxer(); + controller = new AbortController(); + try { + const response = await fetch(httpUrl(`/helper/${device}/stream.avcc`), { + signal: controller.signal, + credentials: access.credentials ? "include" : "same-origin", + }); + if (response.status === 401 || response.status === 403) return handleUnauthorized(); + if (!response.ok || !response.body) throw new Error(`stream ${response.status}`); + const reader = response.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done || stopped) break; + for (const chunk of demuxer.push(value)) { + switch (chunk.type) { + case "seed": + void createImageBitmap(new Blob([chunk.payload as BlobPart], { type: "image/jpeg" })) + .then((bitmap) => { + paint(bitmap, bitmap.width, bitmap.height); + bitmap.close(); + }) + .catch(() => {}); + break; + case "description": { + awaitingKeyframe = true; + const configured = await configureDecoder({ + codec: avcCodecString(chunk.payload), + description: chunk.payload, + }); + if (!configured) { + await reader.cancel().catch(() => {}); + fallBackToMjpeg(); + return; + } + break; + } + case "keyframe": + case "delta": + decode(chunk.type === "keyframe", chunk.payload); + break; + } + } + } + } catch (cause) { + if (stopped) return; + setStatus("connecting", (cause as Error).message); + } + if (!stopped) scheduleRetry("video", () => void readIosVideo()); + }; + + /** + * serve-sim's helper only accepts HID and pushes its screen config once + * screen capture is running, and the AVCC stream does not reliably start + * it. Touching the MJPEG endpoint does; one aborted request is enough. + */ + const primeIosHelper = async () => { + const controller = new AbortController(); + primeController = controller; + const timeout = setTimeout(() => controller.abort(), 2_000); + try { + const response = await fetch(httpUrl(`/helper/${device}/stream.mjpeg`), { + signal: controller.signal, + credentials: access.credentials ? "include" : "same-origin", + }); + if (response.status === 401 || response.status === 403) return handleUnauthorized(); + await response.body?.getReader().read(); + } catch { + // A failed prime just means the socket may take a retry to come up. + } finally { + clearTimeout(timeout); + controller.abort(); + if (primeController === controller) primeController = null; + } + }; + + // iOS input socket; also carries the screen config the helper pushes. + const connectIosInput = async () => { + if (stopped) return; + await primeIosHelper(); + if (stopped) return; + const ws = new WebSocket(wsUrl(`/helper/ws?device=${device}`)); + ws.binaryType = "arraybuffer"; + socket = ws; + ws.onopen = () => { + ws.send(taggedJson(IOS_MSG_HARDWARE_KEYBOARD, { enabled: false })); + events.onInputConnected(true); + }; + ws.onmessage = (event) => { + if (!(event.data instanceof ArrayBuffer)) return; + const bytes = new Uint8Array(event.data); + if (bytes.length < 1 || bytes[0] !== IOS_TAG_SCREEN_CONFIG) return; + try { + const config = JSON.parse(decoder.decode(bytes.subarray(1))) as DeviceScreenSize; + if (config.width > 0 && config.height > 0) { + screen = config; + events.onScreen(config); + } + } catch { + // Ignore malformed config frames. + } + }; + ws.onclose = (event) => { + if (socket === ws) socket = null; + if (!stopped) { + events.onInputConnected( + false, + event.reason || (event.code === 1006 ? "input socket refused" : `closed ${event.code}`), + ); + } + if (event.code === 1008 || event.code === 4401) return handleUnauthorized(); + scheduleRetry("input", () => void connectIosInput()); + }; + ws.onerror = () => ws.close(); + }; + + // Android: one socket for video and input. + const connectAndroid = () => { + if (stopped) return; + const ws = new WebSocket(wsUrl(`/ws?device=${device}&frame-meta=1`)); + ws.binaryType = "arraybuffer"; + socket = ws; + ws.onopen = () => { + setStatus("connecting"); + events.onInputConnected(true); + }; + ws.onmessage = (event) => { + if (typeof event.data === "string") { + // The encoder restarts at a new size when the device rotates; the + // next keyframe carries a fresh SPS, so the decoder is rebuilt from it. + if (isVideoSessionMessage(event.data)) closeDecoder(); + return; + } + if (!(event.data instanceof ArrayBuffer)) return; + const packet = parseSemuPacket(event.data); + const needsScan = + packet.isKey === null || + (packet.isKey && (!videoDecoder || videoDecoder.state !== "configured")); + const scanned = needsScan ? scanAccessUnit(packet.data) : null; + const isKey = packet.isKey ?? scanned?.isKey ?? false; + if (scanned?.sps && (!videoDecoder || videoDecoder.state !== "configured")) { + if (configuring) return; + configuring = true; + void configureDecoder({ codec: avcCodecString(scanned.sps) }).then((configured) => { + configuring = false; + awaitingKeyframe = true; + if (configured) requestKeyframe(); + }); + return; + } + if (!videoDecoder || videoDecoder.state !== "configured") { + if (!isKey) requestKeyframe(); + return; + } + decode(isKey, packet.data, packet.timestamp); + }; + ws.onclose = (event) => { + if (socket === ws) socket = null; + closeDecoder(); + if (!stopped) events.onInputConnected(false, event.reason || `closed ${event.code}`); + if (event.code === 1008 || event.code === 4401) return handleUnauthorized(); + if (!stopped) { + setStatus("connecting", event.reason || undefined); + scheduleRetry("input", connectAndroid); + } + }; + ws.onerror = () => ws.close(); + }; + + const start = () => { + if (!stopped) return; + stopped = false; + firstFrame = false; + events.onStatus("connecting"); + if (platform === "ios") { + void connectIosInput(); + if (useWebCodecs) void readIosVideo(); + else fallBackToMjpeg(); + } else if (useWebCodecs) { + connectAndroid(); + } else { + setStatus("error", "This browser cannot decode the Android stream (WebCodecs unavailable)."); + } + }; + + const stop = () => { + if (stopped) return; + stopped = true; + mjpeg = false; + for (const timer of retryTimers.values()) clearTimeout(timer); + retryTimers.clear(); + primeController?.abort(); + primeController = null; + controller?.abort(); + controller = null; + socket?.close(); + socket = null; + closeDecoder(); + }; + + const send = (payload: Uint8Array | string) => { + if (socket?.readyState === WebSocket.OPEN) socket.send(payload); + }; + + const rawPoint = (x: number, y: number) => { + // serve-sim streams the raw framebuffer; rotated devices need input + // remapped into that raw space. + if (platform !== "ios" || !screen || screen.width > screen.height) return { x, y }; + switch (screen.orientation) { + case "landscape_left": + return { x: y, y: 1 - x }; + case "landscape_right": + return { x: 1 - y, y: x }; + case "portrait_upside_down": + return { x: 1 - x, y: 1 - y }; + default: + return { x, y }; + } + }; + + return { + start, + stop, + sendTouch: (phase, x, y) => { + if (platform === "ios") { + send(taggedJson(IOS_MSG_TOUCH, { type: phase, ...rawPoint(x, y) })); + return; + } + const action = phase === "begin" ? "down" : phase === "move" ? "move" : "up"; + send(JSON.stringify({ type: "touch", action, x, y })); + }, + sendKey: (event, phase) => { + if (platform === "ios") { + const usage = hidUsageForCode(event.code); + if (usage !== null) send(taggedJson(IOS_MSG_KEY, { type: phase, usage })); + return; + } + if (phase !== "down") return; + if (event.key === "Escape") return send(JSON.stringify({ type: "back" })); + const keycode = ANDROID_KEYCODE_BY_KEY[event.key]; + if (keycode !== undefined) return send(JSON.stringify({ type: "key", keycode })); + if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) { + send(JSON.stringify({ type: "text", text: event.key })); + } + }, + pressButton: (button) => { + if (platform === "ios") { + const name = + button === "home" + ? "home" + : button === "appSwitcher" + ? "app_switcher" + : button === "power" + ? "lock" + : null; + if (name) send(taggedJson(IOS_MSG_BUTTON, { button: name })); + return; + } + const type = button === "appSwitcher" ? "recents" : button; + if (type === "home" || type === "back" || type === "recents" || type === "power") { + send(JSON.stringify({ type })); + } + }, + rotate: () => { + if (platform !== "ios") return; + const current = screen?.orientation ?? "portrait"; + const next = + IOS_ORIENTATIONS[(IOS_ORIENTATIONS.indexOf(current) + 1) % IOS_ORIENTATIONS.length]!; + send(taggedJson(IOS_MSG_ORIENTATION, { orientation: next })); + }, + }; +} diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index 90c18984e17b..cc3185a3a77e 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -93,11 +93,19 @@ export function MediaVideoPlayer({ const video = videoRef.current; if (!video) return; const pauseWhenHidden = () => { - if (document.hidden) video.pause(); + // Native fullscreen can hide the inline page while this video is still visible. + const fullscreen = + document.fullscreenElement?.contains(video) || + ("webkitDisplayingFullscreen" in video && video.webkitDisplayingFullscreen === true); + if (document.hidden && !fullscreen) video.pause(); }; document.addEventListener("visibilitychange", pauseWhenHidden); + document.addEventListener("fullscreenchange", pauseWhenHidden); + video.addEventListener("webkitendfullscreen", pauseWhenHidden); return () => { document.removeEventListener("visibilitychange", pauseWhenHidden); + document.removeEventListener("fullscreenchange", pauseWhenHidden); + video.removeEventListener("webkitendfullscreen", pauseWhenHidden); video.pause(); }; }, [src, failed, loadAttempt]); diff --git a/apps/web/src/components/preview/PreviewEmptyState.tsx b/apps/web/src/components/preview/PreviewEmptyState.tsx index 163849154000..35b4b2f13d64 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.tsx @@ -3,6 +3,7 @@ import { Globe, History, RadioTower } from "lucide-react"; import type { BrowserHistoryEntry } from "~/browserHistoryStore"; import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty"; +import { DiscoveryList } from "../ui/discovery-list"; import { PreviewLocalServerCard } from "./PreviewLocalServerCard"; import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard"; @@ -55,7 +56,7 @@ export function PreviewEmptyState({

Recently used

-
+ {recents.map((entry) => ( onRemoveRecent(entry.url)} /> ))} -
+
) : null} {servers.length > 0 ? ( @@ -74,7 +75,7 @@ export function PreviewEmptyState({

Local servers

-
+ {servers.map((server) => ( onOpenUrl(server.requestedUrl)} /> ))} -
+

Select a live local server to open it in this browser tab.

diff --git a/apps/web/src/components/preview/PreviewLocalServerCard.tsx b/apps/web/src/components/preview/PreviewLocalServerCard.tsx index 263cdb294f48..198d0495473c 100644 --- a/apps/web/src/components/preview/PreviewLocalServerCard.tsx +++ b/apps/web/src/components/preview/PreviewLocalServerCard.tsx @@ -1,4 +1,5 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; +import { DiscoveryListRow } from "../ui/discovery-list"; import { PreviewFaviconIcon } from "./PreviewFaviconIcon"; import type { PreviewableServer } from "./useDiscoveredLocalServers"; @@ -12,19 +13,12 @@ interface Props { export function PreviewLocalServerCard({ threadRef, server, onOpen }: Props) { const subtitle = describeServer(server); return ( - + icon={} + title={subtitle} + description={`${server.host}:${server.port}`} + /> ); } diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 9e59e56a24f0..4384019abbad 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -44,6 +44,8 @@ interface Props { readonly bottomInset: number; } +const PREVIEW_MINI_PLAYER_CORNER_RADIUS = 12; + // Invisible grab zones straddling each edge; the cursor is the only affordance. const RESIZE_HANDLES: ReadonlyArray<{ readonly direction: BrowserViewportResizeDirection; @@ -59,6 +61,10 @@ const RESIZE_HANDLES: ReadonlyArray<{ { direction: "southeast", className: "-bottom-2 -right-2 size-4 cursor-nwse-resize" }, ]; +/** + * Floats the thread's browser surface over chat. Native clipping and the DOM + * frame use the same radius so their separately composited edges stay aligned. + */ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props) { const containerRef = useRef(null); const gestureRef = useRef(null); @@ -193,7 +199,13 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props aria-label="Floating browser preview" data-preview-mini-player={tabId} className="pointer-events-none absolute select-none" - style={{ left: frame.x, top: frame.y, width: frame.width, height: frame.height }} + style={{ + left: frame.x, + top: frame.y, + width: frame.width, + height: frame.height, + borderRadius: PREVIEW_MINI_PLAYER_CORNER_RADIUS, + }} >
-
+
-
+
{!desktopOverlay?.hasWebContents ? ( -
+
Reconnecting preview…
) : null} diff --git a/apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts b/apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts new file mode 100644 index 000000000000..6d11ce7cd944 --- /dev/null +++ b/apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { changeRequestWebUrl, resolveLinkPullRequestInput } from "./LinkPullRequestDialog"; + +const project = { + host: "github.com", + repository: "acme/web", + webUrl: (number: number) => changeRequestWebUrl("github", "github.com", "acme/web", number), +}; + +describe("resolveLinkPullRequestInput", () => { + it.each([ + ["https://bitbucket.org/acme/web/pull-requests/42", "bitbucket.org"], + ["https://github.acme.test/acme/web/pull/42", "github.acme.test"], + ["https://git.acme.test/acme/web/-/merge_requests/42", "git.acme.test"], + ])("links supported host URL %s without a thread project", (url, host) => { + expect( + resolveLinkPullRequestInput({ + reference: ` ${url} `, + project: null, + hasProject: (candidate) => candidate.host === host, + }), + ).toEqual({ link: { host, repository: "acme/web", number: 42, url } }); + }); + + it("validates the full Azure repository when resolving a browser URL", () => { + const hasProject = (reference: { host: string; repository: string }) => + reference.host === "dev.azure.com" && reference.repository === "org-a/project/_git/web"; + expect( + resolveLinkPullRequestInput({ + reference: "https://dev.azure.com/org-a/project/_git/web/pullrequest/42", + project: null, + hasProject, + }), + ).toMatchObject({ link: { repository: "org-a/project/_git/web", number: 42 } }); + expect( + resolveLinkPullRequestInput({ + reference: "https://dev.azure.com/org-b/project/_git/web/pullrequest/42", + project: null, + hasProject, + }), + ).toMatchObject({ error: expect.stringContaining("org-b/project/_git/web") }); + }); + + it("resolves bare Azure numbers into canonical browser URLs", () => { + expect( + resolveLinkPullRequestInput({ + reference: "#42", + project: { + host: "ssh.dev.azure.com", + repository: "v3/org/project/web", + webUrl: (number) => + changeRequestWebUrl("azure-devops", "ssh.dev.azure.com", "v3/org/project/web", number), + }, + hasProject: () => true, + }), + ).toMatchObject({ + link: { + host: "dev.azure.com", + repository: "org/project/_git/web", + number: 42, + url: "https://dev.azure.com/org/project/_git/web/pullrequest/42", + }, + }); + }); + + it("returns null for input that is not a reference", () => { + expect( + resolveLinkPullRequestInput({ reference: "hello", project, hasProject: () => true }), + ).toBeNull(); + }); + + it("resolves a bare number against the thread's own repository", () => { + expect( + resolveLinkPullRequestInput({ reference: "#42", project, hasProject: () => true }), + ).toEqual({ + link: { + host: "github.com", + repository: "acme/web", + number: 42, + url: "https://github.com/acme/web/pull/42", + }, + }); + }); + + it("links a URL from another repository on a host with a project", () => { + expect( + resolveLinkPullRequestInput({ + reference: "https://github.com/acme/api/pull/7", + project, + hasProject: (reference) => reference.host === "github.com", + }), + ).toEqual({ + link: { + host: "github.com", + repository: "acme/api", + number: 7, + url: "https://github.com/acme/api/pull/7", + }, + }); + }); + + it("refuses a URL on a host nothing is checked out from", () => { + const result = resolveLinkPullRequestInput({ + reference: "https://gitlab.com/acme/api/-/merge_requests/7", + project, + hasProject: () => false, + }); + expect(result).toMatchObject({ error: expect.stringContaining("gitlab.com") }); + }); + + it("asks for a URL when a bare number has no project to resolve against", () => { + expect( + resolveLinkPullRequestInput({ reference: "12", project: null, hasProject: () => true }), + ).toMatchObject({ error: expect.stringContaining("full URL") }); + }); + + it("accepts a checkout command as a reference", () => { + expect( + resolveLinkPullRequestInput({ + reference: "gh pr checkout https://github.com/acme/web/pull/3", + project, + hasProject: () => true, + }), + ).toMatchObject({ link: { number: 3, repository: "acme/web" } }); + }); +}); + +describe("changeRequestWebUrl", () => { + it("knows the four hosts and nothing else", () => { + expect(changeRequestWebUrl("gitlab", "gitlab.com", "g/sub/repo", 5)).toBe( + "https://gitlab.com/g/sub/repo/-/merge_requests/5", + ); + expect(changeRequestWebUrl("unknown", "x", "a/b", 1)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx new file mode 100644 index 000000000000..aa5dfa789524 --- /dev/null +++ b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx @@ -0,0 +1,254 @@ +import { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl"; +export { changeRequestUrlFor as changeRequestWebUrl } from "@t3tools/shared/changeRequestUrl"; +import { + pullRequestHostOf, + type ScopedThreadRef, + type SourceControlProviderKind, +} from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; +import { parsePullRequestReference } from "~/pullRequestReference"; +import { useProjects, useThreadShell } from "~/state/entities"; +import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { Atom } from "effect/unstable/reactivity"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; + +/** + * Which thread has the link dialog open, set by whichever entry point asked (command palette, + * pull-requests surface, detail panel) and rendered once by the chat view so the dialog outlives + * a palette that closes the moment its command runs. + */ +const linkPullRequestDialogThreadAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("pull-requests:link-dialog-thread"), +); + +export function openLinkPullRequestDialog(threadRef: ScopedThreadRef): void { + appAtomRegistry.set(linkPullRequestDialogThreadAtom, threadRef); +} + +interface LinkPullRequestDialogProps { + open: boolean; + threadRef: ScopedThreadRef; + /** The thread's own project: bare numbers resolve against its repository. */ + projectId: string | null; + onOpenChange: (open: boolean) => void; +} + +/** Mounted once per chat view; shows the dialog for whichever thread asked for it. */ +export function LinkPullRequestDialogHost() { + const threadRef = useAtomValue(linkPullRequestDialogThreadAtom); + const thread = useThreadShell(threadRef); + const linking = usePullRequestLinking(threadRef?.environmentId); + if (threadRef === null || linking.mode === "unsupported") return null; + return ( + { + if (!open) appAtomRegistry.set(linkPullRequestDialogThreadAtom, null); + }} + /> + ); +} + +interface ResolvedLink { + readonly host: string; + readonly repository: string; + readonly number: number; + readonly url: string; +} + +/** + * Which pull request an input names, or why it cannot. A URL carries its own host and + * repository and may point at any repository on a host this environment has a project for; a + * bare `#123` can only mean the thread's own repository. + */ +export function resolveLinkPullRequestInput(input: { + readonly reference: string; + readonly project: { + readonly host: string; + readonly repository: string; + readonly webUrl: (number: number) => string | null; + } | null; + readonly hasProject: (reference: ResolvedLink) => boolean; +}): { link: ResolvedLink } | { error: string } | null { + const parsed = + parseChangeRequestUrl(input.reference.trim()) !== null + ? input.reference.trim() + : parsePullRequestReference(input.reference); + if (parsed === null) return null; + const url = parseChangeRequestUrl(parsed); + if (url !== null) { + if (!input.hasProject({ ...url, url: parsed })) { + return { error: `No project in this environment can read ${url.host}/${url.repository}.` }; + } + return { + link: { host: url.host, repository: url.repository, number: url.number, url: parsed }, + }; + } + const number = Number(parsed); + if (!Number.isSafeInteger(number) || number < 1) return null; + if (input.project === null) { + return { error: "Paste a full URL to link a pull request from another repository." }; + } + const webUrl = input.project.webUrl(number); + const webReference = webUrl === null ? null : parseChangeRequestUrl(webUrl); + if (webUrl === null || webReference === null) { + return { error: "Paste a full URL; this project's host has no known pull request URL." }; + } + return { + link: { ...webReference, url: webUrl }, + }; +} + +function LinkPullRequestDialog({ + open, + threadRef, + projectId, + onOpenChange, +}: LinkPullRequestDialogProps) { + const inputRef = useRef(null); + const [reference, setReference] = useState(""); + const [dirty, setDirty] = useState(false); + const [submitError, setSubmitError] = useState(null); + const projects = useProjects(); + const environmentProjects = useMemo( + () => projects.filter((project) => project.environmentId === threadRef.environmentId), + [projects, threadRef.environmentId], + ); + const ownProject = useMemo(() => { + const project = environmentProjects.find((candidate) => candidate.id === projectId); + const identity = project?.repositoryIdentity; + if (!project || !identity) return null; + const repository = + identity.displayName ?? + (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null); + if (repository === null) return null; + const kind = identity.provider as SourceControlProviderKind; + const host = pullRequestHostOf(identity, kind); + return { + host, + repository, + webUrl: (number: number) => changeRequestWebUrl(kind, host, repository, number), + }; + }, [environmentProjects, projectId]); + const linking = usePullRequestLinking(threadRef.environmentId); + const [pending, setPending] = useState(false); + + useEffect(() => { + if (!open) return; + setReference(""); + setDirty(false); + setSubmitError(null); + const frame = window.requestAnimationFrame(() => inputRef.current?.focus()); + return () => window.cancelAnimationFrame(frame); + }, [open]); + + const resolved = useMemo( + () => + resolveLinkPullRequestInput({ + reference, + project: ownProject, + hasProject: (reference) => linking.canLink(reference.url), + }), + [linking, ownProject, reference], + ); + + const submit = useCallback(async () => { + setDirty(true); + if (resolved === null || "error" in resolved) return; + setSubmitError(null); + setPending(true); + try { + await linking.changeLink(threadRef, resolved.link.url, true); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : "Could not link the pull request."); + return; + } finally { + setPending(false); + } + onOpenChange(false); + }, [linking, onOpenChange, resolved, threadRef]); + + const validation = !dirty + ? null + : reference.trim().length === 0 + ? "Paste a pull request URL or enter 123 / #123." + : resolved === null + ? "Use a pull request URL, 123, or #123." + : "error" in resolved + ? resolved.error + : null; + + return ( + (pending ? undefined : onOpenChange(next))}> + + + Link pull request + + Attach a pull request to this thread. A full URL can point at any repository on a host + this environment has a project for. + + + + { + setDirty(true); + setReference(event.target.value); + }} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + void submit(); + }} + /> + {resolved !== null && "link" in resolved ? ( +

+ {resolved.link.host}/{resolved.link.repository} #{resolved.link.number} +

+ ) : null} + {(validation ?? submitError) ? ( +

{validation ?? submitError}

+ ) : null} +
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index de6f32aaa121..0284a2e6cdec 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,3 +1,5 @@ +import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { usePullRequestStack } from "~/state/usePullRequestStack"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; @@ -66,16 +68,15 @@ import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import type { ReviewCommentContext } from "~/reviewCommentContext"; import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; -import { useProjects } from "~/state/entities"; +import { useProjects, useServerConfigs } from "~/state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; -import { - pullRequestEnvironment, - usePullRequestTurnRefresh, - useSharedPullRequestSummary, -} from "~/state/pullRequests"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { usePullRequestTurnRefresh, useSharedPullRequestSummary } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; +import { PullRequestStackMenu } from "./PullRequestStackMenu"; +import { PullRequestThreadLinks } from "./PullRequestThreadLinks"; import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { useUiStateStore } from "~/uiStateStore"; @@ -105,7 +106,7 @@ import { } from "../ui/menu"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { toastManager } from "../ui/toast"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; import { DiffPanelLoadingState } from "../DiffPanelShell"; @@ -137,6 +138,7 @@ import { readPullRequestDetailSnapshot, resolveDisplayedPullRequestDetail, resolvePullRequestPrimaryControl, + allowsSinglePullRequestMerge, resolveBaseFreshness, resolvePullRequestMergeMethod, type PullRequestFinding, @@ -452,15 +454,18 @@ function PullRequestBaseFreshnessWarning({ export function PullRequestDetailPanel({ environmentId, threadRef = null, - reference, + reference: requestedReference, listEntry = null, refreshToken: forcedRefreshToken = 0, onActed, onClose, context = "page", composerDraftTarget, + onBack, + onSelectPullRequest, }: { environmentId: EnvironmentId; + onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined; /** * The thread this panel sits beside, if any. Links that are not the pull * request itself (check details, host permalinks) can open in that thread's @@ -495,14 +500,35 @@ export function PullRequestDetailPanel({ * land here instead of opening a new thread — the branch is already under the reader's feet. */ composerDraftTarget?: ScopedThreadRef | DraftId; + /** + * Beside a thread, the way back to that thread's list of pull requests. The tab strip can + * close this surface, but closing is not going back: the reader came from the list and + * expects to land on it, with this one still open behind. + */ + onBack?: (() => void) | undefined; }) { - const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`; + const environmentConfigs = useServerConfigs(); + const supportsThreadPullRequests = + environmentConfigs.get(environmentId)?.environment.capabilities.threadPullRequests === true; + const reference = useMemo( + () => + supportsThreadPullRequests + ? requestedReference + : { + projectId: requestedReference.projectId, + repository: requestedReference.repository, + number: requestedReference.number, + }, + [requestedReference, supportsThreadPullRequests], + ); + const pullRequestKey = `${reference.projectId}:${reference.host ?? ""}:${reference.repository}#${reference.number}`; const matchingListEntry = listEntry?.projectId === reference.projectId && listEntry.repository.toLowerCase() === reference.repository.toLowerCase() && listEntry.number === reference.number ? listEntry : null; + const [threadPickerOpen, setThreadPickerOpen] = useState(false); const [tab, setTab] = useState("summary"); const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest"); const [codeCommitScope, setCodeCommitScope] = useState<{ @@ -631,6 +657,11 @@ export function PullRequestDetailPanel({ : { ...resolvedCoreDetail, ...sharedSummary, + author: sharedSummary.author ?? resolvedCoreDetail.author, + additions: sharedSummary.additions ?? resolvedCoreDetail.additions, + deletions: sharedSummary.deletions ?? resolvedCoreDetail.deletions, + changedFiles: sharedSummary.changedFiles ?? resolvedCoreDetail.changedFiles, + mergeability: sharedSummary.mergeability ?? resolvedCoreDetail.mergeability, closedAt: sharedSummary.closedAt === undefined ? resolvedCoreDetail.closedAt @@ -702,12 +733,36 @@ export function PullRequestDetailPanel({ const isStackedPullRequest = detail !== null && isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []); + // The host's own stack, where it keeps one. Only asked for once the detail has landed so a + // pull request nobody can read costs one request rather than two. + const stackReference = useMemo( + () => + detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests + ? null + : { ...reference, host: reference.host ?? parseChangeRequestUrl(detail.url)?.host }, + [detail, reference, supportsThreadPullRequests], + ); + const nativeStackQuery = usePullRequestStack(environmentId, stackReference); + const nativeStack = nativeStackQuery.data; + const supportsStackActions = + supportsThreadPullRequests && + detail?.capabilities.stacks === true && + detail.capabilities.stackActions === true && + environmentConfigs.get(environmentId)?.environment.capabilities.pullRequestStackActions === + true; + const canMergeSinglePullRequest = allowsSinglePullRequestMerge({ + supportsStackActions, + hasStack: nativeStack !== null, + stackPending: !nativeStackQuery.isSuccess || nativeStackQuery.isPending, + stackError: nativeStackQuery.error, + }); const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; const refreshDetail = useCallback(() => { detailQuery.refresh(); activityQuery.refresh(); - }, [activityQuery.refresh, detailQuery.refresh]); + nativeStackQuery.refresh(); + }, [activityQuery.refresh, detailQuery.refresh, nativeStackQuery.refresh]); const [refreshToken, setRefreshToken] = useState(0); const codeRefreshToken = refreshToken + (turnRefresh ?? 0); const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>( @@ -1318,9 +1373,9 @@ export function PullRequestDetailPanel({ checksState, autoMergeEnabled: detail.autoMergeEnabled, hasMergeMethod: allowedMergeMethods.length > 0, - canMerge: can("merge"), + canMerge: canMergeSinglePullRequest && can("merge"), canMarkReady: can("ready"), - canEnableAutoMerge: can("enable-auto-merge"), + canEnableAutoMerge: canMergeSinglePullRequest && can("enable-auto-merge"), }) : null; // What the menu's action group holds. Named once so the separators around it are drawn from @@ -1330,6 +1385,7 @@ export function PullRequestDetailPanel({ can(detail.isDraft ? "ready" : "draft") && !(detail.isDraft && primaryAction === "ready"); const showsAutoMerge = + canMergeSinglePullRequest && detail?.state === "open" && ((autoMergeArmed && can("disable-auto-merge")) || (!autoMergeArmed && @@ -1339,6 +1395,7 @@ export function PullRequestDetailPanel({ can("enable-auto-merge") && allowedMergeMethods.length > 0)); const showsMergeNow = + canMergeSinglePullRequest && detail?.state === "open" && (primaryAction === "enable-auto-merge" || primaryAction === "auto-merge-armed") && can("merge") && @@ -1379,7 +1436,24 @@ export function PullRequestDetailPanel({ return (
-
+ {threadPickerOpen && detail ? ( + + ) : null} +
{detail && statePresentation ? ( <> + {onBack ? ( + + + + + } + /> + Back to pull requests + + ) : null} {detail && statePresentation ? ( <> + {onBack ? ( + + + + + } + /> + Back to pull requests + + ) : null}
{detail ? ( - <> + + {!nativeStack && supportsStackActions && nativeStackQuery.error ? ( + + ) : null} + {nativeStack ? ( + 0 + } + canRebase={ + nativeStackQuery.isFresh && + supportsStackActions && + detail.viewerPermissions.stackRebase === true + } + onActed={() => { + refreshDetail(); + onActed?.(); + }} + /> + ) : null} + {context === "page" ? ( + + ) : null} {/* Checking a pull request out is the reason to open one here at all, so it is a button of its own rather than a side effect of asking an agent for something. It asks where, because the two answers are not interchangeable: one leaves your @@ -1495,24 +1646,34 @@ export function PullRequestDetailPanel({ the page: beside a thread the branch is already checked out right there. */} {context === "page" ? ( - - - - {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} - - - - } - /> + + + + + {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} + + + + } + /> + } + /> + Check out this pull request + startCheckout("worktree")}> @@ -1599,14 +1760,12 @@ export function PullRequestDetailPanel({ @@ -1621,6 +1780,7 @@ export function PullRequestDetailPanel({ -
diff --git a/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx b/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx index a4da0d99514d..ac4c83ad714e 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx @@ -38,15 +38,12 @@ export function PullRequestReviewerPicker({ environmentId, reference, allowed, - onRequested, }: { environmentId: EnvironmentId; reference: PullRequestRef; /** False where the host would refuse this account's request, which is worth saying rather than * hiding: the control disabled with a reason answers the question its absence would raise. */ allowed: boolean; - /** The detail carries who is requested, so it is re-read once the host has taken the change. */ - onRequested: () => void; }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); @@ -96,8 +93,6 @@ export function PullRequestReviewerPicker({ ? `Review request to ${candidate.login} taken back` : `Review requested from ${candidate.login}`, }); - onRequested(); - candidatesQuery.refresh(); }; return ( @@ -111,8 +106,8 @@ export function PullRequestReviewerPicker({ query={query} onQueryChange={setQuery} searchLabel="Search people with access" - isPending={candidatesQuery.isPending} - error={candidatesQuery.error} + isPending={candidatesQuery.isPending && candidatesQuery.data === null} + error={candidatesQuery.data === null ? candidatesQuery.error : null} candidates={candidates} emptyLabel="Nobody else has access to this repository." noMatchLabel="Nobody with access matches that." diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index ae7aee0168f4..69017444ce80 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -1,4 +1,5 @@ import { SearchIcon } from "lucide-react"; +import { PullRequestStackPopover } from "./PullRequestStackPopover"; import { memo, type RefCallback } from "react"; import { cn } from "~/lib/utils"; @@ -13,6 +14,7 @@ import { PullRequestActorLabel, PullRequestDiffStat, PullRequestMetaLine, + PullRequestApprovalGlyph, PullRequestStateGlyph, } from "./pullRequestPresentation"; @@ -60,6 +62,11 @@ function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry[ ); } +export type PullRequestRowTarget = Pick< + EnvironmentPullRequestEntry, + "environmentId" | "projectId" | "host" | "repository" | "number" +>; + function PullRequestRowImpl({ entry, selected, @@ -86,7 +93,7 @@ function PullRequestRowImpl({ /** Used by the list's shared visibility observer to defer optional line-count reads. */ statsKey?: string; statsRef?: RefCallback; - onSelect: (entry: EnvironmentPullRequestEntry) => void; + onSelect: (entry: PullRequestRowTarget) => void; }) { const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); return ( @@ -115,19 +122,29 @@ function PullRequestRowImpl({ {entry.title} - + + {entry.stack ? ( + + onSelect({ ...target, host: entry.host, environmentId: entry.environmentId }) + } + /> + ) : null} {/* Only a verdict somebody has actually given: "review required" is the absence of one, and saying so on every unreviewed row would say nothing. */} - {entry.reviewDecision === "approved" || entry.reviewDecision === "changes-requested" ? ( - - {entry.reviewDecision === "approved" ? "Approved" : "Changes requested"} + {entry.reviewDecision === "approved" ? ( + + ) : entry.reviewDecision === "changes-requested" ? ( + + Changes requested ) : null} {entry.checksState === undefined ? null : ( @@ -141,6 +158,11 @@ function PullRequestRowImpl({ }} /> )} + {matchedElsewhere ? ( @@ -196,7 +218,6 @@ function PullRequestRowImpl({ {entry.labels.length > 0 ? : null} - {formatRelativeTimeLabel(entry.updatedAt)} diff --git a/apps/web/src/components/pullRequest/PullRequestStackHeader.tsx b/apps/web/src/components/pullRequest/PullRequestStackHeader.tsx new file mode 100644 index 000000000000..a97c4ab77a29 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestStackHeader.tsx @@ -0,0 +1,26 @@ +import { MenuGroupLabel } from "../ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +export function PullRequestStackHeader({ + number, + notice, + stale = false, +}: { + number: number; + notice?: string | null | undefined; + stale?: boolean; +}) { + return ( + + Stack #{number} + {notice ? ( + + }> + {stale ? "May be stale" : "Refreshing…"} + + {notice} + + ) : null} + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestStackLayerContent.tsx b/apps/web/src/components/pullRequest/PullRequestStackLayerContent.tsx new file mode 100644 index 000000000000..64d5737b0c13 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestStackLayerContent.tsx @@ -0,0 +1,28 @@ +import type { PullRequestStack } from "@t3tools/contracts"; +import { cn } from "~/lib/utils"; +import { resolvePullRequestState } from "./pullRequestPresentation"; + +export function PullRequestStackLayerContent({ + layer, + compact = false, +}: { + layer: PullRequestStack["layers"][number]; + compact?: boolean; +}) { + const state = resolvePullRequestState({ + state: layer.state, + isDraft: layer.isDraft ?? false, + }); + return ( + <> + + + {layer.title || layer.headBranch} + + #{layer.number} · {compact ? null : `${layer.headBranch} · `} + {state.label} + + + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestStackLayers.tsx b/apps/web/src/components/pullRequest/PullRequestStackLayers.tsx new file mode 100644 index 000000000000..cc7e52d63187 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestStackLayers.tsx @@ -0,0 +1,39 @@ +import type { PullRequestRef, PullRequestStack } from "@t3tools/contracts"; +import { CheckIcon } from "lucide-react"; +import { MenuItem, MenuGroupLabel } from "../ui/menu"; +import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent"; + +export function PullRequestStackLayers({ + stack, + reference, + onSelect, + pending = false, +}: { + stack: PullRequestStack; + reference: PullRequestRef; + onSelect?: ((reference: PullRequestRef) => void) | undefined; + pending?: boolean; +}) { + return ( +
+ {stack.layers.toReversed().map((layer) => { + return ( + { + onSelect?.({ ...reference, number: layer.number }); + }} + disabled={!onSelect || pending} + aria-current={layer.number === reference.number ? "true" : undefined} + > + + {layer.number === reference.number ? ( + + ) : null} + + ); + })} + ↳ {stack.base} +
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx b/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx new file mode 100644 index 000000000000..a9298e982f67 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx @@ -0,0 +1,257 @@ +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; +import type { + EnvironmentId, + PullRequestRef, + PullRequestStack, + PullRequestMergeMethod, +} from "@t3tools/contracts"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { GitMergeIcon, LayersIcon, RefreshCwIcon, TriangleAlertIcon } from "lucide-react"; +import { useState } from "react"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { Button } from "../ui/button"; +import { Menu, MenuPopup, MenuTrigger, MenuItem, MenuGroup, MenuSeparator } from "../ui/menu"; +import { + Dialog, + DialogPopup, + DialogTitle, + DialogDescription, + DialogHeader, + DialogPanel, + DialogFooter, +} from "../ui/dialog"; +import { toastManager } from "../ui/toast"; +import { PullRequestStackLayers } from "./PullRequestStackLayers"; +import { PullRequestStackHeader } from "./PullRequestStackHeader"; +import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent"; + +export function PullRequestStackMenu({ + stack, + reference, + environmentId, + canMerge, + canRebase, + mergeMethod, + onSelect, + onActed, + notice, + onRetry, +}: { + notice?: string | null; + onRetry?: (() => void) | undefined; + stack: PullRequestStack; + reference: PullRequestRef; + environmentId: EnvironmentId; + canMerge: boolean; + canRebase: boolean; + mergeMethod: PullRequestMergeMethod; + onSelect?: ((reference: PullRequestRef) => void) | undefined; + onActed: () => void; +}) { + const [open, setOpen] = useState(false); + const [confirmation, setConfirmation] = useState<"merge" | "update-branch" | null>(null); + const [pending, setPending] = useState(false); + const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false }); + const top = stack.layers.at(-1); + const unmerged = stack.layers.filter((layer) => layer.state !== "merged"); + const hasClosed = unmerged.some((layer) => layer.state !== "open"); + const position = stack.layers.findIndex((layer) => layer.number === reference.number) + 1; + const mergeLayers = stack.layers.slice(0, position).filter((layer) => layer.state !== "merged"); + const selectedLayer = stack.layers[position - 1]; + const mergeHasClosed = mergeLayers.some((layer) => layer.state !== "open"); + const expectedStackHeads = unmerged.flatMap((layer) => + layer.headSha ? [{ number: layer.number, headSha: layer.headSha }] : [], + ); + const hasUnknownHead = expectedStackHeads.length !== unmerged.length; + const mergeDisabled = + pending || + selectedLayer?.state !== "open" || + mergeLayers.some((layer) => !layer.headSha) || + mergeHasClosed || + mergeLayers.length === 0 || + mergeLayers.some((layer) => layer.isDraft); + const rebaseDisabled = pending || hasUnknownHead || hasClosed || unmerged.length === 0; + const run = async () => { + if ( + pending || + !confirmation || + (confirmation === "merge" ? !canMerge || mergeDisabled : !canRebase || rebaseDisabled) + ) + return; + const action = confirmation; + const target = action === "merge" ? selectedLayer : top; + if (!target?.headSha) return; + const actionHeads = (action === "merge" ? mergeLayers : unmerged).flatMap((layer) => + layer.headSha ? [{ number: layer.number, headSha: layer.headSha }] : [], + ); + setPending(true); + const result = await runAction({ + environmentId, + input: { + ...reference, + number: target.number, + stackNumber: stack.number, + expectedStackHeads: actionHeads, + action, + ...(action === "merge" ? { mergeMethod } : { updateMethod: "rebase" }), + }, + }); + setPending(false); + setConfirmation(null); + onActed(); + if (result._tag === "Failure") { + toastManager.add({ + type: "error", + title: "Stack operation did not complete", + description: String(squashAtomCommandFailure(result)), + }); + } else { + toastManager.add({ + type: "success", + title: action === "merge" ? "Stack merge request completed" : "Stack rebased", + description: + action === "merge" + ? "GitHub merged the stack or added it to its merge queue." + : undefined, + }); + } + }; + const confirmationLayers = confirmation === "merge" ? mergeLayers : unmerged; + return ( + <> + + + + } + > + {position}/{stack.layers.length} + {onRetry ? : null} + + } + /> + + View stack #{stack.number}, layer {position} of {stack.layers.length} + {notice ? ` · ${notice}` : null} + + + + + + {onRetry ? Retry stack refresh : null} + { + setOpen(false); + onSelect(target); + } + : undefined + } + /> + + {canMerge || canRebase ? ( + <> + + {canMerge ? ( + setConfirmation("merge")}> + + Merge stack ({mergeLayers.length}) + + ) : null} + {canRebase ? ( + setConfirmation("update-branch")} + > + + Rebase stack + + ) : null} + {mergeHasClosed || mergeLayers.some((layer) => layer.isDraft) ? ( +

+ Every layer being merged must be open and ready for review. +

+ ) : null} + + ) : null} +
+
+ {canMerge && selectedLayer?.state === "open" ? ( + + + +
+ } + /> + + Merge stack through #{reference.number} into {stack.base} ({mergeLayers.length}{" "} + {mergeLayers.length === 1 ? "pull request" : "pull requests"}) + +
+ ) : null} + { + if (!value && !pending) setConfirmation(null); + }} + > + + + + {confirmation === "merge" + ? `Merge ${mergeLayers.length} pull requests?` + : `Rebase ${unmerged.length} pull requests?`} + + + {confirmation === "merge" + ? `Merge #${reference.number} and its unmerged layers below into ${stack.base} using ${mergeMethod}. GitHub checks their rules before merging or queueing them and rebases the remaining stack after merging.` + : `Rebase the remote branches from bottom to top onto ${stack.base}. This rewrites branch history and may restart checks. If a layer fails, earlier updates remain.`} + + + +
    + {confirmationLayers.map((layer) => ( +
  • + +
  • + ))} +
+
+ + + + +
+
+ + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestStackPopover.tsx b/apps/web/src/components/pullRequest/PullRequestStackPopover.tsx new file mode 100644 index 000000000000..451131cb11fe --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestStackPopover.tsx @@ -0,0 +1,108 @@ +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; +import type { EnvironmentId, PullRequestRef, PullRequestStackMembership } from "@t3tools/contracts"; +import { LayersIcon } from "lucide-react"; +import { useState } from "react"; +import { usePullRequestStack } from "~/state/usePullRequestStack"; +import { Menu, MenuTrigger, MenuPopup, MenuGroup, MenuGroupLabel, MenuItem } from "../ui/menu"; +import { PullRequestStackLayers } from "./PullRequestStackLayers"; +import { PullRequestStackHeader } from "./PullRequestStackHeader"; + +/** Mounted only while the menu is open, so list rows do not each fetch a stack. */ +function StackBody({ + environmentId, + reference, + onSelect, + stackNumber, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + onSelect: (reference: PullRequestRef) => void; + stackNumber: number; +}) { + const query = usePullRequestStack(environmentId, reference); + if (query.data !== null) { + return ( + <> + + {query.error ? Retry stack refresh : null} + + + ); + } + return ( + <> + + + {query.error ?? + (query.isPending ? "Loading stack…" : "This pull request is no longer in a stack.")} + + + ); +} + +export function PullRequestStackPopover({ + environmentId, + reference, + membership, + onSelect, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + membership: PullRequestStackMembership; + onSelect: (reference: PullRequestRef) => void; +}) { + const [open, setOpen] = useState(false); + return ( + + + + } + aria-label={`Stack ${membership.number}, layer ${membership.position} of ${membership.size}`} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + {membership.position}/{membership.size} + + } + /> + + View stack #{membership.number}, layer {membership.position} of {membership.size} + + + event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + {open ? ( + { + setOpen(false); + onSelect(target); + }} + /> + ) : null} + + + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index b06630f3bd04..239ebeb7950e 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -12,14 +12,13 @@ import { ChevronRightIcon, GitPullRequestClosedIcon, HammerIcon, - MessageSquareIcon, PencilIcon, RotateCcwIcon, SendIcon, TagIcon, UsersIcon, } from "lucide-react"; -import { useRef, useState, type ReactNode } from "react"; +import { useId, useRef, useState, type ReactNode } from "react"; import { useAtomCommand } from "~/state/use-atom-command"; import { pullRequestEnvironment } from "~/state/pullRequests"; @@ -187,7 +186,7 @@ function CollapsedComment({ @@ -229,8 +228,8 @@ function MetaRow({ children: ReactNode; }) { return ( -
- +
+ {icon} {label} @@ -241,16 +240,13 @@ function MetaRow({ function Section({ title, - count, defaultOpen = true, actions, children, }: { title: string; - count?: number; defaultOpen?: boolean; - /** Controls riding on the heading row itself. A sibling of the trigger, not a child of it — - a button cannot hold a button — and only while open, since they act on what is shown. */ + /** Heading controls stay separate from the collapse trigger so they remain independently usable. */ actions?: ReactNode; children: ReactNode; }) { @@ -286,24 +282,19 @@ function Section({ where it started. Opaque, because the rows it covers scroll beneath it. */}
- {/* Title first, chevron riding to its right, count last: the row reads as a heading - with an affordance rather than a tree node. */} - + {title} - {count === undefined ? null : ( - {count} - )} - {open ? actions : null} + {actions}
{children}
@@ -314,12 +305,14 @@ function Section({ function CommentComposer({ environmentId, + reference, detail, actionPending, onCommentAction, onCommented, }: { environmentId: EnvironmentId; + reference: PullRequestRef; detail: PullRequestDetailView; actionPending: boolean; onCommentAction: ( @@ -355,9 +348,7 @@ function CommentComposer({ const result = await postComment({ environmentId, input: { - projectId: detail.projectId, - repository: detail.repository, - number: detail.number, + ...reference, body: trimmed, }, }); @@ -461,6 +452,9 @@ export function PullRequestSummaryTab({ // Keyed by the pull request, so opening another one starts at the end of its conversation // rather than wherever the last one had been read back to. const [shown, setShown] = useState({ url: detail.url, count: COMMENT_PAGE }); + const checksId = useId(); + const [expandedChecksUrl, setExpandedChecksUrl] = useState(null); + const showChecks = expandedChecksUrl === detail.url; const shownComments = shown.url === detail.url ? shown.count : COMMENT_PAGE; // Windowed by recency regardless of display order: expanding always reaches further back in // time, whether the newest comment currently reads first or last. @@ -596,8 +590,8 @@ export function PullRequestSummaryTab({ return (
-
-
+
+
} label="Reviewers"> {reviewerEntries.length === 0 ? ( @@ -683,7 +677,6 @@ export function PullRequestSummaryTab({ environmentId={environmentId} reference={reference} allowed={detail.viewerPermissions.requestReviewers} - onRequested={onRefresh} /> ) : null} @@ -701,7 +694,7 @@ export function PullRequestSummaryTab({ return ( ) : null} ) : null} - } label="Comments"> - {activityPending - ? "Loading conversation…" - : activityError - ? "Conversation unavailable" - : detail.commentCount === 1 - ? "1 comment" - : `${detail.commentCount} comments`} -
-
+
{bodyScope === detail.url ? (
-
+
-
+
{detail.checks.length === 0 ? (

No checks reported.

) : ( -
- {detail.checks.map((check, index) => { - const finding = { kind: "check", check } as const; - const failing = check.status === "failure" || check.status === "cancelled"; - return ( -
- +
+
+ {(showChecks ? detail.checks : []).map((check, index) => { + const finding = { kind: "check", check } as const; + const failing = check.status === "failure" || check.status === "cancelled"; + return ( +
- - {check.name} - - {pullRequestCheckStatusLabel(check)} - - - {/* Only where there is something to fix. A passing check has no failure to - reproduce, and the button would be an invitation to waste a thread. */} - {onFixFinding && failing ? ( - - ) : null} -
- ); - })} + + {check.name} + + {pullRequestCheckStatusLabel(check)} + + + {/* Only where there is something to fix. A passing check has no failure to + reproduce, and the button would be an invitation to waste a thread. */} + {onFixFinding && failing ? ( + + ) : null} +
+ ); + })} +
)} -
+
0 ? ( - - ) : null + } > {activityPending ? ( @@ -996,7 +994,14 @@ export function PullRequestSummaryTab({ {/* Posting is a core capability and remains usable even if the activity read failed. */} {detail.capabilities.comment && detail.viewerPermissions.comment ? ( void; +} + +/** Thread relations belong to the detail environment, including when another environment is active. */ +export function PullRequestThreadLinks(props: PullRequestThreadLinksProps) { + const configs = useServerConfigs(); + if ( + threadPullRequestLinkMode(configs.get(props.environmentId)?.environment.capabilities) === + "unsupported" + ) { + return null; + } + return ; +} + +function EnabledPullRequestThreadLinks({ + environmentId, + reference, + url, + threadRef, + display, + onPickerOpenChange, +}: PullRequestThreadLinksProps) { + const parsed = parseChangeRequestUrl(url); + const currentThreadRef = threadRef?.environmentId === environmentId ? threadRef : null; + const thread = useThreadShell(currentThreadRef); + const linking = usePullRequestLinking(environmentId); + const linkedHere = linking.isLinked(thread, url); + const relations = useEnvironmentQuery( + linking.mode === "multiple" && display !== "menu-item" + ? pullRequestEnvironment.linkedThreads({ + environmentId, + input: parsed === null ? reference : { ...reference, ...parsed }, + }) + : null, + ); + // Refreshes can briefly clear the query value. Keep the last response so polling + // does not hide the linked-thread count between responses. + const [lastRelations, setLastRelations] = useState(relations.data); + if (relations.data !== null && relations.data !== lastRelations) { + setLastRelations(relations.data); + } + const [pending, setPending] = useState(false); + + const changeLink = async (threadId: ThreadId, remove: boolean) => { + if (parsed === null || pending) return; + setPending(true); + try { + await linking.changeLink(scopeThreadRef(environmentId, threadId), url, !remove); + } catch (error) { + toastManager.add({ + type: "error", + title: remove ? "Could not unlink the pull request" : "Could not link the pull request", + description: error instanceof Error ? error.message : String(error), + }); + return; + } finally { + setPending(false); + } + if (linking.mode === "multiple") { + appAtomRegistry.refresh( + pullRequestEnvironment.linkedThreads({ environmentId, input: { ...reference, ...parsed } }), + ); + } + onPickerOpenChange?.(false); + }; + + if (parsed === null || (!linkedHere && !linking.canLink(url))) return null; + const linkedThreads = + linking.mode === "multiple" ? ((relations.data ?? lastRelations)?.threads ?? []) : []; + const linkedThreadsLabel = + linkedThreads.length > 0 + ? `Linked from ${linkedThreads.length} ${linkedThreads.length === 1 ? "thread" : "threads"}` + : "Linked threads"; + return ( + <> + {display === "count" && (linkedThreads.length > 0 || relations.error !== null) ? ( + + + openCommandPalette({ + query: url, + ...((relations.data ?? lastRelations) === null + ? {} + : { linkedThreads: { environmentId, threads: linkedThreads } }), + }) + } + > + + {linkedThreads.length || "?"} + + } + /> + {linkedThreadsLabel}. Search in the command palette. + + ) : null} + {display === "menu-item" ? ( + { + if (currentThreadRef !== null) { + void changeLink(currentThreadRef.threadId, linkedHere); + } else { + onPickerOpenChange?.(true); + } + }} + > + {linkedHere ? ( + + ) : ( + + )} + {linkedHere + ? "Unlink from this thread" + : currentThreadRef + ? "Link to this thread" + : "Link to thread"} + + ) : null} + {display === "picker" ? ( + + + Link pull request to a thread + void changeLink(threadId, false)} + /> + + + ) : null} + + ); +} + +function ThreadPicker({ + environmentId, + url, + pending, + onSelect, +}: { + environmentId: EnvironmentId; + url: string; + pending: boolean; + onSelect: (threadId: ThreadId) => void; +}) { + const threads = useThreadShells(); + const linking = usePullRequestLinking(environmentId); + const projects = useProjects(); + const [query, setQuery] = useState(""); + const projectNames = new Map( + projects + .filter((project) => project.environmentId === environmentId) + .map((project) => [project.id, project.title]), + ); + const search = query.trim().toLocaleLowerCase(); + const candidates = threads + .filter( + (thread) => + thread.environmentId === environmentId && + thread.archivedAt === null && + `${thread.title} ${projectNames.get(thread.projectId) ?? ""}` + .toLocaleLowerCase() + .includes(search), + ) + .toSorted((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + return ( + + + + {candidates.length === 0 ? ( +
+ No active threads found. +
+ ) : ( + candidates.map((thread) => { + const linked = linking.isLinked(thread, url); + return ( + onSelect(thread.id)} + > + + + {thread.title || "Untitled thread"} + + {projectNames.get(thread.projectId)} + + + {linked ? ( + <> + + Linked + + ) : null} + + ); + }) + )} +
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx index 26c80326aec0..f17ec882c5a0 100644 --- a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx @@ -25,7 +25,7 @@ export function PullRequestsUnavailableState({ gitHubUrl?: string; }) { return ( - + diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx new file mode 100644 index 000000000000..35a449a3da79 --- /dev/null +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -0,0 +1,293 @@ +import type { ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts"; +import { + resolveThreadPullRequestChains, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +import { + GitPullRequestArrow, + LayersIcon, + LinkIcon, + MoreHorizontalIcon, + PlusIcon, +} from "lucide-react"; +import { useCallback, useMemo } from "react"; + +import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { useOpenPrLink } from "~/lib/openPullRequestLink"; +import { cn } from "~/lib/utils"; +import { useServerConfigs, useThreadShell } from "~/state/entities"; +import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; +import { threadEnvironment } from "~/state/threads"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; +import { Button } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { ScrollArea } from "../ui/scroll-area"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { openLinkPullRequestDialog } from "./LinkPullRequestDialog"; +import { pullRequestListLines, type PullRequestListLine } from "./pullRequestListLines"; +import { + PullRequestActorAvatar, + PullRequestDiffStat, + PullRequestApprovalGlyph, + PullRequestStateGlyph, + pullRequestChecksStatePresentation, +} from "./pullRequestPresentation"; + +const SOURCE_LABELS: Record = { + manual: "Linked by you", + created: "Created from this thread", + agent: "Linked by the agent", + stack: "Found in the stack", + "stack-dismissed": "Dismissed", +}; + +function ChecksGlyph({ + state, +}: { + state: NonNullable["checksState"] & string; +}) { + const presentation = pullRequestChecksStatePresentation(state); + return ( + + }> + + + {presentation.label} + + ); +} + +function LinkRow({ + line, + threadRef, + onUnlink, +}: { + line: PullRequestListLine; + threadRef: ScopedThreadRef; + onUnlink: (link: ThreadPullRequestLink) => void; +}) { + const openPrLink = useOpenPrLink(threadRef); + const { link, depth, stack } = line; + const snapshot = link.snapshot; + return ( +
+ {depth > 0 ? : null} + {snapshot === null ? ( + + ) : ( + + )} + openPrLink(event, link.url, threadRef)} + className="min-w-0 flex-1" + > + + + + } + > + #{link.number} + + + {SOURCE_LABELS[link.source]} · {formatRelativeTimeLabel(link.linkedAt)} + + + + {snapshot?.title ?? link.repository} + + {/* Match the full PR list: review verdict, checks, then diff counts. + Each is absent rather than neutral when the + host said nothing, so a row without them reads as unknown, not as fine. */} + + {snapshot?.state === "open" && + (snapshot.reviewDecision === "approved" || + snapshot.reviewDecision === "changes-requested") ? ( + snapshot.reviewDecision === "approved" ? ( + + ) : ( + Changes requested + ) + ) : null} + {snapshot?.state === "open" && snapshot.mergeability === "conflicting" ? ( + Conflicts + ) : null} + {snapshot?.checksState ? : null} + + + + + {stack ? ( + + + } + > + {stack.kind === "native" ? ( + + ) : ( + + )} + {stack.size} + + + {stack.kind === "native" + ? `GitHub stack of ${stack.size}: merging a layer lands the ones below it.` + : `${stack.size} pull requests chained by base branch.`} + + + ) : null} + {snapshot?.author ? ( + + + {snapshot.author.login} + + ) : null} + + {snapshot !== null + ? `${snapshot.headBranch} → ${snapshot.baseBranch}` + : `${link.host}/${link.repository}`} + + {snapshot?.updatedAt ? ( + {formatRelativeTimeLabel(snapshot.updatedAt)} + ) : null} + + + + + + + } + /> + + void writeTextToClipboard(link.url, "link")}>Copy link + openPrLink(event, link.url, threadRef)}>Open + onUnlink(link)}> + {link.source === "stack" ? "Dismiss from thread" : "Unlink from thread"} + + + +
+ ); +} + +export function ThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) { + const configs = useServerConfigs(); + if (configs.get(threadRef.environmentId)?.environment.capabilities.threadPullRequests !== true) { + return ( + + ); + } + return ; +} + +function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThreadRef }) { + const thread = useThreadShell(threadRef); + const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]); + const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true }); + const links = useMemo(() => visibleThreadPullRequests(thread?.pullRequests ?? []), [thread]); + const lines = useMemo(() => pullRequestListLines(resolveThreadPullRequestChains(links)), [links]); + const handleUnlink = useCallback( + (link: ThreadPullRequestLink) => { + void unlink({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + host: link.host, + repository: link.repository, + number: link.number, + }, + }); + }, + [threadRef, unlink], + ); + const openCount = useMemo( + () => links.filter((link) => link.snapshot === null || link.snapshot.state === "open").length, + [links], + ); + const lastSynced = useMemo(() => { + let latest: string | null = null; + for (const link of links) { + const at = link.snapshot?.syncedAt; + if (at !== undefined && (latest === null || at > latest)) latest = at; + } + return latest; + }, [links]); + + if (links.length === 0) { + return ( +
+ +

No linked pull requests

+

+ Pull requests the agent opens from this thread land here. Link one yourself from a URL or + a number. +

+ +
+ ); + } + + return ( +
+ +
+ {lines.map((line) => ( + + ))} +
+
+
+ + {openCount} open · {links.length} linked + {lastSynced ? ` · synced ${formatRelativeTimeLabel(lastSynced)}` : ""} + + +
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 000c594ec6ec..60884bbb7210 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -35,6 +35,7 @@ import { readPullRequestDetailSnapshot, resolveDisplayedPullRequestDetail, resolvePullRequestPrimaryControl, + allowsSinglePullRequestMerge, shouldRefreshPullRequestActivity, resolveBaseFreshness, resolvePullRequestMergeMethod, @@ -1420,6 +1421,42 @@ describe("cached pull request detail", () => { expect(readPullRequestDetailSnapshot(makeStorage(), "env-2", reference)).toBeNull(); }); + it("isolates stored and displayed details between hosts with the same repository and number", () => { + const storage = makeStorage(); + const publicRef = { ...reference, host: "github.com" }; + const enterpriseRef = { ...reference, host: "github.example.com" }; + const publicDetail = detail(); + const enterpriseDetail = detail({ + title: "Enterprise change", + url: "https://github.example.com/acme/web/pull/7", + }); + writePullRequestDetailSnapshot(storage, "env-1", publicRef, publicDetail); + expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull(); + writePullRequestDetailSnapshot(storage, "env-1", enterpriseRef, enterpriseDetail); + expect(readPullRequestDetailSnapshot(storage, "env-1", publicRef)?.title).toBe( + publicDetail.title, + ); + expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)?.title).toBe( + enterpriseDetail.title, + ); + expect( + resolveDisplayedPullRequestDetail({ + live: null, + cached: publicDetail, + reference: enterpriseRef, + }), + ).toBeNull(); + expect( + resolveDisplayedPullRequestDetail({ + live: null, + cached: enterpriseDetail, + reference: enterpriseRef, + }), + ).toBe(enterpriseDetail); + writePullRequestDetailSnapshot(storage, "env-1", enterpriseRef, publicDetail); + expect(readPullRequestDetailSnapshot(storage, "env-1", enterpriseRef)).toBeNull(); + }); + it("shrugs off corrupt storage and no storage at all", () => { const storage = makeStorage(); storage.setItem("t3.pullRequests.detail:env-1:project-1:acme/web#7", "{not json"); @@ -1427,3 +1464,21 @@ describe("cached pull request detail", () => { expect(readPullRequestDetailSnapshot(undefined, "env-1", reference)).toBeNull(); }); }); + +describe("single-PR merge compatibility during stack discovery", () => { + it.each([ + [false, true, false, null, true], + [false, false, true, null, true], + [true, false, true, null, false], + [true, false, false, "Lookup failed", false], + [true, true, false, null, false], + [true, false, false, null, true], + ] as const)( + "capability=%s stack=%s pending=%s error=%s permits=%s", + (supportsStackActions, hasStack, stackPending, stackError, allowed) => { + expect( + allowsSinglePullRequestMerge({ supportsStackActions, hasStack, stackPending, stackError }), + ).toBe(allowed); + }, + ); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 24b37aceafc2..64792164814f 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -1,4 +1,5 @@ import * as Schema from "effect/Schema"; +import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { PullRequestDetail, @@ -28,6 +29,19 @@ export const PULL_REQUEST_MERGE_METHOD_LABELS: Record, current: PullRequestMergeMethod | null, @@ -1028,6 +1042,7 @@ export function pullRequestActionNeedsHostRefresh(action: PullRequestAction): bo type SnapshotStorage = Pick; export interface PullRequestDetailSnapshotRef { + readonly host?: string | undefined; readonly projectId: string; readonly repository: string; readonly number: number; @@ -1037,7 +1052,9 @@ const pullRequestDetailSnapshotKey = ( environmentId: string, reference: PullRequestDetailSnapshotRef, ) => - `t3.pullRequests.detail:${environmentId}:${reference.projectId}:${reference.repository}#${reference.number}`; + reference.host + ? `t3.pullRequests.detail:${JSON.stringify([environmentId, reference.projectId, reference.host.toLowerCase(), reference.repository.toLowerCase(), reference.number])}` + : `t3.pullRequests.detail:${environmentId}:${reference.projectId}:${reference.repository}#${reference.number}`; const decodeDetailSnapshot = Schema.decodeUnknownOption(PullRequestDetail); @@ -1056,7 +1073,9 @@ export function readPullRequestDetailSnapshot( const raw = storage?.getItem(pullRequestDetailSnapshotKey(environmentId, reference)); if (!raw) return null; const decoded = decodeDetailSnapshot(JSON.parse(raw)); - return decoded._tag === "Some" ? decoded.value : null; + return decoded._tag === "Some" + ? resolveDisplayedPullRequestDetail({ live: null, cached: decoded.value, reference }) + : null; } catch { return null; } @@ -1090,7 +1109,9 @@ export function resolveDisplayedPullRequestDetail(input: { input.cached !== null && input.cached.projectId === input.reference.projectId && input.cached.repository.toLowerCase() === input.reference.repository.toLowerCase() && - input.cached.number === input.reference.number + input.cached.number === input.reference.number && + (input.reference.host === undefined || + parseChangeRequestUrl(input.cached.url)?.host === input.reference.host.toLowerCase()) ) { return input.cached; } diff --git a/apps/web/src/components/pullRequest/pullRequestListLines.test.ts b/apps/web/src/components/pullRequest/pullRequestListLines.test.ts new file mode 100644 index 000000000000..fc86c0a694d6 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestListLines.test.ts @@ -0,0 +1,76 @@ +import type { ThreadPullRequestLink } from "@t3tools/contracts"; +import { resolveThreadPullRequestChains } from "@t3tools/shared/threadPullRequests"; +import { describe, expect, it } from "vite-plus/test"; + +import { pullRequestListLines } from "./pullRequestListLines"; + +function link( + number: number, + head: string, + base: string, + updatedAt: string, + stack: ThreadPullRequestLink["stack"] = null, +): ThreadPullRequestLink { + return { + host: "github.com", + repository: "acme/web", + number, + url: `https://github.com/acme/web/pull/${number}`, + source: "manual", + linkedAt: "2026-01-01T00:00:00.000Z", + snapshot: { + state: "open", + title: `PR ${number}`, + headBranch: head, + baseBranch: base, + isDraft: false, + updatedAt, + syncedAt: updatedAt, + }, + stack, + }; +} + +describe("pullRequestListLines", () => { + it("orders newest first and keeps a stack together under its base layer", () => { + const lines = pullRequestListLines( + resolveThreadPullRequestChains([ + link(1, "a", "main", "2026-01-01T10:00:00Z"), + link(2, "b", "a", "2026-01-01T12:00:00Z"), + link(9, "solo", "main", "2026-01-01T11:00:00Z"), + link(5, "old", "main", "2026-01-01T09:00:00Z"), + ]), + ); + expect(lines.map((line) => [line.link.number, line.depth, line.stack?.size ?? null])).toEqual([ + // The stack's newest layer is #2 at 12:00, so the whole stack outranks #9 at 11:00. + [1, 0, 2], + [2, 1, null], + [9, 0, null], + [5, 0, null], + ]); + }); + + it("marks native stacks on their base layer", () => { + const stack = { + kind: "native" as const, + id: "1", + number: 1, + url: "https://github.com/acme/web/stacks/1", + base: "main", + layers: [ + { number: 3, headBranch: "x", state: "open" as const }, + { number: 4, headBranch: "y", state: "open" as const }, + ], + }; + const lines = pullRequestListLines( + resolveThreadPullRequestChains([ + link(4, "y", "x", "2026-01-01T10:00:00Z", stack), + link(3, "x", "main", "2026-01-01T10:00:00Z", stack), + ]), + ); + expect(lines.map((line) => [line.link.number, line.depth, line.stack?.kind ?? null])).toEqual([ + [3, 0, "native"], + [4, 1, null], + ]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestListLines.ts b/apps/web/src/components/pullRequest/pullRequestListLines.ts new file mode 100644 index 000000000000..6602b2f391f4 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestListLines.ts @@ -0,0 +1,49 @@ +import type { ThreadPullRequestLink } from "@t3tools/contracts"; +import type { ThreadPullRequestChain } from "@t3tools/shared/threadPullRequests"; + +/** One line of a thread's pull-request list: a link plus how deep it sits in its stack. */ +export interface PullRequestListLine { + readonly link: ThreadPullRequestLink; + /** 0 for a pull request on the base branch; each layer above steps in by one. */ + readonly depth: number; + /** Which chain the line belongs to, so callers can tell one stack's lines from another's. */ + readonly chainKey: string; + /** Set on the bottom layer of a multi-layer stack, so that row can name the whole stack. */ + readonly stack: { readonly kind: ThreadPullRequestChain["kind"]; readonly size: number } | null; +} + +function activityAt(link: ThreadPullRequestLink): number { + const ms = Date.parse(link.snapshot?.updatedAt ?? link.linkedAt); + return Number.isNaN(ms) ? 0 : ms; +} + +function chainKeyOf(chain: ThreadPullRequestChain): string { + const bottom = chain.layers[0]!; + return `${bottom.host}/${bottom.repository}#${bottom.number}`; +} + +/** + * Flattens chains into indented lines, newest first. A stack sorts by its most recent layer and + * then reads bottom to top beneath that slot, so the layer you would review first is at the + * bottom of the indent and a fresh push anywhere in the stack floats the whole stack up. + */ +export function pullRequestListLines( + chains: ReadonlyArray, +): ReadonlyArray { + const ordered = [...chains].sort( + (left, right) => + Math.max(...right.layers.map(activityAt)) - Math.max(...left.layers.map(activityAt)), + ); + return ordered.flatMap((chain) => { + const chainKey = chainKeyOf(chain); + return chain.layers.map((link, depth) => ({ + link, + depth, + chainKey, + stack: + depth === 0 && chain.layers.length > 1 + ? { kind: chain.kind, size: chain.layers.length } + : null, + })); + }); +} diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index 3c41e0956fed..890efb67cb9b 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -17,6 +17,7 @@ import { GitPullRequestDraftIcon, GitPullRequestIcon, TriangleAlertIcon, + UserCheckIcon, } from "lucide-react"; import { Children, isValidElement, type ReactNode } from "react"; @@ -32,6 +33,21 @@ interface StatePresentation { readonly Icon: typeof GitPullRequestIcon; } +export function PullRequestApprovalGlyph() { + return ( + + }> + + Approved + + Approved + + ); +} + /** * How a pull request's state reads on this page. Open, closed, merged, and draft use the same * ink as the thread badge in `ThreadStatusIndicators`, so one pull request cannot look like two @@ -173,7 +189,7 @@ const CHECKS_STATE_PRESENTATION = { passing: { label: "All checks have passed", Icon: CircleCheckIcon, - toneClassName: "text-emerald-600 dark:text-emerald-300/90", + toneClassName: CHECK_STATUS_PRESENTATION.success.toneClassName, }, failing: { label: "Some checks were not successful", @@ -422,10 +438,8 @@ export function PullRequestDiffStat({ } return ( - - +{additions.toLocaleString()} - - -{deletions.toLocaleString()} + +{additions.toLocaleString()} + -{deletions.toLocaleString()} ); } diff --git a/apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts b/apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts index abbcab162360..8466903eb536 100644 --- a/apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestReviewStore.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { ProjectId } from "@t3tools/contracts"; -import { type PendingReviewComment, usePullRequestReviewStore } from "./pullRequestReviewStore"; +import { + type PendingReviewComment, + pullRequestReviewKey, + usePullRequestReviewStore, +} from "./pullRequestReviewStore"; function comment(id: string, body = id): PendingReviewComment { return { id, body, path: "src/app.ts", position: { kind: "added", newLine: 1 } }; @@ -36,6 +41,30 @@ describe("pull request review drafts", () => { }); }); + it("keeps drafts on different hosts separate when a thread reviews the same repository and number", () => { + const reference = { + projectId: ProjectId.make("project-a"), + repository: "owner/repo", + number: 7, + }; + const publicKey = pullRequestReviewKey({ ...reference, host: "github.com" }); + const enterpriseKey = pullRequestReviewKey({ ...reference, host: "github.example.com" }); + const store = usePullRequestReviewStore.getState(); + store.addComment(publicKey, comment("public")); + store.setSummary(publicKey, "Public review"); + + expect(usePullRequestReviewStore.getState().drafts[enterpriseKey]).toBeUndefined(); + expect(usePullRequestReviewStore.getState().summaries[enterpriseKey]).toBeUndefined(); + + store.addComment(enterpriseKey, comment("enterprise")); + store.setSummary(enterpriseKey, "Enterprise review"); + store.clear(enterpriseKey); + store.clearSummary(enterpriseKey, "Enterprise review"); + + expect(usePullRequestReviewStore.getState().drafts[publicKey]).toEqual([comment("public")]); + expect(usePullRequestReviewStore.getState().summaries[publicKey]).toBe("Public review"); + }); + it("does not clear a summary revised while submission is in flight", () => { const store = usePullRequestReviewStore.getState(); store.setSummary("review-a", "Submitted body"); diff --git a/apps/web/src/components/pullRequest/pullRequestReviewStore.ts b/apps/web/src/components/pullRequest/pullRequestReviewStore.ts index 41906a710fc8..fba8d4c56e13 100644 --- a/apps/web/src/components/pullRequest/pullRequestReviewStore.ts +++ b/apps/web/src/components/pullRequest/pullRequestReviewStore.ts @@ -6,7 +6,7 @@ * hosts that have no pending review of their own. That also means a draft lives only as long * as the tab does, which is why this is deliberately not persisted. */ -import type { ProjectId, PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts"; +import type { PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts"; import { create } from "zustand"; export type PendingReviewComment = PullRequestReviewCommentDraft & { readonly id: string }; @@ -24,9 +24,14 @@ export function nextPendingReviewCommentId(): string { return `pending-review-comment-${pendingCommentSequence}`; } -/** One pull request's draft, scoped by project as well as repository: a repository can be checked out twice. */ +/** A project's thread can review the same repository path and number on different hosts. */ export function pullRequestReviewKey(reference: PullRequestRef): string { - return `${reference.projectId}/${reference.repository}#${reference.number}`; + return JSON.stringify([ + reference.projectId, + reference.host?.toLowerCase() ?? null, + reference.repository.toLowerCase(), + reference.number, + ]); } interface PullRequestReviewStoreState { @@ -81,11 +86,9 @@ export const usePullRequestReviewStore = create()(( })); /** The comments a pull request's draft holds, stable across renders while it is empty. */ -export function usePendingReviewComments(reference: { - readonly projectId: ProjectId; - readonly repository: string; - readonly number: number; -}): ReadonlyArray { +export function usePendingReviewComments( + reference: PullRequestRef, +): ReadonlyArray { return usePullRequestReviewStore( (store) => store.drafts[pullRequestReviewKey(reference)] ?? EMPTY, ); diff --git a/apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts new file mode 100644 index 000000000000..621ba3124e8e --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts @@ -0,0 +1,93 @@ +import type { ThreadPullRequestLink } from "@t3tools/contracts"; +import { ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { savedPullRequestStack, pullRequestStackView } from "./pullRequestStackSnapshot"; + +const reference = { + projectId: ProjectId.make("project"), + host: "github.com", + repository: "acme/web", + number: 2, +}; +const link: ThreadPullRequestLink = { + host: "github.com", + repository: "acme/web", + number: 2, + url: "https://github.com/acme/web/pull/2", + source: "manual", + linkedAt: "2026-09-09T10:00:00Z", + snapshot: { + title: "Top layer", + headBranch: "top", + baseBranch: "bottom", + state: "open", + isDraft: false, + updatedAt: null, + syncedAt: "2026-09-09T10:00:00Z", + }, + stack: { + kind: "native", + id: "stack", + number: 3, + url: "https://github.com/acme/web/pull/3", + base: "main", + layers: [ + { number: 1, headBranch: "bottom", state: "open" }, + { number: 2, headBranch: "top", state: "open" }, + ], + }, +}; + +describe("saved stack navigation", () => { + it("preserves all native layers even when only one has a linked snapshot, without action SHAs", () => { + const saved = savedPullRequestStack([link], reference); + expect(saved?.layers).toEqual([ + { number: 1, headBranch: "bottom", state: "open" }, + { number: 2, headBranch: "top", state: "open", title: "Top layer", isDraft: false }, + ]); + expect(savedPullRequestStack([link], { ...reference, number: 1 })?.number).toBe(3); + }); + it("does not borrow stacks across hosts or repositories", () => { + expect(savedPullRequestStack([link], { ...reference, host: "enterprise.example" })).toBeNull(); + expect(savedPullRequestStack([link], { ...reference, repository: "other/web" })).toBeNull(); + expect(savedPullRequestStack([link], { ...reference, host: undefined })).toBeNull(); + }); + it("honors a newer saved removal across linked threads", () => { + expect( + savedPullRequestStack( + [ + link, + { + ...link, + stack: null, + snapshot: { ...link.snapshot!, syncedAt: "2026-09-09T11:00:00Z" }, + }, + ], + reference, + ), + ).toBeNull(); + }); + it("shows saved data during loading and marks a failed refresh stale", () => { + const saved = savedPullRequestStack([link], reference); + const query = { data: null, isSuccess: false, isPending: true, error: null }; + expect(pullRequestStackView(query, saved)).toMatchObject({ + data: saved, + isFresh: false, + notice: expect.stringContaining("Refreshing"), + }); + expect( + pullRequestStackView({ ...query, isPending: false, error: "Rate limited" }, saved), + ).toMatchObject({ data: saved, isFresh: false, notice: expect.stringContaining("stale") }); + }); + it("prefers refreshed data and honors a successful absence", () => { + const saved = savedPullRequestStack([link], reference); + const query = { data: saved, isSuccess: true, isPending: false, error: null }; + expect(pullRequestStackView(query, null)).toEqual({ data: saved, isFresh: true, notice: null }); + expect(pullRequestStackView({ ...query, data: null }, saved)).toEqual({ + data: null, + isFresh: true, + notice: null, + }); + expect(pullRequestStackView({ ...query, isPending: true }, saved).isFresh).toBe(false); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts new file mode 100644 index 000000000000..4e48a2f3705b --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts @@ -0,0 +1,73 @@ +import type { PullRequestRef, PullRequestStack, ThreadPullRequestLink } from "@t3tools/contracts"; + +/** Saved native membership is enough for navigation, but never supplies action head SHAs. */ +export function savedPullRequestStack( + links: ReadonlyArray, + reference: PullRequestRef, +): PullRequestStack | null { + const host = reference.host?.toLowerCase(); + if (!host) return null; + const matching = links.filter( + (link) => + link.host.toLowerCase() === host && + link.repository.toLowerCase() === reference.repository.toLowerCase(), + ); + const exact = matching.filter((link) => link.number === reference.number); + const candidates = + exact.length > 0 + ? exact + : matching.filter((link) => + link.stack?.layers.some((layer) => layer.number === reference.number), + ); + const newest = candidates.toSorted( + (a, b) => + Date.parse(b.snapshot?.syncedAt ?? b.linkedAt) - + Date.parse(a.snapshot?.syncedAt ?? a.linkedAt), + )[0]; + const stack = newest?.stack; + if (!stack || !stack.layers.some((layer) => layer.number === reference.number)) return null; + return { + id: stack.id, + number: stack.number, + url: stack.url, + base: stack.base, + layers: stack.layers.map((layer) => { + const snapshot = matching + .filter((link) => link.number === layer.number) + .toSorted( + (a, b) => + Date.parse(b.snapshot?.syncedAt ?? b.linkedAt) - + Date.parse(a.snapshot?.syncedAt ?? a.linkedAt), + )[0]?.snapshot; + return { + ...layer, + ...(snapshot ? { title: snapshot.title, isDraft: snapshot.isDraft } : {}), + }; + }), + }; +} + +/** A fresh absence overrides saved membership; failed refreshes preserve available navigation. */ +export function pullRequestStackView( + query: { + data: PullRequestStack | null; + isSuccess: boolean; + isPending: boolean; + error: string | null; + }, + saved: PullRequestStack | null, +) { + const data = query.isSuccess ? query.data : (query.data ?? saved); + return { + data, + isFresh: query.isSuccess && !query.isPending, + notice: + data === null + ? null + : query.error + ? "Stack data may be stale. We couldn’t refresh it." + : !query.isSuccess || query.isPending + ? "Refreshing stack… Showing saved data." + : null, + }; +} diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx new file mode 100644 index 000000000000..e61cf742dfd5 --- /dev/null +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -0,0 +1,334 @@ +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; +import { AppleIcon, AndroidIcon } from "../Icons"; +import { DeviceHostAvailability } from "../device/DeviceHostAvailability"; +import { Spinner } from "../ui/spinner"; +import type { + DevicePlatformAvailability, + EnvironmentId, + SshDeviceHostConfig, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { randomUUID } from "../../lib/utils"; +import { useState } from "react"; +import { deviceEnvironment, useDeviceState } from "../../state/device"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { MoreVertical, PlusIcon } from "lucide-react"; +import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu"; +import { SettingsRow } from "./settingsLayout"; + +/** Host names and identity paths belong to the selected environment, never all environments. */ +export function DeviceHostsSettings(props: { + environmentId: EnvironmentId | null; + hosts: ReadonlyArray; +}) { + const update = useAtomCommand(serverEnvironment.updateSettings); + const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false }); + const { state } = useDeviceState(props.environmentId); + const [editing, setEditing] = useState(null); + const [busy, setBusy] = useState(false); + const validPort = (port: number | undefined) => + port === undefined || (Number.isInteger(port) && port >= 1 && port <= 65535); + const [checks, setChecks] = useState< + Record< + string, + { pending?: boolean; platforms?: ReadonlyArray; error?: string } + > + >({}); + const setCheck = (id: string, value: (typeof checks)[string]) => + setChecks((current) => ({ ...current, [id]: value })); + const save = async (hosts: ReadonlyArray) => { + if (!props.environmentId) return; + setBusy(true); + try { + const saved = await update({ + environmentId: props.environmentId, + input: { patch: { deviceHosts: hosts } }, + }); + if (saved._tag === "Success") { + setEditing(null); + } + } finally { + setBusy(false); + } + }; + const testConnection = async (host: SshDeviceHostConfig) => { + if (!props.environmentId || checks[host.id]?.pending) return; + setCheck(host.id, { pending: true }); + try { + const summary = await test({ environmentId: props.environmentId, input: host }); + setCheck( + host.id, + summary._tag === "Failure" + ? { error: Cause.pretty(summary.cause) } + : { platforms: summary.value.platforms }, + ); + } catch (error) { + setCheck(host.id, { error: error instanceof Error ? error.message : String(error) }); + } + }; + return ( + { + setEditing({ id: randomUUID(), label: "", target: "" }); + }} + > + Add host + + } + > +
+ {!props.environmentId ? ( +

+ Select one connected environment to manage its device hosts. +

+ ) : ( + <> + {props.hosts.map((host) => { + const status = state.hostStatuses[host.id]; + const check = checks[host.id]; + const platforms = + check?.platforms ?? + state.hosts.find((value) => value.id === host.id)?.platforms ?? + []; + const progress = check?.pending + ? "Checking connection…" + : status?.status === "installing" + ? "Installing device support…" + : status?.status === "starting" + ? "Connecting…" + : null; + const error = + check?.error ?? (status?.status === "failed" ? status.detail : undefined); + return ( +
+
+
+

{host.label}

+ {platforms + .filter((platform) => platform.available) + .map((platform) => ( + + + } + > + {platform.platform === "ios" ? ( + + ) : ( + + )} + + + {platform.platform === "ios" ? "iOS available" : "Android available"} + + + ))} +
+

{host.target}

+ {error ? ( +
+
+ Connection failed +

{error}

+
+
+ ) : null} +
+ {progress ? ( + + + {progress} + + ) : null} + + + } + > + + + + { + setEditing(host); + }} + > + Edit + + + void save(props.hosts.filter((value) => value.id !== host.id)) + } + > + Remove + + + + +
+ ); + })} + {editing ? ( +
{ + event.preventDefault(); + void save([...props.hosts.filter((host) => host.id !== editing.id), editing]); + }} + > + + + + +
+ + + +
+ {checks[editing.id]?.pending ? ( + + + Checking connection… + + ) : null} + {checks[editing.id]?.platforms ? ( + + ) : null} + {checks[editing.id]?.error ? ( +

+ {checks[editing.id]?.error} +

+ ) : null} + + ) : null} + + )} +
+
+ ); +} diff --git a/apps/web/src/components/settings/IntegrationsSettings.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.test.tsx index 5d185fee5824..4ef218430ae1 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.test.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.test.tsx @@ -1,4 +1,8 @@ -import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts"; +import { + DEFAULT_CLIENT_SETTINGS, + DEFAULT_UNIFIED_SETTINGS, + type DeviceServiceState, +} from "@t3tools/contracts"; import { createMemoryHistory, createRootRoute, @@ -36,6 +40,7 @@ vi.mock("./settingsLayout", async (importOriginal) => ({ })); import { IntegrationsSettingsPanel } from "./IntegrationsSettings"; +import { platformSetupStatus } from "../device/DeviceSetup"; let renderer: ReactTestRenderer | undefined; @@ -74,4 +79,60 @@ describe("Integrations browser discovery", () => { await openSettings(); expect(listBrowserImportSources).not.toHaveBeenCalled(); }); + + it("places device settings directly after browser settings", async () => { + await openSettings(); + const sections = renderer!.root + .findAll((node) => node.type === "section") + .map((node) => node.props.id) + .filter(Boolean); + expect(sections.indexOf("devices")).toBeGreaterThan(sections.indexOf("browser")); + }); +}); + +const deviceState = (overrides: Partial = {}): DeviceServiceState => ({ + hosts: [ + { + id: "local", + kind: "local", + label: "This machine", + hubInstalled: false, + agentDeviceInstalled: false, + platforms: [ + { platform: "ios", available: true }, + { platform: "android", available: true }, + ], + }, + ], + hostStatus: "ready", + hostStatuses: {}, + devices: [], + sessions: [], + onboardingCompleted: false, + agentAccessEnabled: false, + hubBasePath: "/api/device-hub", + revision: 0, + ...overrides, +}); + +describe("device setup guidance", () => { + it("directs users to install an iOS runtime and create an Android virtual device", () => { + expect(platformSetupStatus(deviceState(), "ios").message).toContain("Xcode Settings"); + expect(platformSetupStatus(deviceState(), "android").message).toContain("Device Manager"); + }); + + it("preserves a specific missing-tool explanation from the server", () => { + const state = deviceState({ + hosts: [ + { + ...deviceState().hosts[0]!, + platforms: [ + { platform: "ios", available: true }, + { platform: "android", available: false, reason: "Android Emulator is missing." }, + ], + }, + ], + }); + expect(platformSetupStatus(state, "android").message).toBe("Android Emulator is missing."); + }); }); diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index a7a3c7553540..8cb21532a426 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -1,3 +1,4 @@ +import { DeviceHostsSettings } from "./DeviceHostsSettings"; /** * Integrations settings - preferences for surfaces T3 Code embeds rather than * owns. Browser is the first section: the defaults a preview tab opens at, @@ -12,6 +13,7 @@ import { type BrowserLinkTarget, type BrowserProfile, type EnvironmentId, + type SshDeviceHostConfig, BROWSER_PROFILE_NAME_MAX_LENGTH, BROWSER_RECORDING_FRAME_RATES, DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW, @@ -39,10 +41,21 @@ import { MoreVertical, Plus as PlusIcon } from "lucide-react"; import { useCallback, useRef, useState } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; +import { AnimatedHeight } from "~/components/AnimatedHeight"; import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic"; import { previewBridge } from "~/components/preview/previewBridge"; import { cn, randomUUID } from "~/lib/utils"; import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; +import { deviceEnvironment, useDeviceState } from "~/state/device"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { + AgentDeviceSetupStatus, + DeviceHubSetupStatus, + PlatformStatus, + platformSetupStatus, + deviceHubDescription, + agentDeviceDescription, +} from "~/components/device/DeviceSetup"; import { isElectron } from "../../env"; import { Badge } from "../ui/badge"; @@ -571,6 +584,175 @@ function AgentBrowserAccessSetting() { ); } +function DeviceIntegrationSettings() { + const primaryEnvironment = usePrimaryEnvironment(); + const { environments } = useEnvironments(); + const [selectedId, setSelectedId] = useState(null); + const selected = + environments.find((environment) => environment.environmentId === selectedId) ?? + environments.find( + (environment) => environment.environmentId === primaryEnvironment?.environmentId, + ) ?? + environments[0]; + const connected = selected?.connection.phase === "connected" && selected.serverConfig !== null; + const environmentId = connected ? selected.environmentId : null; + + return ( + + {environments.length > 1 ? ( + setSelectedId(value)} + > + + {selected?.label ?? "Select environment"} + + + {environments.map((environment) => ( + + {environment.label} + {environment.connection.phase === "connected" ? "" : " · Offline"} + + ))} + + + } + /> + ) : null} + + + ); +} + +function DeviceIntegrationControls({ + environmentId, + hosts, + enabled, + agentAccessEnabled, +}: { + environmentId: EnvironmentId | null; + hosts: ReadonlyArray; + enabled: boolean; + agentAccessEnabled: boolean; +}) { + const { state, loaded } = useDeviceState(environmentId); + const configure = useAtomCommand(deviceEnvironment.configure); + const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); + const [pending, setPending] = useState<"hub" | "check" | "agent" | null>(null); + const busy = state.hostStatus === "installing" || state.hostStatus === "starting"; + const [platformsRevealed, setPlatformsRevealed] = useState(false); + // Keep diagnostics visible through subsequent agent setup and refresh phases. + if (platformsRevealed && !enabled) setPlatformsRevealed(false); + if (enabled && !platformsRevealed && state.hostStatus === "ready" && pending !== "hub") { + setPlatformsRevealed(true); + } + + const update = async ( + kind: NonNullable, + input: { enabled?: boolean; agentAccessEnabled?: boolean }, + ) => { + if (!environmentId) return; + setPending(kind); + try { + const result = await configure({ environmentId, input }); + if (result._tag === "Success" && input.enabled === true && !state.onboardingCompleted) { + await configure({ environmentId, input: { onboardingCompleted: true } }); + } + } finally { + setPending(null); + } + }; + + return ( + <> + + {pending === "hub" ? : null} + + void update("hub", { + enabled: Boolean(checked), + ...(checked ? {} : { agentAccessEnabled: false }), + }) + } + /> + + } + /> + + {platformsRevealed ? ( + + + +
+ } + control={ + + } + /> + ) : null} + + + {pending === "agent" ? : null} + + void update("agent", { agentAccessEnabled: Boolean(checked) }) + } + /> + + } + /> + {state.hostStatus === "failed" && state.hostStatusDetail ? ( +

+ {state.hostStatusDetail} +

+ ) : null} + + + ); +} + function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled: boolean }) { const autoShow = useClientSettings((settings) => settings.browserAutoShowFloatingPreview); const updateSettings = useUpdatePrimarySettings(); @@ -1170,6 +1352,7 @@ export function IntegrationsSettingsPanel() { previewDefaults )} + ); } diff --git a/apps/web/src/components/settings/ProviderModelsSection.test.ts b/apps/web/src/components/settings/ProviderModelsSection.test.ts index 83adbeb97320..cbb95472da19 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.test.ts +++ b/apps/web/src/components/settings/ProviderModelsSection.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { ServerProviderModel } from "@t3tools/contracts"; -import { groupModelsForDisplay } from "./ProviderModelsSection"; +import { groupModelsForDisplay, nextHiddenModelsForBulkToggle } from "./ProviderModelsSection"; function model(slug: string, isCustom = false): ServerProviderModel { return { slug, name: slug, isCustom, capabilities: null }; @@ -21,3 +21,20 @@ describe("groupModelsForDisplay", () => { expect(display.map((entry) => entry.slug)).toEqual(["c", "d", "b", "custom", "a"]); }); }); + +describe("nextHiddenModelsForBulkToggle", () => { + it("hides every built-in model without hiding custom models", () => { + const models = [model("a"), model("b"), model("custom", true)]; + + expect(nextHiddenModelsForBulkToggle(models, ["a"])).toEqual(["a", "b"]); + }); + + it("shows every built-in model while preserving unrelated hidden entries", () => { + const models = [model("a"), model("b"), model("custom", true)]; + + expect(nextHiddenModelsForBulkToggle(models, ["a", "b", "legacy", "custom"])).toEqual([ + "legacy", + "custom", + ]); + }); +}); diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 7866578cfa4a..505a86ff0eac 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -96,6 +96,21 @@ export function groupModelsForDisplay< ]; } +export function nextHiddenModelsForBulkToggle( + models: ReadonlyArray>, + hiddenModels: ReadonlyArray, +): string[] { + const builtInSlugs = models.filter((model) => !model.isCustom).map((model) => model.slug); + const builtInSlugSet = new Set(builtInSlugs); + const allBuiltInModelsHidden = builtInSlugs.every((slug) => hiddenModels.includes(slug)); + + if (allBuiltInModelsHidden) { + return hiddenModels.filter((slug) => !builtInSlugSet.has(slug)); + } + + return [...new Set([...hiddenModels, ...builtInSlugs])]; +} + interface ProviderModelsSectionProps { /** Identifier used to namespace input ids within the DOM. */ readonly instanceId: ProviderInstanceId; @@ -181,6 +196,8 @@ export function ProviderModelsSection({ (model) => !model.isCustom && hiddenModelSet.has(model.slug), ).length; const builtInModels = useMemo(() => models.filter((model) => !model.isCustom), [models]); + const allBuiltInModelsHidden = + builtInModels.length > 0 && builtInModels.every((model) => hiddenModelSet.has(model.slug)); const showFilter = models.length > FILTER_THRESHOLD; const normalizedFilter = filter.trim().toLowerCase(); const isFiltering = showFilter && normalizedFilter.length > 0; @@ -502,11 +519,27 @@ export function ProviderModelsSection({ aria-label="Filter models" /> ) : null} - - {models.length} model{models.length === 1 ? "" : "s"} - {favoriteCount > 0 ? ` · ${favoriteCount} favorite${favoriteCount === 1 ? "" : "s"}` : ""} - {hiddenCount > 0 ? ` · ${hiddenCount} hidden` : ""} - +
+ {builtInModels.length > 0 ? ( + + ) : null} + + {models.length} model{models.length === 1 ? "" : "s"} + {favoriteCount > 0 + ? ` · ${favoriteCount} favorite${favoriteCount === 1 ? "" : "s"}` + : ""} + {hiddenCount > 0 ? ` · ${hiddenCount} hidden` : ""} + +
void) { ? ["Contrast"] : []), ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []), + ...(settings.diffColorScheme !== DEFAULT_UNIFIED_SETTINGS.diffColorScheme + ? ["Diff colors"] + : []), ...(settings.panelAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs ? ["Panel animations"] : []), @@ -597,6 +600,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.browserLinkTarget, settings.browserAutoShowFloatingPreview, settings.appearanceContrast, + settings.diffColorScheme, settings.enableAgentBrowserAccess, settings.confirmQuit, settings.confirmThreadArchive, @@ -701,6 +705,7 @@ export function useSettingsRestore(onRestored?: () => void) { } updateSettings({ appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, + diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, @@ -1240,6 +1245,52 @@ export function AppearanceSettingsPanel() { } /> ) : null} + + updateSettings({ diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme }) + } + /> + ) : null + } + control={ +
+ +
+ } + /> diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index e32baf6f3987..f56579990714 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -106,6 +106,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/appearance", searchTerms: ["transparent transparency solid menus dialogs composer"], }, + { + id: "diff-color-scheme", + title: "Diff colors", + to: "/settings/appearance", + searchTerms: ["red green blue orange additions deletions changes counts palette colorblind"], + }, { id: "panel-animations", title: "Panel animations", @@ -396,6 +402,33 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/projects", searchTerms: ["allow open drive preview tools sessions"], }, + { + id: "device-hosts", + title: "Device hosts", + to: "/settings/integrations", + searchTerms: ["ssh remote simulator emulator ios android mac mini identity key connection"], + }, + { + id: "agent-device-access", + title: "Agent device access", + to: "/settings/integrations", + targetId: "devices", + searchTerms: ["allow simulator emulator ios android drive tools sessions"], + }, + { + id: "device-hub", + title: "Device hub", + to: "/settings/integrations", + targetId: "devices", + searchTerms: ["simulator emulator ios android install start"], + }, + { + id: "device-platform-support", + title: "Simulator support", + to: "/settings/integrations", + targetId: "devices", + searchTerms: ["xcode android studio sdk avd runtime"], + }, { id: "browser-profiles", title: "Browser profiles", diff --git a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx index 066b7e583253..7b1d5c25fcf5 100644 --- a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx @@ -126,7 +126,7 @@ export function SidebarProviderUpdatePill() { return (
{displayedView.tone === "loading" ? ( - + ) : displayedView.tone === "success" ? ( - + ) : displayedView.tone === "error" ? ( - + ) : ( - + )} - {displayedView.title} + {displayedView.title} } /> @@ -199,7 +199,7 @@ export function SidebarProviderUpdatePill() { className="relative z-[1] mr-1 [--control-icon-color:currentColor] rounded-md text-inherit opacity-70 hover:bg-transparent hover:opacity-100" onClick={() => startExit(displayedView.key, null, displayedView.key)} > - + } /> diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 0e688db8376e..4657a0b9a975 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -23,6 +23,7 @@ const buttonVariants = cva( "icon-lg": "size-10 sm:size-9", "icon-micro": "size-5 rounded-sm p-0 before:rounded-[calc(var(--radius-sm)-1px)] [&_svg:not([class*='size-'])]:size-3", + "icon-tiny": "size-4 p-0 [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8 sm:size-7", "icon-xl": "size-11 sm:size-10 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", @@ -85,3 +86,23 @@ function Button({ className, variant, size, render, ...props }: ButtonProps) { } export { Button, buttonVariants }; + +/** An inline action that keeps the geometry of surrounding text or a graph node. */ +export function InlineButton({ + className, + underline = false, + ...props +}: React.ComponentProps<"button"> & { underline?: boolean }) { + return ( + + ); +} diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 0f6483c2ae67..d5e28441dc6b 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -560,7 +560,7 @@ function Toasts({ position }: { position: ToastPosition }) { { expect(body).toMatch(/expensive-model.*token-heavy-model.*token-heavy-cheaper-model/); }); + it("flags a model with no known rates instead of showing it as free", () => { + testState.breakdown = "model"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + const unpricedRow = body.split(" row.includes("unpriced-model")) ?? ""; + + expect(unpricedRow).toContain("Unpriced"); + expect(unpricedRow).not.toContain("$0.00"); + }); + it("sorts models by token usage when the token metric is selected", () => { testState.metric = "tokens"; testState.breakdown = "model"; @@ -202,6 +225,7 @@ describe("UsagePage model breakdown", () => { "expensive-model", "token-heavy-model", "token-heavy-cheaper-model", + "unpriced-model", ]); }); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index deb05f266b98..21970c675596 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -15,6 +15,7 @@ import { useMemo, useRef, useState } from "react"; import { isCompatibleUsageContractVersion, + isModelCostUnknown, type DailyTotals, type HourlyTotals, } from "@t3tools/shared/usageMerge"; @@ -363,9 +364,13 @@ export function UsagePage() { : formatTokens(merged.totalTokens)} - {metric === "cost" - ? `${formatCount(merged.sessions)} sessions · API estimate` - : `${formatCount(merged.sessions)} sessions`} + {metric !== "cost" + ? `${formatCount(merged.sessions)} sessions` + : merged.costQuality.unpricedShare > 0 + ? `${formatCount(merged.sessions)} sessions · API estimate excludes ${formatPercent( + merged.costQuality.unpricedShare, + )} unpriced records` + : `${formatCount(merged.sessions)} sessions · API estimate`}
@@ -511,10 +516,14 @@ export function UsagePage() { - {formatUsd(model.costUsd)} + {isModelCostUnknown(model) ? ( + Unpriced + ) : ( + formatUsd(model.costUsd) + )} - {formatPercent(model.costShare)} + {isModelCostUnknown(model) ? "—" : formatPercent(model.costShare)} {formatTokens(model.totalTokens)} diff --git a/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts b/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts index e96f7eac9d00..20d31c6392a0 100644 --- a/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts +++ b/apps/web/src/hooks/useOpenPanelPullRequestUrl.ts @@ -11,6 +11,7 @@ import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanel import { useProject } from "../state/entities"; import { pullRequestEnvironment } from "../state/pullRequests"; import { useEnvironmentQuery } from "../state/query"; +import { useSupportsMultiplePullRequests } from "./useSupportsMultiplePullRequests"; export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { const surface = useRightPanelStore((state) => @@ -20,6 +21,7 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { const environmentId = reference?.environmentId ? EnvironmentId.make(reference.environmentId) : threadRef?.environmentId; + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId ?? null); const project = useProject( reference && environmentId ? scopeProjectRef(environmentId, ProjectId.make(reference.projectId)) @@ -31,6 +33,9 @@ export function useOpenPanelPullRequestUrl(threadRef: ScopedThreadRef | null) { environmentId, input: { projectId: ProjectId.make(reference.projectId), + ...(supportsMultiplePullRequests && reference.host !== undefined + ? { host: reference.host } + : {}), repository: reference.repository, number: reference.number, }, diff --git a/apps/web/src/hooks/usePullRequestLinking.ts b/apps/web/src/hooks/usePullRequestLinking.ts new file mode 100644 index 000000000000..f6c076f3009d --- /dev/null +++ b/apps/web/src/hooks/usePullRequestLinking.ts @@ -0,0 +1,104 @@ +import { useMemo } from "react"; +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; +import type { + EnvironmentId, + ScopedThreadRef, + ThreadLinkedPullRequest, + ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + planThreadPullRequestMutation, + threadPullRequestLinkMode, +} from "@t3tools/client-runtime/thread-pull-request-compatibility"; +import { + threadPullRequestKeysEqual, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +import { + findProjectForChangeRequest, + findProjectOnChangeRequestHost, + matchesLinkedPullRequestUrl, + parseChangeRequestUrl, +} from "~/lib/openPullRequestLink"; +import { useProjects, useServerConfigs } from "~/state/entities"; +import { threadEnvironment } from "~/state/threads"; +import { useAtomCommand } from "~/state/use-atom-command"; + +/** Routes link actions through the command advertised by this environment. */ +export function usePullRequestLinking(environmentId: EnvironmentId | null | undefined) { + const configs = useServerConfigs(); + const projects = useProjects(); + const capabilities = + environmentId == null ? undefined : configs.get(environmentId)?.environment.capabilities; + const mode = threadPullRequestLinkMode(capabilities); + const link = useAtomCommand(threadEnvironment.linkPullRequest, { reportFailure: false }); + const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: false }); + const updateMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false }); + return useMemo(() => { + const environmentProjects = projects.filter( + (project) => project.environmentId === environmentId, + ); + const canLink = (url: string) => { + const parsed = parseChangeRequestUrl(url); + if (parsed === null || mode === "unsupported") return false; + return ( + (mode === "multiple" ? findProjectOnChangeRequestHost : findProjectForChangeRequest)( + environmentProjects, + parsed, + ) !== undefined + ); + }; + const isLinked = ( + thread: { + readonly pullRequests?: readonly ThreadPullRequestLink[]; + readonly linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; + } | null, + url: string, + ) => { + if (thread === null || mode === "unsupported") return false; + if (mode !== "multiple") + return ( + thread.linkedPullRequest != null && + matchesLinkedPullRequestUrl(thread.linkedPullRequest, url) + ); + const parsed = parseChangeRequestUrl(url); + return ( + parsed !== null && + visibleThreadPullRequests(thread.pullRequests ?? []).some((entry) => + threadPullRequestKeysEqual(entry, parsed), + ) + ); + }; + const changeLink = async (threadRef: ScopedThreadRef, url: string, linked: boolean) => { + const parsed = parseChangeRequestUrl(url); + if (parsed === null || threadRef.environmentId !== environmentId || (linked && !canLink(url))) + throw new Error("The pull request is not available in this environment."); + const legacyProject = findProjectForChangeRequest(environmentProjects, parsed); + const mutation = planThreadPullRequestMutation({ + capabilities, + threadId: threadRef.threadId, + reference: { ...parsed, url }, + legacyProjectId: legacyProject?.id ?? null, + legacyRepository: + sourceControlRepositorySelector(legacyProject?.repositoryIdentity) ?? undefined, + linked, + }); + if (mutation === null) + throw new Error("This environment does not support linking this pull request."); + const result = await (mutation.type === "thread.meta.update" + ? updateMetadata({ environmentId: threadRef.environmentId, input: mutation.input }) + : mutation.type === "thread.pull-request.link" + ? link({ environmentId: threadRef.environmentId, input: mutation.input }) + : unlink({ environmentId: threadRef.environmentId, input: mutation.input })); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) throw new Error("Link update interrupted."); + throw squashAtomCommandFailure(result); + } + }; + return { mode, canLink, isLinked, changeLink }; + }, [capabilities, environmentId, link, mode, projects, unlink, updateMetadata]); +} diff --git a/apps/web/src/hooks/useSupportsMultiplePullRequests.ts b/apps/web/src/hooks/useSupportsMultiplePullRequests.ts new file mode 100644 index 000000000000..6ffae19d5210 --- /dev/null +++ b/apps/web/src/hooks/useSupportsMultiplePullRequests.ts @@ -0,0 +1,10 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { useServerConfigs } from "~/state/entities"; + +export function useSupportsMultiplePullRequests(environmentId: EnvironmentId | null): boolean { + const configs = useServerConfigs(); + return ( + environmentId !== null && + configs.get(environmentId)?.environment.capabilities.threadPullRequests === true + ); +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 1d7975d24677..c11b2d0f4ffe 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -162,6 +162,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --color-update-surface: var(--update-surface); --color-success-foreground: var(--success-foreground); --color-success: var(--success); + --color-diff-addition: var(--diff-addition); + --color-diff-deletion: var(--diff-deletion); + --color-diff-addition-foreground: var(--diff-addition-foreground); + --color-diff-deletion-foreground: var(--diff-deletion-foreground); --color-info-foreground: var(--info-foreground); --color-info: var(--info); --color-destructive-foreground: var(--destructive-foreground); @@ -359,7 +363,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil -webkit-mask-position: top, bottom, right; -webkit-mask-repeat: no-repeat; -webkit-mask-size: - 100% var(--workspace-titlebar-scroll-fade-height), + 100% calc(var(--workspace-titlebar-scroll-fade-height) + 1px), 100% calc(100% - var(--workspace-titlebar-scroll-fade-height)), var(--app-scrollbar-width) 100%; mask-image: @@ -377,7 +381,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil mask-position: top, bottom, right; mask-repeat: no-repeat; mask-size: - 100% var(--workspace-titlebar-scroll-fade-height), + 100% calc(var(--workspace-titlebar-scroll-fade-height) + 1px), 100% calc(100% - var(--workspace-titlebar-scroll-fade-height)), var(--app-scrollbar-width) 100%; } @@ -1955,6 +1959,28 @@ code { } } +:root { + --diff-addition: var(--success); + --diff-deletion: var(--destructive); + --diff-addition-foreground: light-dark(var(--color-emerald-600), var(--color-emerald-400)); + --diff-deletion-foreground: light-dark(var(--color-red-600), var(--color-red-400)); +} + +:root[data-diff-color-scheme="blue-orange"] { + --diff-addition: light-dark(var(--color-blue-600), var(--color-blue-400)); + --diff-deletion: light-dark(var(--color-orange-600), var(--color-orange-400)); + --diff-addition-foreground: var(--diff-addition); + --diff-deletion-foreground: var(--diff-deletion); + --diffs-addition-color-override: var(--diff-addition); + --diffs-deletion-color-override: var(--diff-deletion); + --trees-status-added-override: var(--diff-addition); + --trees-status-deleted-override: var(--diff-deletion); + --trees-status-untracked-override: var(--diff-addition); + --trees-git-added-color-override: var(--diff-addition); + --trees-git-deleted-color-override: var(--diff-deletion); + --trees-git-untracked-color-override: var(--diff-addition); +} + .diff-render-surface diffs-container { border: 0; border-radius: 0; diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index 2866e88f45f6..90c456aa9da1 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -282,37 +282,37 @@ export const DIFF_SURFACE_THEME_UNSAFE_CSS = ` --diffs-bg-buffer-override: color-mix(in srgb, var(--code-background) 90%, var(--code-foreground)); --diffs-bg-addition-override: light-dark( - color-mix(in srgb, var(--code-background) 50%, var(--success)), - color-mix(in srgb, var(--code-background) 70%, var(--success)) + color-mix(in srgb, var(--code-background) 50%, var(--diff-addition)), + color-mix(in srgb, var(--code-background) 70%, var(--diff-addition)) ); --diffs-bg-addition-number-override: light-dark( - color-mix(in srgb, var(--code-background) 35%, var(--success)), - color-mix(in srgb, var(--code-background) 60%, var(--success)) + color-mix(in srgb, var(--code-background) 35%, var(--diff-addition)), + color-mix(in srgb, var(--code-background) 60%, var(--diff-addition)) ); - --diffs-bg-addition-hover-override: color-mix(in srgb, var(--code-background) 85%, var(--success)); + --diffs-bg-addition-hover-override: color-mix(in srgb, var(--code-background) 85%, var(--diff-addition)); --diffs-bg-addition-emphasis-override: color-mix( in srgb, var(--code-background) 80%, - var(--success) + var(--diff-addition) ); --diffs-bg-deletion-override: light-dark( - color-mix(in srgb, var(--code-background) 50%, var(--destructive)), - color-mix(in srgb, var(--code-background) 70%, var(--destructive)) + color-mix(in srgb, var(--code-background) 50%, var(--diff-deletion)), + color-mix(in srgb, var(--code-background) 70%, var(--diff-deletion)) ); --diffs-bg-deletion-number-override: light-dark( - color-mix(in srgb, var(--code-background) 35%, var(--destructive)), - color-mix(in srgb, var(--code-background) 60%, var(--destructive)) + color-mix(in srgb, var(--code-background) 35%, var(--diff-deletion)), + color-mix(in srgb, var(--code-background) 60%, var(--diff-deletion)) ); --diffs-bg-deletion-hover-override: color-mix( in srgb, var(--code-background) 85%, - var(--destructive) + var(--diff-deletion) ); --diffs-bg-deletion-emphasis-override: color-mix( in srgb, var(--code-background) 80%, - var(--destructive) + var(--diff-deletion) ); background-color: var(--diffs-bg) !important; diff --git a/apps/web/src/lib/incrementalHighlighting.test.ts b/apps/web/src/lib/incrementalHighlighting.test.ts new file mode 100644 index 000000000000..36c27cea14a2 --- /dev/null +++ b/apps/web/src/lib/incrementalHighlighting.test.ts @@ -0,0 +1,95 @@ +import { toHtml } from "hast-util-to-html"; +import { getSharedHighlighter } from "@pierre/diffs"; +import { describe, expect, it } from "vite-plus/test"; + +import { createIncrementalHighlightedDocument } from "./incrementalHighlighting"; + +const samples = { + typescript: "/* multi\nline comment */\nconst x = `template\n${1 + 2}`;\nconst re = /abc/;\n", + python: '#!/usr/bin/python\nx = """multi\nline"""\nprint(x)\n', + bash: "#!/bin/bash\ncat <\nconst x = 1;\n\n\n", + markdown: "# heading\n\n```ts\nconst a = 1;\n```\n\ntext\n", + rust: 'fn main() {\n let x = r#"multi\nline"#;\n}\n', + tsx: 'const element = \n{value}\n
;\n', + json: '{\n "value": [1,\n 2, 3]\n}\n', + yaml: "key: |\n multiline\n value\nnext: true\n", + css: '/* comment\n continued */\np::before {\n content: "text";\n}\n', + sql: "SELECT 'multi\nline'\nFROM table_name;\n", +} as const; + +const highlighterPromise = getSharedHighlighter({ + langs: Object.keys(samples) as Array, + themes: ["pierre-dark", "pierre-light"], + preferredHighlighter: "shiki-wasm", +}); + +describe("incremental code highlighting", () => { + it.each(Object.entries(samples))( + "matches full HTML at every streaming prefix in %s", + async (language, code) => { + const highlighter = await highlighterPromise; + for (const theme of ["pierre-dark", "pierre-light"] as const) { + const highlight = createIncrementalHighlightedDocument(highlighter, language, theme); + for (let end = 0; end <= code.length; end++) { + const text = code.slice(0, end); + expect(toHtml(highlight(text)), `${theme}, prefix ${end}`).toBe( + highlighter.codeToHtml(text, { lang: language, theme }), + ); + } + } + }, + ); + + it("resets after edits and truncation, including edits to a completed line", async () => { + const highlighter = await highlighterPromise; + const highlight = createIncrementalHighlightedDocument( + highlighter, + "typescript", + "pierre-dark", + ); + const inputs = [ + "/* open\ncomment\n", + "/* open\ncomment\n*/\nconst x = 1;", + "const edited = 2;\nconst x = 1;", + "const edited = 2;\nconst x = 10;", + "const edited = 2;\n", + "", + "\n\n\nconst fresh = true;\n", + ]; + for (const text of inputs) { + expect(toHtml(highlight(text))).toBe( + highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }), + ); + } + }); + + it.each(["text", "plaintext", "plain", "txt", "ansi"])( + "preserves %s without requesting grammar state", + async (language) => { + const highlighter = await highlighterPromise; + const highlight = createIncrementalHighlightedDocument(highlighter, language, "pierre-dark"); + for (const text of ["plain\ntext", "\u001b[31mred\ncontinued", "\n"]) { + expect(toHtml(highlight(text))).toBe( + highlighter.codeToHtml(text, { lang: language, theme: "pierre-dark" }), + ); + } + }, + ); + + it("preserves partial CRLF and CR line endings", async () => { + const highlighter = await highlighterPromise; + const highlight = createIncrementalHighlightedDocument( + highlighter, + "typescript", + "pierre-dark", + ); + const code = "/* multi\r\nline */\r\nconst x = 1;\r\n"; + for (let end = 0; end <= code.length; end++) { + const text = code.slice(0, end); + expect(toHtml(highlight(text))).toBe( + highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }), + ); + } + }); +}); diff --git a/apps/web/src/lib/incrementalHighlighting.ts b/apps/web/src/lib/incrementalHighlighting.ts new file mode 100644 index 000000000000..fef3598a2152 --- /dev/null +++ b/apps/web/src/lib/incrementalHighlighting.ts @@ -0,0 +1,74 @@ +import type { DiffsHighlighter } from "@pierre/diffs"; + +import type { DiffThemeName } from "./diffRendering"; + +function codeChildren(root: ReturnType) { + const pre = root.children.find((node) => node.type === "element" && node.tagName === "pre"); + if (pre?.type !== "element") throw new Error("Missing highlighted pre element"); + const code = pre.children.find((node) => node.type === "element" && node.tagName === "code"); + if (code?.type !== "element") throw new Error("Missing highlighted code element"); + return code.children; +} + +/** Resume tokenization after the last completed line. Keep its grammar state so + * multiline strings, comments, and embedded languages continue to highlight as + * they do in a full pass. The current line is always highlighted again. + */ +export function createIncrementalHighlightedDocument( + highlighter: DiffsHighlighter, + language: string, + theme: DiffThemeName, +) { + const options = { lang: language, theme }; + const newline = { type: "text" as const, value: "\n" }; + let cached: + | { + prefix: string; + state: ReturnType; + children: ReturnType; + } + | undefined; + + return (code: string) => { + // Plain text and ANSI do not have a TextMate grammar state. A CR at the end + // of a chunk can still become a CRLF, so keep that input on the full path. + if ( + !language || + ["text", "plaintext", "plain", "txt", "ansi"].includes(language) || + code.includes("\r") + ) { + return highlighter.codeToHast(code, options); + } + if (cached && !code.startsWith(cached.prefix)) cached = undefined; + const end = code.lastIndexOf("\n") + 1; + if (end > (cached?.prefix.length ?? 0)) { + // Omit the final newline: Shiki would tokenize an extra empty line and + // advance the grammar state twice before we process the following line. + const root = highlighter.codeToHast(code.slice(cached?.prefix.length ?? 0, end - 1), { + ...options, + ...(cached ? { grammarState: cached.state } : {}), + }); + const state = highlighter.getLastGrammarState(root); + if (!state) { + cached = undefined; + return highlighter.codeToHast(code, options); + } + cached = { + prefix: code.slice(0, end), + state, + children: [...(cached ? [...cached.children, newline] : []), ...codeChildren(root)], + }; + } + const prefix = cached; + if (!prefix) return highlighter.codeToHast(code, options); + return highlighter.codeToHast(code.slice(prefix.prefix.length), { + ...options, + grammarState: prefix.state, + transformers: [ + { + code: (node) => ({ ...node, children: [...prefix.children, newline, ...node.children] }), + }, + ], + }); + }; +} diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index ad4971d331d8..ce9c2193a58e 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { changeRequestRepositoryUrl, findProjectForChangeRequest, + findProjectOnChangeRequestHost, gitHubPullRequestBrowserUrl, matchesLinkedPullRequestUrl, parseChangeRequestUrl, @@ -295,6 +296,81 @@ describe("parseChangeRequestUrl", () => { }); }); +describe("findProjectOnChangeRequestHost", () => { + const project = (id: string, identity: Record) => + ({ id, repositoryIdentity: identity }) as never; + const frontend = project("frontend", { + canonicalKey: "github.com/acme/frontend", + provider: "github", + owner: "acme", + name: "frontend", + }); + const backend = project("backend", { + canonicalKey: "github.com/acme/backend", + provider: "github", + owner: "acme", + name: "backend", + }); + + it.each([ + "ssh.dev.azure.com/v3/org-a/project/web", + "vs-ssh.visualstudio.com/v3/org-a/project/web", + "org-a.visualstudio.com/defaultcollection/project/_git/web", + "dev.azure.com/org-a/project/_git/web", + ])("matches Azure browser references against %s", (canonicalKey) => { + const checkout = project("azure", { + canonicalKey, + provider: "azure-devops", + displayName: canonicalKey.split("/").slice(1).join("/"), + }); + const reference = { host: "dev.azure.com", repository: "org-a/project/_git/web", number: 42 }; + expect(findProjectForChangeRequest([checkout], reference)).toBe(checkout); + expect(findProjectOnChangeRequestHost([checkout], reference)).toBe(checkout); + expect( + findProjectOnChangeRequestHost([checkout], { + ...reference, + repository: "org-b/project/_git/web", + }), + ).toBeUndefined(); + expect( + findProjectOnChangeRequestHost([checkout], { + ...reference, + repository: "org-a/other-project/_git/web", + }), + ).toBeUndefined(); + }); + + it("prefers the project checked out from the link's own repository", () => { + expect( + findProjectOnChangeRequestHost([frontend, backend], { + host: "github.com", + repository: "acme/backend", + number: 7, + }), + ).toBe(backend); + }); + + it("lends any project on the host to a repository nobody has checked out", () => { + expect( + findProjectOnChangeRequestHost([frontend], { + host: "github.com", + repository: "acme/backend", + number: 7, + }), + ).toBe(frontend); + }); + + it("finds nothing on a host nothing is checked out from", () => { + expect( + findProjectOnChangeRequestHost([frontend], { + host: "gitlab.com", + repository: "acme/backend", + number: 7, + }), + ).toBeUndefined(); + }); +}); + describe("findProjectForChangeRequest", () => { const project = (identity: Record) => ({ id: "p1", repositoryIdentity: identity }) as never; diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index ae487b67d3ce..35a9fed4cc5d 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -1,13 +1,13 @@ -import type { - EnvironmentId, - RepositoryIdentity, - ScopedThreadRef, - ThreadLinkedPullRequest, -} from "@t3tools/contracts"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; import { type MouseEvent, useCallback } from "react"; import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; +import { parseChangeRequestUrl, type ChangeRequestLink } from "@t3tools/shared/changeRequestUrl"; +import { + canonicalRepositoryKey, + sourceControlRepositorySelector, +} from "@t3tools/shared/sourceControl"; import { useOpenLink } from "../browser/useOpenLink"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; @@ -17,176 +17,14 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { useProjects, useServerConfigs } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; -/** Builds a GitHub URL that remains available when the pull request API cannot be read. */ -export function gitHubPullRequestBrowserUrl( - identity: RepositoryIdentity | null | undefined, - repository: string, - number: number, -): string | null { - if (identity?.provider !== "github" || !Number.isSafeInteger(number) || number < 1) return null; - const repositoryPath = repository.split("/"); - if ( - repositoryPath.length !== 2 || - repositoryPath.some((segment) => segment.length === 0 || segment === "." || segment === "..") - ) { - return null; - } - - let origin: string | null = null; - try { - const remoteUrl = new URL(identity.locator.remoteUrl.trim()); - if (remoteUrl.protocol === "http:" || remoteUrl.protocol === "https:") { - origin = remoteUrl.origin; - } - } catch { - // SCP-style remotes are read from their normalized identity below. - } - const hostname = identity.canonicalKey.split("/")[0]; - if (origin === null && !hostname) return null; - - try { - const url = new URL(origin ?? `https://${hostname}`); - url.pathname = `/${repositoryPath.join("/")}/pull/${number}`; - return url.toString(); - } catch { - return null; - } -} - -/** - * A change request the page can open, named the way the page names one: the host below which the - * repository is addressed, the repository path as that host writes it, and the number. - * - * The two strings are what `pullRequestHostOf` and the project's `repositoryIdentity` produce - * from a git remote — lower case, no port, the full path below the host — because the page matches - * a link against those. Anything else opens nothing. - */ -export interface ChangeRequestLink { - readonly host: string; - readonly repository: string; - readonly number: number; -} - -/** The host itself, one of its subdomains, or an install named after the provider. */ -function isHostOf(hostname: string, apex: string, label?: string): boolean { - if (hostname === apex || hostname.endsWith(`.${apex}`)) return true; - return label !== undefined && hostname.startsWith(`${label}.`); -} - -/** - * The repository and number behind a change request URL on a host the page can read, or null for - * anything else — an issue, a commit, a repository root, a host this cannot tell apart from an - * ordinary link. Null means the system browser, so a doubtful match is worse than no match: it - * takes the reader out of their browser and into a page that cannot find the change request. - * - * Each host is recognised by the path shape it alone uses, guarded by a hostname it could - * plausibly be served from, since self-hosted installs are named whatever their admin chose: - * GitLab's `/-/` marker is unique enough to trust on any hostname, while `/pull/` is generic - * enough that it is only believed from a GitHub-ish host. - */ -export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | null { - let url: URL; - try { - url = new URL(targetUrl); - } catch { - return null; - } - // `javascript:`, `mailto:` and friends have no host to speak of and nothing to open. - if (url.protocol !== "https:" && url.protocol !== "http:") return null; - // Nothing here tries to tell a lookalike hostname from a real one — `github.com.evil.test`, - // `github.com-evil.test` and the rest are an open set, and blocking spellings of it costs real - // hosts (`gitlab.com.br` is a registrable domain, not a disguise). What a claim is worth is - // decided where it is used: only a link matching a repository this workspace has checked out - // opens the page, and everything else stays the ordinary link it was. - const host = url.hostname.toLowerCase(); - - // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n} - if (isHostOf(host, "github.com", "github")) { - const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); - return claim(host, match); - } - // GitLab, self-hosted included: /{group}/[{subgroup}/...]{repo}/-/merge_requests/{n}. The `/-/` - // separator is GitLab's own, so the hostname is not asked about. - const gitlab = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (gitlab) return claim(host, gitlab); - // Bitbucket Cloud: /{workspace}/{repo}/pull-requests/{n} - if (isHostOf(host, "bitbucket.org", "bitbucket")) { - const match = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u.exec(url.pathname); - return claim(host, match); - } - // Azure DevOps, both the current host and the per-organisation one it replaced. `_git` is part - // of the repository path there, as it is in the remote URL the identity is read from. - if (isHostOf(host, "dev.azure.com") || host.endsWith(".visualstudio.com")) { - const match = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u.exec(url.pathname); - return claim(host, match); - } - return null; -} - -/** - * The pull-request URL a GitHub-style `#123` autolink might name. GitHub writes every bare - * reference through `/issues/`, including pull requests, so this only builds a candidate: the - * caller must successfully read it as a pull request before treating it as one. - */ -export function pullRequestCandidateUrlFromReferenceAutolink(targetUrl: string): string | null { - let url: URL; - try { - url = new URL(targetUrl); - } catch { - return null; - } - if ( - (url.protocol !== "https:" && url.protocol !== "http:") || - !isHostOf(url.hostname.toLowerCase(), "github.com", "github") - ) { - return null; - } - const match = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u.exec(url.pathname); - if (match?.[1] === undefined || match[2] === undefined) return null; - url.pathname = `/${match[1]}/pull/${match[2]}`; - return url.toString(); -} - -/** Match a stored PR without requiring its project to remain available. */ -export function matchesLinkedPullRequestUrl( - linkedPullRequest: ThreadLinkedPullRequest, - targetUrl: string, -): boolean { - const linked = parseChangeRequestUrl(linkedPullRequest.url); - const target = parseChangeRequestUrl(targetUrl); - return ( - linked !== null && - target !== null && - linked.host === target.host && - linked.repository === target.repository && - linked.number === target.number - ); -} - -/** The repository root behind a recognised change-request URL, without PR-specific state. */ -export function changeRequestRepositoryUrl(targetUrl: string): string | null { - const changeRequest = parseChangeRequestUrl(targetUrl); - if (changeRequest === null) return null; - const url = new URL(targetUrl); - const repositoryPath = - /^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ?? - /^(.*?)(?:\/pull\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec( - url.pathname, - )?.[1]; - if (!repositoryPath) return null; - url.pathname = repositoryPath; - url.search = ""; - url.hash = ""; - return url.toString(); -} - -function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { - const repository = match?.[1]; - const number = Number(match?.[2]); - return repository && Number.isSafeInteger(number) && number > 0 - ? { host, repository: repository.toLowerCase(), number } - : null; -} +export { + parseChangeRequestUrl, + type ChangeRequestLink, + gitHubPullRequestBrowserUrl, + pullRequestCandidateUrlFromReferenceAutolink, + matchesLinkedPullRequestUrl, + changeRequestRepositoryUrl, +} from "@t3tools/shared/changeRequestUrl"; /** * Returns a click handler that opens a pull request URL in the system browser. @@ -210,6 +48,12 @@ export function findProjectForChangeRequest( if (!identity) return false; const kind = identity.provider as SourceControlProviderKind | undefined; if (kind === undefined) return false; + if (kind === "azure-devops") { + return ( + canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) === + canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()) + ); + } const repository = identity.displayName ?? (identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null); @@ -221,6 +65,36 @@ export function findProjectForChangeRequest( }); } +/** + * Any project checked out from the link's host. Thread links are host-level, so a pull request + * from a repository nobody has checked out is still linkable as long as one project on that + * host can lend the server its credentials. The link's own project, when it exists, comes first. + */ +export function findProjectOnChangeRequestHost( + projects: ReadonlyArray, + link: ChangeRequestLink, +): EnvironmentProject | undefined { + const own = findProjectForChangeRequest(projects, link); + if (own !== undefined) return own; + // Azure CLI reads use the checkout's organization and project, not host-wide credentials. + if ( + canonicalRepositoryKey(`${link.host}/${link.repository}`.toLowerCase()).startsWith( + "dev.azure.com/", + ) + ) + return undefined; + return projects.find((project) => { + const identity = project.repositoryIdentity; + const kind = identity?.provider as SourceControlProviderKind | undefined; + return ( + identity != null && + kind !== undefined && + kind !== "azure-devops" && + pullRequestHostOf(identity, kind) === link.host.toLowerCase() + ); + }); +} + /** * Opens a change request link on the page, and says whether it did. Anything else — another * organisation's repository, a host nothing here is checked out from, a link that merely looks @@ -284,8 +158,25 @@ export function useOpenChangeRequestLink( Number(right.environmentId === primaryEnvironmentId) - Number(left.environmentId === primaryEnvironmentId), ); - const project = findProjectForChangeRequest(projects, parsed); + const exactProject = findProjectForChangeRequest(projects, parsed); + const project = + exactProject ?? + (resolvedPanelRef + ? findProjectOnChangeRequestHost( + projects.filter( + (candidate) => + serverConfigs.get(candidate.environmentId)?.environment.capabilities + .threadPullRequests === true, + ), + parsed, + ) + : undefined); if (project === undefined || !reads(project.environmentId)) return false; + const repository = + serverConfigs.get(project.environmentId)?.environment.capabilities.threadPullRequests === + true + ? parsed.repository + : (sourceControlRepositorySelector(project.repositoryIdentity) ?? parsed.repository); event.preventDefault(); event.stopPropagation(); if (resolvedPanelRef) { @@ -295,9 +186,12 @@ export function useOpenChangeRequestLink( ? {} : { environmentId: project.environmentId }), projectId: project.id, - // The identity's own spelling, not the one read out of the URL: the panel asks the - // provider for this repository, while matching a link only ever compares lower case. - repository: project.repositoryIdentity?.displayName ?? parsed.repository, + ...(serverConfigs.get(project.environmentId)?.environment.capabilities + .threadPullRequests === true + ? { host: parsed.host } + : {}), + repository, + url: targetUrl, number: parsed.number, }); if (!resolvedThreadRef) { @@ -307,8 +201,9 @@ export function useOpenChangeRequestLink( ...previous, involvement: previous.involvement ?? "all", state: previous.state ?? "all", - repository: project.repositoryIdentity?.displayName ?? parsed.repository, + repository, number: parsed.number, + selectedHost: parsed.host, selectedProjectId: project.id, selectedEnvironmentId: project.environmentId, }), @@ -324,8 +219,9 @@ export function useOpenChangeRequestLink( // Every state, so the pull request being opened is also in the list behind it whether // it is open, merged or closed. state: "all", - repository: parsed.repository, + repository, number: parsed.number, + selectedHost: parsed.host, selectedProjectId: project.id, // Named so the page opens the right one of two servers holding this project. selectedEnvironmentId: project.environmentId, diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index ca9a5986c664..4f2f2a586568 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -34,6 +34,7 @@ function makeThread(overrides: Partial = {}): Thread { branch: null, worktreePath: null, checkpoints: [], + pullRequests: [], activities: [], ...overrides, }; diff --git a/apps/web/src/markdown-incremental.test.tsx b/apps/web/src/markdown-incremental.test.tsx new file mode 100644 index 000000000000..cf06355f3b49 --- /dev/null +++ b/apps/web/src/markdown-incremental.test.tsx @@ -0,0 +1,136 @@ +import type { Root } from "mdast"; +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize from "rehype-sanitize"; +import remarkGfm from "remark-gfm"; +import type { Plugin } from "unified"; +import { describe, expect, it } from "vite-plus/test"; + +import { remarkCodexDirectives } from "@t3tools/client-runtime/codex-markdown-directives"; +import { remarkGithubAlerts } from "./markdown-github-alerts"; +import { createIncrementalMarkdownPlugin } from "./markdown-incremental"; +import { remarkNormalizeListItemIndentation } from "./markdown-list-indentation"; + +function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: string[]) { + let tree: Root | undefined; + const observeParsing: Plugin<[], Root> = function () { + const original = this.parser; + if (original) { + this.parser = (text, file) => { + parsedSources?.push(text); + return original(text, file); + }; + } + }; + const capture: Plugin<[], Root> = () => (root) => { + tree = structuredClone(root); + }; + const html = renderToStaticMarkup( + + {source} + , + ); + return { html, tree }; +} + +const prefix = "# Before\n\n```ts\nconst values = [1, 2];\n```\n\n"; + +describe("incremental Markdown parsing", () => { + it("keeps the document prefix cached when list recovery parses contain fences", () => { + const source = + prefix + + "- first block\n\n ```ts\n const nested = 1;\n ```\n\n tail"; + const incremental = createIncrementalMarkdownPlugin(); + const parsedSources: string[] = []; + expect(render(source, incremental, parsedSources)).toEqual(render(source)); + parsedSources.length = 0; + const next = source + " more"; + expect(render(next, incremental, parsedSources)).toEqual(render(next)); + expect(parsedSources).not.toContain(next); + expect(parsedSources.some((text) => text.startsWith("t3-markdown-inline-prefix:"))).toBe(true); + }); + + it.each([ + "a\n===\n\nb\n---\n", + "- first\n\n continued\n\n- next\n", + "> quoted\n>\n> ```js\n> abc\n> ```\n\nend", + "
\nhello\n\n
\n\nend", + "[ref]\n\n[ref]: /later", + "a[^x]\n\n[^x]: note", + "a | b\n--|--\na | b\n", + "```\na\n```\n\nnext\n\n~~~\nb\n~~~\n\nmore", + "\n\n\tcode\n\nmore", + "text *bold*", + "> [!NOTE]\n> alert\n\n- [ ] task", + "\uFEFFtext after a byte-order mark", + ])("preserves the parse tree, positions, and HTML while streaming %j", (tail) => { + const source = prefix + tail; + const incremental = createIncrementalMarkdownPlugin(); + for (let end = 0; end <= source.length; end++) { + const text = source.slice(0, end); + expect(render(text, incremental), `prefix ${end}`).toEqual(render(text)); + } + }); + + it.each(["\r\n", "\r"])("preserves partial %j line endings", (newline) => { + const source = (prefix + "next\n\n```\nlast\n```\n\nend").replaceAll("\n", newline); + const incremental = createIncrementalMarkdownPlugin(); + for (let end = 0; end <= source.length; end++) { + const text = source.slice(0, end); + expect(render(text, incremental)).toEqual(render(text)); + } + }); + + it("updates earlier references when definitions arrive after the cached prefix", () => { + const before = "[later] and footnote[^note]\n\n" + prefix; + const incremental = createIncrementalMarkdownPlugin(); + for (const tail of ["text", "[later]: /target", "[later]: /target\n\n[^note]: a note"]) { + expect(render(before + tail, incremental)).toEqual(render(before + tail)); + } + }); + + it("handles edits, replacements, and repeated renders without leaking transformed nodes", () => { + const incremental = createIncrementalMarkdownPlugin(); + const documents = [ + prefix + "- first\n - second", + prefix + "> [!NOTE]\n> transformed alert", + prefix + "plain text", + "replacement without fences", + prefix.replace("Before", "Edited") + "edited prefix", + prefix + "plain text", + prefix + "plain text", + ]; + for (const document of documents) { + expect(render(document, incremental)).toEqual(render(document)); + } + }); + + it("does not freeze unclosed, nested, indented, or mismatched fences", () => { + const prefixes = [ + "```\nopen\n\n", + "````\n```\n\n", + "> ```\n> code\n> ```\n\n", + "- ```\n code\n ```\n\n", + " ```\n code\n ```\n\n", + "