From a29a7cc5853e16d4ca7e07acf364836f71b6101a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 9 Sep 2026 10:47:15 -0700 Subject: [PATCH 01/61] fix(mobile): blur glass fallbacks to prevent background text bleed (#10964) --- apps/mobile/src/components/GlassSurface.tsx | 43 +++++++-- .../terminal/ThreadTerminalRouteScreen.tsx | 22 ++++- .../src/features/threads/ThreadComposer.tsx | 10 +- .../features/threads/ThreadDetailScreen.tsx | 92 +++++++++++-------- apps/mobile/src/lib/glassBlurTarget.ts | 6 ++ 5 files changed, 119 insertions(+), 54 deletions(-) create mode 100644 apps/mobile/src/lib/glassBlurTarget.ts diff --git a/apps/mobile/src/components/GlassSurface.tsx b/apps/mobile/src/components/GlassSurface.tsx index 577c43aa890a..390863460df3 100644 --- a/apps/mobile/src/components/GlassSurface.tsx +++ b/apps/mobile/src/components/GlassSurface.tsx @@ -1,17 +1,20 @@ +import { BlurView } from "expo-blur"; import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect"; -import type { ReactNode, Ref } from "react"; +import { useContext, type ReactNode, type Ref, type RefObject } from "react"; import { Platform, + StyleSheet, useColorScheme, View, type ColorValue, - type StyleProp, type ViewProps, type ViewStyle, } from "react-native"; import { withUniwind } from "uniwind"; import { cn } from "../lib/cn"; +import { GlassBlurTargetContext } from "../lib/glassBlurTarget"; +import { themeColorWithAlpha } from "../lib/mobileTheme"; // Explicit mappings keep the native glassEffectStyle enum out of style-array conversion. const ThemedGlassView = withUniwind(GlassView, { @@ -26,8 +29,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,13 +43,21 @@ export function GlassSurface({ chrome = "default", tintColor, tintColorClassName, - fallbackStyle, + fallbackColor, + blurTarget, fallbackClassName, className, style, ...props }: GlassSurfaceProps) { const isDarkMode = useColorScheme() === "dark"; + const inheritedBlurTarget = useContext(GlassBlurTargetContext); + const target = blurTarget ?? inheritedBlurTarget; + const supportsBlur = + Platform.OS === "ios" || + (Platform.OS === "android" && Platform.Version >= 31 && target !== undefined); + const backgroundColor = + fallbackColor === undefined ? undefined : themeColorWithAlpha(String(fallbackColor), 1); const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable(); const surfaceStyle: ViewStyle = { borderRadius: 32, @@ -95,14 +107,27 @@ 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]} > + {supportsBlur ? ( + + ) : null} + {children} ); diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index bca54694bd7b..bc2e6fc3c64a 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"); @@ -1260,7 +1262,21 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) /> ) : ( <> - + + - + {isAccessoryVisible ? ( { @@ -839,17 +841,27 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const handleFeedTouchCancel = useCallback(() => { feedTouchStartRef.current = null; }, []); + const feedBlurTarget = useRef(null); return ( {showContent ? ( - + - + ) : ( )} @@ -1003,41 +1015,43 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread : undefined } > - + + + 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); From e16b8b059c9f5ff6dfed1addecffb831c6aee043 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:36:53 +0530 Subject: [PATCH 02/61] feat(web): add provider model bulk toggle (#10947) --- .../settings/ProviderModelsSection.test.ts | 19 +++++++- .../settings/ProviderModelsSection.tsx | 43 ++++++++++++++++--- 2 files changed, 56 insertions(+), 6 deletions(-) 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` : ""} + +
Date: Wed, 9 Sep 2026 16:26:13 -0500 Subject: [PATCH 03/61] fix(web): allow expanding duplicate tool call commands (#10981) --- apps/web/src/components/chat/MessagesTimeline.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1d4abe39bf39..b4993b5647e1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3309,13 +3309,12 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workspaceRoot, }) : null; - const commandMatchesVisibleLabel = workEntry.command?.trim() === previewText.trim(); const canExpand = (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, @@ -3396,9 +3395,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { Date: Wed, 9 Sep 2026 17:34:39 -0400 Subject: [PATCH 04/61] fix(mobile): prevent Android chat rows overlapping during sync (#10983) --- apps/mobile/src/features/threads/ThreadFeed.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 From 383cc40f4d5d9f61d47b3caa28fac839a8b9c433 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 9 Sep 2026 15:25:50 -0700 Subject: [PATCH 05/61] fix(mobile): prevent text leaking through Android glass (#10998) --- .../src/components/AndroidAnchoredMenu.tsx | 16 +----- apps/mobile/src/components/GlassBackdrop.tsx | 53 +++++++++++++++++++ apps/mobile/src/components/GlassSurface.tsx | 30 ++--------- 3 files changed, 58 insertions(+), 41 deletions(-) create mode 100644 apps/mobile/src/components/GlassBackdrop.tsx 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/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 390863460df3..014a760588ab 100644 --- a/apps/mobile/src/components/GlassSurface.tsx +++ b/apps/mobile/src/components/GlassSurface.tsx @@ -1,9 +1,7 @@ -import { BlurView } from "expo-blur"; import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect"; -import { useContext, type ReactNode, type Ref, type RefObject } from "react"; +import type { ReactNode, Ref, RefObject } from "react"; import { Platform, - StyleSheet, useColorScheme, View, type ColorValue, @@ -13,8 +11,7 @@ import { import { withUniwind } from "uniwind"; import { cn } from "../lib/cn"; -import { GlassBlurTargetContext } from "../lib/glassBlurTarget"; -import { themeColorWithAlpha } from "../lib/mobileTheme"; +import { GlassBackdrop } from "./GlassBackdrop"; // Explicit mappings keep the native glassEffectStyle enum out of style-array conversion. const ThemedGlassView = withUniwind(GlassView, { @@ -51,13 +48,6 @@ export function GlassSurface({ ...props }: GlassSurfaceProps) { const isDarkMode = useColorScheme() === "dark"; - const inheritedBlurTarget = useContext(GlassBlurTargetContext); - const target = blurTarget ?? inheritedBlurTarget; - const supportsBlur = - Platform.OS === "ios" || - (Platform.OS === "android" && Platform.Version >= 31 && target !== undefined); - const backgroundColor = - fallbackColor === undefined ? undefined : themeColorWithAlpha(String(fallbackColor), 1); const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable(); const surfaceStyle: ViewStyle = { borderRadius: 32, @@ -113,21 +103,7 @@ export function GlassSurface({ )} style={[surfaceStyle, style]} > - {supportsBlur ? ( - - ) : null} - + {children} ); From afb84898be3bf9b83bd4cb98c608b79c433579cc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 9 Sep 2026 15:50:58 -0700 Subject: [PATCH 06/61] feat(pull-requests): link multiple pull requests to threads (#10839) --- apps/mobile/src/components/AppSymbol.tsx | 2 + .../archive/archivedThreadList.test.ts | 1 + .../src/features/home/homeListItems.test.ts | 1 + .../src/features/home/homeThreadList.test.ts | 1 + .../HardwareKeyboardCommandProvider.tsx | 1 + .../features/threads/git/GitOverviewSheet.tsx | 59 +- .../features/threads/thread-list-items.tsx | 34 +- .../features/threads/thread-list-v2-items.tsx | 43 +- .../src/features/threads/threadListV2.test.ts | 1 + apps/mobile/src/lib/threadActivity.test.ts | 1 + .../src/state/pending-thread-creation.ts | 1 + .../src/state/thread-pr-presentation.ts | 66 +- .../state/use-selected-thread-git-actions.ts | 2 + apps/mobile/src/state/use-thread-pr.test.ts | 147 +++- apps/mobile/src/state/use-thread-pr.ts | 30 +- apps/mobile/src/state/use-thread-selection.ts | 1 + .../OrchestrationEngineHarness.integration.ts | 8 + apps/server/src/auth/RpcAuthorization.ts | 2 + .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../src/git/linkCreatedPullRequest.test.ts | 230 ++++++ apps/server/src/git/linkCreatedPullRequest.ts | 98 +++ apps/server/src/mcp/McpHttpServer.test.ts | 47 ++ apps/server/src/mcp/McpHttpServer.ts | 11 +- .../src/mcp/McpInvocationContext.test.ts | 27 + apps/server/src/mcp/McpInvocationContext.ts | 45 +- apps/server/src/mcp/McpProviderSession.ts | 2 + .../server/src/mcp/McpSessionRegistry.test.ts | 28 + apps/server/src/mcp/McpSessionRegistry.ts | 12 +- .../toolkits/pullRequests/handlers.test.ts | 418 +++++++++++ .../src/mcp/toolkits/pullRequests/handlers.ts | 243 ++++++ .../src/mcp/toolkits/pullRequests/tools.ts | 231 ++++++ .../Layers/OrchestrationEngine.test.ts | 40 +- .../Layers/OrchestrationReactor.test.ts | 12 + .../Layers/OrchestrationReactor.ts | 3 + .../Layers/ProjectionPipeline.test.ts | 237 ++++++ .../Layers/ProjectionPipeline.ts | 104 +++ .../Layers/ProjectionSnapshotQuery.test.ts | 163 +++- .../Layers/ProjectionSnapshotQuery.ts | 610 ++++++++++----- .../PullRequestSyncReactor.test.ts | 704 ++++++++++++++++++ .../orchestration/PullRequestSyncReactor.ts | 328 ++++++++ apps/server/src/orchestration/Schemas.ts | 6 + .../ThreadPullRequestReactor.test.ts | 1 + .../orchestration/ThreadPullRequestReactor.ts | 19 +- .../ThreadSettlementPolicy.test.ts | 74 ++ .../orchestration/ThreadSettlementPolicy.ts | 25 +- .../ThreadSettlementReactor.test.ts | 52 ++ .../orchestration/ThreadSettlementReactor.ts | 4 +- .../orchestration/commandInvariants.test.ts | 2 + .../decider.active-order.test.ts | 1 + .../src/orchestration/decider.pinned.test.ts | 1 + .../decider.pullRequests.test.ts | 532 +++++++++++++ .../decider.questionAttachments.test.ts | 1 + .../src/orchestration/decider.settled.test.ts | 1 + .../src/orchestration/decider.snoozed.test.ts | 1 + .../decider.titleRegeneration.test.ts | 1 + apps/server/src/orchestration/decider.ts | 221 ++++++ .../decider.userInputDismiss.test.ts | 1 + .../projector.pullRequests.test.ts | 417 +++++++++++ .../src/orchestration/projector.test.ts | 27 +- apps/server/src/orchestration/projector.ts | 237 +++++- .../Layers/ProjectionRepositories.test.ts | 168 ++++- apps/server/src/persistence/Migrations.ts | 2 + .../050_ProjectionThreadPullRequests.test.ts | 181 +++++ .../050_ProjectionThreadPullRequests.ts | 92 +++ .../ProjectionThreadPullRequests.ts | 266 +++++++ .../src/project/AgentSessionImporter.test.ts | 1 + .../src/provider/Layers/ClaudeAdapter.test.ts | 4 +- .../src/provider/Layers/CodexAdapter.ts | 1 + .../provider/Layers/CodexSessionRuntime.ts | 13 +- .../provider/Layers/ProviderService.test.ts | 44 +- .../src/provider/Layers/ProviderService.ts | 36 +- .../Layers/ProviderSessionReaper.test.ts | 1 + .../src/provider/RuntimeInstructions.test.ts | 8 + .../src/provider/RuntimeInstructions.ts | 6 +- .../pullRequest/GitHubPullRequestCli.test.ts | 253 ++++++- .../src/pullRequest/GitHubPullRequestCli.ts | 136 ++-- .../GitHubPullRequestProvider.test.ts | 61 ++ .../pullRequest/GitHubPullRequestProvider.ts | 16 +- .../src/pullRequest/PullRequestProvider.ts | 36 + .../pullRequest/PullRequestService.test.ts | 362 ++++++++- .../src/pullRequest/PullRequestService.ts | 261 +++++-- .../pullRequest/gitHubPullRequestJson.test.ts | 79 ++ .../src/pullRequest/gitHubPullRequestJson.ts | 66 ++ .../src/pullRequest/linkedThreads.test.ts | 117 +++ apps/server/src/pullRequest/linkedThreads.ts | 39 + .../pullRequest/pullRequestSyncKey.test.ts | 46 ++ .../src/pullRequest/pullRequestSyncKey.ts | 44 ++ .../src/relay/AgentAwarenessRelay.test.ts | 3 + apps/server/src/server.test.ts | 11 +- apps/server/src/server.ts | 2 + apps/server/src/ws.ts | 97 ++- apps/web/src/components/ChatMarkdown.test.tsx | 4 +- apps/web/src/components/ChatMarkdown.tsx | 75 +- .../ChatMarkdown.workspace-images.test.tsx | 4 +- .../web/src/components/ChatView.logic.test.ts | 2 + apps/web/src/components/ChatView.logic.ts | 1 + apps/web/src/components/ChatView.tsx | 32 +- .../components/CommandPalette.logic.test.ts | 1 + apps/web/src/components/CommandPalette.tsx | 39 +- apps/web/src/components/GitActionsControl.tsx | 3 + apps/web/src/components/LegacySidebar.tsx | 49 +- .../src/components/RightPanelTabs.test.tsx | 48 ++ apps/web/src/components/RightPanelTabs.tsx | 100 ++- apps/web/src/components/Sidebar.logic.test.ts | 1 + apps/web/src/components/Sidebar.tsx | 125 +++- .../ThreadStatusIndicators.test.tsx | 47 +- .../src/components/ThreadStatusIndicators.tsx | 189 ++++- .../chat/MessagesTimeline.logic.test.ts | 1 + .../LinkBranchPullRequestButton.tsx | 54 ++ .../LinkPullRequestDialog.logic.test.ts | 136 ++++ .../pullRequest/LinkPullRequestDialog.tsx | 254 +++++++ .../pullRequest/PullRequestDetailPanel.tsx | 124 ++- .../pullRequest/PullRequestStackMap.tsx | 87 +++ .../pullRequest/PullRequestSummaryTab.tsx | 15 +- .../pullRequest/PullRequestThreadLinks.tsx | 255 +++++++ .../pullRequest/ThreadPullRequestsPanel.tsx | 296 ++++++++ .../pullRequestDetail.logic.test.ts | 36 + .../pullRequest/pullRequestDetail.logic.ts | 14 +- .../pullRequest/pullRequestListLines.test.ts | 76 ++ .../pullRequest/pullRequestListLines.ts | 49 ++ .../pullRequestReviewStore.test.ts | 31 +- .../pullRequest/pullRequestReviewStore.ts | 19 +- apps/web/src/components/ui/button.tsx | 21 + .../src/hooks/useOpenPanelPullRequestUrl.ts | 5 + apps/web/src/hooks/usePullRequestLinking.ts | 104 +++ .../hooks/useSupportsMultiplePullRequests.ts | 10 + apps/web/src/lib/openPullRequestLink.test.ts | 76 ++ apps/web/src/lib/openPullRequestLink.ts | 258 ++----- apps/web/src/lib/threadSort.test.ts | 1 + apps/web/src/rightPanelStore.test.ts | 12 + apps/web/src/rightPanelStore.ts | 13 +- apps/web/src/routes/_chat.pull-requests.tsx | 2 + apps/web/src/state/pullRequests.ts | 3 + apps/web/src/state/sourceControlActions.ts | 2 + apps/web/src/worktreeCleanup.test.ts | 1 + docs/internals/glossary.md | 8 + docs/internals/overview.md | 19 + docs/user/source-control.md | 24 + packages/client-runtime/package.json | 4 + .../client-runtime/src/operations/commands.ts | 20 + .../client-runtime/src/state/entities.test.ts | 1 + .../src/state/environmentHttpAuth.test.ts | 1 + .../src/state/pullRequests.test.ts | 127 +++- .../client-runtime/src/state/pullRequests.ts | 29 +- .../src/state/shellReducer.test.ts | 1 + .../src/state/threadCommands.ts | 18 + .../src/state/threadReducer.test.ts | 119 +++ .../client-runtime/src/state/threadReducer.ts | 60 ++ .../src/state/threads-atoms.test.ts | 1 + .../src/state/threads-pagination.test.ts | 1 + .../src/state/threads-sync.test.ts | 1 + .../src/state/vcsAction.test.ts | 13 +- .../client-runtime/src/state/vcsAction.ts | 4 + .../threadPullRequestCompatibility.test.ts | 115 +++ .../src/threadPullRequestCompatibility.ts | 75 ++ packages/contracts/src/environment.ts | 7 +- packages/contracts/src/git.ts | 2 + packages/contracts/src/orchestration.test.ts | 172 ++++- packages/contracts/src/orchestration.ts | 168 +++++ packages/contracts/src/previewAutomation.ts | 25 +- packages/contracts/src/pullRequest.ts | 49 ++ packages/contracts/src/rpc.ts | 18 + packages/shared/package.json | 8 + packages/shared/src/changeRequestUrl.test.ts | 142 ++++ packages/shared/src/changeRequestUrl.ts | 210 ++++++ packages/shared/src/sourceControl.test.ts | 32 + packages/shared/src/sourceControl.ts | 46 +- .../shared/src/threadPullRequests.test.ts | 351 +++++++++ packages/shared/src/threadPullRequests.ts | 271 +++++++ packages/shared/src/threadReference.test.ts | 66 ++ packages/shared/src/threadReference.ts | 10 +- 172 files changed, 12057 insertions(+), 908 deletions(-) create mode 100644 apps/server/src/git/linkCreatedPullRequest.test.ts create mode 100644 apps/server/src/git/linkCreatedPullRequest.ts create mode 100644 apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts create mode 100644 apps/server/src/mcp/toolkits/pullRequests/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/pullRequests/tools.ts create mode 100644 apps/server/src/orchestration/PullRequestSyncReactor.test.ts create mode 100644 apps/server/src/orchestration/PullRequestSyncReactor.ts create mode 100644 apps/server/src/orchestration/decider.pullRequests.test.ts create mode 100644 apps/server/src/orchestration/projector.pullRequests.test.ts create mode 100644 apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.test.ts create mode 100644 apps/server/src/persistence/Migrations/050_ProjectionThreadPullRequests.ts create mode 100644 apps/server/src/persistence/ProjectionThreadPullRequests.ts create mode 100644 apps/server/src/pullRequest/linkedThreads.test.ts create mode 100644 apps/server/src/pullRequest/linkedThreads.ts create mode 100644 apps/server/src/pullRequest/pullRequestSyncKey.test.ts create mode 100644 apps/server/src/pullRequest/pullRequestSyncKey.ts create mode 100644 apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx create mode 100644 apps/web/src/components/pullRequest/LinkPullRequestDialog.logic.test.ts create mode 100644 apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestStackMap.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx create mode 100644 apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx create mode 100644 apps/web/src/components/pullRequest/pullRequestListLines.test.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestListLines.ts create mode 100644 apps/web/src/hooks/usePullRequestLinking.ts create mode 100644 apps/web/src/hooks/useSupportsMultiplePullRequests.ts create mode 100644 packages/client-runtime/src/threadPullRequestCompatibility.test.ts create mode 100644 packages/client-runtime/src/threadPullRequestCompatibility.ts create mode 100644 packages/shared/src/changeRequestUrl.test.ts create mode 100644 packages/shared/src/changeRequestUrl.ts create mode 100644 packages/shared/src/threadPullRequests.test.ts create mode 100644 packages/shared/src/threadPullRequests.ts 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/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/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index c5a9f2c6bbcb..eb1722c73fde 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -43,6 +43,7 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { 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/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index e59531fe7ca9..0f7b14bf9365 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -36,6 +36,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/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/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..0ec50c674451 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -56,6 +56,7 @@ function pullRequestTintColor( return dark ? "#34d399" : "#059669"; case "merged": return dark ? "#a78bfa" : "#7c3aed"; + case null: case "closed": return dark ? "#a1a1aa" : "#71717a"; } @@ -618,15 +619,30 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ) : null} {pr !== null ? ( - - + + {pr.kind === "stack" ? ( + + ) : ( + + )} )} {pr ? ( - - #{pr.label} - + + {pr.kind === "stack" ? ( + + ) : null} + + {pr.kind === "stack" ? pr.label : `#${pr.label}`} + + ) : null} {props.providerInstance ? ( ; export interface ThreadPrPresentation { readonly number: number; - readonly state: ThreadPr["state"]; + readonly state: ThreadPr["state"] | null; + readonly kind: "pull-request" | "stack"; readonly isDraft: boolean; /** Provider-side last activity, bounding when a terminal state landed. */ readonly updatedAt: string | null; @@ -30,6 +41,7 @@ export function presentThreadPr( const presentation = resolveChangeRequestPresentation(provider); const isDraft = pr.state === "open" && pr.isDraft === true; return { + kind: "pull-request", number: pr.number, state: pr.state, isDraft, @@ -40,3 +52,53 @@ 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 state = badge.kind === "stack" ? badge.state : (snapshot?.state ?? null); + const isDraft = snapshot?.isDraft === true && state === "open"; + const label = + badge.kind === "stack" + ? String(badge.layers) + : `${link.number}${badge.others > 0 ? ` +${badge.others}` : ""}`; + return { + kind: badge.kind, + number: link.number, + state, + isDraft, + updatedAt: snapshot?.updatedAt ?? null, + url: link.url, + label, + accessibilityLabel: + badge.kind === "stack" + ? `${badge.layers} pull requests in stack, ${state ?? "status pending"}` + : `#${link.number} pull request ${state === null ? "status pending" : isDraft ? "draft" : state}${badge.others > 0 ? `, ${badge.others} more linked` : ""}`, + textClassName: state === null || isDraft ? "text-foreground-muted" : 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..330e31cb0171 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,142 @@ 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: "1 +1", + }); + }); + + 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/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index a069322aa8bf..a63b07f0adef 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, 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..51c3c6679b7c 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -230,6 +230,7 @@ export const make = Effect.gen(function* () { threadPinReorder: true, threadActiveReorder: true, threadTitleRegeneration: true, + threadPullRequests: 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/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 133e71bc3bcb..885629930706 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/", @@ -345,6 +360,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* () { diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 3f3e48ebe4b2..3556a57befdc 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -28,6 +28,8 @@ import { PreviewSnapshotToolkit, PreviewStandardToolkit, } from "./toolkits/preview/tools.ts"; +import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts"; +import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; const unauthorized = HttpServerResponse.jsonUnsafe( { @@ -437,6 +439,10 @@ export const PreviewToolkitRegistrationLive = Layer.mergeAll( PreviewSnapshotRegistrationLive, ); +export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequestsToolkit).pipe( + Layer.provide(PullRequestsToolkitHandlersLive), +); + const McpTransportLive = McpServer.layerHttp({ name: "T3 Code", version: packageJson.version, @@ -444,4 +450,7 @@ 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, +).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..0c0a0ab68ae9 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" | "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.ts b/apps/server/src/mcp/McpProviderSession.ts index d5dc582046c1..61c3ac1e0b20 100644 --- a/apps/server/src/mcp/McpProviderSession.ts +++ b/apps/server/src/mcp/McpProviderSession.ts @@ -7,6 +7,8 @@ export interface McpProviderSessionConfig { readonly providerInstanceId: ProviderInstanceId; readonly endpoint: string; readonly authorizationHeader: string; + /** Whether the credential grants the preview (browser) toolkit; the pull request toolkit always is. */ + readonly preview: boolean; } const sessionsByThread = new Map(); diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index 1d8aead99d0d..2e9749a04062 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"), + preview: true, }); expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp"); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); @@ -54,6 +55,29 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t }), ); +it.effect("always grants pull-requests and gates preview on the request", () => + Effect.gen(function* () { + const registry = yield* makeRegistry(() => 1_000); + const withPreview = yield* registry.issue({ + threadId: ThreadId.make("thread-preview"), + providerInstanceId: ProviderInstanceId.make("codex"), + preview: true, + }); + const withoutPreview = yield* registry.issue({ + threadId: ThreadId.make("thread-no-preview"), + providerInstanceId: ProviderInstanceId.make("codex"), + preview: false, + }); + 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"]); + }), +); + it.effect("builds MCP endpoints from the bound server host", () => Effect.gen(function* () { const cases = [ @@ -68,6 +92,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"), + preview: true, }); expect(issued.config.endpoint).toBe(expectedEndpoint); } @@ -81,6 +106,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"), + preview: true, }); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); timestamp += 101; @@ -96,6 +122,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"), + preview: true, }); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); @@ -117,6 +144,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"), + preview: true, }); 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..130f6dce582b 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -14,6 +14,11 @@ import * as McpProviderSession from "./McpProviderSession.ts"; export interface McpCredentialRequest { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; + /** + * Whether the credential may drive the user's browser. The pull request + * toolkit is always granted: it only touches the thread's own links. + */ + readonly preview: boolean; } export interface McpIssuedCredential { @@ -68,7 +73,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 +133,9 @@ 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( + request.preview ? ["pull-requests", "preview"] : ["pull-requests"], + ), issuedAt, }; yield* SynchronizedRef.update(state, ({ records }) => { @@ -144,6 +151,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( providerInstanceId: scope.providerInstanceId, endpoint, authorizationHeader: `Bearer ${rawToken}`, + preview: request.preview, }, }; }, 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/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 2cb4ed7566ad..3929136f90fc 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.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,20 @@ 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", + }, + }), + }); try { const projectId = ProjectId.make("pr-race-project"); const threadId = ThreadId.make("pr-race-thread"); @@ -1058,6 +1087,7 @@ describe("OrchestrationEngine", () => { linkedPullRequest: previous, }), ); + expect((await system.readModel()).threads[0]?.linkedPullRequest).toEqual(previous); const metadataChanges = { unlink: { linkedPullRequest: null }, relink: { 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/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts new file mode 100644 index 000000000000..78d54c6b2ea9 --- /dev/null +++ b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts @@ -0,0 +1,704 @@ +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) => + Effect.gen(function* () { + 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..e86ef14cd0cc --- /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).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..6f0b4d90aca1 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts @@ -87,6 +87,7 @@ function thread( modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, runtimeMode: "full-access", interactionMode: "default", + pullRequests: [], branch: "feature", worktreePath: null, latestTurn: null, diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts index b694bacbbb33..e6ee699ec4de 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, @@ -138,7 +130,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 +181,7 @@ export const make = Effect.gen(function* () { let replacement: ThreadLinkedPullRequest | undefined; if ( + thread.pullRequests.length === 0 && thread.linkedPullRequest != null && detected?.state === "open" && detectedReference !== null && 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/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 288db92799bc..5422a8730f27 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1,3 +1,4 @@ +import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; @@ -392,8 +393,7 @@ describe("ClaudeAdapterLive", () => { 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); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 2d88e58dc1fb..5c3d1f08e2d0 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -2283,6 +2283,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( "-c", 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"', ], + browserToolsAvailable: mcpSession.preview, } : {}), }; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 78f4b9aa8e50..2ec77ecb7000 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -166,6 +166,13 @@ export interface CodexSessionRuntimeOptions { readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; readonly appServerArgs?: ReadonlyArray; + /** + * Whether the attached `t3-code` MCP server exposes the preview tools. The + * server is attached for every session now (the pull request toolkit is + * always on), so its presence in `appServerArgs` no longer implies browser + * access; the credential's own capability decides the developer prompt. + */ + readonly browserToolsAvailable?: boolean; } export interface CodexSessionRuntimeSendTurnInput { @@ -2349,10 +2356,12 @@ 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: + hasConfiguredMcpServer(options.appServerArgs) && + (options.browserToolsAvailable ?? true), }); const rawResponse = yield* client.raw.request("turn/start", params); const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index e7647866d604..1ce1a396796f 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4797,7 +4797,6 @@ 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 = ( @@ -4806,7 +4805,7 @@ describe("agent browser access", () => { projectOverride?: boolean, ) => Effect.gen(function* () { - const issued: Array = []; + const issued: Array<{ readonly threadId: ThreadId; readonly preview: boolean }> = []; const codex = makeFakeCodexAdapter(); const providerAdapterLayer = Layer.succeed( ProviderAdapterRegistry.ProviderAdapterRegistry, @@ -4865,10 +4864,9 @@ describe("agent browser access", () => { const providerLayer = makeProviderServiceLive({ issueMcpCredential: (request) => Effect.sync(() => { - issued.push(request.threadId); + issued.push({ threadId: request.threadId, preview: request.preview }); return undefined; }), - revokeMcpCredential: (revoked) => Effect.sync(() => void revokedThreads.push(revoked)), }).pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), @@ -4903,48 +4901,34 @@ 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"); - assert.deepEqual(issued, []); - }).pipe(Effect.provide(NodeServices.layer)), - ); - - it.effect("revokes an already-issued credential when access is off", () => - Effect.gen(function* () { - const threadId = asThreadId("thread-browser-revoke"); - revokedThreads.length = 0; - - yield* startSessionWith(false, threadId); + const issued = yield* startSessionWith(false, 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, preview: false }]); }).pipe(Effect.provide(NodeServices.layer)), ); - it.effect("requests an MCP credential when agent browser access is on", () => + it.effect("issues a credential with preview when agent browser access is on", () => Effect.gen(function* () { const threadId = asThreadId("thread-browser-on"); const issued = yield* startSessionWith(true, threadId); - assert.deepEqual(issued, [threadId]); + assert.deepEqual(issued, [{ threadId, preview: true }]); }).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(true, threadId, false); - assert.deepEqual(issued, []); - assert.deepEqual(revokedThreads, [threadId]); + assert.deepEqual(issued, [{ threadId, preview: false }]); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -4952,7 +4936,7 @@ describe("agent browser access", () => { Effect.gen(function* () { const threadId = asThreadId("thread-project-browser-on"); const issued = yield* startSessionWith(false, threadId, true); - assert.deepEqual(issued, [threadId]); + assert.deepEqual(issued, [{ threadId, preview: true }]); }).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..5b9059bfa643 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -249,8 +249,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,8 +477,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); const issueMcpCredential = options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; - const revokeMcpCredential = - options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread; const fileSystem = yield* FileSystem.FileSystem; const runtimeEventPubSub = yield* PubSub.unbounded(); const pendingCompactions = new Map(); @@ -853,14 +849,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,20 +879,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ); + /** + * Attach the `t3-code` MCP server to the session that is about to start. + * + * Every session gets a credential: the pull request toolkit is always on, + * since it only registers links on the session's own thread. Browser access + * is a capability on that credential, so turning the setting off withholds + * the preview tools without taking the server away. `issueActiveMcpCredential` + * revokes the thread's previous token first, which matters because a session + * restart (runtime mode, cwd, model) re-prepares without stopping. + */ 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 preview = yield* agentBrowserAccessEnabled(threadId); + const credential = yield* issueMcpCredential({ threadId, providerInstanceId, preview }); if (credential) { yield* Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config)); } 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..e73c50adfd6d 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({ diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index 102c49c78a9e..5e72586062e5 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.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`; } function toSingleLine(value: string): string { diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index f61b7c3233f6..2941b3643638 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -183,20 +183,37 @@ afterEach(() => { }); 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 +224,207 @@ 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, - 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.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("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", - reference: "https://github.com/acme/web/pull/7", + 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, + }); + + 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", + 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"); }), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 6152df75fb73..136f87e6a051 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -39,6 +39,7 @@ import { decodePullRequestListJson, decodePullRequestNodeIdJson, decodePullRequestSearchJson, + decodePullRequestStacksJson, decodePullRequestStatsJson, decodeReactionSubjectScopeJson, decodeRepositoryAccessJson, @@ -84,6 +85,7 @@ import { type GitHubPullRequestHead, type GitHubPullRequestListItem, type GitHubPullRequestSearchItem, + type GitHubPullRequestStack, type GitHubReviewThreadComments, type GitHubRepositoryAccess, type GitHubWorkflowRunApproval, @@ -91,7 +93,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 @@ -460,21 +462,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => 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 +482,17 @@ 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 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 @@ -807,8 +806,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 || @@ -1630,41 +1629,94 @@ 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, + }), + ); + }), + // 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({ diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 8fd0f09dd3ea..47fb593279e7 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"); }), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index e75ef3547c04..0d46b032a5a5 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -47,6 +47,7 @@ const CAPABILITIES: PullRequestCapabilities = { }, reviewers: { request: true, listCandidates: true }, edit: { changeRequest: true, comment: true }, + stacks: true, labels: true, }; @@ -292,7 +293,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( diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index a021db91007e..2c1fda70d519 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -103,6 +103,34 @@ 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 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 +359,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 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 }, diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index eb2f913c9206..c78cfb647217 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -32,6 +32,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 +48,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, @@ -470,7 +471,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 +1564,247 @@ it.effect("refuses a repository that does not belong to the requested project", }), ); +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 +3619,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 +3871,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({ @@ -4137,3 +4384,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..f97366018dcd 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"; @@ -46,6 +50,7 @@ import { type PullRequestLabelCandidateList, type PullRequestLabelChangeInput, type PullRequestSubmitReviewInput, + type PullRequestStack, type PullRequestSummary, type PullRequestThreadReplyInput, type PullRequestThreadResolutionInput, @@ -144,6 +149,13 @@ 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, + ) => Effect.Effect; readonly subscribeMerges: Effect.Effect< Stream.Stream, never, @@ -241,6 +253,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 +466,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 +487,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 +522,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* () { @@ -554,7 +549,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 +627,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 +646,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 +659,7 @@ export const make = Effect.gen(function* () { continue; } supported.push({ + cursorKey: key, project, api: withRateLimitBackoff(api, host, rateLimits), repository, @@ -667,16 +670,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 +701,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 +878,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 @@ -949,9 +998,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 +1040,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 +1049,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 +1208,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 +1301,67 @@ 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) => + 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, + }).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) => ({ + number: layer.number, + headBranch: layer.headBranch, + state: layer.state, + })), + }, + ), + ); + }), + ); + const detailUncached: PullRequestService["Service"]["detail"] = (input) => requireProject(input).pipe( Effect.flatMap((project) => @@ -1507,7 +1604,11 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.mapError(toPullRequestError("runAction")), - Effect.as(project.repository), + Effect.as( + project.api.kind === "azure-devops" + ? input.repository.trim() + : project.repository, + ), ); }), ); @@ -2133,11 +2234,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]); @@ -2181,8 +2306,7 @@ 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); + return summaryUncached(refOfCacheKey(key)); }, { capacity: DETAIL_CACHE_CAPACITY, @@ -2203,6 +2327,13 @@ export const make = Effect.gen(function* () { ); }; + const stackCache = yield* Cache.makeWith((key: string) => stackUncached(refOfCacheKey(key)), { + capacity: DETAIL_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero), + }); + const stack: PullRequestService["Service"]["stack"] = (input) => + Cache.get(stackCache, refCacheKey(input)); + // 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 +2414,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 +2437,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 +2450,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 +2478,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 +2490,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 +2504,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 +2515,7 @@ export const make = Effect.gen(function* () { ]; return diffUncached({ projectId, + ...(host === null ? {} : { host }), repository, number, ...(cursor === null ? {} : { cursor }), @@ -2385,7 +2526,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 +2535,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, @@ -2523,6 +2665,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..5391e8a567ec 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -11,6 +11,7 @@ import { decodePullRequestListJson, decodePullRequestNodeIdJson, decodePullRequestSearchJson, + decodePullRequestStacksJson, decodeLabelCandidatesJson, decodeRepositoryAccessJson, decodeReviewerCandidatesJson, @@ -1500,3 +1501,81 @@ 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("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); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 7887a81617d6..4c1280464672 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -2445,3 +2445,69 @@ 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({ + number: Schema.Int, + head: Schema.Struct({ ref: 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 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) => ({ + number: pullRequest.number, + headBranch: pullRequest.head.ref, + state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }), + })), + }); +} 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..01f2dd96b18f 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -117,6 +117,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 +332,7 @@ const makeDefaultOrchestrationReadModel = () => { runtimeMode: "full-access" as const, branch: null, worktreePath: null, + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, @@ -361,6 +363,7 @@ const makeDefaultOrchestrationThreadShell = ( interactionMode: "default", branch: null, worktreePath: null, + pullRequests: [], latestTurn: null, createdAt: now, updatedAt: now, @@ -384,7 +387,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)({ @@ -956,6 +959,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( @@ -8104,6 +8112,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..c531cab63c8f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -71,6 +71,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"; @@ -280,6 +281,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), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 740be1330817..5e4ca0a18db9 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"; @@ -124,6 +125,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 +144,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 +482,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; @@ -604,6 +621,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 +2151,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 +2192,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 +2244,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 +2569,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)), ), }), @@ -2932,6 +3011,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( ), }); const pullRequests = yield* PullRequestService.PullRequestService; + const sql = yield* SqlClient.SqlClient; return HttpRouter.add( "GET", "/ws", @@ -2966,6 +3046,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/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 2c4a1fa7af6e..d21960a28e34 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(), })); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 02c61d8e8207..192abaf385f7 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 { @@ -155,7 +156,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 +164,6 @@ import { } from "../workspaceBasenameLookup"; import { findProjectForChangeRequest, - matchesLinkedPullRequestUrl, parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, useOpenChangeRequestLink, @@ -2185,9 +2184,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 +2236,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 +2325,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 +2563,7 @@ function useChatMarkdownState({ openExternalLinkInPreview, openMarkdownMedia, projects, + linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, serverConfig, @@ -2614,6 +2590,7 @@ function useChatMarkdownState({ openExternalLinkInPreview, openMarkdownMedia, projects, + linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, serverConfig, @@ -2732,6 +2709,7 @@ const CHAT_MARKDOWN_COMPONENTS = { linkTargetPreference, openExternalLinkInPreview, projects, + linkedThreadPullRequestFor, resolveThreadPullRequest, serverConfig, updateThreadPullRequestLink, @@ -2866,13 +2844,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({ 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..06fcce22ee3f 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -726,6 +726,7 @@ function makeThread(overrides: Partial = {}): Thread { proposedPlans: [], activities: [], checkpoints: [], + pullRequests: [], createdAt: now, updatedAt: now, archivedAt: null, @@ -843,6 +844,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..66214df385e8 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -353,6 +353,7 @@ export function buildLocalDraftThread( branch: draftThread.branch, worktreePath: draftThread.worktreePath, checkpoints: [], + pullRequests: [], activities: [], proposedPlans: [], }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0fbef88c81e1..2f511c115a3a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -203,6 +203,8 @@ 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 { deriveAgentPanelModel, foldSubagentActivities, @@ -4127,6 +4129,12 @@ 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 openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; @@ -5375,9 +5383,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; @@ -8037,11 +8052,12 @@ 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. + ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( + ) : renderedRightPanelSurface?.kind === "agents" ? ( @@ -8670,12 +8695,14 @@ export default function ChatView(props: ChatViewProps) { onAddDiff={addDiffSurface} onAddFiles={addFilesSurface} onAddPullRequest={addPullRequestSurface} + onAddPullRequests={addPullRequestsSurface} onAddAgents={addAgentsSurface} browserAvailable={isPreviewSupportedInRuntime()} terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} + pullRequestsAvailable={isServerThread && supportsThreadPullRequests} agentsAvailable liveAgentCount={agentPanelModel.liveCount} > @@ -8684,6 +8711,7 @@ export default function ChatView(props: ChatViewProps) { ) : null} + {expandedImage && ( = {}): Thread { branch: null, worktreePath: null, checkpoints: [], + pullRequests: [], activities: [], ...overrides, }; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index d146120f719d..d22759659510 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,5 +1,7 @@ "use client"; +import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; + import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { canCreateProjectInEnvironment, @@ -43,6 +45,7 @@ import { FileSearchIcon, FolderIcon, FolderPlusIcon, + GitPullRequestArrowIcon, LinkIcon, MessageSquareIcon, PaletteIcon, @@ -80,7 +83,7 @@ import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { useProjects, useThreadShells } from "../state/entities"; +import { useProjects, useServerConfigs, useThreadShells } from "../state/entities"; import { useThreadSearch } from "../state/queries"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { @@ -148,6 +151,7 @@ import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons" import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; +import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch"; @@ -605,12 +609,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, }); @@ -1591,6 +1599,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", diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index c575f270f0bc..f816f60b4026 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, }); diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index c0e16c7cce71..3a3936623275 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1,3 +1,10 @@ +import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; +import { GitPullRequestIcon } from "lucide-react"; +import { LinkBranchPullRequestButton } from "./pullRequest/LinkBranchPullRequestButton"; +import { + resolveThreadCurrentPullRequestLink, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; import { Spinner } from "~/components/ui/spinner"; import { ArchiveIcon, @@ -465,10 +472,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 +592,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 +751,25 @@ 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} + {pr && + (supportsMultiplePullRequests + ? visibleThreadPullRequests(thread.pullRequests).length === 0 + : thread.linkedPullRequest == null) ? ( + + ) : null} {threadStatus && } {renamingThreadKey === threadKey ? ( undefined} onAddTerminal={() => undefined} onAddPullRequest={() => undefined} + onAddPullRequests={() => undefined} onAddDiff={() => undefined} onAddFiles={() => undefined} onAddAgents={() => undefined} @@ -126,6 +129,7 @@ function renderTabs( diffAvailable={false} filesAvailable={false} pullRequestAvailable={false} + pullRequestsAvailable={false} agentsAvailable={false} >
content
@@ -278,3 +282,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..d4ba544d8588 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, @@ -14,6 +21,7 @@ import { FileDiff, Files, GitPullRequest, + GitPullRequestArrow, Globe2, Plus, TerminalSquare, @@ -104,12 +112,14 @@ interface RightPanelTabsProps { onAddDiff: () => void; onAddFiles: () => void; onAddPullRequest: () => void; + onAddPullRequests: () => void; onAddAgents: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; + pullRequestsAvailable: boolean; agentsAvailable: boolean; pullRequestStatusSeeds?: Readonly>; /** Running + waiting subagents; badges the Agents card in the empty state. */ @@ -139,6 +149,7 @@ 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.", } as const; @@ -161,6 +172,7 @@ const SURFACE_UNAVAILABLE_HINTS = { 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.", } as const; @@ -298,12 +310,14 @@ function RightPanelEmptyState(props: { onAddDiff: () => void; onAddFiles: () => void; onAddPullRequest: () => void; + onAddPullRequests: () => void; onAddAgents: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; + pullRequestsAvailable: boolean; agentsAvailable: boolean; liveAgentCount: number; }) { @@ -361,6 +375,16 @@ function RightPanelEmptyState(props: { onClick: props.onAddPullRequest, badgeCount: 0, }, + { + label: "Linked pull requests", + description: "Every pull request this thread has linked, stacks included.", + icon: GitPullRequestArrow, + shortcut: "L", + available: props.pullRequestsAvailable, + disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, + onClick: props.onAddPullRequests, + badgeCount: 0, + }, { label: "Agents", description: "Follow subagents and workflows.", @@ -602,6 +626,8 @@ function surfaceTitle( ); case "pull-request": return `#${surface.number}`; + case "pull-requests": + return "Pull requests"; case "agents": return "Agents"; case "preview": { @@ -683,11 +709,42 @@ function SurfaceIcon({ seed={pullRequestStatusSeeds?.[surface.id]} /> ); + case "pull-requests": + return ; case "agents": return ; } } +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({ surface, environmentId, @@ -699,13 +756,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 +794,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 ; } @@ -806,6 +890,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, @@ -1250,12 +1342,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddDiff={props.onAddDiff} onAddFiles={props.onAddFiles} onAddPullRequest={props.onAddPullRequest} + onAddPullRequests={props.onAddPullRequests} onAddAgents={props.onAddAgents} browserAvailable={props.browserAvailable} terminalAvailable={props.terminalAvailable} diffAvailable={props.diffAvailable} filesAvailable={props.filesAvailable} pullRequestAvailable={props.pullRequestAvailable} + pullRequestsAvailable={props.pullRequestsAvailable} agentsAvailable={props.agentsAvailable} 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..f09a9644948d 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -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.tsx b/apps/web/src/components/Sidebar.tsx index ebc1078e672b..31730da523be 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,9 @@ +import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; +import { LinkBranchPullRequestButton } from "./pullRequest/LinkBranchPullRequestButton"; +import { + resolveThreadCurrentPullRequestLink, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { @@ -70,6 +76,7 @@ import { } from "react"; import { useParams, useRouter } from "@tanstack/react-router"; +import { useRightPanelStore } from "../rightPanelStore"; import { isAtomCommandInterrupted, settlePromise, @@ -185,8 +192,12 @@ import { import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; import { createSidebarListMotion } from "./Sidebar.motion"; import { + PR_STATE_COLOR_CLASS, + ThreadPullRequestBadgeIcon, + ThreadPullRequestsMiniList, ThreadWorktreeIndicator, prStatusIndicator, + resolveThreadPullRequestBadge, settledPrHoverColorClass, terminalStatusFromRunningIds, type TerminalStatusIndicator, @@ -209,7 +220,7 @@ import { } from "../providerInstances"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { Button } from "./ui/button"; +import { Button, InlineButton } from "./ui/button"; import { Input } from "./ui/input"; import { Combobox, @@ -330,6 +341,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 +1064,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 +1082,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. @@ -1339,17 +1362,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 @@ -1456,10 +1488,43 @@ 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. + // One badge shape for every thread: the glyph says stack or not, the number is the current + // pull request, and "+N" counts the others behind it. A real link so cmd/ctrl+click and + // middle-click open the host in the browser; a plain click opens T3's pull request view. + const prBadgeShape = supportsMultiplePullRequests + ? resolveThreadPullRequestBadge(thread.pullRequests) + : null; + const prBadgeClassName = (state: "open" | "merged" | "closed", colorClass: string) => + cn( + // Sidebar chrome follows the interface font; tabular digits keep the number from + // reflowing as PR states stream in. A border rather than text-decoration, so the line + // runs under the glyph as well as the number. + "text-xs tabular-nums", + variant === "slim" && variantAction === "unsettle" + ? props.isActive + ? "text-secondary-label" + : cn("text-secondary-label transition-colors", settledPrHoverColorClass(state)) + : colorClass, + ); + const handlePrStackClick = useCallback(() => { + useRightPanelStore.getState().open(threadRef, "pull-requests"); + if (!props.isActive) onThreadActivate(threadRef); + }, [onThreadActivate, props.isActive, threadRef]); const prBadge = - prStatus && pr ? ( + prBadgeShape?.kind === "stack" ? ( + // A stack is one thing with N layers; naming one of them would misrepresent it, so the + // badge counts layers and opens the thread's pull-requests surface. + event.stopPropagation()} + onClick={handlePrStackClick} + className={prBadgeClassName(prBadgeShape.state, PR_STATE_COLOR_CLASS[prBadgeShape.state])} + aria-label={`Stack of ${prBadgeShape.layers} pull requests, ${prBadgeShape.state}`} + > + + {prBadgeShape.layers} + + ) : prStatus && pr ? ( 0 + ? `${prStatus.tooltip}, and ${prBadgeShape.others} more linked` + : prStatus.tooltip + } > - #{pr.number} + + {pr.number} + {prBadgeShape && prBadgeShape.others > 0 ? ( + +{prBadgeShape.others} + ) : null} + + ) : currentLinkedPr ? ( + event.stopPropagation()} + onClick={handlePrClick} + className="inline-flex shrink-0 items-center gap-0.5 text-xs tabular-nums text-muted-foreground hover:underline" + aria-label={`PR #${currentLinkedPr.number}, status pending`} + > + + {currentLinkedPr.number} + {prBadgeShape?.kind === "pull-request" && prBadgeShape.others > 0 ? ( + +{prBadgeShape.others} + ) : null} ) : null; const terminalStatusIcon = terminalStatus ? ( @@ -1589,6 +1678,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { remain visible AND clickable while the row is hovered. Only the time/jump label yields to the settle affordance. */} {prBadge} + {prBadge && + pr && + (supportsMultiplePullRequests + ? visibleThreadPullRequests(thread.pullRequests).length === 0 + : thread.linkedPullRequest == null) ? ( + + ) : null} {sortable?.isDragging ? ( dragDestination ) : ( @@ -1889,6 +1985,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )} {terminalStatusIcon} {prBadge} + {prBadge && + pr && + (supportsMultiplePullRequests + ? visibleThreadPullRequests(thread.pullRequests).length === 0 + : thread.linkedPullRequest == null) ? ( + + ) : null} {diff ? ( +{diff.insertions}{" "} 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..41a72a40e4c1 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,25 +1,35 @@ +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 { + resolveThreadCurrentPullRequestLink, + resolveThreadPullRequestChains, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; +import { FolderGit2Icon, GitPullRequestArrowIcon, LayersIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; +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 +53,151 @@ 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]); +} - return useMemo( +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: "" }, + }; +} + +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. */ +export function ThreadPullRequestBadgeIcon({ + icon, + className, +}: { + icon: "stack" | "pull-request"; + className?: string | undefined; +}) { + const Icon = icon === "stack" ? LayersIcon : GitPullRequestArrowIcon; + return ; +} + +/** + * 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. */ +export const PR_STATE_COLOR_CLASS: Record["state"], string> = { + 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", +}; + export function settledPrHoverColorClass( state: NonNullable["state"], isDraft = false, @@ -282,7 +401,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 +415,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 +443,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar ) : null} + {pendingLink ? ( + + ) : null} {threadStatus ? : null}
); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index e1cf7f0dc6e7..0c5e53261b4f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -390,6 +390,7 @@ describe("streaming row projection", () => { runtimeMode: "full-access", interactionMode: "default", branch: null, + pullRequests: [], worktreePath: null, latestTurn: { ...initial.input.latestTurn, diff --git a/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx b/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx new file mode 100644 index 000000000000..bbcb9e144190 --- /dev/null +++ b/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx @@ -0,0 +1,54 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { Link2 } from "lucide-react"; +import { useState } from "react"; +import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; +import { Button } from "../ui/button"; +import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +/** Adopts a branch discovery as a durable link, even after the thread changes branches. */ +export function LinkBranchPullRequestButton({ + threadRef, + url, +}: { + threadRef: ScopedThreadRef; + url: string; +}) { + const linking = usePullRequestLinking(threadRef.environmentId); + const [pending, setPending] = useState(false); + if (!linking.canLink(url)) return null; + return ( + + event.stopPropagation()} + onClick={async (event) => { + event.preventDefault(); + event.stopPropagation(); + setPending(true); + try { + await linking.changeLink(threadRef, url, true); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not link pull request", + description: error instanceof Error ? error.message : String(error), + }); + } finally { + setPending(false); + } + }} + > + + + } + /> + Link this PR to keep it with this thread + + ); +} 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..5f066d6ef882 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -66,16 +66,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, pullRequestStackAtom } from "~/state/pullRequests"; +import { usePullRequestTurnRefresh, useSharedPullRequestSummary } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; +import { PullRequestStackMap } from "./PullRequestStackMap"; +import { PullRequestThreadLinks } from "./PullRequestThreadLinks"; import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { useUiStateStore } from "~/uiStateStore"; @@ -452,13 +451,14 @@ function PullRequestBaseFreshnessWarning({ export function PullRequestDetailPanel({ environmentId, threadRef = null, - reference, + reference: requestedReference, listEntry = null, refreshToken: forcedRefreshToken = 0, onActed, onClose, context = "page", composerDraftTarget, + onBack, }: { environmentId: EnvironmentId; /** @@ -495,14 +495,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 +652,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,6 +728,13 @@ 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 nativeStack = useEnvironmentQuery( + detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests + ? null + : pullRequestStackAtom({ environmentId, input: reference }), + ).data; const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; const refreshDetail = useCallback(() => { @@ -1379,6 +1412,17 @@ export function PullRequestDetailPanel({ return (
+ {threadPickerOpen && detail ? ( + + ) : null}
{detail && statePresentation ? ( <> + {onBack ? ( + + + + + } + /> + Back to pull requests + + ) : null} {detail && statePresentation ? ( <> + {onBack ? ( + + + + + } + /> + Back to pull requests + + ) : null} {detail ? ( <> + {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 @@ -1714,6 +1804,17 @@ export function PullRequestDetailPanel({ + void refreshFromHost()} @@ -2001,6 +2102,13 @@ export function PullRequestDetailPanel({ />
+ {nativeStack ? ( + + ) : null}
) : null}
diff --git a/apps/web/src/components/pullRequest/PullRequestStackMap.tsx b/apps/web/src/components/pullRequest/PullRequestStackMap.tsx new file mode 100644 index 000000000000..e8e251121cb7 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestStackMap.tsx @@ -0,0 +1,87 @@ +import type { PullRequestStack } from "@t3tools/contracts"; +import { GitPullRequestArrowIcon } from "lucide-react"; + +import { InlineButton } from "../ui/button"; +import { cn } from "~/lib/utils"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { resolvePullRequestState } from "./pullRequestPresentation"; + +/** + * The host's stack as one line of layers, bottom to top, with this pull request marked. Mirrors + * the map GitHub draws above a stacked pull request so a reader who came from there finds the + * same shape here. + */ +export function PullRequestStackMap({ + stack, + currentNumber, + onSelect, + className, +}: { + stack: PullRequestStack; + currentNumber: number; + /** Opens another layer in the same panel; absent where the panel cannot swap references. */ + onSelect?: ((number: number) => void) | undefined; + className?: string; +}) { + return ( +
+ + } + > + + {stack.base} + + + Stack #{stack.number} on {stack.base}. Merging a layer lands every layer below it. + + + {stack.layers.map((layer) => { + const presentation = resolvePullRequestState({ state: layer.state, isDraft: false }); + const isCurrent = layer.number === currentNumber; + const chip = ( + + # + {layer.number} + + ); + return ( + + + → + + + onSelect(layer.number)} /> + ) : ( + + ) + } + > + {chip} + + + #{layer.number} · {layer.headBranch} · {presentation.label} + + + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index b06630f3bd04..fed932d457a5 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -314,12 +314,14 @@ function Section({ function CommentComposer({ environmentId, + reference, detail, actionPending, onCommentAction, onCommented, }: { environmentId: EnvironmentId; + reference: PullRequestRef; detail: PullRequestDetailView; actionPending: boolean; onCommentAction: ( @@ -355,9 +357,7 @@ function CommentComposer({ const result = await postComment({ environmentId, input: { - projectId: detail.projectId, - repository: detail.repository, - number: detail.number, + ...reference, body: trimmed, }, }); @@ -996,7 +996,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 unmount an open menu or move its highlighted thread. + 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) ? ( + + + } + > + + {linkedThreadsLabel} + + {linkedThreads.length || "?"} + + + + {relations.error !== null ? ( + Could not load linked threads. Retry + ) : null} + {linkedThreads.map((linkedThread) => ( + + } + > + + {linkedThread.title || "Untitled thread"} + + {linkedThread.archivedAt !== null ? ( + Archived + ) : null} + + ))} + + + ) : 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/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx new file mode 100644 index 000000000000..c9ef8974ef13 --- /dev/null +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -0,0 +1,296 @@ +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, + 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} + + {/* Right-aligned signals, in the order a reviewer scans them: are checks green, + has someone ruled, how big is it. Each is absent rather than neutral when the + host said nothing, so a row without them reads as unknown, not as fine. */} + + {snapshot?.checksState ? : null} + {snapshot?.state === "open" && + (snapshot.reviewDecision === "approved" || + snapshot.reviewDecision === "changes-requested") ? ( + + {snapshot.reviewDecision === "approved" ? "Approved" : "Changes requested"} + + ) : null} + {snapshot?.state === "open" && snapshot.mergeability === "conflicting" ? ( + Conflicts + ) : 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..f6d4bbd0b360 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -1420,6 +1420,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"); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 24b37aceafc2..3ce7277f0560 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, @@ -1028,6 +1029,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 +1039,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 +1060,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 +1096,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/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/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 ( +
{detail ? ( - <> + + {!nativeStack && supportsStackActions && nativeStackQuery.error ? ( + + ) : null} + {nativeStack ? ( + 0 + } + canRebase={ + nativeStackQuery.isFresh && + supportsStackActions && + detail.viewerPermissions.stackRebase === true + } + onActed={() => { + refreshDetail(); + onActed?.(); + }} + /> + ) : null} {context === "page" ? ( - - - - {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} - - - - } - /> + + + + + {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} + + + + } + /> + } + /> + Check out this pull request + startCheckout("worktree")}> @@ -1663,7 +1728,7 @@ export function PullRequestDetailPanel({
- {nativeStack ? ( - - ) : null} ) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index ae7aee0168f4..5f073684eabd 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"; @@ -60,6 +61,11 @@ function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry[ ); } +export type PullRequestRowTarget = Pick< + EnvironmentPullRequestEntry, + "environmentId" | "projectId" | "host" | "repository" | "number" +>; + function PullRequestRowImpl({ entry, selected, @@ -86,7 +92,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 ( @@ -116,6 +122,21 @@ 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" ? ( 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/PullRequestStackMap.tsx b/apps/web/src/components/pullRequest/PullRequestStackMap.tsx deleted file mode 100644 index e8e251121cb7..000000000000 --- a/apps/web/src/components/pullRequest/PullRequestStackMap.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { PullRequestStack } from "@t3tools/contracts"; -import { GitPullRequestArrowIcon } from "lucide-react"; - -import { InlineButton } from "../ui/button"; -import { cn } from "~/lib/utils"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { resolvePullRequestState } from "./pullRequestPresentation"; - -/** - * The host's stack as one line of layers, bottom to top, with this pull request marked. Mirrors - * the map GitHub draws above a stacked pull request so a reader who came from there finds the - * same shape here. - */ -export function PullRequestStackMap({ - stack, - currentNumber, - onSelect, - className, -}: { - stack: PullRequestStack; - currentNumber: number; - /** Opens another layer in the same panel; absent where the panel cannot swap references. */ - onSelect?: ((number: number) => void) | undefined; - className?: string; -}) { - return ( -
- - } - > - - {stack.base} - - - Stack #{stack.number} on {stack.base}. Merging a layer lands every layer below it. - - - {stack.layers.map((layer) => { - const presentation = resolvePullRequestState({ state: layer.state, isDraft: false }); - const isCurrent = layer.number === currentNumber; - const chip = ( - - # - {layer.number} - - ); - return ( - - - → - - - onSelect(layer.number)} /> - ) : ( - - ) - } - > - {chip} - - - #{layer.number} · {layer.headBranch} · {presentation.label} - - - - ); - })} -
- ); -} 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/PullRequestThreadLinks.tsx b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx index 8eba6cdf2317..9d8dcc2424e6 100644 --- a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx +++ b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx @@ -1,5 +1,5 @@ +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { Link } from "@tanstack/react-router"; import type { EnvironmentId, PullRequestRef, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { CheckIcon, LinkIcon, MessageSquareIcon, UnlinkIcon } from "lucide-react"; import { useState } from "react"; @@ -11,11 +11,11 @@ import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from " import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { appAtomRegistry } from "~/rpc/atomRegistry"; -import { buildThreadRouteParams } from "~/threadRoutes"; +import { openCommandPalette } from "~/commandPaletteBus"; import { Button } from "../ui/button"; import { Command, CommandInput, CommandItem, CommandList } from "../ui/command"; import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { MenuItem } from "../ui/menu"; import { toastManager } from "../ui/toast"; interface PullRequestThreadLinksProps { @@ -61,7 +61,7 @@ function EnabledPullRequestThreadLinks({ : null, ); // Refreshes can briefly clear the query value. Keep the last response so polling - // does not unmount an open menu or move its highlighted thread. + // 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); @@ -101,47 +101,29 @@ function EnabledPullRequestThreadLinks({ return ( <> {display === "count" && (linkedThreads.length > 0 || relations.error !== null) ? ( - - + - } - > - - {linkedThreadsLabel} - - {linkedThreads.length || "?"} - - - - {relations.error !== null ? ( - Could not load linked threads. Retry - ) : null} - {linkedThreads.map((linkedThread) => ( - + onClick={() => + openCommandPalette({ + query: url, + ...((relations.data ?? lastRelations) === null + ? {} + : { linkedThreads: { environmentId, threads: linkedThreads } }), + }) } > - - {linkedThread.title || "Untitled thread"} - - {linkedThread.archivedAt !== null ? ( - Archived - ) : null} - - ))} - - + + {linkedThreads.length || "?"} + + } + /> + {linkedThreadsLabel}. Search in the command palette. + ) : null} {display === "menu-item" ? ( { 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 3ce7277f0560..64792164814f 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -29,6 +29,19 @@ export const PULL_REQUEST_MERGE_METHOD_LABELS: Record, current: PullRequestMergeMethod | null, 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/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 9671f5ea6512..35a9fed4cc5d 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -203,6 +203,7 @@ export function useOpenChangeRequestLink( state: previous.state ?? "all", repository, number: parsed.number, + selectedHost: parsed.host, selectedProjectId: project.id, selectedEnvironmentId: project.environmentId, }), @@ -220,6 +221,7 @@ export function useOpenChangeRequestLink( state: "all", 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/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index cca1cb5b5c2c..62bb23bb305f 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -99,7 +99,10 @@ import { } from "../components/pullRequest/PullRequestListFilters"; import { PullRequestListEmptyState } from "../components/pullRequest/PullRequestListEmptyState"; import { PullRequestListGhost } from "../components/pullRequest/PullRequestGhosts"; -import { PullRequestRow } from "../components/pullRequest/PullRequestRow"; +import { + PullRequestRow, + type PullRequestRowTarget, +} from "../components/pullRequest/PullRequestRow"; import { PullRequestsUnavailableState } from "../components/pullRequest/PullRequestsUnavailableState"; import { RightPanelTabs, type PullRequestTabStatusSeed } from "../components/RightPanelTabs"; import { @@ -163,6 +166,8 @@ export interface PullRequestsSearch extends PullRequestListPreferences { readonly repository?: string; readonly number?: number; readonly selectedProjectId?: ProjectId; + /** Host of the open review; changing tabs must not narrow the list host filter. */ + readonly selectedHost?: string; /** * Which server the selected pull request was read from. A project id only names a project on * its own server, so this is what tells two servers holding one project apart. Optional: a @@ -263,6 +268,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({ ...(typeof raw.selectedProjectId === "string" && raw.selectedProjectId ? { selectedProjectId: raw.selectedProjectId as ProjectId } : {}), + ...(typeof raw.selectedHost === "string" && raw.selectedHost + ? { selectedHost: raw.selectedHost.slice(0, 200) } + : {}), ...(typeof raw.selectedEnvironmentId === "string" && raw.selectedEnvironmentId ? { selectedEnvironmentId: raw.selectedEnvironmentId as EnvironmentId } : {}), @@ -367,6 +375,7 @@ function PullRequestsRouteView() { // A link from a thread or the sidebar only knows the repository, so the owning project is // resolved here; an explicit `projectId` in the URL still wins. + const selectedHost = search.selectedHost ?? search.host; const projectIdForRepository = useMemo(() => { const repository = search.repository?.toLowerCase(); if (repository === undefined) return undefined; @@ -378,14 +387,14 @@ function PullRequestsRouteView() { repository && // The same `owner/name` can exist on two hosts. Without this the first match wins, and // a link that named its host opens the pull request from the other one. - (search.host === undefined || + (selectedHost === undefined || pullRequestHostOf( project.repositoryIdentity, project.repositoryIdentity.provider as SourceControlProviderKind, - ) === search.host.toLowerCase()), + ) === selectedHost.toLowerCase()), ); return identity?.id; - }, [projects, search.host, search.repository]); + }, [projects, selectedHost, search.repository]); // The selection is resolved the same way the scope is: an id no connected environment has can // never be read here, and one that arrived before the projects did is not yet wrong. @@ -472,6 +481,7 @@ function PullRequestsRouteView() { ...(next.projectId ? { projectId: next.projectId } : {}), ...(next.environmentId ? { environmentId: next.environmentId } : {}), ...(next.host ? { host: next.host } : {}), + ...(next.selectedHost ? { selectedHost: next.selectedHost } : {}), ...(next.selectedProjectId ? { selectedProjectId: next.selectedProjectId } : {}), ...(next.selectedEnvironmentId ? { selectedEnvironmentId: next.selectedEnvironmentId } @@ -494,6 +504,7 @@ function PullRequestsRouteView() { number: undefined, selectedProjectId: undefined, selectedEnvironmentId: undefined, + selectedHost: undefined, }; // List controls change the rows behind the detail, not the independent selected surface. The // reader can keep working in that panel while narrowing, sorting, or switching projects. @@ -799,19 +810,11 @@ function PullRequestsRouteView() { // from the first moment rather than the second: a button that stays live through the slow half // of its own work is a button that gets pressed again, and buys the whole cascade twice. const [invalidating, setInvalidating] = useState(false); - const refreshFromHost = async (includeDetail = true) => { - const requestedStatsScope = statsScopeRef.current; - setInvalidating(true); - try { - // Every environment the page is reading, since what the reader pressed refresh for is the - // list in front of them rather than whichever machine happens to be first. - await Promise.all( - queryEnvironmentIds.map((environmentId) => invalidate({ environmentId, input: {} })), - ); - } finally { - setInvalidating(false); - } - refreshList(true); + const refreshListAndStats = ( + requestedStatsScope = statsScopeRef.current, + actedEnvironmentId?: EnvironmentId, + ) => { + refreshList(true, actedEnvironmentId); const visible = visibleStatsKeys.current; const batches = pullRequestStatsRefreshBatches({ requestedScope: requestedStatsScope, @@ -822,9 +825,29 @@ function PullRequestsRouteView() { }); if (batches !== null) { setStatsTargetState({ key: requestedStatsScope.key, batches }); - statsQuery.refresh(batches.map(({ environmentId, input }) => ({ environmentId, input }))); + statsQuery.refresh( + batches + .filter(({ environmentId }) => + actedEnvironmentId === undefined ? true : environmentId === actedEnvironmentId, + ) + .map(({ environmentId, input }) => ({ environmentId, input })), + ); } - if (includeDetail) setDetailRefreshToken((token) => token + 1); + }; + const refreshFromHost = async () => { + const requestedStatsScope = statsScopeRef.current; + setInvalidating(true); + try { + // Every environment the page is reading, since what the reader pressed refresh for is the + // list in front of them rather than whichever machine happens to be first. + await Promise.all( + queryEnvironmentIds.map((environmentId) => invalidate({ environmentId, input: {} })), + ); + } finally { + setInvalidating(false); + } + refreshListAndStats(requestedStatsScope); + setDetailRefreshToken((token) => token + 1); }; const refreshing = invalidating || listQuery.isPending; @@ -1058,17 +1081,28 @@ function PullRequestsRouteView() { // re-reads only its own slice, so the rows loaded before it would never see a merge, a close, // or a retitle. Going back to a single page long enough to cover everything on screen lets the // merge above bring every row up to date in place. - const refreshList = (includeRelated = false) => { - const related = includeRelated - ? [ - ...baselineTargets, - ...facetTargets, - ...partitionTargets.authored, - ...partitionTargets.reviewing, - ] - : []; + const refreshList = (includeRelated = false, actedEnvironmentId?: EnvironmentId) => { + const related = ( + includeRelated + ? [ + ...baselineTargets, + ...facetTargets, + ...partitionTargets.authored, + ...partitionTargets.reviewing, + ] + : [] + ).filter( + ({ environmentId }) => + actedEnvironmentId === undefined || environmentId === actedEnvironmentId, + ); if (sentCursors === null) { - listQuery.refresh([...listTargets, ...related]); + listQuery.refresh([ + ...listTargets.filter( + ({ environmentId }) => + actedEnvironmentId === undefined || environmentId === actedEnvironmentId, + ), + ...related, + ]); return; } if (related.length > 0) listQuery.refresh(related); @@ -1412,9 +1446,10 @@ function PullRequestsRouteView() { repository: search.repository, number: search.number, projectId: selectedProject.id, + ...(selectedHost ? { host: selectedHost } : {}), } : null, - [search.number, search.repository, selectedProject], + [search.number, search.repository, selectedProject, selectedHost], ); const rightPanelAvailable = selectedPullRequestSurface !== null; useEffect(() => { @@ -1429,6 +1464,14 @@ function PullRequestsRouteView() { repository: activePullRequestSurface.repository, number: activePullRequestSurface.number, projectId: activePullRequestSurface.projectId as ProjectId, + host: + activePullRequestSurface.host ?? + (selectedProject?.repositoryIdentity + ? pullRequestHostOf( + selectedProject.repositoryIdentity, + selectedProject.repositoryIdentity.provider as SourceControlProviderKind, + ) + : undefined), } : null; @@ -1440,6 +1483,7 @@ function PullRequestsRouteView() { repository: surface.repository, number: surface.number, selectedProjectId: surface.projectId as ProjectId, + selectedHost: surface.host, ...(surface.environmentId === undefined ? {} : { selectedEnvironmentId: surface.environmentId as EnvironmentId }), @@ -1503,7 +1547,7 @@ function PullRequestsRouteView() { // Stable so the memoized rows can skip re-rendering when the list around them changes. const selectEntry = useCallback( - (entry: EnvironmentPullRequestEntry) => { + (entry: PullRequestRowTarget) => { // The surface carries the row's own server, which is what its detail reads and acts on. if (rightPanelRef === null) return; useRightPanelStore.getState().openPullRequest(rightPanelRef, entry); @@ -1512,6 +1556,7 @@ function PullRequestsRouteView() { number: entry.number, selectedProjectId: entry.projectId, selectedEnvironmentId: entry.environmentId, + selectedHost: entry.host, }); }, [rightPanelRef, updateSearch], @@ -1628,6 +1673,7 @@ function PullRequestsRouteView() { selected={ selected?.environmentId === entry.environmentId && selected.repository === entry.repository && + selected.host?.toLowerCase() === entry.host.toLowerCase() && selected.number === entry.number } onSelect={selectEntry} @@ -1938,10 +1984,30 @@ function PullRequestsRouteView() { { + if (rightPanelRef === null) return; + useRightPanelStore.getState().openPullRequest(rightPanelRef, { + projectId: reference.projectId, + repository: reference.repository, + number: reference.number, + ...(reference.host ? { host: reference.host } : {}), + environmentId: panelEnvironmentId, + }); + updateSearch({ + repository: reference.repository, + number: reference.number, + selectedHost: reference.host, + selectedProjectId: reference.projectId, + selectedEnvironmentId: panelEnvironmentId, + }); + }} reference={{ projectId: renderedPullRequestSurface.projectId as ProjectId, repository: renderedPullRequestSurface.repository, number: renderedPullRequestSurface.number, + ...(renderedPullRequestSurface.host + ? { host: renderedPullRequestSurface.host } + : {}), }} listEntry={ listedPullRequestsBySurface.get( @@ -1952,7 +2018,8 @@ function PullRequestsRouteView() { // Host actions can change both readiness and diff size, so refresh the counts // alongside the list. The panel already refreshes itself after each action. onActed={() => { - void refreshFromHost(false); + // Mutations already invalidate the host's affected caches. + refreshListAndStats(undefined, panelEnvironmentId); }} /> diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 182b2dc97098..e2e840c89c49 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -74,7 +74,10 @@ export function useSharedPullRequestSummary( }, [atom, current, environmentId]); return newestPullRequestSummary(current, observed); } -export const pullRequestStackAtom = createPullRequestStackAtomFamily(connectionAtomRuntime); +export const pullRequestStackAtom = createPullRequestStackAtomFamily( + connectionAtomRuntime, + pullRequestEnvironment.refreshes, +); export interface EnvironmentQueryTarget { readonly environmentId: EnvironmentId; diff --git a/apps/web/src/state/query.ts b/apps/web/src/state/query.ts index 9bc16b01fe32..b2823a51e332 100644 --- a/apps/web/src/state/query.ts +++ b/apps/web/src/state/query.ts @@ -11,6 +11,7 @@ export interface EnvironmentQueryView
{ readonly data: A | null; readonly error: string | null; readonly isPending: boolean; + readonly isSuccess: boolean; readonly refresh: () => void; } @@ -31,6 +32,7 @@ export function useEnvironmentQuery( data: Option.getOrNull(AsyncResult.value(result)), error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null, isPending: atom !== null && result.waiting, + isSuccess: result._tag === "Success", refresh, }; } diff --git a/apps/web/src/state/usePullRequestStack.ts b/apps/web/src/state/usePullRequestStack.ts new file mode 100644 index 000000000000..fb4febddef64 --- /dev/null +++ b/apps/web/src/state/usePullRequestStack.ts @@ -0,0 +1,33 @@ +import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; +import { useMemo } from "react"; +import { + savedPullRequestStack, + pullRequestStackView, +} from "../components/pullRequest/pullRequestStackSnapshot"; +import { useThreadShells } from "./entities"; +import { pullRequestStackAtom } from "./pullRequests"; +import { useEnvironmentQuery } from "./query"; + +/** Detail headers and list popovers keep saved navigation during an unavailable refresh. */ +export function usePullRequestStack( + environmentId: EnvironmentId, + reference: PullRequestRef | null, +) { + const threads = useThreadShells(); + const saved = useMemo( + () => + reference === null + ? null + : savedPullRequestStack( + threads + .filter((thread) => thread.environmentId === environmentId) + .flatMap((thread) => thread.pullRequests ?? []), + reference, + ), + [environmentId, reference, threads], + ); + const query = useEnvironmentQuery( + reference === null ? null : pullRequestStackAtom({ environmentId, input: reference }), + ); + return { ...query, ...pullRequestStackView(query, saved) }; +} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 5a67f7c64a0a..48e3eb1992cd 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -115,3 +115,15 @@ review is terminal. An open or unsynced link keeps it active. Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked out from the matching organization and repository. + +## GitHub stacks + +The Pull Requests page shows each PR's position in its GitHub stack. Open the stack badge in a +review to navigate its layers. **Merge stack** submits the selected pull request and every unmerged +layer below it to GitHub together, respecting branch rules and merge queues. The confirmation shows +the scope and merge strategy. GitHub rebases the remaining stack after merging. + +**Rebase stack** updates remote branches from bottom to top without changing your local checkout. +It can rewrite history and restart checks. If a layer fails, earlier updates remain; resolve that +layer before retrying. GitHub may require manual conflict resolution after a lower layer is amended, +even when its changes look independent. Stack actions require an environment that supports them. diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 09c8e110f10e..fdd109d6e8c4 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -1,4 +1,4 @@ -import { EnvironmentId, ProjectId, WS_METHODS } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, WS_METHODS, type PullRequestStack } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Latch from "effect/Latch"; @@ -19,7 +19,10 @@ import * as EnvironmentRegistry from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; -import { createPullRequestEnvironmentAtoms } from "./pullRequests.ts"; +import { + createPullRequestEnvironmentAtoms, + createPullRequestStackAtomFamily, +} from "./pullRequests.ts"; import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts"; import { executeAtomQuery } from "./runtime.ts"; @@ -80,7 +83,7 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcPro const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => Effect.sync(() => registry.dispose()), ); - return { atoms, registry }; + return { runtime, atoms, registry }; }); it.effect("keeps concurrent diff file reads on different hosts separate", () => @@ -212,3 +215,67 @@ it.effect("refreshes pull request activity after a comment is updated", () => }), ), ); + +it.effect("refreshes stack state after reopening and head SHAs after a turn", () => + Effect.scoped( + Effect.gen(function* () { + const refreshEvents = yield* PubSub.unbounded(); + let state: "closed" | "open" = "closed"; + let headSha = "old-head"; + const client = { + [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.fromPubSub(refreshEvents), + [WS_METHODS.pullRequestsStack]: () => + Effect.sync( + () => + ({ + id: "stack-1", + number: 1, + url: "https://github.com/acme/web/pull/1", + base: "main", + layers: [ + { + number: 1, + headBranch: "feature", + headSha, + state, + isDraft: false, + }, + ], + }) satisfies PullRequestStack, + ), + } as unknown as WsRpcProtocolClient; + const { runtime, registry } = yield* makeTestRuntime(client); + const stacks = createPullRequestStackAtomFamily(runtime); + const stack = stacks({ + environmentId: TARGET.environmentId, + input: { + projectId: ProjectId.make("project-1"), + repository: "acme/web", + number: 1, + }, + }); + const unmount = registry.mount(stack); + yield* Effect.addFinalizer(() => Effect.sync(unmount)); + yield* Effect.promise(() => executeAtomQuery(registry, stack)); + expect((yield* AtomRegistry.getResult(registry, stack))?.layers[0]?.state).toBe("closed"); + state = "open"; + registry.refresh(stack); + expect( + (yield* AtomRegistry.getResult(registry, stack, { suspendOnWaiting: true }))?.layers[0] + ?.state, + ).toBe("open"); + + const refreshed = Latch.makeUnsafe(); + const stop = registry.subscribe(stack, (result) => { + if (AsyncResult.isSuccess(result) && result.value?.layers[0]?.headSha === "new-head") { + refreshed.openUnsafe(); + } + }); + yield* Effect.addFinalizer(() => Effect.sync(stop)); + headSha = "new-head"; + yield* PubSub.publish(refreshEvents, 1); + yield* refreshed.await; + expect((yield* AtomRegistry.getResult(registry, stack))?.layers[0]?.headSha).toBe("new-head"); + }), + ), +); diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 10ec49ac9a39..c9d23d02bf55 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -63,12 +63,14 @@ export function createLinkedPullRequestSummaryAtomFamily( /** The host-native stack a pull request belongs to; null where it is not stacked. */ export function createPullRequestStackAtomFamily( runtime: Atom.AtomRuntime, + refreshes = createPullRequestRefreshAtomFamily(runtime), ) { return createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:stack", tag: WS_METHODS.pullRequestsStack, staleTimeMs: 60_000, idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS, + refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }); } diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index e056e92afe5c..5c57b8de3bd6 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -571,3 +571,79 @@ describe("resolveViewedImageAsset", () => { expect(resolveViewedImageAsset("https://example.com/logo.png", { threadId })).toBeNull(); }); }); + +describe("pull request tool presentation", () => { + it.each([ + "mcp__t3-code__link_pull_request", + "mcp__t3_code__link_pull_request", + "T3-code · link_pull_request", + "t3code/link_pull_request", + "link_pull_request", + ])("recognizes the native linking tool: %s", (label) => { + const entry = { label, tone: "tool" as const, toolLifecycleStatus: "completed" }; + expect(resolveWorkEntryToolPresentation(entry)).toMatchObject({ + displayName: "Linked a pull request", + icon: "pull-request", + }); + expect(toolGroupAction(entry)).toBe("link-pr"); + }); + + it.each([ + ["inProgress", "Linking PR #42"], + ["completed", "Linked PR #42"], + ["failed", "Failed to link PR #42"], + ["declined", "Declined to link PR #42"], + ["stopped", "Stopped linking PR #42"], + ])("describes the target and %s status", (toolLifecycleStatus, displayName) => { + expect( + resolveWorkEntryToolPresentation({ + label: "MCP tool call", + toolTitle: "Custom title", + toolLifecycleStatus, + toolData: { + server: "t3-code", + tool: "link_pull_request", + arguments: { url: "https://github.com/acme/web/pull/42" }, + }, + })?.displayName, + ).toBe(displayName); + }); + + it("recognizes unlink targets supplied as repository and number", () => { + expect( + resolveWorkEntryToolPresentation({ + label: "MCP tool call", + toolLifecycleStatus: "completed", + toolData: { + toolName: "mcp__t3-code__unlink_pull_request", + rawInput: { repository: "acme/web", number: 42 }, + }, + }), + ).toMatchObject({ displayName: "Unlinked PR #42", icon: "pull-request", action: "unlink-pr" }); + }); + + it("summarizes native PR work separately from ordinary tools and integration metadata", () => { + const link: WorkLogPresentationEntry = { + label: "T3-code · link_pull_request", + tone: "tool", + itemType: "mcp_tool_call", + toolLifecycleStatus: "completed", + toolSource: { key: "t3-code", name: "T3 Code", kind: "integration" }, + }; + const list: WorkLogPresentationEntry = { + ...link, + label: "T3-code · list_thread_pull_requests", + }; + expect(summarizeToolGroup([link, link, list])).toBe( + "Linked 2 pull requests and checked linked pull requests", + ); + expect(summarizeToolGroup([{ ...link, label: "T3-code · unlink_pull_request" }])).toBe( + "Unlinked 1 pull request", + ); + expect(toolGroupSummaryKind([link, link, list])).toBe("pull-request"); + expect(summarizeToolGroup([list, list])).toBe("Checked linked pull requests 2 times"); + expect( + resolveWorkEntryToolPresentation({ label: "mcp__another-server__link_pull_request" }), + ).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 2e1ef3bbf003..3e62f75d510b 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -8,6 +8,7 @@ import { } from "@t3tools/contracts"; import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; +import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; export function isWorktreeSetupActivity(kind: string): boolean { @@ -36,6 +37,9 @@ export interface WorkLogPresentationEntry { } export type ToolGroupAction = + | "link-pr" + | "unlink-pr" + | "list-prs" | "read" | "edit" | "command" @@ -46,6 +50,7 @@ export type ToolGroupAction = | "update"; export type ToolGroupSummaryKind = + | "pull-request" | ToolGroupAction | "dynamic-tool" | "agent-tool" @@ -60,6 +65,9 @@ const T3_MCP_TOOL_LABELS: Record< string, readonly [action: string, running: string, completed: string, detail: string] > = { + link_pull_request: ["Link", "Linking", "Linked", "a pull request"], + unlink_pull_request: ["Unlink", "Unlinking", "Unlinked", "a pull request"], + list_thread_pull_requests: ["Check", "Checking", "Checked", "linked pull requests"], orchestrator_capabilities: ["Get", "Getting", "Got", "orchestration capabilities"], delegate_task: ["Delegate", "Delegating", "Delegated", "a child task"], task_status: ["Get", "Getting", "Got", "delegated task status"], @@ -98,7 +106,17 @@ const T3_MCP_TOOL_LABELS: Record< preview_recording_stop: ["Stop", "Stopping", "Stopped", "recording the preview browser"], }; -function resolveT3McpToolPresentation(value: string | undefined, status: string | undefined) { +const PR_TOOL_ACTIONS: Readonly> = { + link_pull_request: "link-pr", + unlink_pull_request: "unlink-pr", + list_thread_pull_requests: "list-prs", +}; + +function resolveT3McpToolPresentation( + value: string | undefined, + status: string | undefined, + data?: unknown, +) { if (!value) return null; const name = normalizeCompactToolLabel(value).replace( /^(?:mcp__(?:t3-code|t3_code|t3code)__|(?:t3-code|t3_code|t3code)(?:[.:/]|\s*·\s*))/i, @@ -120,9 +138,29 @@ function resolveT3McpToolPresentation(value: string | undefined, status: string ? `Stopped ${running.toLowerCase()}` : running; + const actionKind = Object.hasOwn(PR_TOOL_ACTIONS, name) ? PR_TOOL_ACTIONS[name] : undefined; + const payload = asRecord(data); + const input = + asRecord(payload?.arguments) ?? asRecord(payload?.input) ?? asRecord(payload?.rawInput); + const urlTarget = typeof input?.url === "string" ? parseChangeRequestUrl(input.url) : null; + const number = urlTarget?.number ?? input?.number; + const target = + actionKind !== undefined && + actionKind !== "list-prs" && + typeof number === "number" && + Number.isSafeInteger(number) && + number > 0 + ? `PR #${number}` + : detail; return { - displayName: `${verb} ${detail}`, - icon: name.startsWith("preview_") ? ("browser" as const) : ("t3-code" as const), + displayName: `${verb} ${target}`, + icon: + actionKind !== undefined + ? ("pull-request" as const) + : name.startsWith("preview_") + ? ("browser" as const) + : ("t3-code" as const), + ...(actionKind === undefined ? {} : { action: actionKind }), }; } @@ -147,16 +185,16 @@ export function resolveWorkEntryToolPresentation( "tool" in data && typeof data.tool === "string" ) { - return resolveT3McpToolPresentation(`${data.server}.${data.tool}`, status); + return resolveT3McpToolPresentation(`${data.server}.${data.tool}`, status, data); } if ("toolName" in data && typeof data.toolName === "string") { - return resolveT3McpToolPresentation(data.toolName, status); + return resolveT3McpToolPresentation(data.toolName, status, data); } } return ( - resolveT3McpToolPresentation(entry.toolTitle, status) ?? - resolveT3McpToolPresentation(entry.label, status) + resolveT3McpToolPresentation(entry.toolTitle, status, data) ?? + resolveT3McpToolPresentation(entry.label, status, data) ); } @@ -410,7 +448,9 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio ) { return "update"; } - if (resolveWorkEntryToolPresentation(entry)?.icon === "browser") return "browser"; + const presentation = resolveWorkEntryToolPresentation(entry); + if (presentation?.action !== undefined) return presentation.action; + if (presentation?.icon === "browser") return "browser"; if ( entry.requestKind === "file-read" || entry.itemType === "image_view" || @@ -504,6 +544,14 @@ function toolGroupActionCount( function toolGroupActionLabel(action: ToolGroupAction, count: number): string { switch (action) { + case "link-pr": + return `Linked ${count} ${count === 1 ? "pull request" : "pull requests"}`; + case "unlink-pr": + return `Unlinked ${count} ${count === 1 ? "pull request" : "pull requests"}`; + case "list-prs": + return count === 1 + ? "Checked linked pull requests" + : `Checked linked pull requests ${count} times`; case "read": return `Read ${count} ${count === 1 ? "file" : "files"}`; case "edit": @@ -528,7 +576,7 @@ export function summarizeToolGroup(entries: ReadonlyArray(); const groupedEntries = new Map(); for (const entry of summaryEntries) { - if (entry.toolSource) { + if (entry.toolSource && resolveWorkEntryToolPresentation(entry)?.icon !== "pull-request") { sources.set(entry.toolSource.key, entry.toolSource); continue; } @@ -601,6 +649,11 @@ export function omitSupersededLifecycleMarkers( export function toolGroupSummaryKind( entries: ReadonlyArray, ): ToolGroupSummaryKind { + if ( + entries.length > 0 && + entries.every((entry) => resolveWorkEntryToolPresentation(entry)?.icon === "pull-request") + ) + return "pull-request"; const actions = new Set(entries.map(toolGroupAction)); if (actions.size !== 1) return "mixed"; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 865afd410fa6..9dcc844e713a 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -130,6 +130,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threads, and routes PullRequestRef.host across projects on the same host. Same version-skew contract as threadSettlement. */ threadPullRequests: Schema.optionalKey(Schema.Boolean), + pullRequestStackActions: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 0715e12c44f7..44658bbc6c3e 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -418,6 +418,7 @@ export const PullRequestCapabilities = Schema.Struct({ * the stack the host shows. Absent means chains are only ever inferred from base branches. */ stacks: Schema.optional(Schema.Boolean), + stackActions: Schema.optional(Schema.Boolean), /** * The repository's labels can be listed, and one put on a change request or taken off it. * Optional for the same reason `edit` is: a server that says nothing about labels has no way @@ -438,6 +439,8 @@ export type PullRequestCapabilities = typeof PullRequestCapabilities.Type; * offering one they may not use ends in the host's own refusal — which at least says why. */ export const PullRequestViewerPermissions = Schema.Struct({ + /** May request remote stack rebases, including when this layer is already current. */ + stackRebase: Schema.optional(Schema.Boolean), /** Which of the actions this viewer may take; anything absent is theirs to look at only. */ actions: Schema.Array(PullRequestAction), /** This viewer may write a remark: a comment, a reply, or a note against a line. */ @@ -469,7 +472,16 @@ export const PullRequestMergeCapabilities = Schema.Struct({ }); export type PullRequestMergeCapabilities = typeof PullRequestMergeCapabilities.Type; +export const PullRequestStackMembership = Schema.Struct({ + number: PositiveInt, + position: PositiveInt, + size: PositiveInt, + base: TrimmedNonEmptyString, +}); +export type PullRequestStackMembership = typeof PullRequestStackMembership.Type; + export const PullRequestListEntry = Schema.Struct({ + stack: Schema.optional(PullRequestStackMembership), provider: SourceControlProviderKind, /** * The host below which `repository` is addressed, so the same provider kind can serve more @@ -686,6 +698,9 @@ export const PullRequestStack = Schema.Struct({ layers: Schema.Array( Schema.Struct({ number: PositiveInt, + title: Schema.optional(Schema.String), + isDraft: Schema.optional(Schema.Boolean), + headSha: Schema.optional(TrimmedNonEmptyString), headBranch: TrimmedNonEmptyString, state: PullRequestState, }), @@ -908,7 +923,16 @@ export const PullRequestDiffFileContentsResult = Schema.Struct({ }); export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileContentsResult.Type; +export const PullRequestStackHead = Schema.Struct({ + number: PositiveInt, + headSha: TrimmedNonEmptyString, +}); +export type PullRequestStackHead = typeof PullRequestStackHead.Type; + export const PullRequestActionInput = Schema.Struct({ + /** Native stack scope; only send to environments advertising pullRequestStackActions. */ + stackNumber: Schema.optional(PositiveInt), + expectedStackHeads: Schema.optional(Schema.Array(PullRequestStackHead)), ...PullRequestRef.fields, action: PullRequestAction, /** From 33242d0164d47c6935ba5c7725296539384de65d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 9 Sep 2026 16:33:25 -0700 Subject: [PATCH 09/61] fix(server): preserve recent PR reads across server restarts (#11007) --- .../pullRequest/PullRequestReadCache.test.ts | 78 +++++++++++ .../src/pullRequest/PullRequestReadCache.ts | 125 ++++++++++++++++++ .../pullRequest/PullRequestService.test.ts | 9 ++ .../src/pullRequest/PullRequestService.ts | 89 +++++++++---- apps/server/src/server.ts | 2 + 5 files changed, 275 insertions(+), 28 deletions(-) create mode 100644 apps/server/src/pullRequest/PullRequestReadCache.test.ts create mode 100644 apps/server/src/pullRequest/PullRequestReadCache.ts 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 db2f6cf2642f..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; @@ -198,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), + ), ), ), ); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 4c81d0f6d264..37716da44005 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -11,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"; @@ -50,8 +51,8 @@ import { type PullRequestLabelCandidateList, type PullRequestLabelChangeInput, type PullRequestSubmitReviewInput, - type PullRequestStack, - type PullRequestSummary, + PullRequestStack, + PullRequestSummary, type PullRequestThreadReplyInput, type PullRequestThreadResolutionInput, type PullRequestThreadCommentsInput, @@ -71,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 { @@ -114,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. */ @@ -535,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, @@ -2333,18 +2335,49 @@ export const make = Effect.gen(function* () { }; }; - const summaryCache = yield* Cache.makeWith( - (key: string) => { - return summaryUncached(refOfCacheKey(key)); - }, - { - 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") @@ -2356,18 +2389,13 @@ export const make = Effect.gen(function* () { ); }; - const stackCache = yield* Cache.makeWith( - (key: string) => { - const [referenceKey, includeDetails] = JSON.parse(key) as [string, boolean]; - return stackUncached(refOfCacheKey(referenceKey), { includeDetails }); - }, - { - capacity: DETAIL_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero), - }, - ); const stack: PullRequestService["Service"]["stack"] = (input, options) => - Cache.get(stackCache, JSON.stringify([refCacheKey(input), options?.includeDetails !== false])); + 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. @@ -2642,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; @@ -2652,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 @@ -2663,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); @@ -2674,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") { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c531cab63c8f..3831763a3eac 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -93,6 +93,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"; @@ -318,6 +319,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), ); From 8d8189e67dc091ffb91df451c9a957bbd68a5364 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 9 Sep 2026 20:38:19 -0300 Subject: [PATCH 10/61] feat(web): zoom and pan expanded images (#10869) --- apps/web/src/components/ChatView.tsx | 1 + .../components/chat/ExpandedImageDialog.tsx | 15 +- .../web/src/components/chat/ZoomableImage.tsx | 239 ++++++++++++++++++ 3 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/chat/ZoomableImage.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9586a6ef0aed..f1b5afba94e9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -579,6 +579,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])', diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index 76345c1b28e0..f4de19717c61 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -16,6 +16,7 @@ import { } from "./SnapShotAttachmentDetails"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { composerFloatingLayerProps } from "./composerEventScope"; +import { ZoomableImage, type ZoomableImageHandle } from "./ZoomableImage"; interface ExpandedImageDialogProps { preview: ExpandedImagePreview; @@ -63,6 +64,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ onClose, }: ExpandedImageDialogProps) { const [imageOffset, setImageOffset] = useState(0); + const zoomableImageRef = useRef(null); const [failedImageSrc, setFailedImageSrc] = useState(null); const [accessibilityDetailsSrc, setAccessibilityDetailsSrc] = useState(null); const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; @@ -117,6 +119,11 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ onClose(); return; } + if (zoomableImageRef.current?.pan(event.key)) { + event.preventDefault(); + event.stopPropagation(); + return; + } if (preview.images.length <= 1) return; if (event.key === "ArrowLeft") { event.preventDefault(); @@ -206,11 +213,11 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ {openOriginalLink} ) : ( - {item.name} setFailedImageSrc(item.src)} /> )} 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 + +
+ ); +} From c79ab38255210be0e5fd57efac08ec76ed54a490 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 9 Sep 2026 07:52:13 +0000 Subject: [PATCH 11/61] fix(server): tell agents how to nest fenced code inside code blocks A code block whose body contains triple backticks was rendered broken in chat: the inner closing fence ended the outer block, and the rest spilled out as prose. That is correct CommonMark, so the fix is to tell every provider up front to use a longer fence in that case. Co-Authored-By: Claude Fable 5.1 --- apps/server/src/provider/RuntimeInstructions.test.ts | 6 ++++++ apps/server/src/provider/RuntimeInstructions.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/RuntimeInstructions.test.ts b/apps/server/src/provider/RuntimeInstructions.test.ts index e73c50adfd6d..863a87cde6b2 100644 --- a/apps/server/src/provider/RuntimeInstructions.test.ts +++ b/apps/server/src/provider/RuntimeInstructions.test.ts @@ -25,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( + "must be fenced with a longer fence (four backticks or a ~~~ fence)", + ); + }); }); diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index 5e72586062e5..68314fcf0ef5 100644 --- a/apps/server/src/provider/RuntimeInstructions.ts +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -13,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.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`; + 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. Responses render as CommonMark: a code block that itself contains triple backticks must be fenced with a longer fence (four backticks or a ~~~ fence), otherwise the inner closing fence ends the block early.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`; } function toSingleLine(value: string): string { From b7b3ef1e6fcb5c22a9790d2578fe8af7ce396835 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 9 Sep 2026 16:53:53 -0700 Subject: [PATCH 12/61] fix(ui): use available space for composer model names (#11002) --- .../mobile/src/components/ComposerToolbar.tsx | 2 +- .../features/threads/NewTaskDraftScreen.tsx | 35 ++++++++++--------- .../src/features/threads/ThreadComposer.tsx | 4 +-- apps/web/src/components/chat/ChatComposer.tsx | 1 - .../components/chat/ProviderModelPicker.tsx | 5 ++- 5 files changed, 23 insertions(+), 24 deletions(-) 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/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 5c05d7482cc4..2dd0000ea605 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -30,7 +30,6 @@ import { ComposerActionButton, ComposerInlineControl, ComposerToolbarRow, - ComposerToolbarScroller, } from "../../components/ComposerToolbar"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; @@ -1345,21 +1344,23 @@ export function NewTaskDraftScreen(props: { onPickMedia={handlePickMedia} onPickFiles={handlePickFiles} /> - - - } - 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} - +
)} - + } label={currentModelOption?.label ?? currentModelSelection.model} - maxWidth={152} + maxWidth="100%" onPress={openSettings} /> diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7b2e65f8bf3e..7291f8cead22 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -4146,7 +4146,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null} Date: Wed, 9 Sep 2026 22:26:18 -0300 Subject: [PATCH 13/61] fix(web): restore pr list diff counts to the top right (#10609) --- .../components/pullRequest/PullRequestRow.tsx | 64 +++++++++++-------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index 5f073684eabd..be6b61ae2d45 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -1,4 +1,4 @@ -import { SearchIcon } from "lucide-react"; +import { SearchIcon, UserCheckIcon } from "lucide-react"; import { PullRequestStackPopover } from "./PullRequestStackPopover"; import { memo, type RefCallback } from "react"; @@ -121,7 +121,7 @@ function PullRequestRowImpl({ {entry.title} - + {entry.stack ? ( ) : 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"} - - ) : null} - {entry.checksState === undefined ? null : ( - - )} + {matchedElsewhere ? ( @@ -215,9 +195,37 @@ function PullRequestRowImpl({ labelClassName="sr-only @xs/pr-row-meta:not-sr-only @xs/pr-row-meta:truncate" /> {entry.labels.length > 0 ? : 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" ? ( + + }> + + Approved + + Approved + + ) : entry.reviewDecision === "changes-requested" ? ( + + Changes requested + + ) : null} + {entry.checksState === undefined ? null : ( + + )} - {formatRelativeTimeLabel(entry.updatedAt)} From 385cc0a4c669aeae786ad47bfe3ecbbc352eff24 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 9 Sep 2026 23:25:23 -0300 Subject: [PATCH 14/61] fix(web): show message copy buttons on touch devices (#11020) Co-authored-by: Claude Opus 5 (1M context) --- apps/web/src/components/chat/MessagesTimeline.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8e9635f89e43..7764f64ea4b6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1582,7 +1582,7 @@ function UserTimelineRow({ row }: { row: Extract -
+
}> @@ -1734,7 +1734,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, )} > From d1eeb16247a0bd2eca8bbbfa5ab777e096af949c Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 9 Sep 2026 23:25:56 -0300 Subject: [PATCH 15/61] fix(web): middle-click pastes in the terminal on Linux (#11018) Co-authored-by: Claude Opus 5 (1M context) --- apps/web/src/terminal/ghostty/surface.test.ts | 29 ++++++++++- apps/web/src/terminal/ghostty/surface.ts | 50 ++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 7174261e67f6..59150ee320ae 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -167,13 +167,13 @@ describe("GhosttyTerminalSurface visibility", () => { resize() { for (const callback of resizeCallbacks) callback(); }, - pointer(type: string, clientX: number, buttons: number, shiftKey = false) { + pointer(type: string, clientX: number, buttons: number, shiftKey = false, button = 0) { canvas.dispatchEvent( Object.assign(new Event(type, { cancelable: true }), { clientX, clientY: 5, pointerId: 1, - button: 0, + button, buttons, shiftKey, }), @@ -280,6 +280,31 @@ describe("GhosttyTerminalSurface visibility", () => { expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false); }); + it("pastes the terminal selection, and only that, on a Linux middle click", async () => { + const harness = createHarness(); + const readText = vi.fn(async () => "clipboard text"); + vi.stubGlobal("navigator", { platform: "Linux x86_64", clipboard: { readText } }); + const surface = await harness.create(); + surface.write("hello world"); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointermove", 37, 1); + harness.pointer("pointerup", 37, 0); + expect(surface.getSelection()).toBe("hello"); + + harness.onData.mockClear(); + harness.pointer("pointerdown", 5, 4, false, 1); + await vi.waitFor(() => expect(harness.onData).toHaveBeenCalled()); + expect(harness.onData.mock.calls.at(-1)?.[0]).toBe("hello"); + expect(surface.getSelection()).toBe("hello"); + + // Without a selection there is no primary buffer to paste; the clipboard + // holds what the user copied and must not be substituted. + surface.clearSelection(); + harness.pointer("pointerdown", 5, 4, false, 1); + expect(readText).not.toHaveBeenCalled(); + }); + it("starts a selection when dragging from a link", async () => { const harness = createHarness(); const onLinkActivate = vi.fn(); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 6a069902918a..be62ede4d065 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -395,6 +395,15 @@ export function isTerminalPasteShortcut( return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; } +/** + * Middle-click paste is an X11/Wayland convention. macOS and Windows have no + * primary selection and use the button for autoscroll, so only desktops that + * expect the gesture get it. + */ +function isMiddleClickPastePlatform(): boolean { + return /linux|bsd/i.test(navigator.platform); +} + export function isTerminalCompositionCommitInput(event: Pick): boolean { return ( event.inputType === "" || @@ -938,6 +947,20 @@ export class GhosttyTerminalSurface { if (encoded.length > 0) this.options.onData(encoded); } + /** + * Middle-click pastes the terminal's own selection, which is the only + * primary-selection-like buffer a browser can read. It goes through + * pasteFromClipboard so it joins the same paste race as every other path. + * With nothing selected here there is no buffer to paste, and CLIPBOARD is + * deliberately not substituted: middle-click must never emit text the user + * only ever copied. + */ + private pasteTerminalSelection(): void { + const selection = this.getSelection(); + if (selection.length === 0) return; + void this.pasteFromClipboard(() => Promise.resolve(selection)); + } + hasSelection(): boolean { return this.core.selectionText().length > 0; } @@ -1272,6 +1295,12 @@ export class GhosttyTerminalSurface { this.canvas.setPointerCapture(event.pointerId); return; } + if (event.button === 1 && isMiddleClickPastePlatform()) { + // Left uncancelled on purpose: cancelling pointerdown drops the + // compatibility mousedown, which is what activates a split pane. + this.pasteTerminalSelection(); + return; + } if (event.button !== 0) return; const clickCount = this.recordSelectionClick(event); const link = this.linkAt(event.clientX, event.clientY); @@ -1515,6 +1544,10 @@ export class GhosttyTerminalSurface { if (this.canvas.hasPointerCapture(event.pointerId)) { this.canvas.releasePointerCapture(event.pointerId); } + if (event.button === 1 && isMiddleClickPastePlatform()) { + event.preventDefault(); + return; + } if (event.button !== 0) return; if (!this.selectionMoved && this.selectionMode === "cell") { this.clearSelection(); @@ -1551,10 +1584,23 @@ export class GhosttyTerminalSurface { }; private readonly onMouseDown = (event: MouseEvent) => { - if (event.button === 0) event.preventDefault(); + // Cancelling the middle button here stops autoscroll while still letting + // the event bubble to the drawer handler that activates a split pane. + if (event.button === 0 || (event.button === 1 && isMiddleClickPastePlatform())) { + event.preventDefault(); + } this.focus(); }; + /** + * Chromium pastes PRIMARY into the focused editable on a middle mouseup, and + * the hidden textarea is focused, so leaving the default alive would deliver + * a second paste through onPaste on top of the one onPointerDown sent. + */ + private readonly onMouseUp = (event: MouseEvent) => { + if (event.button === 1 && isMiddleClickPastePlatform()) event.preventDefault(); + }; + private readonly onContextMenu = (event: MouseEvent) => { if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { event.preventDefault(); @@ -1644,6 +1690,7 @@ export class GhosttyTerminalSurface { this.canvas.addEventListener("pointercancel", this.onPointerUp); this.canvas.addEventListener("wheel", this.onWheel, { passive: false }); this.canvas.addEventListener("mousedown", this.onMouseDown); + this.canvas.addEventListener("mouseup", this.onMouseUp); this.canvas.addEventListener("contextmenu", this.onContextMenu); this.scrollbar.addEventListener("pointerdown", this.onScrollbarPointerDown); this.scrollbar.addEventListener("pointermove", this.onScrollbarPointerMove); @@ -1669,6 +1716,7 @@ export class GhosttyTerminalSurface { this.canvas.removeEventListener("pointercancel", this.onPointerUp); this.canvas.removeEventListener("wheel", this.onWheel); this.canvas.removeEventListener("mousedown", this.onMouseDown); + this.canvas.removeEventListener("mouseup", this.onMouseUp); this.canvas.removeEventListener("contextmenu", this.onContextMenu); this.scrollbar.removeEventListener("pointerdown", this.onScrollbarPointerDown); this.scrollbar.removeEventListener("pointermove", this.onScrollbarPointerMove); From 0f602b3372b300ae94084bd3fe7dbaadaa58ba3a Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 9 Sep 2026 23:26:51 -0300 Subject: [PATCH 16/61] fix(editors): open remote projects in Zed (#11022) Co-authored-by: Claude Opus 5 (1M context) --- .../src/electron/ElectronShell.test.ts | 30 ++++++++++++++++++- apps/desktop/src/electron/ElectronShell.ts | 16 ++++++---- apps/web/src/remoteOpen.test.ts | 12 +++++++- packages/contracts/src/editor.ts | 23 ++++++++++---- 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index caaa39d88c0d..75eea216df21 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -88,6 +88,33 @@ 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 result = yield* electronShell.openExternal("zed://ssh/example.com/home/user/project"); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [["zed://ssh/example.com/home/user/project"]]); + }).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 +127,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..2089be58c0dc 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/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index ff78967aa3dc..6f7c17d8177a 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -141,8 +141,18 @@ describe("buildRemoteOpenUrl", () => { ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo"); }); + it("builds Zed's ssh deep link", () => { + expect( + buildRemoteOpenUrl({ + editor: "zed", + host: "sol.tail1234.ts.net", + absolutePath: "/home/theo/code/my repo", + }), + ).toBe("zed://ssh/sol.tail1234.ts.net/home/theo/code/my%20repo"); + }); + it("returns undefined for editors without remote support", () => { - expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/tmp/x" })).toBe( + expect(buildRemoteOpenUrl({ editor: "idea", host: "sol", absolutePath: "/tmp/x" })).toBe( undefined, ); }); diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 72efd84a14d6..6331a544d341 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -13,7 +13,8 @@ type EditorDefinition = { /** * URL scheme for editors that support VS Code's remote deep links * (`://vscode-remote/ssh-remote+`). Only set for VS Code - * and forks that ship the Remote-SSH machinery. + * and forks that ship the Remote-SSH machinery, plus Zed, which uses its own + * `zed://ssh/` shape. */ readonly remoteScheme?: string; }; @@ -49,7 +50,13 @@ export const EDITORS = [ launchStyle: "goto", remoteScheme: "vscodium", }, - { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" }, + { + id: "zed", + label: "Zed", + commands: ["zed", "zeditor"], + launchStyle: "direct-path", + remoteScheme: "zed", + }, { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" }, { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" }, { id: "aqua", label: "Aqua", commands: ["aqua"], launchStyle: "line-column" }, @@ -95,9 +102,10 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => { }; /** - * Builds a `://vscode-remote/ssh-remote+` deep link that - * opens `absolutePath` on `host` in the local editor over SSH. Returns - * undefined for editors without remote deep-link support. + * Builds a `://vscode-remote/ssh-remote+` deep link (Zed + * takes `zed://ssh/`) that opens `absolutePath` on `host` in the + * local editor over SSH. Returns undefined for editors without remote + * deep-link support. */ export const buildRemoteOpenUrl = (input: { readonly editor: EditorId; @@ -112,7 +120,10 @@ export const buildRemoteOpenUrl = (input: { const posixPath = input.absolutePath.replaceAll("\\", "/"); const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); - return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(input.host)}${encodedPath}`; + const encodedHost = encodeURIComponent(input.host); + return input.editor === "zed" + ? `${scheme}://ssh/${encodedHost}${encodedPath}` + : `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}`; }; /** From bb5e824c9fcbd76c93ef15b304f89f8b6999f32c Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 10 Sep 2026 02:18:01 -0300 Subject: [PATCH 17/61] feat: add blue and orange diff color palette (#10671) --- apps/web/src/components/GitActionsControl.tsx | 8 +-- apps/web/src/components/Sidebar.tsx | 4 +- .../web/src/components/chat/DiffStatLabel.tsx | 4 +- .../pullRequest/pullRequestPresentation.tsx | 6 +-- .../components/settings/SettingsPanels.tsx | 51 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 +++ apps/web/src/index.css | 26 ++++++++++ apps/web/src/lib/diffRendering.ts | 24 ++++----- apps/web/src/routes/__root.tsx | 5 ++ packages/contracts/src/settings.test.ts | 17 +++++++ packages/contracts/src/settings.ts | 6 +++ 11 files changed, 133 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index f816f60b4026..aa3767a3155a 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1878,9 +1878,9 @@ export default function GitActionsControl({ Excluded ) : ( <> - +{file.insertions} + +{file.insertions} / - -{file.deletions} + -{file.deletions} )} @@ -1891,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/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 78c59e296b90..79573635212e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1932,8 +1932,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null} {diff ? ( - +{diff.insertions}{" "} - −{diff.deletions} + +{diff.insertions}{" "} + −{diff.deletions} ) : null} - ); - // Stacks show their layer count; unrelated links show the current PR and a remainder count. + // Stacks show their layer count; unrelated links show only the remainder count. // Plain clicks open T3; individual PR links also support opening the host in a new tab. const prBadgeShape = supportsMultiplePullRequests ? resolveThreadPullRequestBadge(thread.pullRequests) diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 6a73a805fb67..2d724a726506 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -174,10 +174,11 @@ export function ThreadPullRequestBadgeControl({ const content = ( <> - {isStack ? badge.layers : number} - {badge?.kind === "pull-request" && badge.others > 0 ? ( - +{badge.others} - ) : null} + {isStack + ? badge.layers + : badge?.kind === "pull-request" && badge.others > 0 + ? `+${badge.others}` + : number} ); return ( From 0882431e0fc0fabe950743dd44b674a07a584bc9 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 10 Sep 2026 15:25:31 -0300 Subject: [PATCH 23/61] fix(preview): return to pip when closing the right panel (#11102) --- apps/web/src/components/ChatView.tsx | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f1b5afba94e9..597b7333ce28 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4293,10 +4293,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; @@ -4305,13 +4316,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; From 0527ddf06defc88169c245a0fe53635d41d66469 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 11:34:55 -0700 Subject: [PATCH 24/61] fix: quiet settled threads and simplify PR badges (#11101) --- .../src/features/threads/thread-list-items.tsx | 4 ++-- .../src/features/threads/thread-list-v2-items.tsx | 8 +------- apps/mobile/src/state/thread-pr-presentation.ts | 10 +++++++--- apps/mobile/src/state/use-thread-pr.test.ts | 4 +++- apps/web/src/components/Sidebar.tsx | 10 ++++++---- apps/web/src/components/ThreadStatusIndicators.tsx | 13 +++++++------ 6 files changed, 26 insertions(+), 23 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 0ec50c674451..a4da32ef7018 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -44,11 +44,11 @@ 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"; - if (pr.state === "open" && pr.isDraft === true) { + if (pr.others > 0 || (pr.state === "open" && pr.isDraft === true)) { return dark ? "#a1a1aa" : "#71717a"; } switch (pr.state) { diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 67a917c0079c..937ca5f909a8 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -880,13 +880,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ? materialYouStyleLayoutActive ? "accent-thread-selected-foreground" : "accent-user-bubble-foreground" - : pr.kind === "stack" || pr.isDraft || pr.state === null - ? "accent-foreground-muted" - : pr.state === "open" - ? "accent-adaptive-emerald-600-400" - : pr.state === "merged" - ? "accent-adaptive-violet-600-400" - : "accent-foreground-muted" + : "accent-foreground-muted" } /> ) : null} diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index 48fe3abf5f66..fc310d070acc 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -65,11 +65,12 @@ export function presentThreadLinkedPullRequests( const snapshot = link.snapshot; const state = badge.kind === "stack" ? badge.state : (snapshot?.state ?? null); const isDraft = snapshot?.isDraft === true && state === "open"; + const linkedCount = badge.kind === "pull-request" && badge.others > 0 ? badge.others + 1 : null; const label = badge.kind === "stack" ? String(badge.layers) - : badge.others > 0 - ? `+${badge.others}` + : linkedCount !== null + ? `+${linkedCount}` : String(link.number); return { kind: badge.kind, @@ -84,7 +85,10 @@ export function presentThreadLinkedPullRequests( badge.kind === "stack" ? `${badge.layers} pull requests in stack, ${state ?? "status pending"}` : `#${link.number} pull request ${state === null ? "status pending" : isDraft ? "draft" : state}${badge.others > 0 ? `, ${badge.others} more linked` : ""}`, - textClassName: state === null || isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[state], + textClassName: + linkedCount !== null || state === null || isDraft + ? "text-foreground-muted" + : PR_STATE_TEXT_CLASS[state], }; } diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index 5fdb90e19c65..e36bd7d81434 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -87,7 +87,9 @@ describe("presentThreadLinkedPullRequests", () => { it("counts unrelated links without labelling them a stack", () => { expect(presentThreadLinkedPullRequests([linkedPr(1), linkedPr(2)])).toMatchObject({ kind: "pull-request", - label: "+1", + label: "+2", + others: 1, + textClassName: "text-foreground-muted", }); }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 72a119cb76ba..b1583a346b56 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1387,6 +1387,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 @@ -1469,7 +1470,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 @@ -1485,7 +1486,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ); - // Stacks show their layer count; unrelated links show only the remainder count. + // 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) @@ -1597,8 +1598,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {props.project ? : null} @@ -1617,6 +1618,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { the time/jump label yields to the settle affordance. */} {prBadge} {prBadge && + variantAction !== "unsettle" && pr && (supportsMultiplePullRequests ? visibleThreadPullRequests(thread.pullRequests).length === 0 diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 2d724a726506..e948aa4091ee 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -154,6 +154,7 @@ export function ThreadPullRequestBadgeControl({ 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}` @@ -169,16 +170,16 @@ export function ThreadPullRequestBadgeControl({ "text-xs tabular-nums", variant === "ghost" && "font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]", - isStack ? PR_STATE_COLOR_CLASS[badge.state] : (status?.colorClass ?? "text-muted-foreground"), + linkedCount !== null + ? "text-secondary-label" + : isStack + ? PR_STATE_COLOR_CLASS[badge.state] + : (status?.colorClass ?? "text-muted-foreground"), ); const content = ( <> - {isStack - ? badge.layers - : badge?.kind === "pull-request" && badge.others > 0 - ? `+${badge.others}` - : number} + {isStack ? badge.layers : linkedCount !== null ? `+${linkedCount}` : number} ); return ( From f814983c262b42bd79247bae377a709925c70d63 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 11:34:56 -0700 Subject: [PATCH 25/61] fix(web): emphasize primary pull request actions (#11105) --- .../pullRequest/PullRequestDetailPanel.tsx | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 54ad13980236..236d7cd86ad9 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1728,7 +1728,7 @@ export function PullRequestDetailPanel({ @@ -1777,7 +1774,7 @@ export function PullRequestDetailPanel({ } /> @@ -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)} > - + } /> From 3997b9a3a4ee6b43ee768a9b372ff3dd52fd840e Mon Sep 17 00:00:00 2001 From: Henry Zhang <113233555+caezium@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:37:15 +0800 Subject: [PATCH 28/61] fix(web): align floating browser preview corners (#10915) --- .../preview/ThreadPreviewMiniPlayer.tsx | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) 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} From 60eff99f25dbfc9799043ad979537e2da1aea590 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:37:35 -0700 Subject: [PATCH 29/61] fix(web): save PR body edits with Cmd/Ctrl+Enter (#10660) --- .../pullRequest/PullRequestMarkdownEditor.tsx | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx index fde29d023774..a92418bbb862 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx @@ -55,11 +55,24 @@ export function PullRequestMarkdownEditor({ setDraft(value); } const empty = draft.trim().length === 0; + const saveDisabled = saving || (empty && !allowEmpty); return (
{ + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if ( + event.key === "Enter" && + (event.metaKey || event.ctrlKey) && + !event.shiftKey && + !event.altKey + ) { + event.preventDefault(); + event.stopPropagation(); + if (!saveDisabled && !event.repeat) onSave(draft); + return; + } if (event.key !== "Escape" || saving) return; event.preventDefault(); onCancel(); @@ -106,12 +119,7 @@ export function PullRequestMarkdownEditor({ -
From 502131adff7494d1a1797b4fe518870f7e8431fd Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 10 Sep 2026 15:38:35 -0300 Subject: [PATCH 30/61] fix(web): collapse a tool call by clicking its expanded label (#11017) Co-authored-by: Claude Opus 5 (1M context) --- .../components/chat/MessagesTimeline.test.tsx | 49 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 14 +++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 655156048c1d..c47402f4204b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1764,4 +1764,53 @@ describe("MessagesTimeline", () => { expect(markup).toContain("lucide-circle-alert"); expect(markup).toContain("text-destructive"); }); + + it("only withholds an expanded tool-call label click while text is selected", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("requestAnimationFrame", () => 0); + vi.stubGlobal("cancelAnimationFrame", () => {}); + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create( + , + ); + }); + await act(() => renderer!.root.findByProps({ "aria-expanded": false }).props.onClick()); + const label = renderer!.root.findAll( + (node) => node.type === "span" && String(node.props.className).includes("select-text"), + )[0]; + const stopPropagation = vi.fn(); + // Only the click that ends a selection may be withheld from the row + // toggle; the plain click has to reach it so the label can collapse. + for (const isCollapsed of [false, true]) { + label!.props.onClick({ + currentTarget: { ownerDocument: { getSelection: () => ({ isCollapsed }) } }, + stopPropagation, + }); + } + expect(stopPropagation).toHaveBeenCalledTimes(1); + } finally { + await act(() => renderer?.unmount()); + } + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 7764f64ea4b6..f685d511fd4b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3172,6 +3172,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 @@ -3407,7 +3419,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { expanded ? "whitespace-pre-wrap break-words select-text" : "truncate", headingClass, )} - onClick={expanded ? stopRowToggle : undefined} + onClick={expanded ? stopRowToggleWhileSelectingText : undefined} onPointerDown={expanded ? stopRowToggle : undefined} > {previewText} From dca7b59bea7e8a4a24e75b290027496a523b0ca7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 11:59:32 -0700 Subject: [PATCH 31/61] feat(devices): add simulator and emulator support (#10677) Co-authored-by: Claude Fable 5 --- .../src/features/threads/thread-work-log.tsx | 10 +- apps/mobile/src/lib/threadActivity.ts | 2 +- apps/server/src/auth/RpcAuthorization.ts | 8 + apps/server/src/device/AgentDeviceShim.ts | 35 + apps/server/src/device/DeviceActions.test.ts | 283 +++++++ apps/server/src/device/DeviceActions.ts | 568 +++++++++++++ apps/server/src/device/DeviceHost.ts | 102 +++ apps/server/src/device/DeviceHubProxy.test.ts | 141 ++++ apps/server/src/device/DeviceHubProxy.ts | 224 +++++ apps/server/src/device/DeviceService.test.ts | 337 ++++++++ apps/server/src/device/DeviceService.ts | 764 ++++++++++++++++++ .../server/src/device/DeviceToolchain.test.ts | 39 + apps/server/src/device/DeviceToolchain.ts | 223 +++++ .../server/src/device/LocalDeviceHost.test.ts | 114 +++ apps/server/src/device/LocalDeviceHost.ts | 697 ++++++++++++++++ apps/server/src/mcp/McpDeviceToolkit.test.ts | 127 +++ apps/server/src/mcp/McpHttpServer.ts | 172 ++++ apps/server/src/mcp/McpInvocationContext.ts | 2 +- .../server/src/mcp/McpProviderSession.test.ts | 30 + apps/server/src/mcp/McpProviderSession.ts | 27 +- .../server/src/mcp/McpSessionRegistry.test.ts | 22 +- apps/server/src/mcp/McpSessionRegistry.ts | 15 +- .../src/mcp/toolkits/device/handlers.test.ts | 43 + .../src/mcp/toolkits/device/handlers.ts | 212 +++++ apps/server/src/mcp/toolkits/device/tools.ts | 98 +++ .../provider/CodexDeveloperInstructions.ts | 39 +- .../src/provider/Drivers/AntigravityDriver.ts | 3 +- .../src/provider/Layers/AntigravityAdapter.ts | 3 + .../src/provider/Layers/ClaudeAdapter.ts | 2 +- .../src/provider/Layers/CodexAdapter.ts | 7 +- .../provider/Layers/CodexSessionRuntime.ts | 35 +- .../src/provider/Layers/CursorAdapter.ts | 9 +- .../server/src/provider/Layers/GrokAdapter.ts | 9 +- .../src/provider/Layers/OpenCodeAdapter.ts | 7 +- .../provider/Layers/ProviderService.test.ts | 42 +- .../src/provider/Layers/ProviderService.ts | 91 ++- .../src/provider/acp/AntigravityAcpSupport.ts | 2 + apps/server/src/server.test.ts | 18 + apps/server/src/server.ts | 11 +- apps/server/src/ws.ts | 36 + apps/web/src/components/ChatView.tsx | 75 ++ .../src/components/RightPanelTabs.test.tsx | 2 + apps/web/src/components/RightPanelTabs.tsx | 31 + .../components/chat/MessagesTimeline.logic.ts | 2 +- .../src/components/chat/MessagesTimeline.tsx | 6 + .../web/src/components/device/DevicePanel.tsx | 473 +++++++++++ .../web/src/components/device/DeviceSetup.tsx | 307 +++++++ .../device/DeviceStreamView.test.tsx | 58 ++ .../components/device/DeviceStreamView.tsx | 327 ++++++++ .../components/device/DeviceToolsPanel.tsx | 722 +++++++++++++++++ .../components/device/deviceHubApi.test.ts | 44 + .../web/src/components/device/deviceHubApi.ts | 248 ++++++ .../components/device/deviceStream.test.ts | 180 +++++ .../web/src/components/device/deviceStream.ts | 697 ++++++++++++++++ .../components/preview/PreviewEmptyState.tsx | 9 +- .../preview/PreviewLocalServerCard.tsx | 18 +- .../settings/IntegrationsSettings.test.tsx | 62 +- .../settings/IntegrationsSettings.tsx | 123 +++ .../src/components/settings/settingsSearch.ts | 21 + apps/web/src/components/ui/discovery-list.tsx | 37 + apps/web/src/rightPanelStore.ts | 12 +- apps/web/src/routes/_chat.pull-requests.tsx | 2 + apps/web/src/state/device.ts | 70 ++ apps/web/vite.config.ts | 9 +- docs/README.md | 2 + docs/internals/devices.md | 87 ++ docs/user/devices.md | 63 ++ packages/client-runtime/package.json | 8 + packages/client-runtime/src/rpc/client.ts | 1 + packages/client-runtime/src/state/device.ts | 68 ++ .../src/state/deviceHubAccess.ts | 72 ++ .../src/work-log/presentation.test.ts | 63 ++ .../src/work-log/presentation.ts | 17 +- packages/contracts/src/device.ts | 516 ++++++++++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/rpc.ts | 78 ++ packages/contracts/src/settings.ts | 19 + 77 files changed, 9041 insertions(+), 98 deletions(-) create mode 100644 apps/server/src/device/AgentDeviceShim.ts create mode 100644 apps/server/src/device/DeviceActions.test.ts create mode 100644 apps/server/src/device/DeviceActions.ts create mode 100644 apps/server/src/device/DeviceHost.ts create mode 100644 apps/server/src/device/DeviceHubProxy.test.ts create mode 100644 apps/server/src/device/DeviceHubProxy.ts create mode 100644 apps/server/src/device/DeviceService.test.ts create mode 100644 apps/server/src/device/DeviceService.ts create mode 100644 apps/server/src/device/DeviceToolchain.test.ts create mode 100644 apps/server/src/device/DeviceToolchain.ts create mode 100644 apps/server/src/device/LocalDeviceHost.test.ts create mode 100644 apps/server/src/device/LocalDeviceHost.ts create mode 100644 apps/server/src/mcp/McpDeviceToolkit.test.ts create mode 100644 apps/server/src/mcp/McpProviderSession.test.ts create mode 100644 apps/server/src/mcp/toolkits/device/handlers.test.ts create mode 100644 apps/server/src/mcp/toolkits/device/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/device/tools.ts create mode 100644 apps/web/src/components/device/DevicePanel.tsx create mode 100644 apps/web/src/components/device/DeviceSetup.tsx create mode 100644 apps/web/src/components/device/DeviceStreamView.test.tsx create mode 100644 apps/web/src/components/device/DeviceStreamView.tsx create mode 100644 apps/web/src/components/device/DeviceToolsPanel.tsx create mode 100644 apps/web/src/components/device/deviceHubApi.test.ts create mode 100644 apps/web/src/components/device/deviceHubApi.ts create mode 100644 apps/web/src/components/device/deviceStream.test.ts create mode 100644 apps/web/src/components/device/deviceStream.ts create mode 100644 apps/web/src/components/ui/discovery-list.tsx create mode 100644 apps/web/src/state/device.ts create mode 100644 docs/internals/devices.md create mode 100644 docs/user/devices.md create mode 100644 packages/client-runtime/src/state/device.ts create mode 100644 packages/client-runtime/src/state/deviceHubAccess.ts create mode 100644 packages/contracts/src/device.ts diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index ab1c69beffa5..593a49755376 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -76,7 +76,7 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180; const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140); const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120); -type WorkContentIcon = AppSymbolName | "browser" | "t3-code" | "pull-request"; +type WorkContentIcon = AppSymbolName | "browser" | "device" | "t3-code" | "pull-request"; function WorkLogIcon(props: { readonly icon: WorkContentIcon; @@ -97,7 +97,9 @@ function WorkLogIcon(props: { ? "arrow.triangle.pull" : props.icon === "browser" ? { ios: "globe", android: "public" } - : props.icon + : props.icon === "device" + ? { ios: "iphone", android: "smartphone" } + : props.icon } size={14} weight="medium" @@ -899,7 +901,7 @@ export function ThreadWorkGroupToggle(props: { readonly iconSubtleColor: import("react-native").ColorValue; readonly summary: string; readonly summaryKind: ToolGroupSummaryKind; - readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request"; + readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request"; readonly themeAppearance: "light" | "dark"; readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface; readonly toolIcon?: ToolActivityIcon; @@ -1248,6 +1250,8 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { 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/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 7f0241f949c3..c7d27af11d5e 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -167,7 +167,7 @@ export type ThreadFeedEntry = readonly summaryKind: ToolGroupSummaryKind; readonly toolSurface?: WorkLogEntry["toolSurface"]; readonly toolIcon?: WorkLogEntry["toolIcon"]; - readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request"; + readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request"; readonly hasFailure: boolean; readonly live: boolean; readonly shimmer: boolean; diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index a63b07f0adef..97afa4f40775 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -144,6 +144,14 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.previewAutomationFocusHost]: AuthOrchestrationOperateScope, [WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope, [WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope, + [WS_METHODS.deviceConfigure]: 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..c2a28127e31d --- /dev/null +++ b/apps/server/src/device/AgentDeviceShim.ts @@ -0,0 +1,35 @@ +/** + * 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; + if (platform === "win32") { + const script = `@echo off\r\n"${node}" "${entryPath}" %*\r\n`; + yield* fs.writeFileString(path.join(shimDir, "agent-device.cmd"), script); + } else { + const script = `#!/bin/sh\nexec "${node}" "${entryPath}" "$@"\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/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts new file mode 100644 index 000000000000..ed6b5a87335d --- /dev/null +++ b/apps/server/src/device/DeviceActions.test.ts @@ -0,0 +1,283 @@ +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 = { + 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..12781b31b4b5 --- /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(process.execPath, [ + 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..8d1bafbc5dfc --- /dev/null +++ b/apps/server/src/device/DeviceHost.ts @@ -0,0 +1,102 @@ +/** + * 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; + /** Absolute path of the agent-device entry script for the provider PATH shim. */ + readonly entryPath: string; +} + +export interface DeviceHostReady { + 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..f8685727c882 --- /dev/null +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -0,0 +1,224 @@ +/** + * 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(); + 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"); + 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/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts new file mode 100644 index 000000000000..ed42be64eb02 --- /dev/null +++ b/apps/server/src/device/DeviceService.test.ts @@ -0,0 +1,337 @@ +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, make, stateStream } from "./DeviceService.ts"; + +const baseState: DeviceServiceState = { + hosts: [], + hostStatus: "idle", + 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 = { + 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* make.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..6cca6631bd7e --- /dev/null +++ b/apps/server/src/device/DeviceService.ts @@ -0,0 +1,764 @@ +/** + * 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, + LOCAL_DEVICE_HOST_ID, + type ThreadId, +} from "@t3tools/contracts"; +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 DeviceHost from "./DeviceHost.ts"; +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 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 make = Effect.gen(function* () { + const localHost = yield* DeviceHost.DeviceHost; + 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 hosts: ReadonlyMap = new Map([ + [localHost.id, localHost], + ]); + const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); + const statePubSub = yield* PubSub.unbounded(); + const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary); + const stateRef = yield* SynchronizedRef.make({ + state: { + hosts: initialHosts, + hostStatus: initialSettings.enabled ? "idle" : "disabled", + 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 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((phase) => + publish((state) => ({ ...state, hostStatus: phase, hostStatusDetail: undefined })).pipe( + Effect.asVoid, + ), + ) + .pipe( + Effect.tapError((error) => + publish((state) => ({ + ...state, + hostStatus: "failed", + hostStatusDetail: error.message, + })), + ), + Effect.mapError( + (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), + ), + ); + yield* SynchronizedRef.get(stateRef).pipe( + Effect.flatMap(({ state }) => + state.hostStatus === "ready" + ? Effect.void + : publish((current) => ({ + ...current, + hostStatus: "ready", + hostStatusDetail: undefined, + })), + ), + ); + 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.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.platforms.some((platform) => platform.available)) return null; + const ready = yield* host + .ensureAgentReady((phase) => + publish((state) => ({ ...state, hostStatus: phase, hostStatusDetail: undefined })).pipe( + Effect.asVoid, + ), + ) + .pipe( + Effect.tapError((error) => + publish((state) => ({ + ...state, + hostStatus: "failed", + hostStatusDetail: 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, hostStatus: "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 { 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) + return (yield* SynchronizedRef.get(stateRef)).state; + return yield* publish((state) => ({ + ...state, + hosts: hostSummaries, + devices, + hostStatusDetail: detail, + })); + }), + ); + }); + + const list: DeviceService["Service"]["list"] = Effect.gen(function* () { + if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state; + const ready = yield* readiness(); + return yield* refresh(ready); + }).pipe( + Effect.tapError((error) => + publish((state) => + state.hostStatus === "disabled" + ? state + : { ...state, hostStatus: "failed", hostStatusDetail: error.message }, + ), + ), + 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, + 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), + ), + ); + } + 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.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({ + 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, + }); +}); + +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..e6b43cd81ee8 --- /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"; +const DEVICE_HUB_VERSION = "0.9.0"; +const AGENT_DEVICE_PACKAGE = "agent-device"; +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..b24ebfbd98b6 --- /dev/null +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -0,0 +1,697 @@ +/** + * 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, + 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/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts new file mode 100644 index 000000000000..083ac0c034fe --- /dev/null +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -0,0 +1,127 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { 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 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, + 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, +}); + +const TestLayer = McpHttpServer.DeviceToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(DeviceServiceMock), + 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)), +); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 3556a57befdc..bf7cf0520668 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"; @@ -30,6 +31,15 @@ import { } 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( { @@ -426,6 +436,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), ); @@ -443,6 +601,19 @@ export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequest 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, @@ -453,4 +624,5 @@ const McpTransportLive = McpServer.layerHttp({ export const layer = Layer.mergeAll( PreviewToolkitRegistrationLive, PullRequestsToolkitRegistrationLive, + DeviceToolkitRegistrationLive, ).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 0c0a0ab68ae9..eddfa7270a77 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -8,7 +8,7 @@ import { import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -export type McpCapability = "preview" | "pull-requests"; +export type McpCapability = "preview" | "device" | "pull-requests"; export interface McpInvocationScope { readonly environmentId: EnvironmentId; 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 61c3ac1e0b20..4019cb37bd04 100644 --- a/apps/server/src/mcp/McpProviderSession.ts +++ b/apps/server/src/mcp/McpProviderSession.ts @@ -7,8 +7,31 @@ export interface McpProviderSessionConfig { readonly providerInstanceId: ProviderInstanceId; readonly endpoint: string; readonly authorizationHeader: string; - /** Whether the credential grants the preview (browser) toolkit; the pull request toolkit always is. */ - readonly preview: boolean; + /** 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 2e9749a04062..a1e333bd07b0 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -39,7 +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"), - preview: true, + capabilities: new Set(["preview"]), }); expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp"); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); @@ -55,18 +55,23 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t }), ); -it.effect("always grants pull-requests and gates preview on the request", () => +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"), - preview: true, + capabilities: new Set(["preview"]), }); const withoutPreview = yield* registry.issue({ threadId: ThreadId.make("thread-no-preview"), providerInstanceId: ProviderInstanceId.make("codex"), - preview: false, + 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 @@ -75,6 +80,7 @@ it.effect("always grants pull-requests and gates preview on the request", () => expect(yield* capabilitiesOf(withPreview)).toEqual(["preview", "pull-requests"]); expect(yield* capabilitiesOf(withoutPreview)).toEqual(["pull-requests"]); + expect(yield* capabilitiesOf(withDevice)).toEqual(["device", "pull-requests"]); }), ); @@ -92,7 +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"), - preview: true, + capabilities: new Set(["preview"]), }); expect(issued.config.endpoint).toBe(expectedEndpoint); } @@ -106,7 +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"), - preview: true, + capabilities: new Set(["preview"]), }); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); timestamp += 101; @@ -122,7 +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"), - preview: true, + capabilities: new Set(["preview"]), }); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); @@ -144,7 +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"), - preview: true, + 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 130f6dce582b..383bfbfec390 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -14,11 +14,7 @@ import * as McpProviderSession from "./McpProviderSession.ts"; export interface McpCredentialRequest { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; - /** - * Whether the credential may drive the user's browser. The pull request - * toolkit is always granted: it only touches the thread's own links. - */ - readonly preview: boolean; + readonly capabilities: ReadonlySet; } export interface McpIssuedCredential { @@ -133,9 +129,10 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( threadId: ThreadId.make(request.threadId), providerSessionId, providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), - capabilities: new Set( - request.preview ? ["pull-requests", "preview"] : ["pull-requests"], - ), + capabilities: new Set([ + "pull-requests", + ...request.capabilities, + ]), issuedAt, }; yield* SynchronizedRef.update(state, ({ records }) => { @@ -151,7 +148,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( providerInstanceId: scope.providerInstanceId, endpoint, authorizationHeader: `Bearer ${rawToken}`, - preview: request.preview, + capabilities: scope.capabilities, }, }; }, 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..e37b2a8b22c2 --- /dev/null +++ b/apps/server/src/mcp/toolkits/device/handlers.test.ts @@ -0,0 +1,43 @@ +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("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..bab7391af24d --- /dev/null +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -0,0 +1,212 @@ +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 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): string { + const target = agentDeviceTargetArgs(device).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 the agent-device CLI, which is on PATH and already connected to this environment. Always pass ${target}.`, + "Typical loop:", + ` agent-device open ${target} # or: open `, + ` agent-device snapshot -i ${target} # accessibility tree with @eN refs`, + ` agent-device click @e3 ${target}`, + ` agent-device fill @e5 "text" ${target}`, + ` agent-device screenshot /tmp/shot.png ${target} # or call device_screenshot`, + ` agent-device install ${target}`, + "Prefer snapshot refs over coordinates. Run `agent-device help` for workflow guides and `agent-device --help` for flags.", + "Do not call simctl, adb, xcrun, or serve-sim directly while these tools are attached; use agent-device.", + 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 { + 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); + 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; + return { + device, + agentDevice: { command: "agent-device", targetArgs: agentDeviceTargetArgs(device) }, + quickStart: agentDeviceQuickStart(device), + }; + }).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.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.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..e98d6ab64829 --- /dev/null +++ b/apps/server/src/mcp/toolkits/device/tools.ts @@ -0,0 +1,98 @@ +import { + DeviceToolCloseInput, + DeviceToolError, + DeviceToolListResult, + DeviceToolOpenInput, + DeviceToolOpenResult, + DeviceToolScreenshotResult, + DeviceToolTargetInput, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +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, +}) + .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/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 1c2439a9ad9a..85784d21ca4b 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 and already connected: 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 0 ? { extraArgs } : {}), ...(mcpSession diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 5c3d1f08e2d0..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,7 +2286,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( "-c", 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"', ], - browserToolsAvailable: mcpSession.preview, + mcpCapabilities: mcpSession.capabilities, } : {}), }; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 2ec77ecb7000..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,13 +179,8 @@ export interface CodexSessionRuntimeOptions { readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; readonly appServerArgs?: ReadonlyArray; - /** - * Whether the attached `t3-code` MCP server exposes the preview tools. The - * server is attached for every session now (the pull request toolkit is - * always on), so its presence in `appServerArgs` no longer implies browser - * access; the credential's own capability decides the developer prompt. - */ - readonly browserToolsAvailable?: boolean; + /** Capabilities the session's `t3-code` MCP credential grants; drives the prompt blocks. */ + readonly mcpCapabilities?: ReadonlySet; } export interface CodexSessionRuntimeSendTurnInput { @@ -576,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; @@ -610,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 @@ -2359,9 +2367,10 @@ export const makeCodexSessionRuntime = ( // 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) && - (options.browserToolsAvailable ?? true), + 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 1ce1a396796f..9a3fcb8d65f4 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4800,12 +4800,14 @@ describe("agent browser access", () => { 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<{ readonly threadId: ThreadId; readonly preview: boolean }> = []; + 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, @@ -4864,7 +4866,10 @@ describe("agent browser access", () => { const providerLayer = makeProviderServiceLive({ issueMcpCredential: (request) => Effect.sync(() => { - issued.push({ threadId: request.threadId, preview: request.preview }); + issued.push({ + threadId: request.threadId, + capabilities: [...request.capabilities].toSorted(), + }); return undefined; }), }).pipe( @@ -4874,6 +4879,7 @@ describe("agent browser access", () => { Layer.provide( ServerSettings.ServerSettingsService.layerTest({ enableAgentBrowserAccess, + enableAgentDeviceAccess, projectAgentBrowserAccessOverrides: projectOverride === undefined ? {} : { [projectId]: projectOverride }, }), @@ -4910,7 +4916,7 @@ describe("agent browser access", () => { const issued = yield* startSessionWith(false, threadId); - assert.deepEqual(issued, [{ threadId, preview: false }]); + assert.deepEqual(issued, [{ threadId, capabilities: ["pull-requests"] }]); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -4920,23 +4926,43 @@ describe("agent browser access", () => { const issued = yield* startSessionWith(true, threadId); - assert.deepEqual(issued, [{ threadId, preview: true }]); + assert.deepEqual(issued, [ + { threadId, capabilities: ["device", "preview", "pull-requests"] }, + ]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("drops only the preview capability when browser access alone is off", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-browser-off-device-on"); + + const issued = yield* startSessionWith({ browser: false, device: true }, threadId); + + assert.deepEqual(issued, [{ threadId, capabilities: ["device", "pull-requests"] }]); }).pipe(Effect.provide(NodeServices.layer)), ); it.effect("issues a credential without preview when the project disables browser access", () => Effect.gen(function* () { const threadId = asThreadId("thread-project-browser-off"); + 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, [{ threadId, preview: false }]); + 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, preview: true }]); + 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 5b9059bfa643..f2d94e735b0d 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 { ensureAgentDeviceShim } from "../../device/AgentDeviceShim.ts"; +import * as DeviceService from "../../device/DeviceService.ts"; +import type * as McpInvocationContext from "../../mcp/McpInvocationContext.ts"; import { increment, providerMetricAttributes, @@ -249,6 +254,8 @@ export interface ProviderServiceLiveOptions { * test see whether a credential was requested at all. */ readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential; + /** Overrides the device host lookup used to build the agent-device environment. */ + readonly deviceReadiness?: DeviceService.DeviceService["Service"]["agentReadinessIfSupported"]; } interface TurnAnalyticsMetadata { @@ -477,7 +484,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); const issueMcpCredential = options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; + const deviceReadiness = + options?.deviceReadiness ?? + (() => + Effect.serviceOption(DeviceService.DeviceService).pipe( + Effect.flatMap((service) => + Option.isSome(service) ? service.value.agentReadinessIfSupported() : Effect.succeed(null), + ), + )); const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; const runtimeEventPubSub = yield* PubSub.unbounded(); const pendingCompactions = new Map(); const timedOutNativeCompactions = new Set(); @@ -879,22 +895,75 @@ 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; + }); + /** - * Attach the `t3-code` MCP server to the session that is about to start. - * - * Every session gets a credential: the pull request toolkit is always on, - * since it only registers links on the session's own thread. Browser access - * is a capability on that credential, so turning the setting off withholds - * the preview tools without taking the server away. `issueActiveMcpCredential` - * revokes the thread's previous token first, which matters because a session - * restart (runtime mode, cwd, model) re-prepares without stopping. + * Starting a session with device access also brings the device host up, so + * the `agent-device` CLI is on the provider's PATH from its first turn. The + * environment is fixed at spawn time, so a host started later by + * `device_open` could not reach an already-running agent. Tools install once + * and the host is idempotent, so this is cheap after the first session; + * a host that fails to start withholds only the CLI, not the MCP tools. */ + const hostPlatform = yield* HostProcessPlatform; + const agentDeviceEnvironment = Effect.gen(function* () { + const readiness = yield* deviceReadiness().pipe( + Effect.catch((cause) => + Effect.logWarning("Device host unavailable; starting session without agent-device", { + cause, + }).pipe(Effect.as(null)), + ), + ); + if (!readiness) return undefined; + const shimDir = yield* ensureAgentDeviceShim({ + entryPath: readiness.agentDevice.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_DAEMON_BASE_URL: readiness.agentDevice.baseUrl, + AGENT_DEVICE_DAEMON_AUTH_TOKEN: readiness.agentDevice.token, + AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1", + } satisfies Record; + }); + const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => Effect.gen(function* () { - const preview = yield* agentBrowserAccessEnabled(threadId); - const credential = yield* issueMcpCredential({ threadId, providerInstanceId, preview }); + 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/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/server.test.ts b/apps/server/src/server.test.ts index 01f2dd96b18f..62412ac25853 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, @@ -805,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( @@ -1660,6 +1667,17 @@ const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe( ), ); +const EMPTY_DEVICE_STATE: DeviceServiceState = { + hosts: [], + hostStatus: "idle", + 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* () { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3831763a3eac..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"; @@ -386,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( @@ -469,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. @@ -553,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 5e4ca0a18db9..71a8ed19d905 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -114,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"; @@ -536,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; @@ -2764,6 +2766,40 @@ 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.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, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 597b7333ce28..225d91310014 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -205,6 +205,10 @@ 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, @@ -556,6 +560,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 = [ @@ -4136,6 +4143,34 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef || !supportsThreadPullRequests) return; useRightPanelStore.getState().open(activeThreadRef, "pull-requests"); }, [activeThreadRef, supportsThreadPullRequests]); + const { state: deviceState } = 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]); + // An agent's `device_open` surfaces in every client the same way a + // `preview_open` does: the thread starts a device or gains a session and the panel + // opens on it. Closing the last session leaves the tab in place so the + // user keeps their picker; only new sessions raise the panel. + const threadDeviceSessionCount = activeThreadRef + ? deviceState.sessions.filter((session) => session.threadId === activeThreadRef.threadId) + .length + + (deviceState.bootingDevices?.filter((device) => device.threadId === activeThreadRef.threadId) + .length ?? 0) + : 0; + const previousDeviceSessionCount = useRef(threadDeviceSessionCount); + useEffect(() => { + const previous = previousDeviceSessionCount.current; + previousDeviceSessionCount.current = threadDeviceSessionCount; + if (!activeThreadRef || threadDeviceSessionCount <= previous) return; + if (shouldUseRightPanelSheet) return; + useRightPanelStore.getState().open(activeThreadRef, "device"); + }, [activeThreadRef, shouldUseRightPanelSheet, threadDeviceSessionCount]); const openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; @@ -8113,6 +8148,19 @@ export default function ChatView(props: ChatViewProps) { environmentId={activeThreadRef?.environmentId ?? null} threadId={activeThreadRef?.threadId ?? null} /> + ) : renderedRightPanelSurface?.kind === "device" ? ( + + { + closeRightPanelSurface(renderedRightPanelSurface); + useRightPanelStore.getState().show(activeThreadRef); + }} + /> + ) : (renderedRightPanelSurface?.kind === "files" || renderedRightPanelSurface?.kind === "file") && ((activeProject && activeWorkspaceRoot) || @@ -8168,6 +8216,29 @@ export default function ChatView(props: ChatViewProps) { return (
+ { + if (!open) setDeviceSetupThread(null); + }} + > + + {activeThreadRef ? ( + { + useRightPanelStore.getState().open(activeThreadRef, "device"); + setDeviceSetupThread(null); + }} + /> + ) : null} + + {rightPanelControlsAtRoot ? panelLayoutControls : null}
{rightPanelContent} @@ -8718,6 +8791,7 @@ export default function ChatView(props: ChatViewProps) { onAddPullRequest={addPullRequestSurface} onAddPullRequests={addPullRequestsSurface} onAddAgents={addAgentsSurface} + onAddDevice={addDeviceSurface} browserAvailable={isPreviewSupportedInRuntime()} terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} @@ -8725,6 +8799,7 @@ export default function ChatView(props: ChatViewProps) { pullRequestAvailable={pullRequestSurfaceAvailable} pullRequestsAvailable={isServerThread && supportsThreadPullRequests} agentsAvailable + deviceAvailable={activeThreadRef !== null} liveAgentCount={agentPanelModel.liveCount} > {rightPanelContent} diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 077d3680e45a..dfaaf171c429 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -123,6 +123,7 @@ function renderTabs( onAddDiff={() => undefined} onAddFiles={() => undefined} onAddAgents={() => undefined} + onAddDevice={() => undefined} liveAgentCount={0} browserAvailable terminalAvailable={false} @@ -131,6 +132,7 @@ function renderTabs( pullRequestAvailable={false} pullRequestsAvailable={false} agentsAvailable={false} + deviceAvailable={false} >
content
, diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index d4ba544d8588..546c7c5ad45a 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -15,6 +15,7 @@ import type { import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { Bot, + Smartphone, ChevronDown, ChevronLeft, ChevronRight, @@ -114,6 +115,7 @@ interface RightPanelTabsProps { onAddPullRequest: () => void; onAddPullRequests: () => void; onAddAgents: () => void; + onAddDevice: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; @@ -121,6 +123,7 @@ interface RightPanelTabsProps { pullRequestAvailable: boolean; pullRequestsAvailable: boolean; agentsAvailable: boolean; + deviceAvailable: boolean; pullRequestStatusSeeds?: Readonly>; /** Running + waiting subagents; badges the Agents card in the empty state. */ liveAgentCount: number; @@ -151,6 +154,7 @@ const SURFACE_DISABLED_REASONS = { 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. */ @@ -174,6 +178,7 @@ const SURFACE_UNAVAILABLE_HINTS = { 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 = @@ -312,6 +317,7 @@ function RightPanelEmptyState(props: { onAddPullRequest: () => void; onAddPullRequests: () => void; onAddAgents: () => void; + onAddDevice: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; @@ -319,6 +325,7 @@ function RightPanelEmptyState(props: { pullRequestAvailable: boolean; pullRequestsAvailable: boolean; agentsAvailable: boolean; + deviceAvailable: boolean; liveAgentCount: number; }) { // -1 means no highlight: it only appears on hover or arrow use. @@ -395,6 +402,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]; @@ -630,6 +647,8 @@ function surfaceTitle( return "Pull requests"; case "agents": return "Agents"; + case "device": + return "Device"; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; @@ -713,6 +732,8 @@ function SurfaceIcon({ return ; case "agents": return ; + case "device": + return ; } } @@ -906,6 +927,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) => { @@ -1344,6 +1373,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddPullRequest={props.onAddPullRequest} onAddPullRequests={props.onAddPullRequests} onAddAgents={props.onAddAgents} + onAddDevice={props.onAddDevice} browserAvailable={props.browserAvailable} terminalAvailable={props.terminalAvailable} diffAvailable={props.diffAvailable} @@ -1351,6 +1381,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { pullRequestAvailable={props.pullRequestAvailable} pullRequestsAvailable={props.pullRequestsAvailable} agentsAvailable={props.agentsAvailable} + deviceAvailable={props.deviceAvailable} liveAgentCount={props.liveAgentCount} /> ) : ( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 0082c7dd227c..866cbd0201b5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -337,7 +337,7 @@ export type MessagesTimelineRow = summaryKind: ToolGroupSummaryKind; toolSurface?: WorkLogEntry["toolSurface"]; toolIcon?: WorkLogEntry["toolIcon"]; - summaryToolIcon?: "browser" | "t3-code" | "pull-request"; + summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request"; hasFailure: boolean; } | { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f685d511fd4b..3544e87817bd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -97,6 +97,7 @@ import { MousePointerClickIcon, PaintbrushIcon, SearchIcon, + SmartphoneIcon, SquarePenIcon, TerminalIcon, Undo2Icon, @@ -2239,6 +2240,8 @@ function toolGroupSummaryIconName( return "terminal"; case "browser": return "browser"; + case "device": + return "device"; case "search": return "globe"; case "code-search": @@ -2800,6 +2803,7 @@ type WorkEntryIconName = | "check" | "circle-alert" | "computer" + | "device" | "eye" | "globe" | "hammer" @@ -3022,6 +3026,8 @@ function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className return ; case "computer": return ; + case "device": + return ; case "t3-code": return ; case "check": diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx new file mode 100644 index 000000000000..d94913b03dff --- /dev/null +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -0,0 +1,473 @@ +import type { + DevicePlatform, + DeviceServiceState, + DeviceSummary, + ScopedThreadRef, +} from "@t3tools/contracts"; +import { + ChevronLeft, + Circle, + Home, + Power, + RotateCcw, + SlidersHorizontal, + Smartphone, + Square, + X, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +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 { + Select, + SelectGroup, + SelectGroupLabel, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +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 { DeviceSetup } from "./DeviceSetup"; +import { DeviceToolsPanel } from "./DeviceToolsPanel"; +import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell"; + +const NEW_DEVICE_VALUE = "__new__"; + +const platformLabel = (platform: DevicePlatform) => + platform === "ios" ? "iOS Simulators" : "Android Emulators"; + +const deviceKey = (device: Pick) => + `${device.hostId}\u0000${device.id}`; + +/** + * The Device right-panel surface: one open device (from the thread's device + * sessions) with a picker to switch or boot another. Booting and streaming are + * server-owned; this panel only asks and renders. + */ +export function DevicePanel(props: { + readonly mode: PreviewPanelMode; + readonly threadRef: ScopedThreadRef; + /** `null` renders the picker with nothing open. */ + readonly deviceId: string | null; + 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 [pendingDeviceKey, setPendingDeviceKey] = useState(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.deviceId + ? sessions.find((session) => session.deviceId === props.deviceId) + : undefined) ?? sessions.at(-1); + const activeDevice = activeSession + ? state.devices.find( + (device) => device.hostId === activeSession.hostId && device.id === activeSession.deviceId, + ) + : undefined; + + const grouped = useMemo(() => groupDevices(state), [state]); + + const selectDevice = useCallback( + async (value: string) => { + if (value === NEW_DEVICE_VALUE) return; + const device = state.devices.find((candidate) => deviceKey(candidate) === value); + if (!device) return; + setOperationError(null); + setPendingDeviceKey(value); + 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)); + } finally { + setPendingDeviceKey(null); + } + }, + [environmentId, open, state.devices, threadId], + ); + + const closeActive = useCallback( + (powerOff: boolean) => { + if (!activeSession) return; + setOperationError(null); + void close({ + environmentId, + input: { threadId, deviceId: activeSession.deviceId, shutdown: powerOff }, + }).then((result) => { + if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); + }); + }, + [activeSession, close, environmentId, threadId], + ); + + const bootingDevices = + state.bootingDevices?.filter((device) => device.threadId === threadId) ?? []; + const hostReady = state.hostStatus === "ready"; + const hostBusy = state.hostStatus === "installing" || state.hostStatus === "starting"; + const unavailablePlatforms = state.hosts.flatMap((host) => + host.platforms.filter((platform) => !platform.available), + ); + + if (loaded && (!state.onboardingCompleted || hostDisabled)) { + return ( + { + if (!isOpen) props.onDismissSetup(); + }} + > + + + + + ); + } + + return ( + +
+ + {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 ? ( + <> +
+ +
+ {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} + + ) : ( +
+
+ {grouped.length === 0 || hostBusy || pendingDeviceKey ? ( + <> + {hostBusy || pendingDeviceKey ? ( + + ) : ( + + )} +

+ {state.hostStatus === "failed" + ? (state.hostStatusDetail ?? "The device hub failed to start.") + : pendingDeviceKey + ? "Booting device… this can take a minute." + : hostBusy + ? state.hostStatus === "installing" + ? "Installing device tools…" + : "Starting the device hub…" + : !loaded + ? "Connecting…" + : grouped.length === 0 + ? "No simulators or emulators were found on this environment." + : "Choose a device to open."} +

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

{platformLabel(group.platform)}

+
+ + {group.devices.map((device) => ( + + + + } + title={device.name} + description={`${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} + {unavailablePlatforms.length > 0 && hostReady ? ( +
    + {unavailablePlatforms.map((platform) => ( +
  • + {platform.platform === "ios" ? "iOS" : "Android"}: {platform.reason} +
  • + ))} +
+ ) : 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..a46e5ed7b7b8 --- /dev/null +++ b/apps/web/src/components/device/DeviceStreamView.test.tsx @@ -0,0 +1,58 @@ +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..aed5c6019b9b --- /dev/null +++ b/apps/web/src/components/device/DeviceStreamView.tsx @@ -0,0 +1,327 @@ +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 { Spinner } from "~/components/ui/spinner"; +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 visible: boolean; + /** 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); + 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" ? ( +
+ {status === "connecting" ? : null} + {status === "error" ? (detail ?? "Stream failed.") : "Connecting to device…"} + {status === "connecting" && detail ? ( + {detail} + ) : null} +
+ ) : 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/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/settings/IntegrationsSettings.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.test.tsx index 5d185fee5824..9ce3b2126c0e 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,59 @@ 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", + 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..a3f76f49cd5f 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -39,10 +39,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 +582,117 @@ function AgentBrowserAccessSetting() { ); } +function DeviceIntegrationSettings() { + const primaryEnvironment = usePrimaryEnvironment(); + const environmentId = primaryEnvironment?.environmentId ?? null; + 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 enabled = state.hostStatus !== "disabled"; + 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 (!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 +1292,7 @@ export function IntegrationsSettingsPanel() { previewDefaults )} + ); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 5cf35a543392..24892b0e1e3b 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -402,6 +402,27 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/projects", searchTerms: ["allow open drive preview tools sessions"], }, + { + 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/ui/discovery-list.tsx b/apps/web/src/components/ui/discovery-list.tsx new file mode 100644 index 000000000000..d03ca0edd5e0 --- /dev/null +++ b/apps/web/src/components/ui/discovery-list.tsx @@ -0,0 +1,37 @@ +import type { ComponentProps, ReactNode } from "react"; + +export function DiscoveryList({ children }: { readonly children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function DiscoveryListRow({ + icon, + title, + description, + action, + ...props +}: Omit, "title" | "children" | "className"> & { + readonly icon: ReactNode; + readonly title: ReactNode; + readonly description: ReactNode; + readonly action?: ReactNode; +}) { + return ( + + ); +} diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 1719ae77725c..293733b5b3d9 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -24,6 +24,7 @@ const RIGHT_PANEL_KINDS = [ "files", "file", "preview", + "device", "terminal", "pull-request", "pull-requests", @@ -34,6 +35,12 @@ export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; export type RightPanelSurface = | { id: `browser:${string}`; kind: "preview"; resourceId: string } | { id: "browser:new"; kind: "preview"; resourceId: null } + /** + * One Device tab per thread. The tab is the surface; which device it shows + * comes from the thread's server-side device sessions, so an agent opening a + * device from another client lands in the same tab. + */ + | { id: "device"; kind: "device" } | { id: `terminal:${string}`; kind: "terminal"; @@ -82,7 +89,8 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; // v9 removed the "plan" surface kind (plans render inline in the transcript). // v10 keys pull-request surfaces by reference instead of a singleton tab. // v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh. -const RIGHT_PANEL_STORAGE_VERSION = 11; +// v12 adds the device surface. +const RIGHT_PANEL_STORAGE_VERSION = 12; /** A fixed workspace-level ref: each PR surface carries its own real environment. */ export const PULL_REQUESTS_PANEL_REF = scopeThreadRef( @@ -178,6 +186,8 @@ const singletonSurface = ( return { id: "pull-requests", kind }; case "agents": return { id: "agents", kind }; + case "device": + return { id: "device", kind }; } }; diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 62bb23bb305f..24dcab132b86 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1971,6 +1971,7 @@ function PullRequestsRouteView() { onAddPullRequest={() => undefined} onAddPullRequests={() => undefined} onAddAgents={() => undefined} + onAddDevice={() => undefined} browserAvailable={false} terminalAvailable={false} diffAvailable={false} @@ -1978,6 +1979,7 @@ function PullRequestsRouteView() { pullRequestAvailable={false} pullRequestsAvailable={false} agentsAvailable={false} + deviceAvailable={false} liveAgentCount={0} pullRequestStatusSeeds={listedPullRequestTabStatuses} > diff --git a/apps/web/src/state/device.ts b/apps/web/src/state/device.ts new file mode 100644 index 000000000000..b9c1adf86cdd --- /dev/null +++ b/apps/web/src/state/device.ts @@ -0,0 +1,70 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createDeviceEnvironmentAtoms } from "@t3tools/client-runtime/state/device"; +import { + type DeviceHubAccess, + resolveDeviceHubAccess, +} from "@t3tools/client-runtime/state/deviceHubAccess"; +import type { DeviceServiceState, EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentSession } from "./session"; +import { useEnvironmentQuery } from "./query"; + +export const deviceEnvironment = createDeviceEnvironmentAtoms(connectionAtomRuntime); + +const EMPTY_DEVICE_STATE: DeviceServiceState = { + hosts: [], + hostStatus: "idle", + devices: [], + sessions: [], + onboardingCompleted: false, + agentAccessEnabled: false, + hubBasePath: "/api/device-hub", + revision: 0, +}; + +export function useDeviceState(environmentId: EnvironmentId | null): { + readonly state: DeviceServiceState; + readonly loaded: boolean; +} { + const query = useEnvironmentQuery( + environmentId === null ? null : deviceEnvironment.state({ environmentId, input: {} }), + ); + return { state: query.data ?? EMPTY_DEVICE_STATE, loaded: query.data !== undefined }; +} + +/** + * Hub access for one environment. Bearer and DPoP connections mint a ticket + * here; a stream that gets a 401 back refreshes this atom and reconnects. + * Keyed on the prepared connection so a re-pair produces new credentials. + */ +const deviceHubAccessAtom = Atom.family((environmentId: EnvironmentId) => + connectionAtomRuntime + .atom((get) => { + const prepared = Option.getOrNull( + get(environmentSession.preparedConnectionValueAtom(environmentId)), + ); + if (prepared === null) return Effect.never; + return resolveDeviceHubAccess({ prepared, hubBasePath: EMPTY_DEVICE_STATE.hubBasePath }); + }) + .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`device-hub-access:${environmentId}`)), +); + +export function useDeviceHubAccess(environmentId: EnvironmentId | null): DeviceHubAccess | null { + const result = useAtomValue( + environmentId === null ? EMPTY_ACCESS_ATOM : deviceHubAccessAtom(environmentId), + ); + return AsyncResult.isSuccess(result) ? result.value : null; +} + +const EMPTY_ACCESS_ATOM = Atom.make(AsyncResult.initial()).pipe( + Atom.withLabel("device-hub-access:empty"), +); + +export function refreshDeviceHubAccess(environmentId: EnvironmentId): void { + appAtomRegistry.refresh(deviceHubAccessAtom(environmentId)); +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 702c18840983..ec4f15fc7eeb 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -230,16 +230,17 @@ export default defineConfig(() => { ? { // One entry per shared prefix; the server's dev catch-all 404s the // same list, so the two sides cannot drift. `/ws` is the app's own - // socket — Vite's HMR socket is matched separately and exactly - // (path "/" plus a vite-hmr subprotocol), so the two upgrade - // handlers don't collide. + // socket and `/api` carries the device hub's stream sockets — + // Vite's HMR socket is matched separately and exactly (path "/" + // plus a vite-hmr subprotocol), so the upgrade handlers don't + // collide. proxy: Object.fromEntries( DEV_PROXIED_PATH_PREFIXES.map((prefix) => [ prefix, { target: devProxyTarget, changeOrigin: true, - ...(prefix === "/ws" ? { ws: true } : {}), + ...(prefix === "/ws" || prefix === "/api" ? { ws: true } : {}), }, ]), ), diff --git a/docs/README.md b/docs/README.md index 2d4809b7003a..4691e6f83c8e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ - [Keyboard shortcuts](./user/keybindings.md) - [SnapShots](./user/snap-shot.md) - [Import browser sessions](./user/browser-import.md) +- [Devices](./user/devices.md) - [Usage and limits](./user/usage.md) - [Product usage data](./user/telemetry.md) - [Remote access](./user/remote-access.md) @@ -46,6 +47,7 @@ source alone does not explain. Most code changes do not need an internal documen - [Mobile navigation](./internals/mobile-navigation.md) - [Mobile development lifecycle](./internals/mobile-development.md) - [Terminal runtime](./internals/terminal-runtime.md) +- [Devices](./internals/devices.md) - [Voice input](./internals/voice-input.md) ### Runbooks diff --git a/docs/internals/devices.md b/docs/internals/devices.md new file mode 100644 index 000000000000..49027022fdbc --- /dev/null +++ b/docs/internals/devices.md @@ -0,0 +1,87 @@ +# Devices + +The environment server owns simulators and emulators the way it owns +terminals: discovery, streaming, and agent access all run there, and every +client reaches them through the environment connection. This is what makes the +Device panel work over Tailscale and T3 Connect, and what will let a device +host on another machine slot in later. + +## Two external tools, one seam + +[expo-device-hub](../../apps/server/src/device/LocalDeviceHost.ts) streams and +[agent-device](../../apps/server/src/device/AgentDeviceShim.ts) drives. Each is +npm-installed at a pinned version into the T3 home after its matching Device +panel consent step. Manual setup installs and starts only expo-device-hub; +agent-device remains absent and stopped until agent access is granted. Both run +with the server's Node; `npx` would make the first `device_open` after a reboot +depend on the registry. The hub is a supervised child rather than an imported +middleware because serve-sim loads private CoreSimulator frameworks through a +native addon, and a crash there must not take the server down. + +Everything platform-specific sits behind +[`DeviceHost`](../../apps/server/src/device/DeviceHost.ts). The service, the +proxy, and the MCP tools only see a hub origin and an agent-device endpoint. +An SSH or cloud host would forward those two things to the server and change +nothing above it. + +## The hub is never exposed + +serve-sim has a shell-exec route whose token is readable from its own +unauthenticated `/api`, and serve-emu's action routes have no auth at all. The +hub binds loopback and the only way in is the +[proxy](../../apps/server/src/device/DeviceHubProxy.ts), which allowlists the +stream, config, and screenshot routes and authenticates every request as an +environment session. `` and `WebSocket` cannot carry headers, so the proxy +authenticates like the `/ws` upgrade: cookie, or a short-lived `wsTicket` that +bearer and DPoP clients mint over authenticated HTTP. The ticket is stripped +before the request reaches the hub. + +Stream responses carry `Cache-Control: no-transform`; the compression +middleware would otherwise buffer an MJPEG body that never ends. In browser dev, +the Vite proxy must forward WebSocket upgrades for `/api`, not only `/ws`. + +## Device settings never go through the hub + +serve-sim's preview drives its Tools panel by sending shell commands over that +same exec channel. Proxying it, even allowlisted, would hand any environment +session arbitrary command execution on the host, so T3 does not. The +[`device.action`](../../apps/server/src/device/DeviceActions.ts) RPC runs the +underlying `simctl`, `adb`, and serve-sim helper binaries itself through +`DeviceHostReady.run`, one typed action per control, and returns the settings +it reads back. The proxy allowlist grows only with read routes (accessibility +tree, foreground app, event log) and refuses non-GET methods everywhere except +screenshot capture and stream tuning. + +## Agents drive through the CLI + +The `device_*` toolkit is deliberately four tools: list, open, screenshot, and +close. Driving happens through the `agent-device` CLI, which has the semantic +snapshot model agents need and stays current with its own releases. T3 prepends +a shim directory to the provider's PATH and sets +`AGENT_DEVICE_DAEMON_BASE_URL` and `AGENT_DEVICE_DAEMON_AUTH_TOKEN` so the +agent never handles the endpoint or token. + +That environment is fixed when the provider subprocess spawns, so +[`prepareMcpSession`](../../apps/server/src/provider/Layers/ProviderService.ts) +starts agent-device only when device support and agent access have both been +enabled, the session has the `device` capability, and the machine can run at +least one platform. Starting it later from `device_open` would leave the +already-running agent without the CLI. + +How to drive a device is returned from `device_open`, not kept in an +always-loaded prompt or skill: it costs nothing in threads that never open a +device and cannot drift from the pinned CLI version. The always-on prompt block +is a few lines that point at the tools and forbid raw `simctl` and `adb`. + +## The viewer decodes both vendored protocols + +The hub vendors two streaming servers with different wire formats. iOS video is +an HTTP body of AVCC envelopes decoded with WebCodecs, with input on a separate +binary WebSocket; Android multiplexes SEMU-framed H.264 and JSON gestures over +one WebSocket. [`deviceStream.ts`](../../apps/web/src/components/device/deviceStream.ts) +speaks both so one panel covers both platforms. + +Simulators encode H.264 High 5.1. Hardware decoders on some machines and all +headless browsers reject that profile, and WebCodecs is secure-context only, so +the viewer probes `isConfigSupported` and falls back to the MJPEG endpoint on +iOS. Android has no MJPEG; there the panel reports that it cannot decode. diff --git a/docs/user/devices.md b/docs/user/devices.md new file mode 100644 index 000000000000..51a0a578b607 --- /dev/null +++ b/docs/user/devices.md @@ -0,0 +1,63 @@ +# Devices + +The Device panel shows a live iOS Simulator or Android Emulator next to a +thread, so you can watch an agent verify mobile work and tap the device +yourself. Agents get the same device through `device_*` tools and the +`agent-device` command line, which T3 Code sets up for them. + +## Open a device + +Open the right panel in a project thread and choose **Device**. On first use, +the panel walks through three steps: starting the device hub, checking iOS and +Android support, and choosing whether agents may control devices. Opening the +panel alone does not download or start anything. If the hub is already +installed, the setup screen says so and reuses it. + +Choose a running device to watch it, or choose **Start** next to a stopped +device to boot it. The panel shows when you or an agent starts a device. +Turn off the device hub in **Settings → Integrations → Devices** to stop the +helper processes; simulators and emulators keep running until you power them +off. + +Simulators run on the machine that hosts the environment server. iOS needs +macOS with Xcode. Android needs the SDK Platform-Tools, Android Emulator, +and Command-line Tools (latest), plus a virtual device created in Android +Studio's Device Manager. T3 Code detects standard SDK locations; set +`ANDROID_HOME` for a custom location. The panel explains missing dependencies. +After installing them, restart the environment server and refresh devices. + +The screen is interactive: click and drag to touch, type while the screen is +focused, and use the toolbar for Home, Back, and Recents on Android, rotate on +iOS, and power off. Close the tab to stop watching; the device keeps running +unless you power it off. + +## Tools + +The toolbar's **Tools** button opens a drawer for the open device. It shows the +foreground app, and lets you switch light and dark mode, change text size, +flip accessibility settings, overlay the accessibility element frames on the +screen, set a fake location, and grant or revoke app permissions. iOS also +exposes Liquid Glass, color filters, VoiceOver, and sending a test push +notification; Android adds orientation and toggling the network. The drawer +only shows what the platform can do, and every control reflects the value read +back from the device after a change. + +## Agents and devices + +When an agent opens a device, the panel opens in web and desktop clients connected +to the thread. Mobile clients show device activity in the thread timeline. Agents drive the device through the `agent-device` command line. T3 +Code installs and starts it only after **Agent device access** is enabled. iOS +taps build a small test runner on first use, which takes a couple of minutes +once per server. Restart an existing agent session after granting access so it +receives the device CLI environment. + +To keep agents away from simulators, turn off **Agent device access** in +**Settings → Integrations → Devices**. This hides the device tools from agents +started from then on; your own Device panel is unaffected. + +## Remote connections + +The device stream goes through the environment server, so it works over the +local network, Tailscale, and T3 Connect. Live video needs a secure page +(HTTPS or localhost); on a plain-HTTP remote origin iOS falls back to a slower +still-image stream and Android cannot show video. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 2bacdfcb228a..d0bf4aad8e9e 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -131,6 +131,14 @@ "types": "./src/state/presentation.ts", "default": "./src/state/presentation.ts" }, + "./state/device": { + "types": "./src/state/device.ts", + "default": "./src/state/device.ts" + }, + "./state/deviceHubAccess": { + "types": "./src/state/deviceHubAccess.ts", + "default": "./src/state/deviceHubAccess.ts" + }, "./state/preview": { "types": "./src/state/preview.ts", "default": "./src/state/preview.ts" diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 4ddfb9c4160e..cfabaa00c0b7 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -51,6 +51,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeTerminalMetadata | typeof WS_METHODS.subscribePreviewEvents | typeof WS_METHODS.subscribeDiscoveredLocalServers + | typeof WS_METHODS.subscribeDeviceState | typeof WS_METHODS.subscribeResourceTelemetry | typeof WS_METHODS.pullRequestsSubscribeRefreshes | typeof WS_METHODS.previewAutomationConnect diff --git a/packages/client-runtime/src/state/device.ts b/packages/client-runtime/src/state/device.ts new file mode 100644 index 000000000000..df27f793720c --- /dev/null +++ b/packages/client-runtime/src/state/device.ts @@ -0,0 +1,68 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { + createAtomCommandScheduler, + createEnvironmentRpcCommand, + createEnvironmentRpcSubscriptionAtomFamily, +} from "./runtime.ts"; + +export function createDeviceEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const scheduler = createAtomCommandScheduler(); + const concurrency = { + mode: "serial" as const, + key: ({ environmentId }: { environmentId: string }) => environmentId, + }; + return { + /** Server-pushed device hosts, devices, and open sessions for one environment. */ + state: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:device:state", + tag: WS_METHODS.subscribeDeviceState, + }), + configure: createEnvironmentRpcCommand(runtime, { + label: "environment-data:device:configure", + tag: WS_METHODS.deviceConfigure, + scheduler, + concurrency, + }), + list: createEnvironmentRpcCommand(runtime, { + label: "environment-data:device:list", + tag: WS_METHODS.deviceList, + scheduler, + concurrency, + }), + open: createEnvironmentRpcCommand(runtime, { + label: "environment-data:device:open", + tag: WS_METHODS.deviceOpen, + scheduler, + concurrency, + }), + close: createEnvironmentRpcCommand(runtime, { + label: "environment-data:device:close", + tag: WS_METHODS.deviceClose, + scheduler, + concurrency, + }), + shutdown: createEnvironmentRpcCommand(runtime, { + label: "environment-data:device:shutdown", + tag: WS_METHODS.deviceShutdown, + scheduler, + concurrency, + }), + detail: createEnvironmentRpcCommand(runtime, { + label: "environment-data:device:detail", + tag: WS_METHODS.deviceDetail, + scheduler, + concurrency, + }), + action: createEnvironmentRpcCommand(runtime, { + label: "environment-data:device:action", + tag: WS_METHODS.deviceAction, + scheduler, + concurrency, + }), + }; +} diff --git a/packages/client-runtime/src/state/deviceHubAccess.ts b/packages/client-runtime/src/state/deviceHubAccess.ts new file mode 100644 index 000000000000..328dea83ce7a --- /dev/null +++ b/packages/client-runtime/src/state/deviceHubAccess.ts @@ -0,0 +1,72 @@ +/** + * Credentials for the Device panel's media requests. + * + * The panel reaches simulator streams through `/api/device-hub/*` on the + * environment origin. ``, `EventSource`, and `WebSocket` cannot set + * bearer or DPoP headers, so bearer and DPoP connections mint a + * short-lived WebSocket ticket and pass it as `wsTicket`, the same way the + * app's own `/ws` upgrade authenticates. Cookie sessions send the cookie. + * + * A ticket lives five minutes server-side and is bound to the session, not + * to one request, so one ticket covers everything a panel opens at once. + * Callers fetch a fresh one each time they (re)connect a stream. + */ +import * as Effect from "effect/Effect"; +import type { HttpClient } from "effect/unstable/http"; + +import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; +import type { PreparedConnection } from "../connection/model.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import type { RemoteEnvironmentRequestError } from "../rpc/http.ts"; +import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAuth.ts"; + +const TICKET_TIMEOUT_MS = 8_000; + +export interface DeviceHubAccess { + /** Absolute origin-relative base, e.g. `https://env.example/api/device-hub`. */ + readonly httpBase: string; + /** Same base with the `ws(s)` scheme. */ + readonly wsBase: string; + /** Query parameters to append to every hub request; empty for cookie sessions. */ + readonly query: Readonly>; + /** Whether requests must include cookies (same-origin session). */ + readonly credentials: boolean; +} + +export const resolveDeviceHubAccess = Effect.fn("clientRuntime.state.resolveDeviceHubAccess")( + function* (input: { + readonly prepared: PreparedConnection; + readonly hubBasePath: string; + }): Effect.fn.Return { + const httpBase = environmentEndpointUrl(input.prepared.httpBaseUrl, input.hubBasePath); + const wsBase = httpBase.replace(/^http/, "ws"); + if (input.prepared.httpAuthorization === null) { + return { httpBase, wsBase, query: {}, credentials: true }; + } + const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); + const remoteAuthorization = yield* Effect.serviceOption(RemoteEnvironmentAuthorization); + const ticket = yield* executeAuthenticatedEnvironmentHttpRequest({ + prepared: input.prepared, + signer, + remoteAuthorization, + method: "POST", + url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/auth/websocket-ticket"), + timeoutMs: TICKET_TIMEOUT_MS, + request: ({ client, headers }) => client.auth.webSocketTicket({ headers }), + }); + return { + httpBase, + wsBase, + query: { wsTicket: ticket.ticket }, + credentials: false, + }; + }, +); + +export const withDeviceHubQuery = (url: string, access: DeviceHubAccess): string => { + const entries = Object.entries(access.query); + if (entries.length === 0) return url; + const separator = url.includes("?") ? "&" : "?"; + return `${url}${separator}${new URLSearchParams(entries).toString()}`; +}; diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 5c57b8de3bd6..a0909117ad39 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -221,6 +221,19 @@ describe("resolveWorkEntryToolPresentation", () => { }); }); + it("labels device tools with the device icon", () => { + expect( + resolveWorkEntryToolPresentation({ + label: "mcp__t3-code__device_open", + toolLifecycleStatus: "completed", + }), + ).toEqual({ displayName: "Opened a device in the Device panel", icon: "device" }); + expect(resolveWorkEntryToolPresentation({ label: "t3-code · device_screenshot" })).toEqual({ + displayName: "Taking a screenshot of the device", + icon: "device", + }); + }); + it("uses structured MCP identity when the provider supplies a custom title", () => { expect( resolveWorkEntryToolPresentation({ @@ -647,3 +660,53 @@ describe("pull request tool presentation", () => { ).toBeNull(); }); }); + +describe("device group summaries", () => { + const deviceEntry = (tool: string): WorkLogPresentationEntry => ({ + label: "MCP tool call", + toolData: { server: "t3-code", tool }, + itemType: "mcp_tool_call", + toolLifecycleStatus: "completed", + tone: "tool", + }); + + it.each(["device_list", "device_open", "device_screenshot", "device_close"])( + "recognizes %s as device controls", + (tool) => { + const entry = deviceEntry(tool); + expect(summarizeToolGroup([entry])).toBe("Used device controls 1 time"); + expect(toolGroupSummaryKind([entry])).toBe("device"); + }, + ); + + it("summarizes device calls alongside shell commands", () => { + expect( + summarizeToolGroup([ + { label: "Ran command", itemType: "command_execution", command: "pwd", tone: "tool" }, + deviceEntry("device_list"), + deviceEntry("device_open"), + ]), + ).toBe("Ran 1 command and used device controls 2 times"); + }); + + it("recognizes Claude tool names and preserves screenshot previews", () => { + const entry = { + ...deviceEntry("device_screenshot"), + toolData: { toolName: "mcp__t3_code__device_screenshot" }, + viewedImagePath: "/workspace/device.png", + }; + expect(summarizeToolGroup([entry])).toBe("Used device controls 1 time"); + expect(workEntryViewedImagePath(entry)).toBe("/workspace/device.png"); + }); + + it("does not classify another server's tools as T3 device controls", () => { + expect( + summarizeToolGroup([ + { + ...deviceEntry("device_open"), + toolData: { server: "another-server", tool: "device_open" }, + }, + ]), + ).toBe("Used 1 tool"); + }); +}); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 3e62f75d510b..8d62bb0794ca 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -44,6 +44,7 @@ export type ToolGroupAction = | "edit" | "command" | "browser" + | "device" | "code-search" | "search" | "other" @@ -104,6 +105,15 @@ const T3_MCP_TOOL_LABELS: Record< preview_set_appearance: ["Set", "Setting", "Set", "preview browser appearance"], preview_recording_start: ["Start", "Starting", "Started", "recording the preview browser"], preview_recording_stop: ["Stop", "Stopping", "Stopped", "recording the preview browser"], + device_list: ["List", "Listing", "Listed", "simulators and emulators"], + device_open: ["Open", "Opening", "Opened", "a device in the Device panel"], + device_screenshot: [ + "Take a screenshot of", + "Taking a screenshot of", + "Took a screenshot of", + "the device", + ], + device_close: ["Close", "Closing", "Closed", "a device"], }; const PR_TOOL_ACTIONS: Readonly> = { @@ -159,7 +169,9 @@ function resolveT3McpToolPresentation( ? ("pull-request" as const) : name.startsWith("preview_") ? ("browser" as const) - : ("t3-code" as const), + : name.startsWith("device_") + ? ("device" as const) + : ("t3-code" as const), ...(actionKind === undefined ? {} : { action: actionKind }), }; } @@ -451,6 +463,7 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio const presentation = resolveWorkEntryToolPresentation(entry); if (presentation?.action !== undefined) return presentation.action; if (presentation?.icon === "browser") return "browser"; + if (presentation?.icon === "device") return "device"; if ( entry.requestKind === "file-read" || entry.itemType === "image_view" || @@ -558,6 +571,8 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string { return `Changed ${count} ${count === 1 ? "file" : "files"}`; case "command": return `Ran ${count} ${count === 1 ? "command" : "commands"}`; + case "device": + return `Used device controls ${count} ${count === 1 ? "time" : "times"}`; case "browser": return `Used browser ${count} ${count === 1 ? "time" : "times"}`; case "search": diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts new file mode 100644 index 000000000000..309d156430fa --- /dev/null +++ b/packages/contracts/src/device.ts @@ -0,0 +1,516 @@ +/** + * Device - Schemas for first-class iOS Simulator and Android Emulator support. + * + * The server owns device discovery, the streaming helper (expo-device-hub), + * and the agent driver (agent-device). Clients render the live screen from the + * server-proxied stream, and agents reach devices through the `device_*` MCP + * tools plus the `agent-device` CLI the server preconfigures for them. + * + * Devices live on a *host*. Only the local host (the machine the server runs + * on) exists today; the host id is carried everywhere so SSH and cloud hosts + * can be added without changing the client contract. + * + * @module Device + */ +import { Schema } from "effect"; + +import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const DevicePlatform = Schema.Literals(["ios", "android"]); +export type DevicePlatform = typeof DevicePlatform.Type; + +export const DeviceHostId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); +export type DeviceHostId = typeof DeviceHostId.Type; + +/** The server machine. Always present; other host kinds are future work. */ +export const LOCAL_DEVICE_HOST_ID = "local" as DeviceHostId; + +/** Simulator udid or adb serial (an AVD name while it is not running). */ +export const DeviceId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)); +export type DeviceId = typeof DeviceId.Type; + +export const DeviceSummary = Schema.Struct({ + hostId: DeviceHostId, + id: DeviceId, + platform: DevicePlatform, + name: TrimmedNonEmptyString, + /** OS label such as "iOS 18.0" or "Android 15.0". */ + version: Schema.String, + booted: Schema.Boolean, + physical: Schema.Boolean, +}); +export type DeviceSummary = typeof DeviceSummary.Type; + +/** + * What the host can do right now. Platforms missing their toolchain are + * reported rather than hidden so the picker and the agent can explain why a + * platform is absent instead of showing an empty list. + */ +export const DevicePlatformAvailability = Schema.Struct({ + platform: DevicePlatform, + available: Schema.Boolean, + reason: Schema.optional(Schema.String), +}); +export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type; + +export const DeviceHostSummary = Schema.Struct({ + id: DeviceHostId, + kind: Schema.Literals(["local"]), + label: TrimmedNonEmptyString, + platforms: Schema.Array(DevicePlatformAvailability), + hubInstalled: Schema.Boolean, + agentDeviceInstalled: Schema.Boolean, +}); +export type DeviceHostSummary = typeof DeviceHostSummary.Type; + +/** + * Lifecycle of the helper processes on a host. Tools are installed on first + * use, so a fresh install spends a while in `installing` before any device can + * stream; the UI shows that instead of an empty picker. + */ +export const DeviceHostStatus = Schema.Literals([ + "disabled", + "idle", + "installing", + "starting", + "ready", + "failed", +]); +export type DeviceHostStatus = typeof DeviceHostStatus.Type; + +/** + * A device a thread is looking at. One session per (thread, device); the same + * device may be open in several threads, since the stream is shared. + */ +export const DeviceSession = Schema.Struct({ + threadId: ThreadId, + hostId: DeviceHostId, + deviceId: DeviceId, + platform: DevicePlatform, + openedAt: Schema.String, +}); +export type DeviceSession = typeof DeviceSession.Type; + +export const DeviceServiceState = Schema.Struct({ + hosts: Schema.Array(DeviceHostSummary), + hostStatus: DeviceHostStatus, + hostStatusDetail: Schema.optional(Schema.String), + devices: Schema.Array(DeviceSummary), + sessions: Schema.Array(DeviceSession), + bootingDevices: Schema.optional( + Schema.Array(Schema.Struct({ ...DeviceSummary.fields, threadId: ThreadId })), + ), + onboardingCompleted: Schema.Boolean, + agentAccessEnabled: Schema.Boolean, + /** Origin-relative path the client prefixes to hub routes. */ + hubBasePath: Schema.String, + revision: Schema.Int, +}); +export type DeviceServiceState = typeof DeviceServiceState.Type; + +export const DeviceListInput = Schema.Struct({}); +export type DeviceListInput = typeof DeviceListInput.Type; + +export const DeviceConfigureInput = Schema.Struct({ + enabled: Schema.optional(Schema.Boolean), + agentAccessEnabled: Schema.optional(Schema.Boolean), + onboardingCompleted: Schema.optional(Schema.Boolean), +}); +export type DeviceConfigureInput = typeof DeviceConfigureInput.Type; + +export const DeviceOpenInput = Schema.Struct({ + threadId: ThreadId, + hostId: Schema.optional(DeviceHostId), + deviceId: DeviceId, + platform: DevicePlatform, + /** Boot the simulator or emulator when it is not running. Defaults to true. */ + boot: Schema.optional(Schema.Boolean), +}); +export type DeviceOpenInput = typeof DeviceOpenInput.Type; + +export const DeviceCloseInput = Schema.Struct({ + threadId: ThreadId, + /** Omit to close every device session for the thread. */ + deviceId: Schema.optional(DeviceId), + /** Also shut the simulator or emulator down. Defaults to false. */ + shutdown: Schema.optional(Schema.Boolean), +}); +export type DeviceCloseInput = typeof DeviceCloseInput.Type; + +export const DeviceShutdownInput = Schema.Struct({ + hostId: Schema.optional(DeviceHostId), + deviceId: DeviceId, + platform: DevicePlatform, +}); +export type DeviceShutdownInput = typeof DeviceShutdownInput.Type; + +// Device settings and actions. Each setting names the platforms that support +// it; the panel hides the rest. Values are normalized across platforms where +// both have the concept (appearance, text size) and platform-specific where +// only one does. + +export const DeviceAppearance = Schema.Literals(["light", "dark"]); +export type DeviceAppearance = typeof DeviceAppearance.Type; + +/** + * iOS content-size categories map onto twelve steps; Android `font_scale` + * is continuous. Four shared steps cover what people actually reach for. + */ +export const DeviceTextSize = Schema.Literals(["small", "default", "large", "extra-large"]); +export type DeviceTextSize = typeof DeviceTextSize.Type; + +export const DeviceColorFilter = Schema.Literals([ + "none", + "grayscale", + "red-green", + "green-red", + "blue-yellow", +]); +export type DeviceColorFilter = typeof DeviceColorFilter.Type; + +export const DeviceOrientation = Schema.Literals([ + "portrait", + "landscape_left", + "portrait_upside_down", + "landscape_right", +]); +export type DeviceOrientation = typeof DeviceOrientation.Type; + +/** Current values as read from the device; `undefined` means unsupported or unread. */ +export const DeviceSettings = Schema.Struct({ + appearance: Schema.optional(DeviceAppearance), + textSize: Schema.optional(DeviceTextSize), + reduceMotion: Schema.optional(Schema.Boolean), + increaseContrast: Schema.optional(Schema.Boolean), + reduceTransparency: Schema.optional(Schema.Boolean), + showBorders: Schema.optional(Schema.Boolean), + voiceOver: Schema.optional(Schema.Boolean), + liquidGlass: Schema.optional(Schema.Literals(["clear", "tinted"])), + colorFilter: Schema.optional(DeviceColorFilter), + networkEnabled: Schema.optional(Schema.Boolean), + location: Schema.optional( + Schema.NullOr(Schema.Struct({ latitude: Schema.Number, longitude: Schema.Number })), + ), +}); +export type DeviceSettings = typeof DeviceSettings.Type; + +/** The app in the foreground, when the platform can tell us. */ +export const DeviceForegroundApp = Schema.Struct({ + id: Schema.String, + name: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), +}); +export type DeviceForegroundApp = typeof DeviceForegroundApp.Type; + +export const DeviceDetail = Schema.Struct({ + hostId: DeviceHostId, + deviceId: DeviceId, + settings: DeviceSettings, + foregroundApp: Schema.NullOr(DeviceForegroundApp), + readAt: Schema.String, +}); +export type DeviceDetail = typeof DeviceDetail.Type; + +export const DevicePermission = Schema.Literals([ + "camera", + "microphone", + "photos", + "contacts", + "calendar", + "reminders", + "location", + "notifications", + "motion", + "media-library", + "faceid", +]); +export type DevicePermission = typeof DevicePermission.Type; + +const DeviceTarget = { + hostId: Schema.optional(DeviceHostId), + deviceId: DeviceId, +}; + +export const DeviceActionInput = Schema.Union([ + Schema.Struct({ + ...DeviceTarget, + type: Schema.Literal("setAppearance"), + value: DeviceAppearance, + }), + Schema.Struct({ ...DeviceTarget, type: Schema.Literal("setTextSize"), value: DeviceTextSize }), + Schema.Struct({ + ...DeviceTarget, + type: Schema.Literal("setToggle"), + setting: Schema.Literals([ + "reduceMotion", + "increaseContrast", + "reduceTransparency", + "showBorders", + "voiceOver", + "networkEnabled", + ]), + value: Schema.Boolean, + }), + Schema.Struct({ + ...DeviceTarget, + type: Schema.Literal("setLiquidGlass"), + value: Schema.Literals(["clear", "tinted"]), + }), + Schema.Struct({ + ...DeviceTarget, + type: Schema.Literal("setColorFilter"), + value: DeviceColorFilter, + }), + Schema.Struct({ + ...DeviceTarget, + type: Schema.Literal("setOrientation"), + value: DeviceOrientation, + }), + Schema.Struct({ + ...DeviceTarget, + type: Schema.Literal("setLocation"), + latitude: Schema.Number.check(Schema.isBetween({ minimum: -90, maximum: 90 })), + longitude: Schema.Number.check(Schema.isBetween({ minimum: -180, maximum: 180 })), + }), + Schema.Struct({ ...DeviceTarget, type: Schema.Literal("clearLocation") }), + Schema.Struct({ + ...DeviceTarget, + type: Schema.Literal("setPermission"), + appId: TrimmedNonEmptyString, + permission: DevicePermission, + decision: Schema.Literals(["grant", "revoke", "reset"]), + }), + Schema.Struct({ ...DeviceTarget, type: Schema.Literal("openUrl"), url: TrimmedNonEmptyString }), + Schema.Struct({ + ...DeviceTarget, + type: Schema.Literal("launchApp"), + appId: TrimmedNonEmptyString, + }), + Schema.Struct({ + ...DeviceTarget, + type: Schema.Literal("terminateApp"), + appId: TrimmedNonEmptyString, + }), + Schema.Struct({ ...DeviceTarget, type: Schema.Literal("shake") }), + Schema.Struct({ + ...DeviceTarget, + type: Schema.Literal("sendPush"), + appId: TrimmedNonEmptyString, + /** APNs-style payload; a bare string becomes the alert body. */ + payload: Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.Unknown)]), + }), +]); +export type DeviceActionInput = typeof DeviceActionInput.Type; +export type DeviceActionType = DeviceActionInput["type"]; + +export const DeviceDetailInput = Schema.Struct(DeviceTarget); +export type DeviceDetailInput = typeof DeviceDetailInput.Type; + +export class DeviceHostUnavailableError extends Schema.TaggedError()( + "DeviceHostUnavailableError", + { + hostId: DeviceHostId, + reason: Schema.String, + }, +) { + override get message(): string { + return `Device host ${this.hostId} is unavailable: ${this.reason}`; + } +} + +export class DevicePlatformUnavailableError extends Schema.TaggedError()( + "DevicePlatformUnavailableError", + { + hostId: DeviceHostId, + platform: DevicePlatform, + reason: Schema.String, + }, +) { + override get message(): string { + return `${this.platform} devices are unavailable on host ${this.hostId}: ${this.reason}`; + } +} + +export class DeviceNotFoundError extends Schema.TaggedError()( + "DeviceNotFoundError", + { + hostId: DeviceHostId, + deviceId: DeviceId, + }, +) { + override get message(): string { + return `Device ${this.deviceId} was not found on host ${this.hostId}.`; + } +} + +export class DeviceBootError extends Schema.TaggedError()("DeviceBootError", { + hostId: DeviceHostId, + deviceId: DeviceId, + reason: Schema.Literals(["disk_space", "timeout", "launch_failed"]), + cause: Schema.Defect(), +}) { + override get message(): string { + const explanation = { + disk_space: "There is not enough free disk space on the environment server.", + timeout: "The device did not become ready in time.", + launch_failed: + "The simulator or emulator could not start. Check its configuration on the environment server.", + }[this.reason]; + return `Device ${this.deviceId} failed to boot: ${explanation}`; + } +} + +export class DeviceOperationError extends Schema.TaggedError()( + "DeviceOperationError", + { + operation: Schema.String, + reason: Schema.Literals([ + "command_failed", + "request_failed", + "invalid_payload", + "settings_failed", + "hub_rejected", + ]), + exitCode: Schema.optional(Schema.Number), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const explanation = { + command_failed: `The device command failed${this.exitCode === undefined ? "" : ` (exit code ${this.exitCode})`}.`, + request_failed: "Could not communicate with device support. Try refreshing devices.", + invalid_payload: "The device request could not be encoded.", + settings_failed: "Could not read or save device settings.", + hub_rejected: "The device hub could not complete the request.", + }[this.reason]; + return `Device ${this.operation} failed: ${explanation}`; + } +} + +export class DeviceActionUnavailableError extends Schema.TaggedError()( + "DeviceActionUnavailableError", + { + operation: Schema.String, + platform: DevicePlatform, + reason: Schema.Literals(["unsupported", "helper_missing"]), + }, +) { + override get message(): string { + return this.reason === "helper_missing" + ? `Device ${this.operation} requires a helper missing from this install. Set up device support again.` + : `Device ${this.operation} is not supported on ${this.platform}.`; + } +} + +export const DeviceError = Schema.Union([ + DeviceHostUnavailableError, + DevicePlatformUnavailableError, + DeviceNotFoundError, + DeviceBootError, + DeviceOperationError, + DeviceActionUnavailableError, +]); +export type DeviceError = typeof DeviceError.Type; + +// MCP tool shapes. Kept next to the RPC shapes so the tool surface and the +// panel describe devices the same way. + +export const DeviceToolListResult = Schema.Struct({ + hosts: Schema.Array(DeviceHostSummary), + devices: Schema.Array(DeviceSummary), + /** Devices already open in this thread's Device panel. */ + open: Schema.Array(Schema.Struct({ hostId: DeviceHostId, deviceId: DeviceId })), +}); +export type DeviceToolListResult = typeof DeviceToolListResult.Type; + +export const DeviceToolOpenInput = Schema.Struct({ + deviceId: Schema.optional( + DeviceId.annotate({ + description: + "Simulator udid or emulator serial from device_list. Omit to use the booted device for the platform, or the most recently used one.", + }), + ), + platform: Schema.optional( + DevicePlatform.annotate({ + description: "Required when deviceId is omitted and both platforms are available.", + }), + ), + hostId: Schema.optional( + DeviceHostId.annotate({ description: "Device host from device_list. Defaults to local." }), + ), +}).annotate({ + description: + "Boots the device if needed, starts its live stream, and opens the Device panel so the user can watch. Returns how to drive it with the agent-device CLI.", +}); +export type DeviceToolOpenInput = typeof DeviceToolOpenInput.Type; + +export const DeviceToolOpenResult = Schema.Struct({ + device: DeviceSummary, + /** Ready-to-run agent-device invocation pinned to this device. */ + agentDevice: Schema.Struct({ + command: Schema.String, + /** Flags that pin every command to this device, e.g. `--udid `. */ + targetArgs: Schema.Array(Schema.String), + }), + quickStart: Schema.String, +}); +export type DeviceToolOpenResult = typeof DeviceToolOpenResult.Type; + +export const DeviceToolTargetInput = Schema.Struct({ + deviceId: Schema.optional( + DeviceId.annotate({ + description: + "Device from device_list. Omit to use the device most recently opened in this thread.", + }), + ), + hostId: Schema.optional(DeviceHostId), +}); +export type DeviceToolTargetInput = typeof DeviceToolTargetInput.Type; + +export const DeviceToolScreenshotResult = Schema.Struct({ + device: DeviceSummary, + screenshot: Schema.Struct({ + mimeType: Schema.Literal("image/png"), + data: Schema.String, + width: Schema.Int, + height: Schema.Int, + }), +}); +export type DeviceToolScreenshotResult = typeof DeviceToolScreenshotResult.Type; + +export const DeviceToolCloseInput = Schema.Struct({ + deviceId: Schema.optional( + DeviceId.annotate({ + description: "Device to close. Omit to close every device in this thread.", + }), + ), + hostId: Schema.optional(DeviceHostId), + shutdown: Schema.optional( + Schema.Boolean.annotate({ + description: "Also power the simulator or emulator off. Defaults to false.", + }), + ), +}); +export type DeviceToolCloseInput = typeof DeviceToolCloseInput.Type; + +export class DeviceToolUnavailableError extends Schema.TaggedError()( + "DeviceToolUnavailableError", + { + reason: Schema.String, + }, +) { + override get message(): string { + return this.reason; + } +} + +export const DeviceToolError = Schema.Union([ + DeviceToolUnavailableError, + DeviceHostUnavailableError, + DevicePlatformUnavailableError, + DeviceNotFoundError, + DeviceBootError, + DeviceOperationError, + DeviceActionUnavailableError, +]); +export type DeviceToolError = typeof DeviceToolError.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 74a1b4939f1a..32ac53dae9ba 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -34,6 +34,7 @@ export * from "./assets.ts"; export * from "./review.ts"; export * from "./browserImport.ts"; export * from "./browserProfile.ts"; +export * from "./device.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 76cca2562afc..a5d35bf2361d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -181,6 +181,19 @@ import { PreviewResizeInput, PreviewSessionSnapshot, } from "./preview.ts"; +import { + DeviceActionInput, + DeviceCloseInput, + DeviceConfigureInput, + DeviceDetail, + DeviceDetailInput, + DeviceError, + DeviceListInput, + DeviceOpenInput, + DeviceServiceState, + DeviceSession, + DeviceShutdownInput, +} from "./device.ts"; import { PreviewAutomationError, PreviewAutomationHost, @@ -312,6 +325,15 @@ export const WS_METHODS = { previewAutomationRespond: "previewAutomation.respond", previewAutomationFocusHost: "previewAutomation.focusHost", + // Device methods + deviceConfigure: "device.configure", + deviceList: "device.list", + deviceOpen: "device.open", + deviceClose: "device.close", + deviceShutdown: "device.shutdown", + deviceDetail: "device.detail", + deviceAction: "device.action", + // Server meta serverProbe: "server.probe", serverGetConfig: "server.getConfig", @@ -378,6 +400,7 @@ export const WS_METHODS = { subscribeTerminalMetadata: "subscribeTerminalMetadata", subscribePreviewEvents: "subscribePreviewEvents", subscribeDiscoveredLocalServers: "subscribeDiscoveredLocalServers", + subscribeDeviceState: "subscribeDeviceState", subscribeServerConfig: "subscribeServerConfig", subscribeServerLifecycle: "subscribeServerLifecycle", subscribeAuthAccess: "subscribeAuthAccess", @@ -1078,6 +1101,53 @@ const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(WS_METHODS.subscribeDiscov stream: true, }); +const WsDeviceListRpc = Rpc.make(WS_METHODS.deviceList, { + payload: DeviceListInput, + success: DeviceServiceState, + error: Schema.Union([DeviceError, EnvironmentAuthorizationError]), +}); + +const WsDeviceConfigureRpc = Rpc.make(WS_METHODS.deviceConfigure, { + payload: DeviceConfigureInput, + success: DeviceServiceState, + error: Schema.Union([DeviceError, EnvironmentAuthorizationError]), +}); + +const WsDeviceOpenRpc = Rpc.make(WS_METHODS.deviceOpen, { + payload: DeviceOpenInput, + success: DeviceSession, + error: Schema.Union([DeviceError, EnvironmentAuthorizationError]), +}); + +const WsDeviceCloseRpc = Rpc.make(WS_METHODS.deviceClose, { + payload: DeviceCloseInput, + error: Schema.Union([DeviceError, EnvironmentAuthorizationError]), +}); + +const WsDeviceShutdownRpc = Rpc.make(WS_METHODS.deviceShutdown, { + payload: DeviceShutdownInput, + error: Schema.Union([DeviceError, EnvironmentAuthorizationError]), +}); + +const WsDeviceDetailRpc = Rpc.make(WS_METHODS.deviceDetail, { + payload: DeviceDetailInput, + success: DeviceDetail, + error: Schema.Union([DeviceError, EnvironmentAuthorizationError]), +}); + +const WsDeviceActionRpc = Rpc.make(WS_METHODS.deviceAction, { + payload: DeviceActionInput, + success: DeviceDetail, + error: Schema.Union([DeviceError, EnvironmentAuthorizationError]), +}); + +const WsSubscribeDeviceStateRpc = Rpc.make(WS_METHODS.subscribeDeviceState, { + payload: Schema.Struct({}), + success: DeviceServiceState, + error: EnvironmentAuthorizationError, + stream: true, +}); + const WsOrchestrationDispatchCommandRpc = Rpc.make(ORCHESTRATION_WS_METHODS.dispatchCommand, { payload: ClientOrchestrationCommand, success: OrchestrationRpcSchemas.dispatchCommand.output, @@ -1308,6 +1378,14 @@ export const WsRpcGroup = RpcGroup.make( WsPreviewAutomationFocusHostRpc, WsSubscribePreviewEventsRpc, WsSubscribeDiscoveredLocalServersRpc, + WsDeviceConfigureRpc, + WsDeviceListRpc, + WsDeviceOpenRpc, + WsDeviceCloseRpc, + WsDeviceShutdownRpc, + WsDeviceDetailRpc, + WsDeviceActionRpc, + WsSubscribeDeviceStateRpc, WsSubscribeServerConfigRpc, WsSubscribeServerLifecycleRpc, WsSubscribeAuthAccessRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9fd445d0899e..96516367612c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -967,6 +967,22 @@ export const ServerSettings = Schema.Struct({ defaultModelSelection: Schema.NullOr(ModelSelection).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + /** + * Whether agents may drive simulators and emulators. Gates the `device_*` + * MCP tools and the preconfigured `agent-device` CLI the same way + * `enableAgentBrowserAccess` gates the browser: server-authoritative, applied + * when the provider session is prepared. The user's own Device panel is + * unaffected. + */ + enableAgentDeviceAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** + * Whether this server may install and run T3's device helper processes. + * Kept separate from agent access so enabling the user's Device panel does + * not also grant providers control of simulators and emulators. + */ + enableDeviceSupport: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** Whether the server-local Device panel setup flow has been completed. */ + deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -1239,6 +1255,9 @@ export const ServerSettingsPatch = Schema.Struct({ Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), ), defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), + enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean), + enableDeviceSupport: Schema.optionalKey(Schema.Boolean), + deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( From e022fa430edbf8d448efe0f34303ff290ebb3dc0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 11:59:33 -0700 Subject: [PATCH 32/61] feat(devices): scope targets and sessions to their hosts (#10854) --- apps/server/src/device/DeviceHubProxy.ts | 3 +- .../server/src/device/DeviceMultiHost.test.ts | 86 ++++++ apps/server/src/device/DeviceService.test.ts | 1 + apps/server/src/device/DeviceService.ts | 109 ++++---- apps/server/src/mcp/McpDeviceToolkit.test.ts | 1 + .../src/mcp/toolkits/device/handlers.ts | 5 +- apps/server/src/server.test.ts | 3 +- apps/web/src/components/ChatView.tsx | 78 ++++-- apps/web/src/components/Icons.tsx | 26 ++ apps/web/src/components/RightPanelTabs.tsx | 73 ++++-- .../components/device/DeviceLoadingView.tsx | 47 ++++ .../web/src/components/device/DevicePanel.tsx | 248 +++++++----------- .../device/DeviceStreamView.test.tsx | 1 + .../components/device/DeviceStreamView.tsx | 21 +- .../settings/IntegrationsSettings.test.tsx | 1 + apps/web/src/rightPanelStore.test.ts | 81 ++++++ apps/web/src/rightPanelStore.ts | 97 ++++++- apps/web/src/state/device.ts | 17 +- packages/contracts/src/device.ts | 8 + 19 files changed, 649 insertions(+), 257 deletions(-) create mode 100644 apps/server/src/device/DeviceMultiHost.test.ts create mode 100644 apps/web/src/components/device/DeviceLoadingView.tsx diff --git a/apps/server/src/device/DeviceHubProxy.ts b/apps/server/src/device/DeviceHubProxy.ts index f8685727c882..d2cb618fc488 100644 --- a/apps/server/src/device/DeviceHubProxy.ts +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -196,7 +196,7 @@ const handler = Effect.gen(function* () { (!readOnly && /\/api\/stream-(mode|settings)$/.test(hubPath)); yield* authenticate(controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope); const devices = yield* DeviceService.DeviceService; - const ready = yield* devices.currentReadiness(); + const ready = yield* devices.currentReadiness(url.value.searchParams.get("hostId") ?? undefined); if (!ready) { return HttpServerResponse.text("Device hub is not running", { status: 503 }); } @@ -206,6 +206,7 @@ const handler = Effect.gen(function* () { // 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) { diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts new file mode 100644 index 000000000000..d586dffa1567 --- /dev/null +++ b/apps/server/src/device/DeviceMultiHost.test.ts @@ -0,0 +1,86 @@ +import { expect, it } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; +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 = { + 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: "local", + hubInstalled: true, + agentDeviceInstalled: true, + platforms: [{ 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 service = yield* makeWithHosts(hosts).pipe( + Effect.provideService(HttpClient.HttpClient, http), + ); + 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"); + yield* service.agentReadinessIfSupported("b"); + expect((yield* service.state).hostStatuses.b?.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 index ed42be64eb02..49a8545bfd6d 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -21,6 +21,7 @@ import { type DeviceService, make, stateStream } from "./DeviceService.ts"; const baseState: DeviceServiceState = { hosts: [], hostStatus: "idle", + hostStatuses: {}, devices: [], sessions: [], onboardingCompleted: false, diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 6cca6631bd7e..597e1a5d2b10 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -136,8 +136,9 @@ interface ServiceState { const vendorPrefix = (platform: DevicePlatform) => platform === "ios" ? "/vendor/serve-sim" : "/vendor/serve-emu"; -export const make = Effect.gen(function* () { - const localHost = yield* DeviceHost.DeviceHost; +export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ( + hosts: ReadonlyMap, +) { const settings = yield* ServerSettings.ServerSettingsService; const lifecycleLock = yield* Semaphore.make(1); const readDeviceSettings = settings.getSettings.pipe( @@ -152,9 +153,7 @@ export const make = Effect.gen(function* () { ), ); const initialSettings = yield* readDeviceSettings; - const hosts: ReadonlyMap = new Map([ - [localHost.id, localHost], - ]); + const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); const statePubSub = yield* PubSub.unbounded(); const initialHosts = yield* Effect.forEach(hosts.values(), (host) => host.summary); @@ -162,6 +161,7 @@ export const make = Effect.gen(function* () { state: { hosts: initialHosts, hostStatus: initialSettings.enabled ? "idle" : "disabled", + hostStatuses: {}, devices: [], sessions: [], onboardingCompleted: initialSettings.onboardingCompleted, @@ -187,6 +187,18 @@ export const make = Effect.gen(function* () { return host; }); + const setHostStatus = ( + hostId: DeviceHostId, + status: DeviceServiceState["hostStatuses"][string], + ) => + 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); @@ -198,34 +210,19 @@ export const make = Effect.gen(function* () { }); } const ready = yield* host - .ensureReady((phase) => - publish((state) => ({ ...state, hostStatus: phase, hostStatusDetail: undefined })).pipe( - Effect.asVoid, - ), - ) + .ensureReady((status) => setHostStatus(host.id, { status }).pipe(Effect.asVoid)) .pipe( Effect.tapError((error) => - publish((state) => ({ - ...state, - hostStatus: "failed", - hostStatusDetail: error.message, - })), + setHostStatus(host.id, { status: "failed", detail: error.message }), ), Effect.mapError( (error) => new DeviceHostUnavailableError({ hostId: host.id, reason: error.message }), ), ); - yield* SynchronizedRef.get(stateRef).pipe( - Effect.flatMap(({ state }) => - state.hostStatus === "ready" - ? Effect.void - : publish((current) => ({ - ...current, - hostStatus: "ready", - hostStatusDetail: undefined, - })), - ), - ); + 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, @@ -249,25 +246,18 @@ export const make = Effect.gen(function* () { const summary = yield* host.summary; if (!summary.platforms.some((platform) => platform.available)) return null; const ready = yield* host - .ensureAgentReady((phase) => - publish((state) => ({ ...state, hostStatus: phase, hostStatusDetail: undefined })).pipe( - Effect.asVoid, - ), - ) + .ensureAgentReady((phase) => setHostStatus(host.id, { status: phase }).pipe(Effect.asVoid)) .pipe( Effect.tapError((error) => - publish((state) => ({ - ...state, - hostStatus: "failed", - hostStatusDetail: error.message, - })), + 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, hostStatus: "ready" })); + yield* publish((state) => ({ ...state, hosts: hostSummaries })); + yield* setHostStatus(host.id, { status: "ready" }); return { hostId: host.id, ...ready }; }, lifecycleLock.withPermit); @@ -357,8 +347,15 @@ export const make = Effect.gen(function* () { return yield* publish((state) => ({ ...state, hosts: hostSummaries, - devices, - hostStatusDetail: detail, + ...(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 } : {}) }, + }, })); }), ); @@ -366,18 +363,21 @@ export const make = Effect.gen(function* () { const list: DeviceService["Service"]["list"] = Effect.gen(function* () { if (!(yield* readDeviceSettings).enabled) return (yield* SynchronizedRef.get(stateRef)).state; - const ready = yield* readiness(); - return yield* refresh(ready); - }).pipe( - Effect.tapError((error) => - publish((state) => - state.hostStatus === "disabled" - ? state - : { ...state, hostStatus: "failed", hostStatusDetail: error.message }, - ), - ), - Effect.withSpan("DeviceService.list"), - ); + 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) { @@ -415,6 +415,7 @@ export const make = Effect.gen(function* () { ...state, hostStatus: nextEnabled ? "idle" : "disabled", hostStatusDetail: undefined, + hostStatuses: {}, devices: nextEnabled ? state.devices : [], sessions: nextEnabled ? state.sessions : [], bootingDevices: nextEnabled ? state.bootingDevices : [], @@ -636,6 +637,7 @@ export const make = Effect.gen(function* () { 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; @@ -749,6 +751,11 @@ export const make = Effect.gen(function* () { }); }); +export const make = Effect.gen(function* () { + const host = yield* DeviceHost.DeviceHost; + return yield* makeWithHosts(new Map([[host.id, host]])); +}); + export const layer = Layer.effect(DeviceService, make).pipe(Layer.provide(LocalDeviceHost.layer)); /** State stream for WS subscribers: current snapshot first, then every change. */ diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 083ac0c034fe..32f919cbb682 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -56,6 +56,7 @@ const state = { }, ], hostStatus: "ready" as const, + hostStatuses: { local: { status: "ready" as const } }, devices: [device], sessions: [], onboardingCompleted: true, diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index bab7391af24d..5ad57031306e 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -161,7 +161,9 @@ const handlers = { const target = input.deviceId !== undefined ? { hostId: input.hostId ?? LOCAL_DEVICE_HOST_ID, deviceId: input.deviceId } - : sessions.at(-1); + : 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.", @@ -183,6 +185,7 @@ const handlers = { 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 }), }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 62412ac25853..afb4adfef79c 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1669,7 +1669,8 @@ const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe( const EMPTY_DEVICE_STATE: DeviceServiceState = { hosts: [], - hostStatus: "idle", + hostStatus: "disabled", + hostStatuses: {}, devices: [], sessions: [], onboardingCompleted: false, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 225d91310014..283e85365667 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4143,7 +4143,9 @@ export default function ChatView(props: ChatViewProps) { if (!activeThreadRef || !supportsThreadPullRequests) return; useRightPanelStore.getState().open(activeThreadRef, "pull-requests"); }, [activeThreadRef, supportsThreadPullRequests]); - const { state: deviceState } = useDeviceState(activeThreadRef?.environmentId ?? null); + const { state: deviceState, loaded: deviceStateLoaded } = useDeviceState( + activeThreadRef?.environmentId ?? null, + ); const [deviceSetupThread, setDeviceSetupThread] = useState(null); const addDeviceSurface = useCallback(() => { if (!activeThreadRef) return; @@ -4153,24 +4155,53 @@ export default function ChatView(props: ChatViewProps) { } useRightPanelStore.getState().open(activeThreadRef, "device"); }, [activeThreadRef, deviceState.onboardingCompleted, deviceState.hostStatus]); - // An agent's `device_open` surfaces in every client the same way a - // `preview_open` does: the thread starts a device or gains a session and the panel - // opens on it. Closing the last session leaves the tab in place so the - // user keeps their picker; only new sessions raise the panel. - const threadDeviceSessionCount = activeThreadRef - ? deviceState.sessions.filter((session) => session.threadId === activeThreadRef.threadId) - .length + - (deviceState.bootingDevices?.filter((device) => device.threadId === activeThreadRef.threadId) - .length ?? 0) - : 0; - const previousDeviceSessionCount = useRef(threadDeviceSessionCount); + // 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(() => { - const previous = previousDeviceSessionCount.current; - previousDeviceSessionCount.current = threadDeviceSessionCount; - if (!activeThreadRef || threadDeviceSessionCount <= previous) return; - if (shouldUseRightPanelSheet) return; - useRightPanelStore.getState().open(activeThreadRef, "device"); - }, [activeThreadRef, shouldUseRightPanelSheet, threadDeviceSessionCount]); + 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; @@ -8153,7 +8184,8 @@ export default function ChatView(props: ChatViewProps) { { closeRightPanelSurface(renderedRightPanelSurface); @@ -8725,6 +8757,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} @@ -8779,6 +8815,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} 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/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 546c7c5ad45a..79caadf90e06 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -46,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 { @@ -97,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; @@ -182,6 +184,7 @@ const SURFACE_UNAVAILABLE_HINTS = { } as const; type TabContextMenuAction = + | "rename" | "copy-path" | "toggle-mute" | "close" @@ -648,7 +651,7 @@ function surfaceTitle( case "agents": return "Agents"; case "device": - return "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"; @@ -733,7 +736,13 @@ function SurfaceIcon({ case "agents": return ; case "device": - return ; + return surface.target?.platform === "ios" ? ( + + ) : surface.target?.platform === "android" ? ( + + ) : ( + + ); } } @@ -832,6 +841,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, @@ -958,6 +968,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" }); } @@ -1001,6 +1013,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); @@ -1202,20 +1217,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} + + )}
); })} 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 index d94913b03dff..5b312f7fc4f4 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -6,7 +6,6 @@ import type { } from "@t3tools/contracts"; import { ChevronLeft, - Circle, Home, Power, RotateCcw, @@ -15,21 +14,13 @@ import { Square, X, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "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 { - Select, - SelectGroup, - SelectGroupLabel, - SelectItem, - SelectPopup, - SelectTrigger, - SelectValue, -} from "~/components/ui/select"; import { Spinner } from "~/components/ui/spinner"; import { Toggle } from "~/components/ui/toggle"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; @@ -38,28 +29,22 @@ import { deviceEnvironment, useDeviceHubAccess, useDeviceState } from "~/state/d 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 NEW_DEVICE_VALUE = "__new__"; - const platformLabel = (platform: DevicePlatform) => platform === "ios" ? "iOS Simulators" : "Android Emulators"; const deviceKey = (device: Pick) => `${device.hostId}\u0000${device.id}`; -/** - * The Device right-panel surface: one open device (from the thread's device - * sessions) with a picker to switch or boot another. Booting and streaming are - * server-owned; this panel only asks and renders. - */ +/** Each surface owns one host/device; only the visible surface streams. */ export function DevicePanel(props: { readonly mode: PreviewPanelMode; readonly threadRef: ScopedThreadRef; - /** `null` renders the picker with nothing open. */ - readonly deviceId: string | null; + readonly surface: Extract; readonly visible: boolean; readonly onDismissSetup: () => void; }) { @@ -69,7 +54,8 @@ export function DevicePanel(props: { const open = useAtomCommand(deviceEnvironment.open); const close = useAtomCommand(deviceEnvironment.close); const [operationError, setOperationError] = useState(null); - const [pendingDeviceKey, setPendingDeviceKey] = 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); @@ -87,10 +73,13 @@ export function DevicePanel(props: { () => state.sessions.filter((session) => session.threadId === threadId), [state.sessions, threadId], ); - const activeSession = - (props.deviceId - ? sessions.find((session) => session.deviceId === props.deviceId) - : undefined) ?? sessions.at(-1); + 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, @@ -99,51 +88,67 @@ export function DevicePanel(props: { const grouped = useMemo(() => groupDevices(state), [state]); - const selectDevice = useCallback( - async (value: string) => { - if (value === NEW_DEVICE_VALUE) return; - const device = state.devices.find((candidate) => deviceKey(candidate) === value); - if (!device) return; - setOperationError(null); - setPendingDeviceKey(value); - 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)); - } finally { - setPendingDeviceKey(null); - } - }, - [environmentId, open, state.devices, threadId], - ); - - const closeActive = useCallback( - (powerOff: boolean) => { - if (!activeSession) return; - setOperationError(null); - void close({ + 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, deviceId: activeSession.deviceId, shutdown: powerOff }, - }).then((result) => { - if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); + input: { + threadId, + hostId: device.hostId, + deviceId: device.id, + platform: device.platform, + }, }); - }, - [activeSession, close, environmentId, threadId], - ); + 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 = state.hostStatus === "ready"; - const hostBusy = state.hostStatus === "installing" || state.hostStatus === "starting"; + 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), + host.platforms + .filter((platform) => !platform.available) + .map((platform) => ({ ...platform, hostId: host.id, hostLabel: host.label })), ); if (loaded && (!state.onboardingCompleted || hostDisabled)) { @@ -164,63 +169,11 @@ export function DevicePanel(props: { 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 ? ( <> host.id === activeDevice.hostId)?.label ?? "Device host"} · ${activeDevice.version}`} deviceId={activeDevice.id} + hostId={activeDevice.hostId} visible={props.visible} axOverlay={axOverlay} onHandle={setHandle} @@ -330,6 +286,25 @@ export function DevicePanel(props: { /> ) : 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 || hostBusy || pendingDeviceKey ? ( + {grouped.length === 0 ? ( <> - {hostBusy || pendingDeviceKey ? ( - - ) : ( - - )} +

{state.hostStatus === "failed" ? (state.hostStatusDetail ?? "The device hub failed to start.") - : pendingDeviceKey - ? "Booting device… this can take a minute." - : hostBusy - ? state.hostStatus === "installing" - ? "Installing device tools…" - : "Starting the device hub…" - : !loaded - ? "Connecting…" - : grouped.length === 0 - ? "No simulators or emulators were found on this environment." - : "Choose a device to open."} + : "No simulators or emulators were found on this environment."}

) : null} @@ -380,7 +341,7 @@ export function DevicePanel(props: { } title={device.name} - description={`${device.version} · ${device.booted ? "Running" : "Stopped"}`} + 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))} @@ -418,15 +379,6 @@ export function DevicePanel(props: { Refresh devices ) : null} - {unavailablePlatforms.length > 0 && hostReady ? ( -
    - {unavailablePlatforms.map((platform) => ( -
  • - {platform.platform === "ios" ? "iOS" : "Android"}: {platform.reason} -
  • - ))} -
- ) : null}
)} diff --git a/apps/web/src/components/device/DeviceStreamView.test.tsx b/apps/web/src/components/device/DeviceStreamView.test.tsx index a46e5ed7b7b8..9c8a1197926a 100644 --- a/apps/web/src/components/device/DeviceStreamView.test.tsx +++ b/apps/web/src/components/device/DeviceStreamView.test.tsx @@ -36,6 +36,7 @@ it("removes MJPEG requests while hidden and reconnects when shown", async () => ); const view = (visible: boolean) => ( void; readonly onScreen?: (screen: DeviceScreenSize | null) => void; }) { - const access = useDeviceHubAccess(props.environmentId); + const access = useDeviceHubAccess(props.environmentId, props.hostId); const canvasRef = useRef(null); const clientRef = useRef(null); const [status, setStatus] = useState("connecting"); @@ -314,12 +317,14 @@ export function DeviceStreamView(props: {
) : null} {status !== "streaming" ? ( -
- {status === "connecting" ? : null} - {status === "error" ? (detail ?? "Stream failed.") : "Connecting to device…"} - {status === "connecting" && detail ? ( - {detail} - ) : null} +
+
) : null}
diff --git a/apps/web/src/components/settings/IntegrationsSettings.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.test.tsx index 9ce3b2126c0e..4ef218430ae1 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.test.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.test.tsx @@ -105,6 +105,7 @@ const deviceState = (overrides: Partial = {}): DeviceService }, ], hostStatus: "ready", + hostStatuses: {}, devices: [], sessions: [], onboardingCompleted: false, diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index ad1788f4cfe9..e2299511b87c 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -21,6 +21,87 @@ beforeEach(() => { }); describe("rightPanelStore", () => { + it("gives each host/device its own tab and preserves renamed tabs", () => { + const store = useRightPanelStore.getState(); + const android = { + hostId: "nucbox", + deviceId: "emulator-5580", + name: "Pixel", + platform: "android", + } as const; + const ios = { hostId: "macmini", deviceId: "ios-1", name: "iPhone", platform: "ios" } as const; + store.open(refA, "device"); + store.openDevice(refA, android); + store.open(refA, "device"); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces, + ).toHaveLength(2); + store.openDevice(refA, ios); + let state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + expect(state.surfaces.map((surface) => surface.id)).toEqual([ + "device:nucbox:emulator-5580", + "device:macmini:ios-1", + ]); + store.renameDevice(refA, "device:nucbox:emulator-5580", "Android test"); + store.openDevice(refA, android); + state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + expect(state.surfaces).toHaveLength(2); + expect(state.surfaces[0]).toMatchObject({ title: "Android test", target: android }); + expect(state.activeSurfaceId).toBe("device:nucbox:emulator-5580"); + store.closeSurface(refA, state.activeSurfaceId!); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces, + ).toEqual([expect.objectContaining({ target: ios })]); + }); + + it("does not collide when two hosts expose the same device id", () => { + const store = useRightPanelStore.getState(); + const device = { deviceId: "emulator-5554", name: "Pixel", platform: "android" } as const; + store.openDevice(refA, { ...device, hostId: "a:b" }); + store.openDevice(refA, { ...device, hostId: "a" }); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces, + ).toHaveLength(2); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refB).surfaces, + ).toHaveLength(0); + }); + + it.each(["one", "all", "others", "right"])( + "keeps device tabs dismissed across reload after closing %s", + (mode) => { + const store = useRightPanelStore.getState(); + const target = { + hostId: "nucbox", + deviceId: "emulator-5580", + name: "Pixel", + platform: "android", + } as const; + store.open(refA, "files"); + store.openDevice(refA, target); + if (mode === "one") store.closeSurface(refA, "device:nucbox:emulator-5580"); + if (mode === "all") store.closeAllSurfaces(refA); + if (mode === "others") store.closeOtherSurfaces(refA, "files"); + if (mode === "right") store.closeSurfacesToRight(refA, "files"); + const persisted = JSON.parse( + JSON.stringify({ byThreadKey: useRightPanelStore.getState().byThreadKey }), + ); + useRightPanelStore.setState(migratePersistedRightPanelState(persisted)); + store.openDevice(refA, target, true); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.some( + (surface) => surface.kind === "device", + ), + ).toBe(false); + store.openDevice(refA, target); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.some( + (surface) => surface.kind === "device", + ), + ).toBe(true); + }, + ); + const completedDiff = { id: "diff", kind: "diff" } as const; const linkedPullRequest = pullRequestSurface({ projectId: "project-a", diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 293733b5b3d9..c44c106c68c8 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -32,15 +32,17 @@ const RIGHT_PANEL_KINDS = [ ] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; +export interface DeviceTabTarget { + hostId: string; + deviceId: string; + platform: "ios" | "android"; + name: string; +} + export type RightPanelSurface = | { id: `browser:${string}`; kind: "preview"; resourceId: string } | { id: "browser:new"; kind: "preview"; resourceId: null } - /** - * One Device tab per thread. The tab is the surface; which device it shows - * comes from the thread's server-side device sessions, so an agent opening a - * device from another client lands in the same tab. - */ - | { id: "device"; kind: "device" } + | { id: "device" | `device:${string}`; kind: "device"; target?: DeviceTabTarget; title?: string } | { id: `terminal:${string}`; kind: "terminal"; @@ -90,7 +92,7 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; // v10 keys pull-request surfaces by reference instead of a singleton tab. // v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh. // v12 adds the device surface. -const RIGHT_PANEL_STORAGE_VERSION = 12; +const RIGHT_PANEL_STORAGE_VERSION = 13; /** A fixed workspace-level ref: each PR surface carries its own real environment. */ export const PULL_REQUESTS_PANEL_REF = scopeThreadRef( @@ -108,6 +110,7 @@ export interface ThreadRightPanelState { isOpen: boolean; activeSurfaceId: string | null; surfaces: RightPanelSurface[]; + dismissedDeviceSurfaceIds?: string[]; } interface RightPanelStoreState { @@ -128,6 +131,8 @@ interface RightPanelStoreState { ref: ScopedThreadRef, kind: Exclude, ) => void; + openDevice: (ref: ScopedThreadRef, target: DeviceTabTarget, automatic?: boolean) => void; + renameDevice: (ref: ScopedThreadRef, surfaceId: string, title: string) => void; openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void; openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void; @@ -281,7 +286,12 @@ const updateThread = ( ): Record => { const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE; const next = updater(current); - if (!next.isOpen && next.activeSurfaceId === null && next.surfaces.length === 0) { + if ( + !next.isOpen && + next.activeSurfaceId === null && + next.surfaces.length === 0 && + !next.dismissedDeviceSurfaceIds?.length + ) { if (!(threadKey in byThreadKey)) return byThreadKey; const { [threadKey]: _removed, ...rest } = byThreadKey; return rest; @@ -306,7 +316,25 @@ const userAction = ( threadKey: string, updater: (current: ThreadRightPanelState) => ThreadRightPanelState, ): Partial => ({ - byThreadKey: updateThread(state.byThreadKey, threadKey, updater), + byThreadKey: updateThread(state.byThreadKey, threadKey, (current) => { + const next = updater(current); + const removed = current.surfaces.filter( + (surface) => + surface.kind === "device" && + surface.target && + !next.surfaces.some((entry) => entry.id === surface.id), + ); + if (removed.length === 0) return next; + return { + ...next, + dismissedDeviceSurfaceIds: [ + ...new Set([ + ...(next.dismissedDeviceSurfaceIds ?? []), + ...removed.map((surface) => surface.id), + ]), + ], + }; + }), userActionRevisionByThreadKey: { ...state.userActionRevisionByThreadKey, [threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1, @@ -426,7 +454,22 @@ export function migratePersistedRightPanelState(persistedState: unknown): { // first survivor instead of rendering an open empty panel. const activeSurfaceId = persistedActiveSurfaceId ?? (isOpen ? (surfaces[0]?.id ?? null) : null); - return [threadKey, { isOpen, surfaces, activeSurfaceId }]; + return [ + threadKey, + { + isOpen, + surfaces, + activeSurfaceId, + ...(Array.isArray(validThreadState?.dismissedDeviceSurfaceIds) + ? { + dismissedDeviceSurfaceIds: + validThreadState.dismissedDeviceSurfaceIds.filter( + (id): id is string => typeof id === "string", + ), + } + : {}), + }, + ]; }), ) : {}; @@ -472,6 +515,40 @@ export const useRightPanelStore = create()( return upsertSurface(current, singletonSurface(kind)); }), ), + openDevice: (ref, target, automatic = false) => + set((state) => + (automatic ? automaticUpdate : userAction)(state, scopedThreadKey(ref), (current) => { + const id = + `device:${encodeURIComponent(target.hostId)}:${encodeURIComponent(target.deviceId)}` as const; + if (automatic && current.dismissedDeviceSurfaceIds?.includes(id)) return current; + const surface: RightPanelSurface = { id, kind: "device", target }; + const existing = current.surfaces.find((entry) => entry.id === id); + const surfaces = existing + ? current.surfaces.filter((entry) => entry.id !== "device") + : current.surfaces.map((entry) => (entry.id === "device" ? surface : entry)); + return upsertSurface( + { + ...current, + surfaces, + dismissedDeviceSurfaceIds: (current.dismissedDeviceSurfaceIds ?? []).filter( + (entry) => entry !== id, + ), + }, + existing ?? surface, + ); + }), + ), + renameDevice: (ref, surfaceId, title) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => ({ + ...current, + surfaces: current.surfaces.map((surface) => + surface.id === surfaceId && surface.kind === "device" + ? { ...surface, title: title.trim() || surface.target?.name || "Device" } + : surface, + ), + })), + ), openBrowser: (ref, tabId) => set((state) => userAction(state, scopedThreadKey(ref), (current) => { diff --git a/apps/web/src/state/device.ts b/apps/web/src/state/device.ts index b9c1adf86cdd..9a42cb0b1305 100644 --- a/apps/web/src/state/device.ts +++ b/apps/web/src/state/device.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useAtomValue } from "@effect/atom-react"; import { createDeviceEnvironmentAtoms } from "@t3tools/client-runtime/state/device"; import { @@ -18,7 +19,8 @@ export const deviceEnvironment = createDeviceEnvironmentAtoms(connectionAtomRunt const EMPTY_DEVICE_STATE: DeviceServiceState = { hosts: [], - hostStatus: "idle", + hostStatus: "disabled", + hostStatuses: {}, devices: [], sessions: [], onboardingCompleted: false, @@ -54,11 +56,20 @@ const deviceHubAccessAtom = Atom.family((environmentId: EnvironmentId) => .pipe(Atom.setIdleTTL(60_000), Atom.withLabel(`device-hub-access:${environmentId}`)), ); -export function useDeviceHubAccess(environmentId: EnvironmentId | null): DeviceHubAccess | null { +export function useDeviceHubAccess( + environmentId: EnvironmentId | null, + hostId = "local", +): DeviceHubAccess | null { const result = useAtomValue( environmentId === null ? EMPTY_ACCESS_ATOM : deviceHubAccessAtom(environmentId), ); - return AsyncResult.isSuccess(result) ? result.value : null; + return useMemo( + () => + AsyncResult.isSuccess(result) + ? { ...result.value, query: { ...result.value.query, hostId } } + : null, + [result, hostId], + ); } const EMPTY_ACCESS_ATOM = Atom.make(AsyncResult.initial()).pipe( diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 309d156430fa..895a954e2e40 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -95,6 +95,13 @@ export const DeviceServiceState = Schema.Struct({ hosts: Schema.Array(DeviceHostSummary), hostStatus: DeviceHostStatus, hostStatusDetail: Schema.optional(Schema.String), + hostStatuses: Schema.Record( + DeviceHostId, + Schema.Struct({ + status: DeviceHostStatus, + detail: Schema.optional(Schema.String), + }), + ), devices: Schema.Array(DeviceSummary), sessions: Schema.Array(DeviceSession), bootingDevices: Schema.optional( @@ -129,6 +136,7 @@ export const DeviceOpenInput = Schema.Struct({ export type DeviceOpenInput = typeof DeviceOpenInput.Type; export const DeviceCloseInput = Schema.Struct({ + hostId: Schema.optional(DeviceHostId), threadId: ThreadId, /** Omit to close every device session for the thread. */ deviceId: Schema.optional(DeviceId), From 7734c6d71b6dd5df9c9b4856cff8e419b8837fe8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 11:59:33 -0700 Subject: [PATCH 33/61] feat(devices): target concurrent agent sessions across hosts (#10855) --- apps/server/src/device/AgentDeviceShim.ts | 28 +++++- .../src/device/AgentDeviceTarget.test.ts | 89 ++++++++++++++++++ apps/server/src/device/AgentDeviceTarget.ts | 43 +++++++++ apps/server/src/device/DeviceService.test.ts | 4 +- apps/server/src/device/DeviceService.ts | 90 ++++++++++++++++++- apps/server/src/mcp/McpDeviceToolkit.test.ts | 49 +++++++++- .../src/mcp/toolkits/device/handlers.test.ts | 13 +++ .../src/mcp/toolkits/device/handlers.ts | 66 +++++++++++--- apps/server/src/mcp/toolkits/device/tools.ts | 5 +- .../provider/CodexDeveloperInstructions.ts | 2 +- .../src/provider/Layers/ProviderService.ts | 35 ++------ 11 files changed, 376 insertions(+), 48 deletions(-) create mode 100644 apps/server/src/device/AgentDeviceTarget.test.ts create mode 100644 apps/server/src/device/AgentDeviceTarget.ts diff --git a/apps/server/src/device/AgentDeviceShim.ts b/apps/server/src/device/AgentDeviceShim.ts index c2a28127e31d..e3f92de492f3 100644 --- a/apps/server/src/device/AgentDeviceShim.ts +++ b/apps/server/src/device/AgentDeviceShim.ts @@ -1,3 +1,4 @@ +// @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 @@ -22,11 +23,34 @@ export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(functio 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}" "${entryPath}" %*\r\n`; + const script = `@echo off\r\n"${node}" "${launcherPath}" %*\r\n`; yield* fs.writeFileString(path.join(shimDir, "agent-device.cmd"), script); } else { - const script = `#!/bin/sh\nexec "${node}" "${entryPath}" "$@"\n`; + 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); 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/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 49a8545bfd6d..b45db236fab6 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -16,7 +16,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerSettingsService } from "../serverSettings.ts"; import * as DeviceHost from "./DeviceHost.ts"; -import { type DeviceService, make, stateStream } from "./DeviceService.ts"; +import { type DeviceService, makeWithHosts, stateStream } from "./DeviceService.ts"; const baseState: DeviceServiceState = { hosts: [], @@ -108,7 +108,7 @@ const fixture = Effect.fn("fixture")(function* ( starts.push("stop"); }), }; - const service = yield* make.pipe( + const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe( Effect.provideService(DeviceHost.DeviceHost, host), Effect.provideService( ServerSettingsService, diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 597e1a5d2b10..866663dbf21c 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -33,6 +33,15 @@ import { 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"; @@ -49,6 +58,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab 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 LocalDeviceHost from "./LocalDeviceHost.ts"; @@ -94,6 +104,12 @@ export interface DeviceAgentReadiness extends DeviceReadiness { export class DeviceService extends Context.Service< DeviceService, { + readonly agentCli: 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: ( @@ -138,6 +154,16 @@ const vendorPrefix = (platform: DevicePlatform) => export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ( hosts: ReadonlyMap, + 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); @@ -733,6 +759,29 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ); return DeviceService.of({ + 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 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* 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, @@ -751,9 +800,46 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }); }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { - const host = yield* DeviceHost.DeviceHost; - return yield* makeWithHosts(new Map([[host.id, host]])); + 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 service = yield* makeWithHosts(new Map([[localHost.id, localHost]]), (hostId, ready) => { + 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), + ); + }); + 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)); diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 32f919cbb682..24097a92faac 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -1,10 +1,16 @@ import { expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +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"; @@ -85,11 +91,14 @@ const DeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ sessionsForThread: () => Effect.succeed([]), screenshot: () => Effect.succeed({ device, png }), close: () => Effect.void, + agentCli: Effect.succeed("/cli"), + 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), ); @@ -126,3 +135,41 @@ it.effect("registers the device tools and returns the screenshot as image conten }), ).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/toolkits/device/handlers.test.ts b/apps/server/src/mcp/toolkits/device/handlers.test.ts index e37b2a8b22c2..110d4cbba72a 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.test.ts @@ -30,6 +30,19 @@ describe("device tool helpers", () => { 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); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 5ad57031306e..3bc89d22bc60 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -8,6 +8,10 @@ import { 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"; @@ -26,24 +30,37 @@ export function agentDeviceTargetArgs(device: DeviceSummary): ReadonlyArray + /^[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 the agent-device CLI, which is on PATH and already connected to this environment. Always pass ${target}.`, + `Drive it with ${executable}. Use this exact executable path; login shells may reset PATH. Always pass ${target}.`, "Typical loop:", - ` agent-device open ${target} # or: open `, - ` agent-device snapshot -i ${target} # accessibility tree with @eN refs`, - ` agent-device click @e3 ${target}`, - ` agent-device fill @e5 "text" ${target}`, - ` agent-device screenshot /tmp/shot.png ${target} # or call device_screenshot`, - ` agent-device install ${target}`, - "Prefer snapshot refs over coordinates. Run `agent-device help` for workflow guides and `agent-device --help` for flags.", + ` ${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"); } @@ -136,6 +153,12 @@ const handlers = { }); } 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, @@ -147,10 +170,29 @@ const handlers = { 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: "agent-device", targetArgs: agentDeviceTargetArgs(device) }, - quickStart: agentDeviceQuickStart(device), + agentDevice: { command, targetArgs }, + quickStart: agentDeviceQuickStart(device, targetArgs, command), }; }).pipe(Effect.mapError(toolError)), device_screenshot: (input) => diff --git a/apps/server/src/mcp/toolkits/device/tools.ts b/apps/server/src/mcp/toolkits/device/tools.ts index e98d6ab64829..58bfed41d940 100644 --- a/apps/server/src/mcp/toolkits/device/tools.ts +++ b/apps/server/src/mcp/toolkits/device/tools.ts @@ -8,6 +8,9 @@ import { 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"; @@ -48,7 +51,7 @@ const DeviceOpenTool = Tool.make("device_open", { parameters: DeviceToolOpenInput, success: DeviceToolOpenResult, failure: DeviceToolError, - dependencies, + dependencies: [...dependencies, FileSystem.FileSystem, Path.Path, ServerConfig], }) .annotate(Tool.Title, "Open device") .annotate(Tool.Readonly, false) diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 85784d21ca4b..6a7fee351bce 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -16,7 +16,7 @@ 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 and already connected: 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. +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 { diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index f2d94e735b0d..d04dcae7f126 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -54,8 +54,8 @@ import * as Stream from "effect/Stream"; import { appendUserInputAttachmentPaths } from "../userInputAttachments.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import * as ServerConfig from "../../config.ts"; -import { ensureAgentDeviceShim } from "../../device/AgentDeviceShim.ts"; import * as DeviceService from "../../device/DeviceService.ts"; +import { ensureAgentDeviceShim } from "../../device/AgentDeviceShim.ts"; import type * as McpInvocationContext from "../../mcp/McpInvocationContext.ts"; import { increment, @@ -254,8 +254,6 @@ export interface ProviderServiceLiveOptions { * test see whether a credential was requested at all. */ readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential; - /** Overrides the device host lookup used to build the agent-device environment. */ - readonly deviceReadiness?: DeviceService.DeviceService["Service"]["agentReadinessIfSupported"]; } interface TurnAnalyticsMetadata { @@ -484,14 +482,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); const issueMcpCredential = options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; - const deviceReadiness = - options?.deviceReadiness ?? - (() => - Effect.serviceOption(DeviceService.DeviceService).pipe( - Effect.flatMap((service) => - Option.isSome(service) ? service.value.agentReadinessIfSupported() : Effect.succeed(null), - ), - )); const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const runtimeEventPubSub = yield* PubSub.unbounded(); @@ -914,26 +904,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return capabilities; }); - /** - * Starting a session with device access also brings the device host up, so - * the `agent-device` CLI is on the provider's PATH from its first turn. The - * environment is fixed at spawn time, so a host started later by - * `device_open` could not reach an already-running agent. Tools install once - * and the host is idempotent, so this is cheap after the first session; - * a host that fails to start withholds only the CLI, not the MCP tools. - */ + /** 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 readiness = yield* deviceReadiness().pipe( + 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("Device host unavailable; starting session without agent-device", { - cause, - }).pipe(Effect.as(null)), + Effect.logWarning("Agent device CLI unavailable", { cause }).pipe(Effect.as(null)), ), ); - if (!readiness) return undefined; + if (!entryPath) return undefined; const shimDir = yield* ensureAgentDeviceShim({ - entryPath: readiness.agentDevice.entryPath, + entryPath, stateDir: serverConfig.stateDir, }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), @@ -944,8 +927,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( return { PATH: shimDir, PATH_SEPARATOR: hostPlatform === "win32" ? ";" : ":", - AGENT_DEVICE_DAEMON_BASE_URL: readiness.agentDevice.baseUrl, - AGENT_DEVICE_DAEMON_AUTH_TOKEN: readiness.agentDevice.token, AGENT_DEVICE_NO_UPDATE_NOTIFIER: "1", } satisfies Record; }); From d2eeacd8ccde4d8763ca315b4cddf2964db93329 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 11:59:34 -0700 Subject: [PATCH 34/61] feat(devices): connect simulator hosts over SSH (#10856) --- apps/server/package.json | 1 + apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/device/DeviceActions.test.ts | 1 + apps/server/src/device/DeviceActions.ts | 2 +- apps/server/src/device/DeviceHost.ts | 3 +- .../server/src/device/DeviceMultiHost.test.ts | 54 ++- apps/server/src/device/DeviceService.test.ts | 1 + apps/server/src/device/DeviceService.ts | 272 ++++++++--- apps/server/src/device/DeviceToolchain.ts | 4 +- apps/server/src/device/LocalDeviceHost.ts | 1 + apps/server/src/device/SshDeviceHost.test.ts | 131 ++++++ apps/server/src/device/SshDeviceHost.ts | 434 ++++++++++++++++++ .../server/src/device/sshDeviceScript.test.ts | 220 +++++++++ apps/server/src/device/sshDeviceScript.ts | 192 ++++++++ apps/server/src/mcp/McpDeviceToolkit.test.ts | 1 + .../src/mcp/toolkits/device/handlers.ts | 3 + apps/server/src/ws.ts | 4 + .../device/DeviceHostAvailability.tsx | 27 ++ .../settings/DeviceHostsSettings.tsx | 334 ++++++++++++++ .../settings/IntegrationsSettings.tsx | 72 ++- .../src/components/settings/settingsSearch.ts | 6 + docs/internals/devices.md | 12 +- docs/user/devices.md | 29 +- packages/client-runtime/src/state/device.ts | 4 + packages/contracts/src/device.ts | 24 +- packages/contracts/src/rpc.ts | 10 + packages/contracts/src/settings.test.ts | 13 + packages/contracts/src/settings.ts | 3 + packages/shared/src/serverSettings.test.ts | 10 + pnpm-lock.yaml | 3 + 30 files changed, 1794 insertions(+), 78 deletions(-) create mode 100644 apps/server/src/device/SshDeviceHost.test.ts create mode 100644 apps/server/src/device/SshDeviceHost.ts create mode 100644 apps/server/src/device/sshDeviceScript.test.ts create mode 100644 apps/server/src/device/sshDeviceScript.ts create mode 100644 apps/web/src/components/device/DeviceHostAvailability.tsx create mode 100644 apps/web/src/components/settings/DeviceHostsSettings.tsx 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 97afa4f40775..d7d1be455fa1 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -145,6 +145,7 @@ export const RPC_REQUIRED_SCOPES = { [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, diff --git a/apps/server/src/device/DeviceActions.test.ts b/apps/server/src/device/DeviceActions.test.ts index ed6b5a87335d..c22dcfe86610 100644 --- a/apps/server/src/device/DeviceActions.test.ts +++ b/apps/server/src/device/DeviceActions.test.ts @@ -16,6 +16,7 @@ const makeReady = ( ) => { const calls: Call[] = []; const ready: DeviceHostReady = { + nodePath: process.execPath, hub: { origin: "http://127.0.0.1:1" }, helpers, run: (command, args, options) => { diff --git a/apps/server/src/device/DeviceActions.ts b/apps/server/src/device/DeviceActions.ts index 12781b31b4b5..509dac526b93 100644 --- a/apps/server/src/device/DeviceActions.ts +++ b/apps/server/src/device/DeviceActions.ts @@ -327,7 +327,7 @@ const serveSimPermissions = ( reason: "helper_missing", }); yield* ready - .run(process.execPath, [ + .run(ready.nodePath, [ cli, "permissions", input.decision, diff --git a/apps/server/src/device/DeviceHost.ts b/apps/server/src/device/DeviceHost.ts index 8d1bafbc5dfc..34ff2769362e 100644 --- a/apps/server/src/device/DeviceHost.ts +++ b/apps/server/src/device/DeviceHost.ts @@ -46,11 +46,12 @@ export interface DeviceHubEndpoint { export interface AgentDeviceEndpoint { readonly baseUrl: string; readonly token: string; - /** Absolute path of the agent-device entry script for the provider PATH shim. */ + /** 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) diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts index d586dffa1567..7a40e1e53c72 100644 --- a/apps/server/src/device/DeviceMultiHost.test.ts +++ b/apps/server/src/device/DeviceMultiHost.test.ts @@ -1,5 +1,7 @@ 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"; @@ -10,6 +12,7 @@ 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 }), @@ -20,10 +23,10 @@ it.effect("keeps hosts independent when serials collide and another host fails", summary: Effect.succeed({ id, label: id, - kind: "local", + kind: id === "b" ? "ssh" : "local", hubInstalled: true, agentDeviceInstalled: true, - platforms: [{ platform: "android", available: true }], + platforms: id === "b" ? [] : [{ platform: "android", available: true }], }), platformAvailability: (platform) => Effect.succeed({ platform, available: true }), ensureReady: () => @@ -59,9 +62,19 @@ it.effect("keeps hosts independent when serials collide and another host fails", ), ); const hosts = new Map(["a", "b", "offline"].map((id) => [id, host(id, id === "offline")])); - const service = yield* makeWithHosts(hosts).pipe( - Effect.provideService(HttpClient.HttpClient, http), - ); + 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"); @@ -74,8 +87,35 @@ it.effect("keeps hosts independent when serials collide and another host fails", expect(state.sessions.map((session) => session.hostId)).toEqual(["b"]); expect(state.hostStatuses.a?.status).toBe("ready"); expect(state.hostStatuses.offline?.status).toBe("failed"); - yield* service.agentReadinessIfSupported("b"); - expect((yield* service.state).hostStatuses.b?.status).toBe("ready"); + 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( diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index b45db236fab6..46e159dbfc33 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -70,6 +70,7 @@ const fixture = Effect.fn("fixture")(function* ( 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: "" }), diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 866663dbf21c..a60cda76c648 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -30,6 +30,8 @@ import { type DeviceSession, type DeviceShutdownInput, type DeviceSummary, + type SshDeviceHostConfig, + type DeviceHostSummary, LOCAL_DEVICE_HOST_ID, type ThreadId, } from "@t3tools/contracts"; @@ -60,6 +62,8 @@ 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. */ @@ -105,6 +109,9 @@ export class DeviceService extends Context.Service< DeviceService, { readonly agentCli: Effect.Effect; + readonly testHost: ( + config: SshDeviceHostConfig, + ) => Effect.Effect; readonly agentTarget: (input: { threadId: ThreadId; hostId: DeviceHostId; @@ -154,6 +161,13 @@ const vendorPrefix = (platform: DevicePlatform) => 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, @@ -183,6 +197,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* 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, @@ -217,13 +232,17 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* hostId: DeviceHostId, status: DeviceServiceState["hostStatuses"][string], ) => - publish((state) => ({ - ...state, - ...(hostId === LOCAL_DEVICE_HOST_ID - ? { hostStatus: status.status, hostStatusDetail: status.detail } - : {}), - hostStatuses: { ...state.hostStatuses, [hostId]: status }, - })); + 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) { @@ -245,6 +264,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (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" }); @@ -260,7 +284,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (!(yield* readDeviceSettings).enabled) return null; const host = yield* resolveHost(hostId); const summary = yield* host.summary; - if (!summary.platforms.some((platform) => platform.available)) return null; + if (summary.kind === "local" && !summary.platforms.some((platform) => platform.available)) + return null; return yield* readiness(host.id); }); @@ -270,7 +295,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* if (!deviceSettings.enabled || !deviceSettings.agentAccessEnabled) return null; const host = yield* resolveHost(hostId); const summary = yield* host.summary; - if (!summary.platforms.some((platform) => platform.available)) return null; + 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( @@ -364,11 +390,12 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }); 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) + if (!(yield* readDeviceSettings).enabled || !host || hosts.get(ready.hostId) !== host) return (yield* SynchronizedRef.get(stateRef)).state; return yield* publish((state) => ({ ...state, @@ -578,6 +605,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ), ); } + 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, @@ -758,46 +790,76 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* Effect.map(({ state }) => state.sessions.filter((session) => session.threadId === threadId)), ); - return DeviceService.of({ - 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 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* 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, - }); + 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. */ @@ -807,7 +869,12 @@ export const make = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runner = yield* ProcessRunner.ProcessRunner; - const service = yield* makeWithHosts(new Map([[localHost.id, localHost]]), (hostId, ready) => { + 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), @@ -822,7 +889,108 @@ export const make = Effect.gen(function* () { ), 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( diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index e6b43cd81ee8..fa8d8cd11d17 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -25,9 +25,9 @@ import * as Semaphore from "effect/Semaphore"; import * as ProcessRunner from "../processRunner.ts"; const DEVICE_HUB_PACKAGE = "expo-device-hub"; -const DEVICE_HUB_VERSION = "0.9.0"; +export const DEVICE_HUB_VERSION = "0.9.0"; const AGENT_DEVICE_PACKAGE = "agent-device"; -const AGENT_DEVICE_VERSION = "0.20.10"; +export const AGENT_DEVICE_VERSION = "0.20.10"; const INSTALL_TIMEOUT = Duration.minutes(10); const installLock = Semaphore.makeUnsafe(1); diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index b24ebfbd98b6..5a18d6e826a6 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -644,6 +644,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const toReady = (running: RunningHost): DeviceHost.DeviceHostReady => ({ hub: { origin: running.hub.origin } satisfies DeviceHost.DeviceHubEndpoint, + nodePath: process.execPath, run, helpers: running.helpers, }); 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/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 24097a92faac..3a9307314c39 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -92,6 +92,7 @@ const DeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ 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"]), }); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 3bc89d22bc60..feb69ec630d6 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -134,6 +134,9 @@ const handlers = { .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) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 71a8ed19d905..f59a753d9c72 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2770,6 +2770,10 @@ const makeWsRpcLayer = ( 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", 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/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.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index a3f76f49cd5f..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, @@ -584,17 +586,74 @@ function AgentBrowserAccessSetting() { function DeviceIntegrationSettings() { const primaryEnvironment = usePrimaryEnvironment(); - const environmentId = primaryEnvironment?.environmentId ?? null; + 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 enabled = state.hostStatus !== "disabled"; 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 (!platformsRevealed && state.hostStatus === "ready" && pending !== "hub") { + if (enabled && !platformsRevealed && state.hostStatus === "ready" && pending !== "hub") { setPlatformsRevealed(true); } @@ -615,7 +674,7 @@ function DeviceIntegrationSettings() { }; return ( - + <> {pending === "agent" ? : null} @@ -689,7 +748,8 @@ function DeviceIntegrationSettings() { {state.hostStatusDetail}

) : null} -
+ + ); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 24892b0e1e3b..f56579990714 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -402,6 +402,12 @@ 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", diff --git a/docs/internals/devices.md b/docs/internals/devices.md index 49027022fdbc..9b34851439ca 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -3,8 +3,7 @@ The environment server owns simulators and emulators the way it owns terminals: discovery, streaming, and agent access all run there, and every client reaches them through the environment connection. This is what makes the -Device panel work over Tailscale and T3 Connect, and what will let a device -host on another machine slot in later. +Device panel work over Tailscale and T3 Connect, including when an SSH host runs the devices. ## Two external tools, one seam @@ -21,8 +20,8 @@ native addon, and a crash there must not take the server down. Everything platform-specific sits behind [`DeviceHost`](../../apps/server/src/device/DeviceHost.ts). The service, the proxy, and the MCP tools only see a hub origin and an agent-device endpoint. -An SSH or cloud host would forward those two things to the server and change -nothing above it. +SSH hosts forward both endpoints to server loopback. Every proxied request +also carries the host id; device ids alone are not unique across hosts. ## The hub is never exposed @@ -57,9 +56,8 @@ screenshot capture and stream tuning. The `device_*` toolkit is deliberately four tools: list, open, screenshot, and close. Driving happens through the `agent-device` CLI, which has the semantic snapshot model agents need and stays current with its own releases. T3 prepends -a shim directory to the provider's PATH and sets -`AGENT_DEVICE_DAEMON_BASE_URL` and `AGENT_DEVICE_DAEMON_AUTH_TOKEN` so the -agent never handles the endpoint or token. +a shim directory to the provider's PATH. The CLI installs on the environment +server even when that server cannot run simulators. Hosts start on demand. That environment is fixed when the provider subprocess spawns, so [`prepareMcpSession`](../../apps/server/src/provider/Layers/ProviderService.ts) diff --git a/docs/user/devices.md b/docs/user/devices.md index 51a0a578b607..7d1b91792ae3 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -15,6 +15,9 @@ installed, the setup screen says so and reuses it. Choose a running device to watch it, or choose **Start** next to a stopped device to boot it. The panel shows when you or an agent starts a device. +Each device opens in its own tab. Use **+ → Device** to open another, and +double-click a tab name or choose **Rename** from its context menu to rename it. +Only the visible tab streams video; switching tabs keeps both devices running. Turn off the device hub in **Settings → Integrations → Devices** to stop the helper processes; simulators and emulators keep running until you power them off. @@ -29,7 +32,8 @@ After installing them, restart the environment server and refresh devices. The screen is interactive: click and drag to touch, type while the screen is focused, and use the toolbar for Home, Back, and Recents on Android, rotate on iOS, and power off. Close the tab to stop watching; the device keeps running -unless you power it off. +unless you power it off. Closed tabs stay closed after a reload. To watch the +device again, choose it from **+ → Device**. ## Tools @@ -61,3 +65,26 @@ The device stream goes through the environment server, so it works over the local network, Tailscale, and T3 Connect. Live video needs a secure page (HTTPS or localhost); on a plain-HTTP remote origin iOS falls back to a slower still-image stream and Android cannot show video. + +## SSH device hosts + +In Settings → Integrations → Devices, select one connected environment +and add a host under **Device hosts**. Enter an SSH alias or `user@host`, with +an optional identity file and port. These resolve on the environment server, +so use the SSH configuration and keys available there. Password prompts are +not supported. + +**Test connection** checks SSH, Node, npm, and platform tools without installing +anything. The first device listing installs pinned device tools on the host. +Node 22 or newer and npm must be available to non-interactive SSH commands. +T3 checks common Homebrew and Android SDK locations; custom installations need +the appropriate PATH and ANDROID_HOME on the host. + +The picker identifies devices by host when several hosts are configured. +Connections recover after interruptions. Removing a host closes its device +sessions and stops its T3 helpers when reachable; simulators keep running. + +T3 provides discovery, streaming, and control. Arrange app builds, +installation, and connectivity to development servers such as Metro separately. +A simulator on another machine cannot reach Metro through your environment's +localhost without forwarding or another reachable address. diff --git a/packages/client-runtime/src/state/device.ts b/packages/client-runtime/src/state/device.ts index df27f793720c..1e6523497ef3 100644 --- a/packages/client-runtime/src/state/device.ts +++ b/packages/client-runtime/src/state/device.ts @@ -28,6 +28,10 @@ export function createDeviceEnvironmentAtoms( scheduler, concurrency, }), + testHost: createEnvironmentRpcCommand(runtime, { + label: "environment-data:device:test-host", + tag: WS_METHODS.deviceTestHost, + }), list: createEnvironmentRpcCommand(runtime, { label: "environment-data:device:list", tag: WS_METHODS.deviceList, diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 895a954e2e40..03102dd62b44 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -25,6 +25,27 @@ export type DeviceHostId = typeof DeviceHostId.Type; /** The server machine. Always present; other host kinds are future work. */ export const LOCAL_DEVICE_HOST_ID = "local" as DeviceHostId; +/** SSH aliases and key paths are resolved on the environment server. */ +export const SshDeviceHostConfig = Schema.Struct({ + id: DeviceHostId.check( + Schema.isPattern(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/), + Schema.makeFilter((id) => id !== "local" || "The local host id is reserved."), + ), + label: TrimmedNonEmptyString, + target: TrimmedNonEmptyString.check(Schema.isPattern(/^[^\s-][^\s]*$/)), + identityFile: Schema.optional(TrimmedNonEmptyString), + port: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), +}); +export type SshDeviceHostConfig = typeof SshDeviceHostConfig.Type; + +export const SshDeviceHostConfigs = Schema.Array(SshDeviceHostConfig).check( + Schema.makeFilter( + (hosts) => + new Set(hosts.map((host) => host.id)).size === hosts.length || + "Device host ids must be unique.", + ), +); + /** Simulator udid or adb serial (an AVD name while it is not running). */ export const DeviceId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)); export type DeviceId = typeof DeviceId.Type; @@ -55,7 +76,7 @@ export type DevicePlatformAvailability = typeof DevicePlatformAvailability.Type; export const DeviceHostSummary = Schema.Struct({ id: DeviceHostId, - kind: Schema.Literals(["local"]), + kind: Schema.Literals(["local", "ssh"]), label: TrimmedNonEmptyString, platforms: Schema.Array(DevicePlatformAvailability), hubInstalled: Schema.Boolean, @@ -424,6 +445,7 @@ export type DeviceError = typeof DeviceError.Type; // panel describe devices the same way. export const DeviceToolListResult = Schema.Struct({ + hostStatuses: DeviceServiceState.fields.hostStatuses, hosts: Schema.Array(DeviceHostSummary), devices: Schema.Array(DeviceSummary), /** Devices already open in this thread's Device panel. */ diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index a5d35bf2361d..5e11aeb28fa3 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -189,6 +189,8 @@ import { DeviceDetailInput, DeviceError, DeviceListInput, + SshDeviceHostConfig, + DeviceHostSummary, DeviceOpenInput, DeviceServiceState, DeviceSession, @@ -328,6 +330,7 @@ export const WS_METHODS = { // Device methods deviceConfigure: "device.configure", deviceList: "device.list", + deviceTestHost: "device.testHost", deviceOpen: "device.open", deviceClose: "device.close", deviceShutdown: "device.shutdown", @@ -1101,6 +1104,12 @@ const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(WS_METHODS.subscribeDiscov stream: true, }); +const WsDeviceTestHostRpc = Rpc.make(WS_METHODS.deviceTestHost, { + payload: SshDeviceHostConfig, + success: DeviceHostSummary, + error: Schema.Union([DeviceError, EnvironmentAuthorizationError]), +}); + const WsDeviceListRpc = Rpc.make(WS_METHODS.deviceList, { payload: DeviceListInput, success: DeviceServiceState, @@ -1380,6 +1389,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeDiscoveredLocalServersRpc, WsDeviceConfigureRpc, WsDeviceListRpc, + WsDeviceTestHostRpc, WsDeviceOpenRpc, WsDeviceCloseRpc, WsDeviceShutdownRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 8cbf0e55b9df..7322307c1f89 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -759,3 +759,16 @@ describe("ServerSettings environment icon", () => { expect(encodeServerSettings(linuxSettings).environmentIcon).toBe("linux"); }); }); + +const decodeDeviceHostSettings = Schema.decodeSync(ServerSettings); + +it("validates remote device hosts and rejects ambiguous host ids", () => { + const host = { id: "mini", label: "Mac mini", target: "user@mini", port: 2222 }; + expect(decodeDeviceHostSettings({ deviceHosts: [host] }).deviceHosts).toEqual([host]); + expect(() => decodeDeviceHostSettings({ deviceHosts: [host, host] })).toThrow(); + expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, id: "local" }] })).toThrow(); + expect(() => + decodeDeviceHostSettings({ deviceHosts: [{ ...host, target: "-oProxyCommand=bad" }] }), + ).toThrow(); + expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, port: 0 }] })).toThrow(); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 96516367612c..dd6136461fc1 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,3 +1,4 @@ +import { SshDeviceHostConfigs } from "./device.ts"; import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; @@ -983,6 +984,7 @@ export const ServerSettings = Schema.Struct({ enableDeviceSupport: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), /** Whether the server-local Device panel setup flow has been completed. */ deviceOnboardingCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + deviceHosts: SshDeviceHostConfigs.pipe(Schema.withDecodingDefault(Effect.succeed([]))), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -1258,6 +1260,7 @@ export const ServerSettingsPatch = Schema.Struct({ enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean), enableDeviceSupport: Schema.optionalKey(Schema.Boolean), deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean), + deviceHosts: Schema.optionalKey(SshDeviceHostConfigs), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index a5e428fcdaac..cc783fe64bf3 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -21,6 +21,16 @@ import { } from "./serverSettings.ts"; describe("serverSettings helpers", () => { + it("replaces SSH host lists when saving, editing, and removing hosts", () => { + const host = { id: "mini", label: "Mac mini", target: "mini" }; + const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { deviceHosts: [host] }); + expect(saved.deviceHosts).toEqual([host]); + const replacement = { ...host, target: "other-mini" }; + const edited = applyServerSettingsPatch(saved, { deviceHosts: [replacement] }); + expect(edited.deviceHosts).toEqual([replacement]); + expect(applyServerSettingsPatch(edited, { deviceHosts: [] }).deviceHosts).toEqual([]); + }); + it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => { const project = { id: ProjectId.make("project-actions"), scripts: [] }; const action = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c58c455b465..bebd36cec2e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -540,6 +540,9 @@ importers: '@t3tools/shared': specifier: workspace:* version: link:../../packages/shared + '@t3tools/ssh': + specifier: workspace:* + version: link:../../packages/ssh '@t3tools/tailscale': specifier: workspace:* version: link:../../packages/tailscale From dfa345b3697a9117364cc6730a1598d6c27096f2 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 10 Sep 2026 16:41:54 -0300 Subject: [PATCH 35/61] feat(web): use a compact right-panel surface menu (#11111) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/DiffPanelShell.tsx | 2 +- apps/web/src/components/RightPanelTabs.tsx | 96 +++++++++------------- apps/web/src/components/ui/toast.tsx | 2 +- apps/web/src/routes/__root.tsx | 8 +- 4 files changed, 47 insertions(+), 61 deletions(-) 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/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 79caadf90e06..c791a632b79e 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -171,7 +171,7 @@ 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.", @@ -304,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 @@ -337,7 +337,6 @@ function RightPanelEmptyState(props: { const actions = [ { label: "Browser", - description: "Open a local app or URL.", icon: Globe2, shortcut: "B", available: props.browserAvailable, @@ -347,7 +346,6 @@ function RightPanelEmptyState(props: { }, { label: "Terminal", - description: "Start a shell in this workspace.", icon: TerminalSquare, shortcut: "T", available: props.terminalAvailable, @@ -357,7 +355,6 @@ function RightPanelEmptyState(props: { }, { label: "Files", - description: "Browse and read workspace files.", icon: Files, shortcut: "F", available: props.filesAvailable, @@ -367,7 +364,6 @@ function RightPanelEmptyState(props: { }, { label: "Diff", - description: "Review changes in this thread.", icon: FileDiff, shortcut: "D", available: props.diffAvailable, @@ -377,7 +373,6 @@ function RightPanelEmptyState(props: { }, { label: "Pull request", - description: "Open this branch's pull request.", icon: GitPullRequest, shortcut: "P", available: props.pullRequestAvailable, @@ -387,7 +382,6 @@ function RightPanelEmptyState(props: { }, { label: "Linked pull requests", - description: "Every pull request this thread has linked, stacks included.", icon: GitPullRequestArrow, shortcut: "L", available: props.pullRequestsAvailable, @@ -397,7 +391,6 @@ function RightPanelEmptyState(props: { }, { label: "Agents", - description: "Follow subagents and workflows.", icon: Bot, shortcut: "A", available: props.agentsAvailable, @@ -464,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(); @@ -500,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. */} @@ -574,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} +
+ } + /> ), )}
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 }) { settings.glassOpacity); useEffect(() => { - document.documentElement.style.setProperty("--glass-opacity", `${glassOpacity}%`); + const style = document.documentElement.style; + style.setProperty("--glass-opacity", `${glassOpacity}%`); + if (glassOpacity === 100) { + style.setProperty("--glass-blur", "0px"); + } else { + style.removeProperty("--glass-blur"); + } }, [glassOpacity]); return null; From 32b690934039ea31c8bf8079aea8e9c088e97d9f Mon Sep 17 00:00:00 2001 From: Nick Anisimov Date: Fri, 11 Sep 2026 00:17:49 +0400 Subject: [PATCH 36/61] fix(mobile): keep Android markdown icons aligned (#11118) --- .../t3markdowntext/T3MarkdownTextSelectionModule.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) 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) } From 47dbb06c3b171c40ebdfbd5b38cd954127949bc0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 13:31:45 -0700 Subject: [PATCH 37/61] fix(mobile): add close controls to tablet files and terminal (#11115) --- .../files/thread-file-navigator-pane.tsx | 23 ++++++++++++++++++- .../terminal/ThreadTerminalRouteScreen.tsx | 21 ++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) 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" /> + + + { + 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. @@ -1143,7 +1156,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) navigation.goBack() : undefined} + onBack={handleCloseTerminal} trailing={ <> {layout.usesSplitView ? ( @@ -1179,6 +1192,12 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) {layout.usesSplitView ? ( + Date: Thu, 10 Sep 2026 13:32:09 -0700 Subject: [PATCH 38/61] fix(mobile): preserve the final composer animation frame (#11114) --- apps/mobile/package.json | 1 + .../components/ComposerAttachmentButton.tsx | 3 + patches/react-native-reanimated@4.5.1.patch | 69 +++++++++++++++++++ pnpm-lock.yaml | 13 ++-- pnpm-workspace.yaml | 2 + 5 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 patches/react-native-reanimated@4.5.1.patch 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/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/patches/react-native-reanimated@4.5.1.patch b/patches/react-native-reanimated@4.5.1.patch new file mode 100644 index 000000000000..6bf44826d4fd --- /dev/null +++ b/patches/react-native-reanimated@4.5.1.patch @@ -0,0 +1,69 @@ +diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h +index 86035915aa330d2011e7c3027ae315c689e40f58..3d43e949550e1bc1311c38d11a7920c237a41018 100644 +--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h ++++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h +@@ -22,6 +22,7 @@ struct LayoutAnimation { + Tag parentTag; + std::optional opacity; + bool isViewAlreadyMounted = false; ++ bool isExitingWhenSettled = false; + int count = 1; + LayoutAnimation &operator=(const LayoutAnimation &other) = default; + +diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp +index bc62f8fc31a1d3b08353df514bf4f954c6279ceb..0c16264538a13ae8b7bd4fd11e1227d8580c5c65 100644 +--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp ++++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.cpp +@@ -290,6 +290,7 @@ std::optional LayoutAnimationsProxy_Experimental::endLayoutAnimation( + if (--layoutAnimation.count > 0) { + return {}; + } ++ layoutAnimation.isExitingWhenSettled = shouldRemove; + maybeSettledAnimationTags_.insert(tag); + auto surfaceId = layoutAnimation.finalView.surfaceId; + +@@ -407,7 +408,8 @@ void LayoutAnimationsProxy_Experimental::addOngoingAnimations(SurfaceId surfaceI + + const auto layoutAnimationIt = layoutAnimations_.find(tag); + +- if (layoutAnimationIt == layoutAnimations_.end() || layoutAnimationIt->second.isSettled()) { ++ if (layoutAnimationIt == layoutAnimations_.end() || ++ (layoutAnimationIt->second.isSettled() && layoutAnimationIt->second.isExitingWhenSettled)) { + continue; + } + +@@ -554,6 +556,8 @@ void LayoutAnimationsProxy_Experimental::maybeCancelAnimation(const int tag) con + } + if (layoutAnimationIt->second.isSettled()) { + // Already settled - cleanupAnimations will erase it together with its updateMap entry. ++ // Do not flush a pending Update after the caller queues this view for removal. ++ layoutAnimationIt->second.isExitingWhenSettled = true; + return; + } + layoutAnimations_.erase(layoutAnimationIt); +diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp +index 9402e3e5ba4859ed344901b8b5884b9f708dd82b..2b1e4294ef798b7b6aabd04cc6663aae8297d034 100644 +--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp ++++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp +@@ -119,6 +119,7 @@ std::optional LayoutAnimationsProxy_Legacy::endLayoutAnimation(int ta + if (--layoutAnimation.count > 0) { + return {}; + } ++ layoutAnimation.isExitingWhenSettled = shouldRemove; + maybeSettledAnimationTags_.insert(tag); + auto surfaceId = layoutAnimation.finalView.surfaceId; + +@@ -414,12 +415,7 @@ void LayoutAnimationsProxy_Legacy::addOngoingAnimations(SurfaceId surfaceId, Sha + auto layoutAnimationIt = layoutAnimations_.find(tag); + + if (layoutAnimationIt == layoutAnimations_.end() || +- // A settled animation is normally cleaned up without applying further +- // updates. The exception is a flaky entering animation whose opacity was +- // never restored (the view wasn't mounted in time) - we still need to +- // apply that pending opacity, otherwise the view stays invisible. Only +- // entering animations carry an opacity value. +- (layoutAnimationIt->second.isSettled() && !layoutAnimationIt->second.opacity.has_value())) { ++ (layoutAnimationIt->second.isSettled() && layoutAnimationIt->second.isExitingWhenSettled)) { + continue; + } + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bebd36cec2e8..2cba4e1a8771 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,7 @@ patchedDependencies: react-native-gesture-handler@2.32.0: 96573c000f7fe56b5abfa13e2e5f0d065907e674cb8e2300155226d7c9874398 react-native-keyboard-controller@1.21.13: 6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 + react-native-reanimated@4.5.1: a23baea5d82cbf1254720110aef6671e39d57a425f8be446756f91ab806eb72c react-native-screens@4.26.2: 8156dd0f3407822404793cfdaa95639a36b62102f4507c981b8be83600bb382d uniwind@1.11.0: 17d92be2eec71bb6396b402e8d034968e54b28746876d7977cb3139655f42b90 @@ -429,7 +430,7 @@ importers: version: 0.2.2(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-keyboard-controller: specifier: 1.21.13 - version: 1.21.13(patch_hash=6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787)(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.21.13(patch_hash=6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787)(react-native-reanimated@4.5.1(patch_hash=a23baea5d82cbf1254720110aef6671e39d57a425f8be446756f91ab806eb72c)(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-markdown: specifier: ^0.5.0 version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -438,7 +439,7 @@ importers: version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-reanimated: specifier: 4.5.1 - version: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.5.1(patch_hash=a23baea5d82cbf1254720110aef6671e39d57a425f8be446756f91ab806eb72c)(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-safe-area-context: specifier: ~5.7.0 version: 5.7.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -20932,12 +20933,12 @@ snapshots: react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-keyboard-controller@1.21.13(patch_hash=6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787)(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): - dependencies: + ? react-native-keyboard-controller@1.21.13(patch_hash=6e4339347bc5bb3c9ea67d85ff5c814058b211c5750f247aba59d07869a2e787)(react-native-reanimated@4.5.1(patch_hash=a23baea5d82cbf1254720110aef6671e39d57a425f8be446756f91ab806eb72c)(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + : dependencies: react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-is-edge-to-edge: 1.3.1(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.5.1(patch_hash=a23baea5d82cbf1254720110aef6671e39d57a425f8be446756f91ab806eb72c)(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: @@ -20952,7 +20953,7 @@ snapshots: react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-reanimated@4.5.1(patch_hash=a23baea5d82cbf1254720110aef6671e39d57a425f8be446756f91ab806eb72c)(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ae279419d428..8a4bf972f82f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -179,6 +179,8 @@ patchedDependencies: react-native-gesture-handler@2.32.0: patches/react-native-gesture-handler@2.32.0.patch react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch + # Preserve the final layout frame. Backport of [#10171](https://github.com/software-mansion/react-native-reanimated/pull/10171). + react-native-reanimated@4.5.1: patches/react-native-reanimated@4.5.1.patch react-native-screens@4.26.2: patches/react-native-screens@4.26.2.patch uniwind@1.11.0: patches/uniwind@1.11.0.patch From 859304b7808ab9a4be87b1bddcd07c6485bf9c4f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 14:03:00 -0700 Subject: [PATCH 39/61] fix(mobile): keep composer transitions aligned (#11127) --- .../layout/AdaptiveWorkspaceLayout.tsx | 19 ++++++++-- .../layout/workspace-content-width.ts | 10 ++++++ .../layout/workspace-inspector-pane.tsx | 4 ++- .../src/features/threads/ThreadComposer.tsx | 36 +++++++++++++++---- .../features/threads/ThreadDetailScreen.tsx | 10 +++++- .../use-thread-settings-sheet-presentation.ts | 27 ++++++++------ 6 files changed, 84 insertions(+), 22 deletions(-) create mode 100644 apps/mobile/src/features/layout/workspace-content-width.ts 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/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 7e5de3f3b4e5..7ea20e49ce52 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -37,7 +37,7 @@ import { import Animated, { FadeIn, FadeOut, - LinearTransition, + type LayoutAnimationFunction, ReduceMotion, useAnimatedStyle, useSharedValue, @@ -153,10 +153,32 @@ export interface ThreadComposerProps { // running alongside that translate reads as jitter. Snapping the layout and // letting the keyboard-synced slide be the only motion looks native there. export const COMPOSER_TRANSITION_DURATION_MS = 220; +// Side panes already animate the dock's width. Nested horizontal layout +// transitions would leave the surface trailing its toolbar's new position. +// Keep the vertical pill/card morph while horizontal layout follows the dock. +const composerHeightTransition: LayoutAnimationFunction = (values) => { + "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" @@ -351,7 +373,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ); const isVoiceInputPresented = voicePresentation.statusLabel !== null; // An open draft stays visible; only a collapsed composer becomes a voice strip. - const isExpanded = isFocused || settingsSheetPresentation.isActive; + const isExpanded = isFocused || settingsSheetPresentation.keepsComposerExpanded; const showsCompactDictation = isVoiceInputPresented && !isExpanded; const isToolbarVisible = isExpanded || isVoiceInputPresented; const attachmentBlockReason = composerAttachmentUploadBlockReason({ @@ -407,11 +429,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const handleBlur = useCallback(() => { 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. diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 42bb4df1495a..55cd9e9a6c83 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -58,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"; @@ -647,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(() => { @@ -915,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 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, From 1a9336bcda98e75814c1985e9274d919e580dbc5 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:48:56 -0400 Subject: [PATCH 40/61] refactor(mobile): name shared markdown renderer without iOS suffixes (#11128) --- .../modules/t3-markdown-text/package.json | 2 +- ...nBlock.ios.tsx => NativeMarkdownBlock.tsx} | 2 +- ...s.tsx => NativeMarkdownSelectableText.tsx} | 0 .../src/SelectableMarkdownText.ios.tsx | 117 ------------------ .../src/SelectableMarkdownText.tsx | 108 +++++++++++++++- 5 files changed, 107 insertions(+), 122 deletions(-) rename apps/mobile/modules/t3-markdown-text/src/{NativeMarkdownBlock.ios.tsx => NativeMarkdownBlock.tsx} (99%) rename apps/mobile/modules/t3-markdown-text/src/{NativeMarkdownSelectableText.ios.tsx => NativeMarkdownSelectableText.tsx} (100%) delete mode 100644 apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx 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} + + ); + })} + + + + ); } From a5ac766596bdf6c7d6852ecda453e2a85a7b08d4 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 10 Sep 2026 19:50:41 -0300 Subject: [PATCH 41/61] fix(media): preserve playback during fullscreen transitions (#11113) --- apps/mobile/src/components/MediaVideoPlayer.tsx | 13 +++++++++++-- apps/web/src/components/media/MediaVideoPlayer.tsx | 10 +++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) 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/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]); From 39ca4171e95cc85c5e85bc9a5b94b02a4d06646a Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:10:56 +0000 Subject: [PATCH 42/61] fix(marketing): redirect /app to app.t3.codes (#11145) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- apps/marketing/vercel.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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, + }, + ], }; From 2afa02a280acb0db4868d36409ba9bf7eae286c1 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:34:57 +0000 Subject: [PATCH 43/61] chore(marketing): update to 300k users and 22k stars (#11146) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- apps/marketing/src/lib/site.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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; From c52b8d96e4b34201f19b5e5bb12c6b2a77bfaa9a Mon Sep 17 00:00:00 2001 From: Justin Nel Date: Fri, 11 Sep 2026 08:50:28 +0900 Subject: [PATCH 44/61] feat(command-palette): show environments in search results (#10722) --- .../components/CommandPalette.logic.test.ts | 119 ++++++++++++++++++ .../src/components/CommandPalette.logic.ts | 17 +++ apps/web/src/components/CommandPalette.tsx | 71 ++++++++++- .../src/components/ThreadCommandSubtitle.tsx | 7 ++ 4 files changed, 211 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index b82147b376c8..25896baf7555 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -3,6 +3,7 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools import type { Project, Thread } from "../types"; import { buildBrowseGroups, + buildCommandPaletteProjectMetadata, buildProjectActionItems, buildThreadActionItems, buildLinkedThreadActionItems, @@ -55,6 +56,124 @@ describe("linked pull request thread navigation", () => { }); }); +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; diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 95313ee940e6..57c1711de158 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,6 +1,7 @@ import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import type { CommandPaletteLinkedThreads } from "../commandPaletteBus"; import { + type EnvironmentId, type FilesystemBrowseEntry, type KeybindingCommand, THREAD_JUMP_KEYBINDING_COMMANDS, @@ -185,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; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index c54f919125d3..4a9877f87a02 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -126,6 +126,7 @@ import { ADDON_ICON_CLASS, browseInputEndPaddingClass, buildBrowseGroups, + buildCommandPaletteProjectMetadata, buildProjectActionItems, buildRootGroups, buildThreadActionItems, @@ -186,6 +187,38 @@ function projectFavicon(project: Project) { return ; } +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"; @@ -1079,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( @@ -1176,6 +1237,9 @@ function OpenCommandPaletteDialog(props: { ) : null} {projectLabel} + {props.environmentLabel ? ( + <> + + {props.environmentLabel} + + ) : null} ) : null} From 6a2d2466673c010a15cb530fa2a8f77b363e046d Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 10 Sep 2026 23:36:40 -0300 Subject: [PATCH 45/61] fix(pr): update labels and reviewers without redundant reloads (#11117) --- .../pullRequest/PullRequestLabelPicker.tsx | 9 +- .../pullRequest/PullRequestReviewerPicker.tsx | 9 +- .../pullRequest/PullRequestSummaryTab.tsx | 2 - .../src/state/pullRequests.test.ts | 242 ++++++++++++++++++ .../client-runtime/src/state/pullRequests.ts | 205 +++++++++++++-- 5 files changed, 427 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx b/apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx index f1e5fc6a1da4..fe9062f83e55 100644 --- a/apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx +++ b/apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx @@ -33,15 +33,12 @@ export function PullRequestLabelPicker({ environmentId, reference, allowed, - onChanged, }: { environmentId: EnvironmentId; reference: PullRequestRef; /** False where the host would refuse this account's change. Disabled with the reason rather * than hidden, like the reviewer control beside it. */ allowed: boolean; - /** The detail carries the labels, so it is re-read once the host has taken the change. */ - onChanged: () => void; }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); @@ -79,8 +76,6 @@ export function PullRequestLabelPicker({ }); return; } - onChanged(); - candidatesQuery.refresh(); }; return ( @@ -94,8 +89,8 @@ export function PullRequestLabelPicker({ query={query} onQueryChange={setQuery} searchLabel="Search labels" - isPending={candidatesQuery.isPending} - error={candidatesQuery.error} + isPending={candidatesQuery.isPending && candidatesQuery.data === null} + error={candidatesQuery.data === null ? candidatesQuery.error : null} candidates={candidates} emptyLabel="This repository has no labels." noMatchLabel="No label matches that." 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/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index fed932d457a5..9501d61a67e2 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -683,7 +683,6 @@ export function PullRequestSummaryTab({ environmentId={environmentId} reference={reference} allowed={detail.viewerPermissions.requestReviewers} - onRequested={onRefresh} /> ) : null} @@ -718,7 +717,6 @@ export function PullRequestSummaryTab({ environmentId={environmentId} reference={reference} allowed={detail.viewerPermissions.labels !== false} - onChanged={onRefresh} /> ) : null} diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index fdd109d6e8c4..6187cf2e7bd0 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -1,5 +1,6 @@ import { EnvironmentId, ProjectId, WS_METHODS, type PullRequestStack } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; +import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Latch from "effect/Latch"; import * as Layer from "effect/Layer"; @@ -26,6 +27,8 @@ import { import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts"; import { executeAtomQuery } from "./runtime.ts"; +class MutationRefused extends Data.TaggedError("MutationRefused") {} + const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), label: "Test environment", @@ -216,6 +219,245 @@ it.effect("refreshes pull request activity after a comment is updated", () => ), ); +it.effect("updates cached labels after successful edits without rereading the host", () => + Effect.scoped( + Effect.gen(function* () { + let detailReads = 0; + let candidateReads = 0; + let refuse = false; + let failDetail = false; + const existing = { name: "existing", color: "111111" }; + const addedLabel = { name: "new", color: "abcdef" }; + const detailRefreshStarted = yield* Latch.make(); + const releaseDetailRefresh = yield* Latch.make(); + const client = { + [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.never, + [WS_METHODS.pullRequestsDetail]: () => + Effect.gen(function* () { + detailReads++; + if (failDetail) { + yield* detailRefreshStarted.open; + yield* releaseDetailRefresh.await; + return yield* Effect.fail(new MutationRefused()); + } + return { title: "keep this title", labels: [existing] }; + }), + [WS_METHODS.pullRequestsLabelCandidates]: () => + Effect.sync(() => { + candidateReads++; + return { + candidates: [ + { ...existing, description: null, isApplied: true }, + { ...addedLabel, description: "description", isApplied: false }, + ], + truncated: false, + }; + }), + [WS_METHODS.pullRequestsSetLabels]: () => + refuse ? Effect.fail(new MutationRefused()) : Effect.void, + } as unknown as WsRpcProtocolClient; + const { atoms, registry } = yield* makeTestRuntime(client); + const target = { + environmentId: TARGET.environmentId, + input: { + projectId: ProjectId.make("project-1"), + repository: "acme/web", + number: 1, + host: "github.example.com", + }, + }; + const detail = atoms.detail(target); + const candidates = atoms.labelCandidates(target); + registry.mount(detail); + const unmountCandidates = registry.mount(candidates); + yield* AtomRegistry.getResult(registry, detail, { suspendOnWaiting: true }); + yield* AtomRegistry.getResult(registry, candidates, { suspendOnWaiting: true }); + + const added = yield* Effect.promise(() => + atoms.setLabels.run(registry, { + ...target, + input: { + host: target.input.host, + projectId: target.input.projectId, + repository: target.input.repository, + number: target.input.number, + labels: ["new"], + applied: true, + }, + }), + ); + expect(AsyncResult.isSuccess(added)).toBe(true); + expect(yield* AtomRegistry.getResult(registry, detail)).toEqual({ + title: "keep this title", + labels: [existing, addedLabel], + }); + unmountCandidates(); + registry.mount(atoms.labelCandidates(target)); + expect((yield* AtomRegistry.getResult(registry, candidates)).candidates[1]).toEqual({ + ...addedLabel, + description: "description", + isApplied: true, + }); + + for (const name of ["existing", "new"]) { + refuse = name === "new"; + const result = yield* Effect.promise(() => + atoms.setLabels.run(registry, { + ...target, + input: { ...target.input, labels: [name], applied: false }, + }), + ); + expect(result._tag).toBe(refuse ? "Failure" : "Success"); + expect((yield* AtomRegistry.getResult(registry, detail)).labels).toEqual([addedLabel]); + expect((yield* AtomRegistry.getResult(registry, candidates)).candidates).toMatchObject([ + { name: "existing", isApplied: false }, + { name: "new", isApplied: true }, + ]); + } + expect(detailReads).toBe(1); + expect(candidateReads).toBe(1); + + failDetail = true; + registry.refresh(detail); + yield* detailRefreshStarted.await; + expect(registry.get(detail).waiting).toBe(true); + expect(Option.getOrThrow(AsyncResult.value(registry.get(detail))).labels).toEqual([ + addedLabel, + ]); + yield* releaseDetailRefresh.open; + yield* Effect.exit(AtomRegistry.getResult(registry, detail, { suspendOnWaiting: true })); + expect(AsyncResult.isFailure(registry.get(detail))).toBe(true); + expect(Option.getOrThrow(AsyncResult.value(registry.get(detail))).labels).toEqual([ + addedLabel, + ]); + failDetail = false; + registry.refresh(detail); + expect( + (yield* AtomRegistry.getResult(registry, detail, { suspendOnWaiting: true })).labels, + ).toEqual([existing]); + expect(detailReads).toBe(3); + }), + ), +); + +it.effect("updates reviewer requests and enriched reviewers without rereading the host", () => + Effect.scoped( + Effect.gen(function* () { + let reads = 0; + let refuse = false; + const actor = { login: "reviewer", name: "Reviewer", avatarUrl: null }; + const hostActor = { ...actor, login: "Reviewer" }; + let hostRequested = false; + let reviewed = false; + let pauseActivity = false; + const activityStarted = yield* Latch.make(); + const client = { + [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.never, + [WS_METHODS.pullRequestsDetail]: () => + Effect.sync(() => { + reads++; + return { reviewers: hostRequested ? [hostActor] : [] }; + }), + [WS_METHODS.pullRequestsActivity]: () => + Effect.gen(function* () { + reads++; + if (pauseActivity) { + pauseActivity = false; + yield* activityStarted.open; + return yield* Effect.never; + } + return { + reviewers: hostRequested ? [hostActor] : [], + comments: reviewed ? [{ kind: "review-comment", author: hostActor }] : [], + }; + }), + [WS_METHODS.pullRequestsReviewerCandidates]: (input: { number: number }) => + input.number === 2 + ? Effect.never + : Effect.sync(() => { + reads++; + return { + candidates: [{ ...actor, id: "12", kind: "user", isRequested: false }], + truncated: false, + }; + }), + [WS_METHODS.pullRequestsRequestReviewers]: (input: { requested: boolean }) => + refuse + ? Effect.fail(new MutationRefused()) + : Effect.sync(() => { + hostRequested = input.requested; + }), + } as unknown as WsRpcProtocolClient; + const { atoms, registry } = yield* makeTestRuntime(client); + const target = { + environmentId: TARGET.environmentId, + input: { + projectId: ProjectId.make("project-1"), + repository: "acme/web", + number: 1, + host: "github.example.com", + }, + }; + const detail = atoms.detail(target); + const activity = atoms.activity(target); + const candidates = atoms.reviewerCandidates(target); + registry.mount(detail); + registry.mount(activity); + registry.mount(candidates); + yield* AtomRegistry.getResult(registry, detail, { suspendOnWaiting: true }); + yield* AtomRegistry.getResult(registry, activity, { suspendOnWaiting: true }); + yield* AtomRegistry.getResult(registry, candidates, { suspendOnWaiting: true }); + const request = (requested: boolean, reference = target) => + Effect.promise(() => + atoms.requestReviewers.run(registry, { + ...reference, + input: { ...reference.input, reviewers: [{ id: "12", kind: "user" }], requested }, + }), + ); + for (const operation of ["request", "refuse", "remove"]) { + refuse = operation === "refuse"; + expect((yield* request(operation === "request"))._tag).toBe(refuse ? "Failure" : "Success"); + const expected = operation === "remove" ? [] : [actor]; + expect((yield* AtomRegistry.getResult(registry, detail)).reviewers).toEqual(expected); + expect((yield* AtomRegistry.getResult(registry, activity)).reviewers).toEqual(expected); + expect((yield* AtomRegistry.getResult(registry, candidates)).candidates).toMatchObject([ + { isRequested: operation !== "remove" }, + ]); + } + expect(reads).toBe(3); + // A slow activity read started before the write must not hide the new request. + pauseActivity = true; + registry.refresh(activity); + yield* activityStarted.await; + expect(AsyncResult.isSuccess(yield* request(true))).toBe(true); + expect( + (yield* AtomRegistry.getResult(registry, activity, { suspendOnWaiting: true })).reviewers, + ).toEqual([hostActor]); + expect(reads).toBe(5); + expect(AsyncResult.isSuccess(yield* request(false))).toBe(true); + expect((yield* AtomRegistry.getResult(registry, activity)).reviewers).toEqual([]); + + reviewed = true; + yield* request(true); + registry.refresh(activity); + yield* AtomRegistry.getResult(registry, activity, { suspendOnWaiting: true }); + yield* request(false); + expect((yield* AtomRegistry.getResult(registry, activity)).reviewers).toEqual([hostActor]); + + // A caller without an open picker still needs authoritative reviewer identities. + const otherTarget = { ...target, input: { ...target.input, number: 2 } }; + const otherDetail = atoms.detail(otherTarget); + registry.mount(otherDetail); + yield* AtomRegistry.getResult(registry, otherDetail, { suspendOnWaiting: true }); + yield* request(true, otherTarget); + expect( + (yield* AtomRegistry.getResult(registry, otherDetail, { suspendOnWaiting: true })) + .reviewers, + ).toEqual([hostActor]); + }), + ), +); + it.effect("refreshes stack state after reopening and head SHAs after a turn", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index c9d23d02bf55..26a0c08c13ed 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -1,7 +1,10 @@ import { WS_METHODS, + type EnvironmentId, + type PullRequestActor, type PullRequestDetail, type PullRequestDiffInput, + type PullRequestRef, type PullRequestSummary, type VcsStatusResult, } from "@t3tools/contracts"; @@ -9,7 +12,7 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { Atom } from "effect/unstable/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { createAtomCommandScheduler, @@ -36,6 +39,52 @@ export class EnvironmentHttpConnectionNotReadyError extends Data.TaggedError( const LINKED_PULL_REQUEST_IDLE_TTL_MS = 5_000; +/** Keep confirmed edits on the same cached reference regardless of input property order. */ +function writableQueryFamily( + family: (target: { + readonly environmentId: EnvironmentId; + readonly input: PullRequestRef; + }) => Atom.Atom>, +) { + const writable = Atom.family((source: Atom.Atom>) => + Atom.writable( + (get) => { + const result = get(source); + if (result._tag === "Success" && !result.waiting) return result; + const previous = get.self>(); + const value = Option.flatMap(previous, AsyncResult.value); + if (Option.isNone(value)) return result; + return result._tag === "Failure" + ? AsyncResult.failureWithPrevious(result.cause, { previous, waiting: result.waiting }) + : AsyncResult.success(value.value, result); + }, + (context, value: AsyncResult.AsyncResult) => context.setSelf(value), + (refresh) => refresh(source), + ).pipe(Atom.setIdleTTL(5 * 60_000)), + ); + return ({ + environmentId, + input: { projectId, host, repository, number }, + }: Parameters[0]) => + writable( + family({ + environmentId, + input: { projectId, ...(host === undefined ? {} : { host }), repository, number }, + }), + ); +} + +/** Restart pre-mutation reads before patching so they cannot restore stale values. */ +function updateCached( + registry: AtomRegistry.AtomRegistry, + atom: Atom.Writable>, + update: (value: A) => A, + refresh = false, +) { + if (refresh || registry.get(atom).waiting) registry.refresh(atom); + registry.update(atom, AsyncResult.map(update)); +} + function createPullRequestRefreshAtomFamily( runtime: Atom.AtomRuntime, ) { @@ -92,7 +141,7 @@ export function pullRequestDetailToVcsStatus( /** * Reopening a PR within a minute reuses detail and activity. Explicit refreshes and * turn notifications still revalidate. Mutations run serially per environment: actions on the same - * pull request are order-sensitive, and the detail view refetches after each one. + * pull request are order-sensitive. Confirmed label and reviewer edits update cached state. */ export function createPullRequestEnvironmentAtoms( runtime: Atom.AtomRuntime, @@ -103,12 +152,36 @@ export function createPullRequestEnvironmentAtoms( mode: "serial", key: ({ environmentId }: { readonly environmentId: string }) => environmentId, } as const; - const activity = createEnvironmentRpcQueryAtomFamily(runtime, { - label: "environment-data:pull-requests:activity", - tag: WS_METHODS.pullRequestsActivity, - staleTimeMs: 60_000, - refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), - }); + const activity = writableQueryFamily( + createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:activity", + tag: WS_METHODS.pullRequestsActivity, + staleTimeMs: 60_000, + refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), + }), + ); + const detail = writableQueryFamily( + createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:detail", + tag: WS_METHODS.pullRequestsDetail, + staleTimeMs: 60_000, + refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), + }), + ); + const labelCandidates = writableQueryFamily( + createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:label-candidates", + tag: WS_METHODS.pullRequestsLabelCandidates, + staleTimeMs: 60_000, + }), + ); + const reviewerCandidates = writableQueryFamily( + createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:pull-requests:reviewer-candidates", + tag: WS_METHODS.pullRequestsReviewerCandidates, + staleTimeMs: 60_000, + }), + ); return { refreshes, linkedThreads: createEnvironmentRpcQueryAtomFamily(runtime, { @@ -137,12 +210,7 @@ export function createPullRequestEnvironmentAtoms( staleTimeMs: 60_000, refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }), - detail: createEnvironmentRpcQueryAtomFamily(runtime, { - label: "environment-data:pull-requests:detail", - tag: WS_METHODS.pullRequestsDetail, - staleTimeMs: 60_000, - refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), - }), + detail, activity, threadComments: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:thread-comments", @@ -241,28 +309,117 @@ export function createPullRequestEnvironmentAtoms( * for a minute, because who has access to a repository changes far more slowly than the * change request it is being read for. */ - reviewerCandidates: createEnvironmentRpcQueryAtomFamily(runtime, { - label: "environment-data:pull-requests:reviewer-candidates", - tag: WS_METHODS.pullRequestsReviewerCandidates, - staleTimeMs: 60_000, - }), + reviewerCandidates, requestReviewers: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:request-reviewers", tag: WS_METHODS.pullRequestsRequestReviewers, scheduler: commandScheduler, concurrency: serialPerEnvironment, + onSuccess: (target, registry) => + Effect.sync(() => { + const { reviewers, requested } = target.input; + const candidatesAtom = reviewerCandidates(target); + const candidates = Option.getOrNull(AsyncResult.value(registry.get(candidatesAtom))); + const selected = + candidates?.candidates.filter((candidate) => + reviewers.some( + (reviewer) => reviewer.id === candidate.id && reviewer.kind === candidate.kind, + ), + ) ?? []; + const missingIdentities = selected.length < reviewers.length; + updateCached(registry, candidatesAtom, (value) => ({ + ...value, + candidates: value.candidates.map((candidate) => + selected.includes(candidate) ? { ...candidate, isRequested: requested } : candidate, + ), + })); + const selectedLogins = new Set( + selected.map((candidate) => candidate.login.toLowerCase()), + ); + const updateReviewers = ( + actors: ReadonlyArray, + keep = (_actor: PullRequestActor) => false, + ) => + requested + ? [ + ...actors, + ...selected + .filter( + (candidate) => + !actors.some( + (actor) => actor.login.toLowerCase() === candidate.login.toLowerCase(), + ), + ) + .map(({ login, name, avatarUrl }) => ({ login, name, avatarUrl })), + ] + : actors.filter( + (actor) => !selectedLogins.has(actor.login.toLowerCase()) || keep(actor), + ); + updateCached( + registry, + detail(target), + (value) => ({ + ...value, + reviewers: updateReviewers(value.reviewers), + }), + missingIdentities, + ); + updateCached( + registry, + activity(target), + (value) => ({ + ...value, + reviewers: + value.reviewers === undefined + ? undefined + : updateReviewers(value.reviewers, (actor) => + value.comments.some( + (comment) => + (comment.kind === "review" || comment.kind === "review-comment") && + comment.author?.login.toLowerCase() === actor.login.toLowerCase(), + ), + ), + }), + missingIdentities, + ); + }), }), /** Read when the label menu opens, and kept for a minute, like the reviewer candidates. */ - labelCandidates: createEnvironmentRpcQueryAtomFamily(runtime, { - label: "environment-data:pull-requests:label-candidates", - tag: WS_METHODS.pullRequestsLabelCandidates, - staleTimeMs: 60_000, - }), + labelCandidates, setLabels: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:set-labels", tag: WS_METHODS.pullRequestsSetLabels, scheduler: commandScheduler, concurrency: serialPerEnvironment, + onSuccess: (target, registry) => + Effect.sync(() => { + const { labels, applied } = target.input; + const candidatesAtom = labelCandidates(target); + const candidates = Option.getOrNull(AsyncResult.value(registry.get(candidatesAtom))); + const names = new Set(labels); + updateCached(registry, candidatesAtom, (value) => ({ + ...value, + candidates: value.candidates.map((candidate) => + names.has(candidate.name) ? { ...candidate, isApplied: applied } : candidate, + ), + })); + updateCached(registry, detail(target), (value) => ({ + ...value, + labels: applied + ? [ + ...value.labels, + ...labels + .filter((name) => !value.labels.some((label) => label.name === name)) + .map((name) => ({ + name, + color: + candidates?.candidates.find((candidate) => candidate.name === name) + ?.color ?? null, + })), + ] + : value.labels.filter((label) => !names.has(label.name)), + })); + }), }), setThreadResolution: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:set-thread-resolution", From 27eb79dc719d70a99db495b0cb20bd6e3ac10b8d Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 10 Sep 2026 23:38:42 -0300 Subject: [PATCH 46/61] fix(chat): fold question answers into tool activity (#11014) --- .../threads/QuestionAnswerHistory.tsx | 13 +- .../src/features/threads/thread-work-log.tsx | 21 +- apps/mobile/src/lib/threadActivity.ts | 39 ++-- .../ActivityPayloadProjection.ts | 6 +- .../components/chat/MessagesTimeline.test.tsx | 14 +- .../src/components/chat/MessagesTimeline.tsx | 79 ++++++-- apps/web/src/session-logic.ts | 18 +- packages/client-runtime/package.json | 4 + .../client-runtime/src/work-log/userInput.ts | 186 ++++++++++++++++++ packages/shared/src/toolActivity.ts | 33 ++++ 10 files changed, 368 insertions(+), 45 deletions(-) create mode 100644 packages/client-runtime/src/work-log/userInput.ts diff --git a/apps/mobile/src/features/threads/QuestionAnswerHistory.tsx b/apps/mobile/src/features/threads/QuestionAnswerHistory.tsx index 2ff0a3b370f8..2a08ee65bb08 100644 --- a/apps/mobile/src/features/threads/QuestionAnswerHistory.tsx +++ b/apps/mobile/src/features/threads/QuestionAnswerHistory.tsx @@ -4,6 +4,7 @@ import type { UserInputAttachments, } from "@t3tools/contracts"; import { Image, Linking, Pressable, View } from "react-native"; +import { getQuestionAnswerText } from "@t3tools/client-runtime/work-log/user-input"; import { AppText as Text } from "../../components/AppText"; import { useAssetUrl } from "../../state/assets"; @@ -41,6 +42,7 @@ export function QuestionAnswerHistory(props: { {[ ...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) => ( {displayText} + {answerPreview ? ( + {` ${answerPreview}`} + ) : null} )} diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index c7d27af11d5e..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, @@ -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/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 98294e63b35c..43db2cfcc4cc 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -1,3 +1,4 @@ +import { projectQuestionToolInput } from "@t3tools/shared/toolActivity"; import type { OrchestrationEvent, OrchestrationThreadActivity, @@ -370,18 +371,19 @@ export function projectActivityPayload( payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined") ? { ...payload, status: itemStatus } : payload; + 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/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index c47402f4204b..07252d6ab7fb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -348,12 +348,24 @@ describe("MessagesTimeline", () => { }); const toggle = renderer!.root.findByProps({ "aria-expanded": false }); await act(() => toggle.props.onClick()); + const questionToggle = renderer!.root.find( + (node) => + node.props["aria-label"]?.startsWith("Question answer submitted:") && + node.props["aria-expanded"] === false, + ); + expect(questionToggle.props["aria-label"]).toContain( + Object.values(answers)[0] ?? "spec.txt", + ); + expect(JSON.stringify(renderer!.toJSON())).not.toContain("Provide a spec"); + await act(() => questionToggle.props.onClick()); const markup = JSON.stringify(renderer!.toJSON()); expect(markup.match(/Provide a spec/g)).toHaveLength(1); - expect(markup.match(/spec\.txt/g)).toHaveLength(1); + expect(markup).toContain("spec.txt"); expect(markup).toContain("Provide a screenshot"); expect(markup).toContain("shot.png"); for (const answer of Object.values(answers)) expect(markup).toContain(answer); + await act(() => questionToggle.props.onClick()); + expect(JSON.stringify(renderer!.toJSON())).not.toContain("Provide a spec"); } finally { await act(() => renderer?.unmount()); } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 3544e87817bd..13c1e1dc61b3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,4 +1,9 @@ import { GitPullRequestIcon } from "lucide-react"; +import { + getQuestionAnswerPreview, + getQuestionAnswerText, + hasQuestionAnswer, +} from "@t3tools/client-runtime/work-log/user-input"; import { type AssistantCitation, type EnvironmentId, @@ -2115,7 +2120,7 @@ function LiveActivityRow({ active = false, shimmer = false, }: { - label: string; + label: ReactNode; iconName?: WorkEntryIconName; toolIcon?: ToolActivityIcon | undefined; failed?: boolean; @@ -2155,7 +2160,7 @@ function LiveActivityContent({ active = false, highlighted = false, }: { - label: string; + label: ReactNode; iconName: WorkEntryIconName | undefined; toolIcon?: ToolActivityIcon | undefined; failed?: boolean; @@ -2213,7 +2218,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} @@ -3148,6 +3171,7 @@ const toolCallExpandedBodyClassName = function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { if ( + workEntry.questionAnswer || workEntry.sourceActivityKind === "user-input.requested" || workEntry.sourceActivityKind === "user-input.resolved" ) { @@ -3325,9 +3349,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 @@ -3337,6 +3362,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { }) : null; const canExpand = + Boolean(workEntry.questionAnswer) || (showFailedIndicator && previewText.trim().length > 0) || (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) || Boolean( @@ -3374,9 +3400,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, @@ -3421,7 +3448,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {

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

{showFailedIndicator && @@ -3470,10 +3511,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/session-logic.ts b/apps/web/src/session-logic.ts index f84c978a04c8..0ffcc7fdd7c6 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -3,6 +3,7 @@ import { type PendingApproval, } from "@t3tools/client-runtime/pending-requests"; import { UserInputAttachmentAnswerPayload } from "@t3tools/contracts"; +import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Arr from "effect/Array"; @@ -451,7 +452,7 @@ export function deriveWorkLogEntries( ): WorkLogEntry[] { const ordered = [...activities].toSorted(compareActivitiesByOrder); 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; // Agent task.started rows are CTA seeds: they carry the true spawn turn, @@ -1622,14 +1623,25 @@ export function deriveTimelineEntriesWithState( const entries = replaceStreamingTimelineMessages(messages, previous); if (entries !== null) return { messages, proposedPlans, workEntries, entries }; } + const foldedAnswerMessageIds = new Set( + workEntries.flatMap((entry) => + entry.questionAnswer ? [`async-answer:${entry.questionAnswer.requestId}`] : [], + ), + ); + const showMessage = (message: ChatMessage) => + message.role !== "user" || !foldedAnswerMessageIds.has(message.id); const canAppend = previous !== null && + !previous.entries.some((entry) => entry.kind === "message" && !showMessage(entry.message)) && hasExactArrayPrefix(previous.messages, messages) && hasExactArrayPrefix(previous.proposedPlans, proposedPlans) && hasExactArrayPrefix(previous.workEntries, workEntries); if (canAppend) { - const messageRows = messages.slice(previous.messages.length).map(timelineEntryFromMessage); + const messageRows = messages + .slice(previous.messages.length) + .filter(showMessage) + .map(timelineEntryFromMessage); const proposedPlanRows = proposedPlans .slice(previous.proposedPlans.length) .map(timelineEntryFromProposedPlan); @@ -1645,7 +1657,7 @@ export function deriveTimelineEntriesWithState( }; } - const messageRows = messages.map(timelineEntryFromMessage); + const messageRows = messages.filter(showMessage).map(timelineEntryFromMessage); const proposedPlanRows = proposedPlans.map(timelineEntryFromProposedPlan); const workRows = workEntries.map(timelineEntryFromWork); return { diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d0bf4aad8e9e..2a5eb2c7fe93 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -223,6 +223,10 @@ "types": "./src/state/vcs.ts", "default": "./src/state/vcs.ts" }, + "./work-log/user-input": { + "types": "./src/work-log/userInput.ts", + "default": "./src/work-log/userInput.ts" + }, "./work-log/presentation": { "types": "./src/work-log/presentation.ts", "default": "./src/work-log/presentation.ts" diff --git a/packages/client-runtime/src/work-log/userInput.ts b/packages/client-runtime/src/work-log/userInput.ts new file mode 100644 index 000000000000..355083c04836 --- /dev/null +++ b/packages/client-runtime/src/work-log/userInput.ts @@ -0,0 +1,186 @@ +import { projectQuestionToolInput } from "@t3tools/shared/toolActivity"; +import { + type OrchestrationThreadActivity, + UserInputAttachmentAnswerPayload, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +const isQuestionAnswer = Schema.is(UserInputAttachmentAnswerPayload); + +function displayOptionAnswer(value: unknown, labels: ReadonlyMap): unknown { + if (typeof value === "string") return labels.get(value) ?? value; + if (Array.isArray(value)) return value.map((answer) => displayOptionAnswer(answer, labels)); + const nested = record(value); + return nested && "answers" in nested + ? { ...nested, answers: displayOptionAnswer(nested.answers, labels) } + : value; +} + +function questionFingerprint( + turnId: string, + questions: ReadonlyArray, +): string | undefined { + const texts = questions.map((question) => (typeof question === "string" ? question.trim() : "")); + return texts.length > 0 && texts.every(Boolean) + ? JSON.stringify([turnId, texts.toSorted()]) + : undefined; +} + +function withoutDuplicateQuestionTools( + activities: ReadonlyArray, +): ReadonlyArray { + const questions = new Set(); + for (const activity of activities) { + if (activity.kind !== "user-input.answer-submitted" || !activity.turnId) continue; + const payload = record(activity.payload); + const texts = Object.values(record(payload?.questionTextById) ?? {}); + const fingerprint = questionFingerprint(activity.turnId, texts); + if (fingerprint) questions.add(fingerprint); + } + if (questions.size === 0) return activities; + const duplicateToolIds = new Set(); + for (const activity of activities) { + if (!activity.kind.startsWith("tool.") || !activity.turnId) continue; + const payload = record(activity.payload); + if (typeof payload?.toolCallId !== "string") continue; + const input = projectQuestionToolInput(record(payload.data) ?? {}, payload.title).input; + if (!input) continue; + const fingerprint = questionFingerprint( + activity.turnId, + input.questions.map((question) => record(question)?.question), + ); + if (fingerprint && questions.has(fingerprint)) { + duplicateToolIds.add(JSON.stringify([activity.turnId, payload.toolCallId])); + } + } + return activities.filter((activity) => { + const payload = record(activity.payload); + const toolCallId = payload?.toolCallId; + return ( + activity.tone === "error" || + /^(failed|declined|stopped|cancelled)$/.test(String(payload?.status)) || + !activity.kind.startsWith("tool.") || + typeof toolCallId !== "string" || + !duplicateToolIds.has(JSON.stringify([activity.turnId, toolCallId])) + ); + }); +} + +/** Keep a question and its answer at the original tool position in the work log. */ +export function foldUserInputActivities( + activities: ReadonlyArray, +): ReadonlyArray { + const requests = new Map(); + for (const activity of activities) { + if ( + activity.kind !== "user-input.requested" && + activity.kind !== "user-input.resolved" && + activity.kind !== "user-input.answer-submitted" + ) + continue; + const requestId = record(activity.payload)?.requestId; + if (typeof requestId !== "string" || !requestId) continue; + const group = requests.get(requestId) ?? []; + group.push(activity); + requests.set(requestId, group); + } + const replacements = new Map(); + for (const [requestId, group] of requests) { + const payloads = group.map((activity) => record(activity.payload)!); + const questions = new Map>(); + const texts = new Map(); + for (const payload of payloads) { + for (const [id, text] of Object.entries(record(payload.questionTextById) ?? {})) + texts.set(id, text); + for (const value of Array.isArray(payload.questions) ? payload.questions : []) { + const question = record(value); + if (typeof question?.id !== "string") continue; + questions.set(question.id, question); + if (typeof question.question === "string") texts.set(question.id, question.question); + } + } + const questionTextById = Object.fromEntries(texts); + const submitted = group.findLast( + (activity) => + activity.kind === "user-input.answer-submitted" && + record(record(activity.payload)?.answers), + ); + const rawAnswers = + record(record(submitted?.payload)?.answers) ?? + payloads.map((payload) => record(payload.answers)).findLast(Boolean) ?? + {}; + const answers = Object.fromEntries( + Object.entries(rawAnswers).map(([id, value]) => { + const options = questions.get(id)?.options; + const labels = new Map(); + for (const candidate of Array.isArray(options) ? options : []) { + const option = record(candidate); + if (typeof option?.value === "string" && typeof option.label === "string") + labels.set(option.value, option.label); + } + return [id, displayOptionAnswer(value, labels)]; + }), + ); + const attachmentsByQuestionId = Object.fromEntries( + payloads.flatMap((payload) => Object.entries(record(payload.attachmentsByQuestionId) ?? {})), + ); + const answer = { requestId, questionTextById, answers, attachmentsByQuestionId }; + if (!isQuestionAnswer(answer)) continue; + const submittedAnswer = + Object.keys(answers).length > 0 || Object.keys(attachmentsByQuestionId).length > 0; + for (const activity of group) replacements.set(activity, null); + replacements.set(group[0]!, { + ...group[0]!, + kind: "user-input.answer-submitted", + tone: "tool", + summary: submittedAnswer + ? "User input submitted" + : group.some((activity) => activity.kind === "user-input.resolved") + ? "User input dismissed" + : "User input requested", + payload: answer, + }); + } + return withoutDuplicateQuestionTools( + activities.flatMap((activity) => { + const replacement = replacements.get(activity); + return replacement === null ? [] : [replacement ?? activity]; + }), + ); +} + +export function getQuestionAnswerText(value: unknown): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) return value.map(getQuestionAnswerText).filter(Boolean).join(", "); + const nested = record(value); + return nested ? getQuestionAnswerText(nested.answers) : ""; +} + +export function getQuestionAnswerPreview(answer: UserInputAttachmentAnswerPayload): string { + const answers = Object.values(answer.answers).map(getQuestionAnswerText).filter(Boolean); + const attachments = Object.values(answer.attachmentsByQuestionId) + .flat() + .map((attachment) => attachment.name); + return ( + answers.length > 0 + ? answers.join(" · ") + : attachments.length > 0 + ? attachments.join(", ") + : Object.values(answer.questionTextById ?? {}).join(" · ") + ) + .replace(/\s+/g, " ") + .trim(); +} + +export function hasQuestionAnswer(answer: UserInputAttachmentAnswerPayload): boolean { + return ( + Object.values(answer.answers).some(getQuestionAnswerText) || + Object.values(answer.attachmentsByQuestionId).some((attachments) => attachments.length > 0) + ); +} diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index 2fd04e76696f..8c1c65565f51 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -259,3 +259,36 @@ export function deriveToolActivityPresentation( summary: title ?? fallbackSummary, }; } + +export function projectQuestionToolInput(data: Record, title: unknown) { + const item = asRecord(data.item); + const toolName = data.toolName ?? data.tool ?? item?.tool ?? title; + if (typeof toolName !== "string") return {}; + const name = toolName + .split(/__|[./]/) + .at(-1) + ?.replace(/[_\s]/g, "") + .toLowerCase(); + if (!name || !/^(askuserquestion|requestuserinput(?:async)?|askquestion|question)$/.test(name)) + return {}; + const input = asRecord( + data.input ?? data.rawInput ?? asRecord(data.state)?.input ?? item?.arguments, + ); + const questions = input?.questions ?? asRecord(input?.params)?.questions; + if (!Array.isArray(questions)) return {}; + // Clients match native tools to the canonical question; choices and answers + // already live on the user-input activities and need not cross the wire twice. + return { + toolName, + input: { + questions: questions.map((value) => { + const question = asRecord(value); + return { + question: asTrimmedString( + question?.question ?? question?.question_text ?? question?.prompt ?? question?.title, + ), + }; + }), + }, + }; +} From 48654c1182c41dbd5e99ef0d6620a13ba2400f26 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 10 Sep 2026 23:39:21 -0300 Subject: [PATCH 47/61] fix(usage): flag unpriced model activity instead of showing $0.00 (#11021) Co-authored-by: Claude Opus 5 (1M context) --- .../src/features/usage/UsageRouteScreen.tsx | 9 +++-- .../src/components/usage/UsagePage.test.tsx | 24 +++++++++++++ apps/web/src/components/usage/UsagePage.tsx | 19 ++++++++--- packages/shared/src/usageMerge.test.ts | 34 ++++++++++++++++++- packages/shared/src/usageMerge.ts | 24 ++++++++++++- 5 files changed, 101 insertions(+), 9 deletions(-) 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/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index e41843e6d9cc..0743b91f6edf 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -87,6 +87,7 @@ const modelTotals = Object.freeze([ costUsd: 10, totalTokens: 100, records: 1, + unpricedRecords: 0, costShare: 10 / 16, }, { @@ -95,6 +96,7 @@ const modelTotals = Object.freeze([ costUsd: 5, totalTokens: 1_000, records: 1, + unpricedRecords: 0, costShare: 5 / 16, }, { @@ -103,8 +105,18 @@ const modelTotals = Object.freeze([ costUsd: 1, totalTokens: 1_000, records: 1, + unpricedRecords: 0, costShare: 1 / 16, }, + { + model: "unpriced-model", + provider: "codex" as const, + costUsd: 0, + totalTokens: 500, + records: 2, + unpricedRecords: 2, + costShare: 0, + }, ]); const environments = [ @@ -190,6 +202,17 @@ describe("UsagePage model breakdown", () => { 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/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 6c706395c6ff..24bbc3b7c2e8 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -8,7 +8,7 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; +import { isModelCostUnknown, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; function bucket(overrides: Partial = {}): UsageBucket { return { @@ -221,6 +221,38 @@ describe("mergeUsage", () => { expect(merged.costQuality.cacheSavingsUsd).toBe(4); }); + it("marks a model with no known rates as unpriced rather than free", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ costUsd: 75 }), + bucket({ + provider: "codex", + model: "unknown-model", + costUsd: 0, + costSource: "unpriced", + unpricedRecords: 5, + }), + ], + [ + { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, + { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.models.find((model) => model.model === "unknown-model")?.unpricedRecords).toBe(5); + expect(merged.models.filter(isModelCostUnknown).map((model) => model.model)).toEqual([ + "unknown-model", + ]); + }); + it("keeps two machines apart when hostname and home path collide", () => { // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make // one machine's usage vanish. Filesystem identity separates them. diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 95982bf507da..e0cb0510eb71 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -37,9 +37,22 @@ export interface ModelTotals { readonly costUsd: number; readonly totalTokens: number; readonly records: number; + /** + * Records whose tokens are counted here but which contributed nothing to + * `costUsd`. When it equals `records` the cost is unknown, not zero. + */ + readonly unpricedRecords: number; readonly costShare: number; } +/** + * A model whose every record lacked rates has an unknown cost, not a zero one. + * Clients must not present its `costUsd` as a real dollar figure. + */ +export function isModelCostUnknown(model: ModelTotals): boolean { + return model.records > 0 && model.unpricedRecords >= model.records; +} + export interface DailyTotals { readonly day: string; readonly costUsd: number; @@ -249,7 +262,13 @@ export function mergeUsage( >(); const modelAccumulator = new Map< string, - { provider: UsageProviderKind; costUsd: number; totalTokens: number; records: number } + { + provider: UsageProviderKind; + costUsd: number; + totalTokens: number; + records: number; + unpricedRecords: number; + } >(); const dailyAccumulator = new Map< string, @@ -319,10 +338,12 @@ export function mergeUsage( costUsd: 0, totalTokens: 0, records: 0, + unpricedRecords: 0, }; model.costUsd += bucket.costUsd; model.totalTokens += tokens; model.records += bucket.records; + model.unpricedRecords += bucket.unpricedRecords; modelAccumulator.set(modelKey, model); const day = dailyAccumulator.get(bucket.day) ?? { @@ -381,6 +402,7 @@ export function mergeUsage( costUsd: totals.costUsd, totalTokens: totals.totalTokens, records: totals.records, + unpricedRecords: totals.unpricedRecords, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, })) .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); From 5735693d4acb949c2b0daf1219b4f62845115435 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 10 Sep 2026 23:39:31 -0300 Subject: [PATCH 48/61] fix(server): let Claude launch args override the derived permission mode (#11026) Co-authored-by: Claude Opus 5 (1M context) --- .../src/provider/Layers/ClaudeAdapter.test.ts | 23 +++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 15 ++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 5422a8730f27..c4136156df10 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -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 4af5a0633654..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 } : {}), From 20ef25037f455a570426ac2a93fcf499d1f91167 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 10 Sep 2026 23:39:41 -0300 Subject: [PATCH 49/61] fix(editors): accept root paths and Windows servers in Zed remote links (#11044) Co-authored-by: Claude Fable 5.1 --- apps/desktop/src/electron/ElectronShell.test.ts | 12 +++++++++--- apps/desktop/src/electron/ElectronShell.ts | 2 +- apps/web/src/remoteOpen.test.ts | 9 +++++++++ packages/contracts/src/editor.ts | 17 ++++++++++++----- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 75eea216df21..8ba8082f0e57 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -93,10 +93,16 @@ describe("ElectronShell", () => { openExternalMock.mockResolvedValue(undefined); const electronShell = yield* ElectronShell.ElectronShell; - const result = yield* electronShell.openExternal("zed://ssh/example.com/home/user/project"); + const results = yield* Effect.all([ + electronShell.openExternal("zed://ssh/example.com/home/user/project"), + electronShell.openExternal("zed://ssh/example.com/"), + ]); - assert.equal(result, true); - assert.deepEqual(openExternalMock.mock.calls, [["zed://ssh/example.com/home/user/project"]]); + 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)), ); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 2089be58c0dc..aa97c018bd21 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -36,7 +36,7 @@ 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 ZED_SSH_PATHNAME = /^\/[^/@:]+\/.*$/; const isRemoteEditorUrl = (url: URL) => REMOTE_EDITOR_PROTOCOLS.has(url.protocol) && diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index 6f7c17d8177a..0f636a526cef 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -151,6 +151,15 @@ describe("buildRemoteOpenUrl", () => { ).toBe("zed://ssh/sol.tail1234.ts.net/home/theo/code/my%20repo"); }); + it("drops the Windows drive letter for Zed", () => { + expect( + buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "C:\\Users\\theo" }), + ).toBe("zed://ssh/sol/Users/theo"); + expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/C:/project" })).toBe( + "zed://ssh/sol/C%3A/project", + ); + }); + it("returns undefined for editors without remote support", () => { expect(buildRemoteOpenUrl({ editor: "idea", host: "sol", absolutePath: "/tmp/x" })).toBe( undefined, diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 6331a544d341..0680a398879e 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -91,7 +91,7 @@ export type LaunchEditorInput = typeof LaunchEditorInput.Type; const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme; -/** Editors that can open a remote workspace via `vscode-remote` deep links. */ +/** Editors that can open a remote workspace via an SSH deep link. */ export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray = EDITORS.flatMap((editor) => remoteSchemeOf(editor) !== undefined ? [editor.id] : [], ); @@ -119,11 +119,18 @@ export const buildRemoteOpenUrl = (input: { // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs. const posixPath = input.absolutePath.replaceAll("\\", "/"); const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; - const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); const encodedHost = encodeURIComponent(input.host); - return input.editor === "zed" - ? `${scheme}://ssh/${encodedHost}${encodedPath}` - : `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}`; + if (input.editor === "zed") { + // Zed's remote server resolves a rooted path on the system drive, so a + // Windows `C:\Users\x` must become `/Users/x` (verified in #8938). Other + // drives are untested and kept as is rather than silently remapped, and a + // POSIX path that happens to start with `/C:` is left alone. + const zedPath = /^[Cc]:[\\/]/.test(input.absolutePath) ? rootedPath.slice(3) : rootedPath; + const encodedZedPath = zedPath.split("/").map(encodeURIComponent).join("/"); + return `${scheme}://ssh/${encodedHost}${encodedZedPath}`; + } + const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); + return `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}`; }; /** From 4d06156dd7821851007e61489105f33dbb241a7f Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 10 Sep 2026 23:59:10 -0300 Subject: [PATCH 50/61] fix(web): center pull request unavailable states (#11110) --- .../components/pullRequest/PullRequestDetailPanel.tsx | 10 ++++++++-- .../pullRequest/PullRequestsUnavailableState.tsx | 2 +- apps/web/src/routes/_chat.pull-requests.tsx | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 236d7cd86ad9..5b86ef5ee3ba 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1447,7 +1447,13 @@ export function PullRequestDetailPanel({ onPickerOpenChange={setThreadPickerOpen} /> ) : null} -
+
{ const scroller = event.target as HTMLElement; scrollerRef.current = scroller; 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/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 24dcab132b86..5a21a2cc37ff 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -2362,7 +2362,7 @@ function PullRequestsColumn({ {/* The top padding is the shared fade band's height, the same pairing the settings page makes: at rest the controls sit fully below the mask, and only content actually passing under the chrome fades. */} - +
From 0a37240a87bb2fed8f48cf20dc8312ee3b94eba6 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 11 Sep 2026 00:13:23 -0300 Subject: [PATCH 51/61] fix(web): remove sidebar pull request link icon (#11179) --- apps/web/src/components/LegacySidebar.tsx | 12 +---- apps/web/src/components/Sidebar.tsx | 21 +------- .../LinkBranchPullRequestButton.tsx | 54 ------------------- 3 files changed, 2 insertions(+), 85 deletions(-) delete mode 100644 apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 3a3936623275..81fd6047f13e 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1,10 +1,6 @@ import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { GitPullRequestIcon } from "lucide-react"; -import { LinkBranchPullRequestButton } from "./pullRequest/LinkBranchPullRequestButton"; -import { - resolveThreadCurrentPullRequestLink, - visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { Spinner } from "~/components/ui/spinner"; import { ArchiveIcon, @@ -764,12 +760,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : null} - {pr && - (supportsMultiplePullRequests - ? visibleThreadPullRequests(thread.pullRequests).length === 0 - : thread.linkedPullRequest == null) ? ( - - ) : null} {threadStatus && } {renamingThreadKey === threadKey ? ( - ) : null} {sortable?.isDragging ? ( dragDestination ) : ( @@ -1925,13 +1913,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )} {terminalStatusIcon} {prBadge} - {prBadge && - pr && - (supportsMultiplePullRequests - ? visibleThreadPullRequests(thread.pullRequests).length === 0 - : thread.linkedPullRequest == null) ? ( - - ) : null} {diff ? ( +{diff.insertions}{" "} diff --git a/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx b/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx deleted file mode 100644 index bbcb9e144190..000000000000 --- a/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { ScopedThreadRef } from "@t3tools/contracts"; -import { Link2 } from "lucide-react"; -import { useState } from "react"; -import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; -import { Button } from "../ui/button"; -import { toastManager } from "../ui/toast"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; - -/** Adopts a branch discovery as a durable link, even after the thread changes branches. */ -export function LinkBranchPullRequestButton({ - threadRef, - url, -}: { - threadRef: ScopedThreadRef; - url: string; -}) { - const linking = usePullRequestLinking(threadRef.environmentId); - const [pending, setPending] = useState(false); - if (!linking.canLink(url)) return null; - return ( - - event.stopPropagation()} - onClick={async (event) => { - event.preventDefault(); - event.stopPropagation(); - setPending(true); - try { - await linking.changeLink(threadRef, url, true); - } catch (error) { - toastManager.add({ - type: "error", - title: "Could not link pull request", - description: error instanceof Error ? error.message : String(error), - }); - } finally { - setPending(false); - } - }} - > - - - } - /> - Link this PR to keep it with this thread - - ); -} From 02297e3dbd896ef619d5c916938c751e343e76a8 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 11 Sep 2026 00:58:57 -0300 Subject: [PATCH 52/61] fix(ui): color linked pr counts by aggregate status (#11180) --- .../features/threads/thread-list-items.tsx | 10 +++-- .../features/threads/thread-list-v2-items.tsx | 8 +++- .../src/state/thread-pr-presentation.ts | 23 ++++++++--- apps/mobile/src/state/use-thread-pr.test.ts | 34 ++++++++++++++- .../src/components/ThreadStatusIndicators.tsx | 13 +++--- .../shared/src/threadPullRequests.test.ts | 41 ++++++++++++++++++- packages/shared/src/threadPullRequests.ts | 22 ++++++---- 7 files changed, 123 insertions(+), 28 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index a4da32ef7018..cad6181e9623 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -44,11 +44,11 @@ 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"; - if (pr.others > 0 || (pr.state === "open" && pr.isDraft === true)) { + if (pr.state === "open" && pr.isDraft === true) { return dark ? "#a1a1aa" : "#71717a"; } switch (pr.state) { @@ -56,8 +56,12 @@ function pullRequestTintColor( return dark ? "#34d399" : "#059669"; case "merged": return dark ? "#a78bfa" : "#7c3aed"; - case null: case "closed": + if (pr.kind === "stack" || pr.others > 0) { + return dark ? "#fb7185" : "#e11d48"; + } + return dark ? "#a1a1aa" : "#71717a"; + case null: return dark ? "#a1a1aa" : "#71717a"; } } diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 937ca5f909a8..a234147471c6 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -880,7 +880,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ? materialYouStyleLayoutActive ? "accent-thread-selected-foreground" : "accent-user-bubble-foreground" - : "accent-foreground-muted" + : pr.state === null || pr.isDraft + ? "accent-foreground-muted" + : pr.state === "open" + ? "accent-adaptive-emerald-600-400" + : pr.state === "closed" + ? "accent-adaptive-rose-600-400" + : "accent-adaptive-violet-600-400" } /> ) : null} diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index fc310d070acc..21234b7ff150 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -63,9 +63,16 @@ export function presentThreadLinkedPullRequests( const badge = resolveThreadPullRequestBadge(links); if (link === null || badge === null) return null; const snapshot = link.snapshot; - const state = badge.kind === "stack" ? badge.state : (snapshot?.state ?? null); - const isDraft = snapshot?.isDraft === true && state === "open"; 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) @@ -83,12 +90,16 @@ export function presentThreadLinkedPullRequests( label, accessibilityLabel: badge.kind === "stack" - ? `${badge.layers} pull requests in stack, ${state ?? "status pending"}` - : `#${link.number} pull request ${state === null ? "status pending" : isDraft ? "draft" : state}${badge.others > 0 ? `, ${badge.others} more linked` : ""}`, + ? `${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: - linkedCount !== null || state === null || isDraft + state === null || isDraft ? "text-foreground-muted" - : PR_STATE_TEXT_CLASS[state], + : isMultiple && state === "closed" + ? "text-adaptive-rose-600-400" + : PR_STATE_TEXT_CLASS[state], }; } diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index e36bd7d81434..75ec6743ac59 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -89,10 +89,42 @@ describe("presentThreadLinkedPullRequests", () => { kind: "pull-request", label: "+2", others: 1, - textClassName: "text-foreground-muted", + 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); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index e948aa4091ee..2a69eaeddc9d 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -160,7 +160,7 @@ export function ThreadPullRequestBadgeControl({ ? `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` + ? `, and ${badge.others} more linked; overall ${badge.state}` : "" }`; const className = cn( @@ -170,11 +170,9 @@ export function ThreadPullRequestBadgeControl({ "text-xs tabular-nums", variant === "ghost" && "font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]", - linkedCount !== null - ? "text-secondary-label" - : isStack - ? PR_STATE_COLOR_CLASS[badge.state] - : (status?.colorClass ?? "text-muted-foreground"), + badge !== null && (isStack || linkedCount !== null) + ? PR_STATE_COLOR_CLASS[badge.state] + : (status?.colorClass ?? "text-muted-foreground"), ); const content = ( <> @@ -276,10 +274,11 @@ export function ThreadPullRequestsMiniList({ } /** The ink each pull-request state wears in the sidebar, shared by the number and stack badges. */ -const PR_STATE_COLOR_CLASS: Record["state"], string> = { +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( diff --git a/packages/shared/src/threadPullRequests.test.ts b/packages/shared/src/threadPullRequests.test.ts index 7369b526dad3..05fc7b12b021 100644 --- a/packages/shared/src/threadPullRequests.test.ts +++ b/packages/shared/src/threadPullRequests.test.ts @@ -248,6 +248,40 @@ describe("resolveThreadPullRequestChains", () => { }); describe("chain selection and badge state", () => { + it.each([ + ["open", false, "open", false, "open"], + ["closed", false, "closed", false, "closed"], + ["open", true, "open", true, "draft"], + ["open", false, "open", true, "open"], + ["closed", false, "open", true, "open"], + ["merged", false, "merged", false, "merged"], + ["merged", false, "closed", true, "closed"], + ] as const)( + "aggregates %s (draft %s) and %s (draft %s) as %s", + (firstState, firstDraft, secondState, secondDraft, state) => { + for (const stacked of [false, true]) { + const links = [ + link(1, { + snapshot: snapshot({ state: firstState, isDraft: firstDraft, headBranch: "base" }), + }), + link(2, { + snapshot: snapshot({ + state: secondState, + isDraft: secondDraft, + baseBranch: stacked ? "base" : "main", + }), + }), + link(3, { source: "stack-dismissed" }), + ]; + expect(resolveThreadPullRequestBadge(links)).toEqual( + stacked + ? { kind: "stack", layers: 2, state } + : { kind: "pull-request", others: 1, state }, + ); + } + }, + ); + it.each(["open", "merged", "closed"] as const)( "targets the top of a derived %s chain despite a later bottom update and link", (state) => { @@ -309,6 +343,7 @@ describe("chain selection and badge state", () => { expect(resolveThreadPullRequestBadge([bottom, top, link(3)])).toEqual({ kind: "pull-request", others: 2, + state: "open", }); expect(resolveThreadPullRequestBadge([link(3, { source: "stack-dismissed" })])).toBeNull(); }); @@ -340,7 +375,11 @@ describe("chain selection and badge state", () => { kind: "stack", top: { number: 2 }, }); - expect(resolveThreadPullRequestBadge(links)).toEqual({ kind: "pull-request", others: 1 }); + expect(resolveThreadPullRequestBadge(links)).toEqual({ + kind: "pull-request", + others: 1, + state: "open", + }); }); it("does not guess a parent when a head branch was reused", () => { diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index 424433b3457c..5d91d98d52c9 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -243,31 +243,35 @@ export function resolveThreadPullRequestChains( return chains; } -export type ThreadPullRequestBadge = +export type ThreadPullRequestBadge = { + readonly state: "open" | "closed" | "merged" | "draft"; +} & ( | { readonly kind: "stack"; readonly layers: number; - readonly state: "open" | "closed" | "merged"; } - | { readonly kind: "pull-request"; readonly others: number }; + | { readonly kind: "pull-request"; readonly others: number } +); -/** Aggregate a single chain's state; unrelated links show a count beside the current PR. */ +/** Aggregate visible links' state for both stacks and unrelated linked counts. */ export function resolveThreadPullRequestBadge( pullRequests: ReadonlyArray | undefined, ): ThreadPullRequestBadge | null { const visible = visibleThreadPullRequests(pullRequests ?? []); if (visible.length === 0) return null; - const chains = resolveThreadPullRequestChains(visible); - if (visible.length > 1 && chains.length === 1) { - const states = visible.map((link) => link.snapshot?.state ?? "open"); - const state = states.includes("open") + const states = visible.map((link) => link.snapshot?.state ?? "open"); + const state = visible.every((link) => link.snapshot?.state === "open" && link.snapshot.isDraft) + ? "draft" + : states.includes("open") ? "open" : states.every((entry) => entry === "merged") ? "merged" : "closed"; + const chains = resolveThreadPullRequestChains(visible); + if (visible.length > 1 && chains.length === 1) { return { kind: "stack", layers: visible.length, state }; } - return { kind: "pull-request", others: visible.length - 1 }; + return { kind: "pull-request", others: visible.length - 1, state }; } /** Search terms for visible PR links, including the legacy single-link projection. */ From 6c69534a5800a95e1640b0571580561ef71ec461 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 11 Sep 2026 01:04:14 -0300 Subject: [PATCH 53/61] fix(preview): render website favicons for browser tool activity (#11032) --- apps/server/src/mcp/McpHttpServer.test.ts | 47 +++++--- apps/server/src/mcp/McpHttpServer.ts | 7 ++ .../src/mcp/PreviewAutomationBroker.test.ts | 102 +++++++++++------- .../server/src/mcp/PreviewAutomationBroker.ts | 6 ++ .../src/mcp/toolkits/preview/handlers.ts | 69 +++++++++--- .../src/mcp/toolkits/preview/tools.test.ts | 1 + apps/server/src/mcp/toolkits/preview/tools.ts | 17 ++- .../ActivityPayloadProjection.test.ts | 78 ++++++++++++++ .../ActivityPayloadProjection.ts | 71 +++++++++++- 9 files changed, 320 insertions(+), 78 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 885629930706..9cbc3fde2977 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -250,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([ { @@ -279,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); }), @@ -329,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({}); @@ -429,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( @@ -471,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, @@ -482,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"); @@ -533,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, @@ -615,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; @@ -664,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.", @@ -721,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 } }, @@ -741,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 bf7cf0520668..5a8cb573ad88 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -410,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 ? [] 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/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/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 43db2cfcc4cc..0525aae7b72b 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -5,6 +5,7 @@ import type { 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) @@ -241,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 @@ -367,10 +432,14 @@ 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") { From 18c5a1d2dfbb02856ec51b54edac82952852a58a Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 11 Sep 2026 01:23:45 -0300 Subject: [PATCH 54/61] fix(web): simplify pull request summary sections (#10612) --- .../pullRequest/PullRequestDetailPanel.tsx | 4 +- .../pullRequest/PullRequestMarkdown.tsx | 8 +- .../components/pullRequest/PullRequestRow.tsx | 52 ++--- .../pullRequest/PullRequestSummaryTab.tsx | 188 +++++++++--------- .../pullRequest/ThreadPullRequestsPanel.tsx | 23 +-- .../pullRequest/pullRequestPresentation.tsx | 18 +- 6 files changed, 152 insertions(+), 141 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 5b86ef5ee3ba..0284a2e6cdec 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1654,7 +1654,7 @@ export function PullRequestDetailPanel({ render={ + +
+ {(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 ? ( diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index c9ef8974ef13..35a449a3da79 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -29,6 +29,7 @@ import { pullRequestListLines, type PullRequestListLine } from "./pullRequestLis import { PullRequestActorAvatar, PullRequestDiffStat, + PullRequestApprovalGlyph, PullRequestStateGlyph, pullRequestChecksStatePresentation, } from "./pullRequestPresentation"; @@ -111,27 +112,23 @@ function LinkRow({ {snapshot?.title ?? link.repository} - {/* Right-aligned signals, in the order a reviewer scans them: are checks green, - has someone ruled, how big is it. Each is absent rather than neutral when the + {/* 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?.checksState ? : null} {snapshot?.state === "open" && (snapshot.reviewDecision === "approved" || snapshot.reviewDecision === "changes-requested") ? ( - - {snapshot.reviewDecision === "approved" ? "Approved" : "Changes requested"} - + snapshot.reviewDecision === "approved" ? ( + + ) : ( + Changes requested + ) ) : null} {snapshot?.state === "open" && snapshot.mergeability === "conflicting" ? ( Conflicts ) : null} + {snapshot?.checksState ? : null} {snapshot?.updatedAt ? ( - · {formatRelativeTimeLabel(snapshot.updatedAt)} + {formatRelativeTimeLabel(snapshot.updatedAt)} ) : null} diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index 9cd3340832dd..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", From ef6fa118749572da1905eca5d0543d1e98f0b1b2 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 11 Sep 2026 01:46:33 -0300 Subject: [PATCH 55/61] fix(web): preserve drafts when compacting context (#11103) --- apps/web/src/components/ChatView.tsx | 87 +++++++++++++++++-- apps/web/src/components/chat/ChatComposer.tsx | 33 ++----- 2 files changed, 84 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 283e85365667..7a0e6a719326 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5881,15 +5881,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 ( @@ -6479,6 +6477,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", @@ -8575,6 +8645,7 @@ export default function ChatView(props: ChatViewProps) { onPageScrollKeyDown={onComposerPageScrollKeyDown} onPageScrollKeyUp={onComposerPageScrollKeyUp} onPageScrollRelease={onComposerPageScrollRelease} + onCompactContext={onCompactContext} onSend={onSend} onInterrupt={onInterrupt} onImplementPlanInNewThread={onImplementPlanInNewThread} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7291f8cead22..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) { From 57aee3e19f1910f3323f06384bb2a1d02b79e369 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 11 Sep 2026 01:46:46 -0300 Subject: [PATCH 56/61] fix(server): queue messages during context compaction (#11107) Co-authored-by: Claude Fable 5.1 --- .../Layers/ProviderCommandReactor.test.ts | 310 +++++++++++++----- .../Layers/ProviderCommandReactor.ts | 157 ++++++++- 2 files changed, 374 insertions(+), 93 deletions(-) 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; From 8fc253605e6d203c3654dbb1ce22fa1fd6fa0767 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 23:50:31 -0700 Subject: [PATCH 57/61] perf(web): format minimap previews only when opened (#11181) --- .../src/components/chat/MessagesTimeline.tsx | 65 +++------------ .../chat/timelineMinimapItems.test.ts | 81 +++++++++++++++++++ .../components/chat/timelineMinimapItems.ts | 66 +++++++++++++++ 3 files changed, 159 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/components/chat/timelineMinimapItems.test.ts create mode 100644 apps/web/src/components/chat/timelineMinimapItems.ts diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 13c1e1dc61b3..41e3a0740a95 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -4,6 +4,11 @@ import { getQuestionAnswerText, hasQuestionAnswer, } from "@t3tools/client-runtime/work-log/user-input"; +import { + deriveTimelineMinimapItems, + resolveTimelineMinimapPreview, + type TimelineMinimapItem, +} from "./timelineMinimapItems"; import { type AssistantCitation, type EnvironmentId, @@ -918,13 +923,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; @@ -933,51 +931,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; @@ -1011,7 +964,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 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), + }; +} From a9dabbf100d1f6c0b2ed7b5e879d469ac14fd186 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 23:50:32 -0700 Subject: [PATCH 58/61] perf(web): reuse completed Markdown prefixes while streaming (#11193) --- apps/web/package.json | 2 + apps/web/src/components/ChatMarkdown.tsx | 8 +- apps/web/src/markdown-incremental.test.tsx | 136 +++++++++++++++++++++ apps/web/src/markdown-incremental.ts | 107 ++++++++++++++++ pnpm-lock.yaml | 6 + 5 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/markdown-incremental.test.tsx create mode 100644 apps/web/src/markdown-incremental.ts diff --git a/apps/web/package.json b/apps/web/package.json index c2ce73b42b11..069a222ca122 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -59,6 +59,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 +69,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/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 192abaf385f7..88d34b8d58bc 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -69,6 +69,7 @@ import React, { } from "react"; import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; import ReactMarkdown from "react-markdown"; +import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; import { defaultUrlTransform } from "react-markdown"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; @@ -3104,12 +3105,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/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", + "\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 = createIncrementalHighlighter(highlighter, language, theme); + for (let end = 0; end <= code.length; end++) { + const text = code.slice(0, end); + expect(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 = createIncrementalHighlighter(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(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 = createIncrementalHighlighter(highlighter, language, "pierre-dark"); + for (const text of ["plain\ntext", "\u001b[31mred\ncontinued", "\n"]) { + expect(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 = createIncrementalHighlighter(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(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..a2f90b6935c2 --- /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 createIncrementalHighlighter( + 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): 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.codeToHtml(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.codeToHtml(code, options); + } + cached = { + prefix: code.slice(0, end), + state, + children: [...(cached ? [...cached.children, newline] : []), ...codeChildren(root)], + }; + } + const prefix = cached; + if (!prefix) return highlighter.codeToHtml(code, options); + return highlighter.codeToHtml(code.slice(prefix.prefix.length), { + ...options, + grammarState: prefix.state, + transformers: [ + { + code: (node) => ({ ...node, children: [...prefix.children, newline, ...node.children] }), + }, + ], + }); + }; +} From 8078c532ceeee5cb951325031f216171507f3d5e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 23:50:32 -0700 Subject: [PATCH 60/61] perf(web): preserve completed code-line DOM while streaming (#11198) --- apps/web/package.json | 2 + apps/web/src/components/ChatMarkdown.test.tsx | 31 ++++++++++-- apps/web/src/components/ChatMarkdown.tsx | 43 ++++++++++++----- .../chat/HighlightedCodeLines.test.tsx | 26 ++++++++++ .../components/chat/HighlightedCodeLines.tsx | 48 +++++++++++++++++++ .../src/lib/incrementalHighlighting.test.ts | 27 +++++++---- apps/web/src/lib/incrementalHighlighting.ts | 12 ++--- pnpm-lock.yaml | 6 +++ 8 files changed, 164 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/components/chat/HighlightedCodeLines.test.tsx create mode 100644 apps/web/src/components/chat/HighlightedCodeLines.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 069a222ca122..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", diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index d21960a28e34..adc3ddb77444 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -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 9be8b0a7b393..0dd85e9147dc 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -69,6 +69,7 @@ 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"; @@ -123,7 +124,8 @@ import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; import { GitHubIcon } from "./Icons"; -import { createIncrementalHighlighter } from "../lib/incrementalHighlighting"; +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"; @@ -1011,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 ( @@ -1031,6 +1038,7 @@ function SuspenseShikiCodeBlock({ themeName={themeName} cacheKey={cacheKey} isStreaming={isStreaming} + preserveLines={isStreaming || hasStreamed} /> ); } @@ -1041,6 +1049,7 @@ interface UncachedShikiCodeBlockProps { themeName: DiffThemeName; cacheKey: string; isStreaming: boolean; + preserveLines: boolean; } function UncachedShikiCodeBlock({ @@ -1049,16 +1058,19 @@ function UncachedShikiCodeBlock({ themeName, cacheKey, isStreaming, + preserveLines, }: UncachedShikiCodeBlockProps) { const highlighter = use(getSyntaxHighlighterPromise(language)); const incrementalHighlight = useMemo( - () => (isStreaming ? createIncrementalHighlighter(highlighter, language, themeName) : null), - [highlighter, isStreaming, language, themeName], + () => + preserveLines ? createIncrementalHighlightedDocument(highlighter, language, themeName) : null, + [highlighter, preserveLines, language, themeName], ); - const highlightedHtml = useMemo(() => { + const highlighted = useMemo(() => { try { - return incrementalHighlight - ? incrementalHighlight(code) + 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 @@ -1067,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, incrementalHighlight, 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" ? ( +
+ ) : ( +
+ +
); } diff --git a/apps/web/src/components/chat/HighlightedCodeLines.test.tsx b/apps/web/src/components/chat/HighlightedCodeLines.test.tsx new file mode 100644 index 000000000000..66af6dcc5ed0 --- /dev/null +++ b/apps/web/src/components/chat/HighlightedCodeLines.test.tsx @@ -0,0 +1,26 @@ +import { getSharedHighlighter } from "@pierre/diffs"; +import { toHtml } from "hast-util-to-html"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { createIncrementalHighlightedDocument } from "../../lib/incrementalHighlighting"; +import { HighlightedCodeLines } from "./HighlightedCodeLines"; + +describe("highlighted code lines", () => { + it("preserves Shiki HTML, including colors, escaping, whitespace, and blank lines", async () => { + const highlighter = await getSharedHighlighter({ + langs: ["typescript"], + themes: ["pierre-dark", "pierre-light"], + preferredHighlighter: "shiki-wasm", + }); + for (const theme of ["pierre-dark", "pierre-light"] as const) { + const highlight = createIncrementalHighlightedDocument(highlighter, "typescript", theme); + const code = + 'const html = "";\n\n/* multi\nline */\n\tconst x = 1;\n'; + for (let end = 0; end <= code.length; end++) { + const root = highlight(code.slice(0, end)); + expect(renderToStaticMarkup()).toBe(toHtml(root)); + } + } + }); +}); diff --git a/apps/web/src/components/chat/HighlightedCodeLines.tsx b/apps/web/src/components/chat/HighlightedCodeLines.tsx new file mode 100644 index 000000000000..f0f971681c10 --- /dev/null +++ b/apps/web/src/components/chat/HighlightedCodeLines.tsx @@ -0,0 +1,48 @@ +import type { DiffsHighlighter } from "@pierre/diffs"; +import { toHtml } from "hast-util-to-html"; +import { toJsxRuntime } from "hast-util-to-jsx-runtime"; +import { cloneElement, isValidElement, memo, type DOMAttributes } from "react"; +import { Fragment, jsx, jsxs } from "react/jsx-runtime"; + +type HighlightedRoot = ReturnType; +type HighlightedNode = HighlightedRoot["children"][number]; +const runtime = { Fragment, jsx, jsxs }; + +function elementShell(node: Extract) { + const element = toJsxRuntime({ ...node, children: [] }, runtime); + if (!isValidElement>(element)) { + throw new Error("Expected a highlighted code element"); + } + return element; +} + +const HighlightedLine = memo(function HighlightedLine({ node }: { node: HighlightedNode }) { + if (node.type !== "element") return toJsxRuntime(node, runtime); + return cloneElement(elementShell(node), { + dangerouslySetInnerHTML: { __html: toHtml({ type: "root", children: node.children }) }, + }); +}); + +/** Completed line nodes retain their identity in the incremental highlighter. + * Keep their DOM mounted too: replacing the entire pre makes the browser parse + * and resolve styles for thousands of unchanged token spans on each update. + */ +export function HighlightedCodeLines({ root }: { root: HighlightedRoot }) { + const pre = root.children[0]; + if (pre?.type !== "element" || pre.tagName !== "pre") return toJsxRuntime(root, runtime); + const code = pre.children[0]; + if (code?.type !== "element" || code.tagName !== "code") return toJsxRuntime(root, runtime); + return cloneElement( + elementShell(pre), + undefined, + cloneElement( + elementShell(code), + undefined, + code.children.map((node, index) => ( + // A line's position is stable as tokens and new lines are appended. + // oxlint-disable-next-line react/no-array-index-key + + )), + ), + ); +} diff --git a/apps/web/src/lib/incrementalHighlighting.test.ts b/apps/web/src/lib/incrementalHighlighting.test.ts index b61cfc3b48a5..36c27cea14a2 100644 --- a/apps/web/src/lib/incrementalHighlighting.test.ts +++ b/apps/web/src/lib/incrementalHighlighting.test.ts @@ -1,7 +1,8 @@ +import { toHtml } from "hast-util-to-html"; import { getSharedHighlighter } from "@pierre/diffs"; import { describe, expect, it } from "vite-plus/test"; -import { createIncrementalHighlighter } from "./incrementalHighlighting"; +import { createIncrementalHighlightedDocument } from "./incrementalHighlighting"; const samples = { typescript: "/* multi\nline comment */\nconst x = `template\n${1 + 2}`;\nconst re = /abc/;\n", @@ -29,10 +30,10 @@ describe("incremental code highlighting", () => { async (language, code) => { const highlighter = await highlighterPromise; for (const theme of ["pierre-dark", "pierre-light"] as const) { - const highlight = createIncrementalHighlighter(highlighter, language, theme); + const highlight = createIncrementalHighlightedDocument(highlighter, language, theme); for (let end = 0; end <= code.length; end++) { const text = code.slice(0, end); - expect(highlight(text), `${theme}, prefix ${end}`).toBe( + expect(toHtml(highlight(text)), `${theme}, prefix ${end}`).toBe( highlighter.codeToHtml(text, { lang: language, theme }), ); } @@ -42,7 +43,11 @@ describe("incremental code highlighting", () => { it("resets after edits and truncation, including edits to a completed line", async () => { const highlighter = await highlighterPromise; - const highlight = createIncrementalHighlighter(highlighter, "typescript", "pierre-dark"); + const highlight = createIncrementalHighlightedDocument( + highlighter, + "typescript", + "pierre-dark", + ); const inputs = [ "/* open\ncomment\n", "/* open\ncomment\n*/\nconst x = 1;", @@ -53,7 +58,7 @@ describe("incremental code highlighting", () => { "\n\n\nconst fresh = true;\n", ]; for (const text of inputs) { - expect(highlight(text)).toBe( + expect(toHtml(highlight(text))).toBe( highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }), ); } @@ -63,9 +68,9 @@ describe("incremental code highlighting", () => { "preserves %s without requesting grammar state", async (language) => { const highlighter = await highlighterPromise; - const highlight = createIncrementalHighlighter(highlighter, language, "pierre-dark"); + const highlight = createIncrementalHighlightedDocument(highlighter, language, "pierre-dark"); for (const text of ["plain\ntext", "\u001b[31mred\ncontinued", "\n"]) { - expect(highlight(text)).toBe( + expect(toHtml(highlight(text))).toBe( highlighter.codeToHtml(text, { lang: language, theme: "pierre-dark" }), ); } @@ -74,11 +79,15 @@ describe("incremental code highlighting", () => { it("preserves partial CRLF and CR line endings", async () => { const highlighter = await highlighterPromise; - const highlight = createIncrementalHighlighter(highlighter, "typescript", "pierre-dark"); + 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(highlight(text)).toBe( + 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 index a2f90b6935c2..fef3598a2152 100644 --- a/apps/web/src/lib/incrementalHighlighting.ts +++ b/apps/web/src/lib/incrementalHighlighting.ts @@ -14,7 +14,7 @@ function codeChildren(root: ReturnType) { * 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 createIncrementalHighlighter( +export function createIncrementalHighlightedDocument( highlighter: DiffsHighlighter, language: string, theme: DiffThemeName, @@ -29,7 +29,7 @@ export function createIncrementalHighlighter( } | undefined; - return (code: string): string => { + 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 ( @@ -37,7 +37,7 @@ export function createIncrementalHighlighter( ["text", "plaintext", "plain", "txt", "ansi"].includes(language) || code.includes("\r") ) { - return highlighter.codeToHtml(code, options); + return highlighter.codeToHast(code, options); } if (cached && !code.startsWith(cached.prefix)) cached = undefined; const end = code.lastIndexOf("\n") + 1; @@ -51,7 +51,7 @@ export function createIncrementalHighlighter( const state = highlighter.getLastGrammarState(root); if (!state) { cached = undefined; - return highlighter.codeToHtml(code, options); + return highlighter.codeToHast(code, options); } cached = { prefix: code.slice(0, end), @@ -60,8 +60,8 @@ export function createIncrementalHighlighter( }; } const prefix = cached; - if (!prefix) return highlighter.codeToHtml(code, options); - return highlighter.codeToHtml(code.slice(prefix.prefix.length), { + if (!prefix) return highlighter.codeToHast(code, options); + return highlighter.codeToHast(code.slice(prefix.prefix.length), { ...options, grammarState: prefix.state, transformers: [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 704437983bac..eb2ff380f6ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -637,6 +637,12 @@ importers: effect: specifier: 4.0.0-rc.112 version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) + hast-util-to-html: + specifier: ^9.0.5 + version: 9.0.5 + hast-util-to-jsx-runtime: + specifier: ^2.3.6 + version: 2.3.6 heic-to: specifier: ^1.5.2 version: 1.5.2 From 211618fd9fe39d3dde01171a6856ce9f633571c9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 23:50:33 -0700 Subject: [PATCH 61/61] perf(web): huge-thread switch no longer blanks the chat pane (#11169) Co-authored-by: Cursor Agent Co-authored-by: Julius Marminge --- .../web/src/components/ChatView.logic.test.ts | 181 ++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 141 ++++++++++++++ apps/web/src/components/ChatView.tsx | 135 ++++++++++--- .../src/components/chat/MessagesTimeline.tsx | 59 ++++-- 4 files changed, 472 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 06fcce22ee3f..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"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 66214df385e8..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; @@ -612,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 7a0e6a719326..51b9c5eabc48 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -415,6 +415,12 @@ import { resolveComposerInteractionMode, resolveComposerProviderSelection, resolveDraftHeroState, + isPaintOnlyThreadTimeline, + peekHeldThreadTimeline, + peekRememberedThreadTimeline, + rememberReadyThreadTimeline, + resolveThreadSwitchTimeline, + timelineHasEphemeralPreviewUrls, observeProactivePanelUserChoice, resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, @@ -1379,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 @@ -3239,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; @@ -3337,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 @@ -4910,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; @@ -8435,54 +8490,78 @@ export default function ChatView(props: ChatViewProps) { />
{/* Messages Wrapper */} -
+
{/* Messages — LegendList handles virtualization and scrolling internally */} {/* scroll to end pill — shown when user has scrolled away from the live edge */} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 41e3a0740a95..e2c3b6520de1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -331,6 +331,12 @@ interface MessagesTimelineProps { runningTurnId: TurnId | null; turnDiffSummaries: ReadonlyArray; routeThreadKey: string; + /** + * Thread whose entries are currently painted. Differs from `routeThreadKey` + * while a jump is still holding the previous list. Identity for row + * projection and list extraData — do not remount on this value. + */ + displayThreadKey?: string; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; supportsConversationRollback: boolean; onRevertToTurnCount: (targetTurnCount: number) => void; @@ -389,6 +395,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ runningTurnId, turnDiffSummaries, routeThreadKey, + displayThreadKey, onOpenTurnDiff, supportsConversationRollback, onRevertToTurnCount, @@ -416,17 +423,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({ loadEarlier = null, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); + const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); + const listIdentityKey = displayThreadKey ?? routeThreadKey; + const listIdentityRef = useRef(listIdentityKey); + const previousLatestTurnRef = useRef(latestTurn); + let paintedExpandedTurnIds = expandedTurnIds; + let paintedExpandedWorkGroupIds = expandedWorkGroupIds; + if (listIdentityRef.current !== listIdentityKey) { + listIdentityRef.current = listIdentityKey; + previousLatestTurnRef.current = latestTurn; + paintedExpandedTurnIds = new Set(); + paintedExpandedWorkGroupIds = new Set(); + setExpandedTurnIds(paintedExpandedTurnIds); + setExpandedWorkGroupIds(paintedExpandedWorkGroupIds); + } const citationThreadRef = useMemo(() => parseScopedThreadKey(routeThreadKey), [routeThreadKey]); const expandCitedTurn = useCallback((turnId: TurnId) => { setExpandedTurnIds((current) => current.has(turnId) ? current : new Set([...current, turnId]), ); }, []); - const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); // Scroll/disclosure state outlives virtualized rows, but never the current thread. const workGroupViewState = useMemo( () => ({ scrollPositions: new Map(), expandedEntries: new Set() }), - [routeThreadKey], + [listIdentityKey], ); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [minimapStripMap] = useState(() => new Map()); @@ -518,7 +538,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ // An in-session interrupt leaves its turn expanded so the user keeps their // place; the next turn (or a reload, since this is local state) folds it. - const previousLatestTurnRef = useRef(latestTurn); useEffect(() => { const previous = previousLatestTurnRef.current; previousLatestTurnRef.current = latestTurn; @@ -557,34 +576,34 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timelineEntries, latestTurn, runningTurnId, - expandedTurnIds, - expandedWorkGroupIds, + expandedTurnIds: paintedExpandedTurnIds, + expandedWorkGroupIds: paintedExpandedWorkGroupIds, isWorking, activeTurnStartedAt, turnDiffSummaries, supportsConversationRollback, }, - previous?.threadKey === routeThreadKey && previous.workspaceRoot === workspaceRoot + previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot ? previous.projection : null, ); - rowsProjectionRef.current = { threadKey: routeThreadKey, workspaceRoot, projection }; + rowsProjectionRef.current = { threadKey: listIdentityKey, workspaceRoot, projection }; return projection.rows; }, [ rowsProjectionRef, - routeThreadKey, + listIdentityKey, workspaceRoot, timelineEntries, latestTurn, runningTurnId, - expandedTurnIds, - expandedWorkGroupIds, + paintedExpandedTurnIds, + paintedExpandedWorkGroupIds, isWorking, activeTurnStartedAt, turnDiffSummaries, supportsConversationRollback, ]); - const rows = useStableRows(rawRows); + const rows = useStableRows(rawRows, listIdentityKey); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); const [timelineViewportElement, setTimelineViewportElement] = useState( null, @@ -822,7 +841,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (rows.length === 0 && !isWorking) { if (hideEmptyPlaceholder) { - return null; + // Occupy the pane with the theme surface so a thread switch cannot + // punch a hole through to the window chrome (white in light mode). + return
; } return (
@@ -849,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} @@ -2742,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]); } // ---------------------------------------------------------------------------