From 476590d0adede15a4eec0d05b8adac8c63736452 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 00:50:23 -0700 Subject: [PATCH 01/31] feat(desktop): back/forward buttons and trackpad swipe navigation (#27430) Co-authored-by: Claude Opus 5.5 --- apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/preload.ts | 5 + apps/desktop/src/window/DesktopWindow.ts | 7 +- apps/web/src/components/AppSidebarLayout.tsx | 4 +- .../components/HistorySwipeNavigation.test.ts | 15 ++ .../src/components/HistorySwipeNavigation.tsx | 189 ++++++++++++++++++ .../src/components/sidebar/SidebarChrome.tsx | 68 ++++++- apps/web/src/navigationHistory.ts | 22 ++ packages/contracts/src/ipc.ts | 1 + 9 files changed, 307 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/HistorySwipeNavigation.test.ts create mode 100644 apps/web/src/components/HistorySwipeNavigation.tsx create mode 100644 apps/web/src/navigationHistory.ts diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index bba4f6449b8c1..538dd06b0b42a 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -14,6 +14,7 @@ export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; export const WEB_LINK_OPEN_CHANNEL = "desktop:web-link-open"; export const WEB_LINK_READY_CHANNEL = "desktop:web-link-ready"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; +export const TRACKPAD_SCROLL_BEGIN_CHANNEL = "desktop:trackpad-scroll-begin"; export const TRACKPAD_SCROLL_END_CHANNEL = "desktop:trackpad-scroll-end"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 965b7db5d6d40..950ed972c6453 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -91,6 +91,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); }, + onTrackpadScrollBegin: (listener) => { + const handler = () => listener(); + ipcRenderer.on(IpcChannels.TRACKPAD_SCROLL_BEGIN_CHANNEL, handler); + return () => ipcRenderer.removeListener(IpcChannels.TRACKPAD_SCROLL_BEGIN_CHANNEL, handler); + }, onTrackpadScrollEnd: (listener) => { const handler = () => listener(); ipcRenderer.on(IpcChannels.TRACKPAD_SCROLL_END_CHANNEL, handler); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index ab5fc8be26a92..c1c0cf5446cd2 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -23,6 +23,7 @@ import { MENU_ACTION_CHANNEL, QUIT_SHORTCUT_CHANNEL, SNAP_SHOT_EVENT_CHANNEL, + TRACKPAD_SCROLL_BEGIN_CHANNEL, TRACKPAD_SCROLL_END_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL, } from "../ipc/channels.ts"; @@ -652,7 +653,11 @@ export const make = Effect.gen(function* () { } }); window.webContents.on("input-event", (_event, input) => { - if (input.type === "gestureScrollEnd") window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL); + if (input.type === "gestureScrollBegin") { + window.webContents.send(TRACKPAD_SCROLL_BEGIN_CHANNEL); + } else if (input.type === "gestureScrollEnd") { + window.webContents.send(TRACKPAD_SCROLL_END_CHANNEL); + } }); // Ctrl+wheel bypasses menus; guest contents emit their own zoom-changed event. window.webContents.on("zoom-changed", (_event, direction) => { diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 6c8837e63f40a..91df5616f7f50 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -38,6 +38,7 @@ import { usePanelNavigationSuppression, } from "../panelAnimations"; import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer"; +import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; import ThreadSidebar from "./Sidebar"; import { SidebarBrandWidthProbe } from "./sidebar/SidebarChrome"; import { isAppModalPage } from "./sidebar/mainAppLocation"; @@ -449,7 +450,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { defaultOpen style={sidebarProviderStyle} > - + + {isElectron ? : null} ); diff --git a/apps/web/src/components/HistorySwipeNavigation.test.ts b/apps/web/src/components/HistorySwipeNavigation.test.ts new file mode 100644 index 0000000000000..9eef35f0dc61e --- /dev/null +++ b/apps/web/src/components/HistorySwipeNavigation.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isMomentumTail } from "./HistorySwipeNavigation"; + +describe("isMomentumTail", () => { + it.each([ + ["steady momentum decay", [40, 37, 34, 31.3, 28.8], true], + ["fingers still moving", [12, 18, 9, 15, 11], false], + ["fingers slowing unevenly", [30, 22, 20, 12, 11], false], + ["reversing", [10, 8, 6, 4, -2], false], + ["too few samples", [40, 37, 34], false], + ])("%s", (_name, deltas, expected) => { + expect(isMomentumTail(deltas)).toBe(expected); + }); +}); diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx new file mode 100644 index 0000000000000..c020878b25363 --- /dev/null +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -0,0 +1,189 @@ +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { cn } from "../lib/utils"; +import { canNavigateHistory, navigateHistory, type HistoryDirection } from "../navigationHistory"; + +const SWIPE_TRIGGER_DISTANCE = 120; +// Movement needed before the gesture picks an axis; first events are often diagonal. +const SWIPE_AXIS_LOCK_DISTANCE = 8; +const MOMENTUM_SAMPLE_SIZE = 5; + +type SwipeBounds = { left: number; top: number; width: number; height: number }; +type SwipeGesture = + | { kind: "pending"; target: EventTarget | null; y: number; sumX: number; sumY: number } + | { + kind: "tracking"; + direction: HistoryDirection; + distance: number; + deltas: number[]; + y: number; + bounds: SwipeBounds; + } + // Not a swipe, or one already decided: the rest of its events are ignored. + | { kind: "ignored" }; +type SwipeIndicatorState = { + direction: HistoryDirection; + progress: number; + y: number; + bounds: SwipeBounds; + tracking: boolean; +}; + +function readMainContentBounds(): SwipeBounds { + const main = document.querySelector('[data-slot="sidebar-inset"]'); + if (!main) return { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }; + const { left, top, width, height } = main.getBoundingClientRect(); + return { left, top, width, height }; +} + +function canScrollHorizontally(target: EventTarget | null, deltaX: number): boolean { + for ( + let element = target instanceof Element ? target : null; + element; + element = element.parentElement + ) { + if (element.scrollWidth <= element.clientWidth) continue; + const { overflowX } = getComputedStyle(element); + if (overflowX !== "auto" && overflowX !== "scroll") continue; + const canScroll = + deltaX < 0 + ? element.scrollLeft > 0 + : element.scrollLeft + element.clientWidth < element.scrollWidth - 1; + if (canScroll) return true; + } + return false; +} + +function lockSwipeAxis( + pending: Extract, +): Exclude { + const { target, sumX, sumY } = pending; + if (Math.abs(sumX) <= Math.abs(sumY)) return { kind: "ignored" }; + if (target instanceof Element && target.closest('[role="dialog"]')) return { kind: "ignored" }; + if (canScrollHorizontally(target, sumX)) return { kind: "ignored" }; + const direction = sumX < 0 ? "back" : "forward"; + if (!canNavigateHistory(direction)) return { kind: "ignored" }; + return { + kind: "tracking", + direction, + distance: Math.abs(sumX), + deltas: [], + y: pending.y, + bounds: readMainContentBounds(), + }; +} + +/** + * macOS momentum after the fingers lift decays by a near-constant ratio per + * event; finger movement is noisier. Deltas are signed along the swipe. + */ +export function isMomentumTail(deltas: readonly number[]): boolean { + if (deltas.length < MOMENTUM_SAMPLE_SIZE) return false; + const recent = deltas.slice(-MOMENTUM_SAMPLE_SIZE); + if (recent.some((delta) => delta <= 0)) return false; + const ratios = recent.slice(1).map((delta, index) => delta / recent[index]!); + return ( + ratios.every((ratio) => ratio >= 0.7 && ratio < 1) && + Math.max(...ratios) - Math.min(...ratios) < 0.1 + ); +} + +/** Two-finger horizontal trackpad swipes go back and forward, like a browser. */ +export function HistorySwipeNavigation() { + const [swipe, setSwipe] = useState(null); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window wheel and desktop gesture listeners track trackpad swipes + useEffect(() => { + let gesture: SwipeGesture | null = null; + const hideIndicator = () => + setSwipe((current) => (current === null ? null : { ...current, tracking: false })); + // The fingers lifted: navigate if the swipe went far enough, then ignore its momentum. + const release = () => { + if (gesture?.kind === "tracking" && gesture.distance >= SWIPE_TRIGGER_DISTANCE) { + navigateHistory(gesture.direction); + } + gesture = { kind: "ignored" }; + hideIndicator(); + }; + // Chromium brackets each trackpad scroll, momentum included, with these. + const unsubscribeScrollBegin = window.desktopBridge?.onTrackpadScrollBegin(() => { + if (gesture?.kind === "ignored") gesture = null; + }); + const unsubscribeScrollEnd = window.desktopBridge?.onTrackpadScrollEnd(() => { + if (gesture?.kind === "tracking") release(); + gesture = null; + }); + const onWheel = (event: WheelEvent) => { + if (gesture === null) { + gesture = + event.ctrlKey || event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL + ? { kind: "ignored" } + : { kind: "pending", target: event.target, y: event.clientY, sumX: 0, sumY: 0 }; + } + if (gesture.kind === "ignored") return; + if (gesture.kind === "pending") { + gesture.sumX += event.deltaX; + gesture.sumY += event.deltaY; + if (Math.abs(gesture.sumX) + Math.abs(gesture.sumY) < SWIPE_AXIS_LOCK_DISTANCE) return; + gesture = lockSwipeAxis(gesture); + if (gesture.kind === "ignored") return; + } else { + const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX; + if (event.deltaX === 0 && event.deltaY === 0) return release(); + gesture.deltas = [...gesture.deltas.slice(1 - MOMENTUM_SAMPLE_SIZE), delta]; + if (isMomentumTail(gesture.deltas)) return release(); + gesture.distance = Math.max(0, gesture.distance + delta); + } + setSwipe({ + direction: gesture.direction, + progress: Math.min(1, gesture.distance / SWIPE_TRIGGER_DISTANCE), + y: gesture.y, + bounds: gesture.bounds, + tracking: true, + }); + }; + window.addEventListener("wheel", onWheel, { capture: true, passive: true }); + return () => { + unsubscribeScrollBegin?.(); + unsubscribeScrollEnd?.(); + window.removeEventListener("wheel", onWheel, { capture: true }); + }; + }, []); + + return swipe === null ? null : ; +} + +// A circle pinned to the edge of the main content that peeks in up to half its width. +function SwipeIndicator({ direction, progress, y, bounds, tracking }: SwipeIndicatorState) { + const armed = progress >= 1; + const Icon = direction === "back" ? ArrowLeftIcon : ArrowRightIcon; + const hidden = 100 - progress * 50; + return ( + + ); +} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index b136d47fd9c40..dd41ddd2a0213 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,9 +1,17 @@ -import { ArrowLeftIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react"; +import { ArrowLeftIcon, ArrowRightIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react"; import type { ReactNode } from "react"; import { memo } from "react"; import { Link, useLocation, useNavigate } from "@tanstack/react-router"; +import { useAtomValue } from "@effect/atom-react"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; +import { shortcutLabelForCommand } from "../../keybindings"; +import { + navigateHistory, + useCanNavigateHistory, + type HistoryDirection, +} from "../../navigationHistory"; +import { primaryServerKeybindingsAtom } from "../../state/server"; import { cn } from "../../lib/utils"; import { usePullRequestsSupported } from "../../state/environments"; import { T3Wordmark } from "../T3Wordmark"; @@ -14,6 +22,7 @@ import { useEnvironmentStageLabel, } from "../SidebarStageBackdrop"; import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; import { SidebarFooter, SidebarMenu, @@ -63,7 +72,15 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ />
- +
+ + {isElectron ? ( +
+ + +
+ ) : null} +
{pillLabel ? (
@@ -77,8 +94,10 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ }); export function SidebarBrandWidthProbe({ + isElectron, onWidthChange, }: { + isElectron: boolean; onWidthChange: (width: number) => void; }) { const observeWidth = (probe: HTMLDivElement) => { @@ -93,13 +112,56 @@ export function SidebarBrandWidthProbe({ className="pointer-events-none invisible fixed top-0 left-0 flex w-max border-r border-transparent pr-3" ref={observeWidth} > -
+
+ {isElectron ?
: null}
); } +function HistoryNavigationButton({ + direction, + onBackdrop, +}: { + direction: HistoryDirection; + onBackdrop: boolean; +}) { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const enabled = useCanNavigateHistory(direction); + const label = direction === "back" ? "Go back" : "Go forward"; + const shortcutLabel = shortcutLabelForCommand( + keybindings, + direction === "back" ? "navigation.back" : "navigation.forward", + ); + const Icon = direction === "back" ? ArrowLeftIcon : ArrowRightIcon; + return ( + + navigateHistory(direction)} + size="icon-sm" + variant={onBackdrop ? "media-navigation" : "ghost"} + /> + } + > + + + + {label} + {shortcutLabel ? ` (${shortcutLabel})` : ""} + + + ); +} + function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { return ( void): () => void { + window.navigation.addEventListener("currententrychange", onChange); + return () => window.navigation.removeEventListener("currententrychange", onChange); +} + +export function canNavigateHistory(direction: HistoryDirection): boolean { + return direction === "back" ? window.navigation.canGoBack : window.navigation.canGoForward; +} + +export function navigateHistory(direction: HistoryDirection) { + if (direction === "back") window.history.back(); + else window.history.forward(); +} + +/** Only for Electron: the Navigation API is Chromium-only. */ +export function useCanNavigateHistory(direction: HistoryDirection): boolean { + return useSyncExternalStore(subscribeToNavigationHistory, () => canNavigateHistory(direction)); +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f460619f11355..f14452fec5a92 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1018,6 +1018,7 @@ export interface DesktopBridge { getClientPlatform: () => string; setNotificationBadge: (badge: { count: number; image: string | null }) => Promise; onNotificationBadgeClear: (listener: () => void) => () => void; + onTrackpadScrollBegin: (listener: () => void) => () => void; onTrackpadScrollEnd: (listener: () => void) => () => void; /** * Renderer `window.focus()` cannot raise an Electron window, and on Wayland From 5ef56853ef1854da50bc1909c6c0a32700f2cb13 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:00:33 +0300 Subject: [PATCH 02/31] feat(web): review local commits in the Diff pane (#19941) Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- .../mobile/src/features/review/reviewModel.ts | 27 +- apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/observability/RpcInstrumentation.ts | 1 + apps/server/src/review/ReviewService.ts | 36 ++- apps/server/src/vcs/GitVcsDriver.ts | 5 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 136 ++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 236 +++++++++++++++- apps/server/src/ws.ts | 1 + apps/web/src/browser/browserLinkTarget.ts | 13 +- apps/web/src/browser/useOpenLink.ts | 8 +- apps/web/src/components/ChatView.tsx | 7 +- apps/web/src/components/DiffPanel.tsx | 252 ++++++++++++++++-- ...DesktopWebLinkCoordinator.browser.test.tsx | 117 +++++++- .../desktop/DesktopWebLinkCoordinator.tsx | 58 ++-- .../components/diffs/useReviewFilePatches.ts | 1 + .../src/components/settings/settingsSearch.ts | 2 +- apps/web/src/diffPanelStore.ts | 52 +++- .../lib/openPullRequestLink.browser.test.tsx | 160 +++++++++++ apps/web/src/lib/openPullRequestLink.ts | 219 +++++++++++---- apps/web/src/rightPanelStore.ts | 11 + docs/user/source-control.md | 10 + .../client-runtime/src/changeRequestLinks.ts | 18 ++ packages/client-runtime/src/state/review.ts | 5 + packages/contracts/src/review.ts | 28 +- packages/contracts/src/rpc.ts | 10 + .../src/shared/definition.ts | 9 + .../src/shared/definition.ts | 6 + .../src/shared/definition.ts | 74 ++++- .../src/registry.ts | 2 +- .../src/shared/definition.ts | 5 + .../src/shared/definition.ts | 6 + .../src/shared/definition.ts | 16 +- .../src/shared/definition.ts | 5 + 33 files changed, 1375 insertions(+), 162 deletions(-) create mode 100644 apps/web/src/lib/openPullRequestLink.browser.test.tsx diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 1b2131c73e0c1..21b57322da1cd 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -534,17 +534,22 @@ export function buildReviewSectionItems(input: { }, ); - const gitItems = input.gitSections.map((section) => ({ - id: `git:${section.kind}`, - kind: section.kind, - title: section.title, - subtitle: gitSubtitle(section), - diff: section.diff, - source: section, - ...(section.files ? { files: section.files } : {}), - truncated: section.truncated, - isLoading: false, - })); + const gitItems = input.gitSections + .filter( + (section): section is typeof section & { kind: ReviewSectionKind } => + section.kind !== "commit", + ) + .map((section) => ({ + id: `git:${section.kind}`, + kind: section.kind, + title: section.title, + subtitle: gitSubtitle(section), + diff: section.diff, + source: section, + ...(section.files ? { files: section.files } : {}), + truncated: section.truncated, + isLoading: false, + })); const hasChangesItem = gitItems.some((item) => item.id === CHANGES_SECTION_ID); const visibleGitItems = input.loadingGitSections && !hasChangesItem diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 3806262cced53..bf2b8d49daf8d 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -170,6 +170,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope, [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, + [WS_METHODS.reviewGetBranchCommits]: AuthFilesystemReadScope, [WS_METHODS.reviewGetDiffPreview]: AuthFilesystemReadScope, [WS_METHODS.reviewGetDiffFileContents]: AuthFilesystemReadScope, [WS_METHODS.terminalOpen]: AuthTerminalOperateScope, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 028ea5a561d44..cb8b0b6a86cf3 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -181,6 +181,7 @@ const RPC_AGGREGATES = { [WS_METHODS.vcsCreateRef]: "vcs", [WS_METHODS.vcsSwitchRef]: "vcs", [WS_METHODS.vcsInit]: "vcs", + [WS_METHODS.reviewGetBranchCommits]: "review", [WS_METHODS.reviewGetDiffPreview]: "review", [WS_METHODS.reviewGetDiffFileContents]: "review", [WS_METHODS.terminalOpen]: "terminal", diff --git a/apps/server/src/review/ReviewService.ts b/apps/server/src/review/ReviewService.ts index 8f88b387bd454..39ce1554cea61 100644 --- a/apps/server/src/review/ReviewService.ts +++ b/apps/server/src/review/ReviewService.ts @@ -8,6 +8,8 @@ import * as Path from "effect/Path"; import { VcsRepositoryDetectionError, VcsUnsupportedOperationError, + type ReviewBranchCommitsInput, + type ReviewBranchCommitsResult, type ReviewDiffFileContentsInput, type ReviewDiffFileContentsResult, type ReviewDiffPreviewError, @@ -20,9 +22,18 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as KnownWorkspaces from "../workspace/KnownWorkspaces.ts"; +const WORKSPACE_BOUND_SUBJECTS = { + "ReviewService.getBranchCommits": "commit list", + "ReviewService.getDiffPreview": "diff preview", + "ReviewService.getDiffFileContents": "diff file contents", +} as const; + export class ReviewService extends Context.Service< ReviewService, { + readonly getBranchCommits: ( + input: ReviewBranchCommitsInput, + ) => Effect.Effect; readonly getDiffPreview: ( input: ReviewDiffPreviewInput, ) => Effect.Effect; @@ -61,7 +72,7 @@ export const make = Effect.gen(function* () { }; const assertWorkspaceBoundCwd = Effect.fn("ReviewService.assertWorkspaceBoundCwd")(function* ( - operation: "ReviewService.getDiffPreview" | "ReviewService.getDiffFileContents", + operation: keyof typeof WORKSPACE_BOUND_SUBJECTS, cwd: string, ) { const [candidate, workspaceRoot] = yield* Effect.all([ @@ -78,13 +89,27 @@ export const make = Effect.gen(function* () { return yield* new VcsRepositoryDetectionError({ operation, cwd, - detail: - operation === "ReviewService.getDiffPreview" - ? "Review diff preview cwd must stay within the configured workspace root." - : "Review diff file contents cwd must stay within the configured workspace root.", + detail: `Review ${WORKSPACE_BOUND_SUBJECTS[operation]} cwd must stay within the configured workspace root.`, }); }); + const getBranchCommits: ReviewService["Service"]["getBranchCommits"] = Effect.fn( + "ReviewService.getBranchCommits", + )(function* (input) { + yield* assertWorkspaceBoundCwd("ReviewService.getBranchCommits", input.cwd); + + const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" }); + if (!handle) return { commits: [], truncated: false }; + if (handle.kind !== "git") { + return yield* new VcsUnsupportedOperationError({ + operation: "ReviewService.getBranchCommits", + kind: handle.kind, + detail: "Review commit lists require a Git repository.", + }); + } + return yield* git.getReviewBranchCommits(input); + }); + const getDiffPreview: ReviewService["Service"]["getDiffPreview"] = Effect.fn( "ReviewService.getDiffPreview", )(function* (input) { @@ -120,6 +145,7 @@ export const make = Effect.gen(function* () { }); return ReviewService.of({ + getBranchCommits, getDiffPreview, getDiffFileContents, }); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 95833200a1b5f..74e409a8bc184 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -20,6 +20,8 @@ import { type VcsCreateRefResult, type VcsCreateWorktreeInput, type VcsCreateWorktreeResult, + type ReviewBranchCommitsInput, + type ReviewBranchCommitsResult, type ReviewDiffPreviewInput, type ReviewDiffPreviewResult, type ReviewDiffFileContentsInput, @@ -328,6 +330,9 @@ export class GitVcsDriver extends Context.Service< cwd: string, baseRef: string, ) => Effect.Effect; + readonly getReviewBranchCommits: ( + input: ReviewBranchCommitsInput, + ) => Effect.Effect; readonly getReviewDiffPreview: ( input: ReviewDiffPreviewInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index d05a41a6f3824..cd4b6949fad86 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,5 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not. import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, it, describe } from "@effect/vitest"; @@ -1466,6 +1468,140 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }); describe("review diff previews", () => { + it.effect("diffs a root commit against the empty tree but rejects a shallow boundary", () => + Effect.gen(function* () { + const upstream = yield* makeTmpDir(); + yield* initRepoWithCommit(upstream); + const driver = yield* GitVcsDriver.GitVcsDriver; + const emptyTree = yield* git(upstream, ["hash-object", "-t", "tree", NodeOS.devNull]); + const root = yield* git(upstream, ["rev-parse", "HEAD"]); + const rootSource = (yield* driver.getReviewDiffPreview({ cwd: upstream, commitSha: root })) + .sources[0]!; + assert.equal(rootSource.baseRef, emptyTree); + assert.include(rootSource.diff, "+# test"); + + yield* writeTextFile(upstream, "README.md", "# boundary\n"); + yield* git(upstream, ["commit", "-am", "boundary"]); + const sha = yield* git(upstream, ["rev-parse", "HEAD"]); + const cwd = yield* makeTmpDir(); + yield* git(upstream, ["clone", "--depth=1", NodeURL.pathToFileURL(upstream).href, cwd], { + GIT_CONFIG_GLOBAL: NodeOS.devNull, + GIT_CONFIG_NOSYSTEM: "1", + }); + assert.equal(yield* git(cwd, ["rev-parse", "--is-shallow-repository"]), "true"); + for (const request of [ + driver.getReviewDiffPreview({ cwd, commitSha: sha }), + driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd, { + sourceKind: "commit", + changeType: "new", + headRef: sha, + baseRef: emptyTree, + }), + ), + ]) { + const error = yield* Effect.asVoid(request).pipe( + Effect.flip, + ); + assert.include(error.detail, "parent"); + } + }), + ); + + it.effect("lists the branch's commits newest first, at most 100, and flags the rest", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "feature/commits"]); + for (let index = 0; index < 102; index++) { + yield* git(cwd, ["commit", "--allow-empty", "-m", `feature ${index}`]); + } + + const result = yield* driver.getReviewBranchCommits({ cwd, baseRef: initialBranch }); + assert.strictEqual(result.truncated, true); + assert.strictEqual(result.commits.length, 100); + assert.strictEqual(result.commits[0]?.sha, yield* git(cwd, ["rev-parse", "HEAD"])); + assert.deepStrictEqual( + [result.commits[0]?.subject, result.commits[99]?.subject], + ["feature 101", "feature 2"], + ); + }), + ); + + it.effect("previews one selected commit, a merge against its first parent", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "feature/merge-preview"]); + yield* git(cwd, ["checkout", "-b", "feature/side"]); + yield* writeTextFile(cwd, "README.md", "# side commit\n"); + yield* git(cwd, ["add", "README.md"]); + yield* git(cwd, ["commit", "-m", "side change"]); + yield* git(cwd, ["checkout", "feature/merge-preview"]); + yield* writeTextFile(cwd, "feature.txt", "feature commit\n"); + yield* git(cwd, ["add", "feature.txt"]); + yield* git(cwd, ["commit", "-m", "feature change"]); + const firstParent = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["merge", "--no-ff", "--no-edit", "feature/side"]); + const mergeSha = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["checkout", "-b", "feature/elsewhere", initialBranch]); + yield* writeTextFile(cwd, "elsewhere.txt", "elsewhere\n"); + yield* git(cwd, ["add", "elsewhere.txt"]); + yield* git(cwd, ["commit", "-m", "elsewhere change"]); + const elsewhereSha = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["checkout", "feature/merge-preview"]); + yield* writeTextFile(cwd, "README.md", "# dirty tree\n"); + + const elsewhere = yield* driver.getReviewDiffPreview({ cwd, commitSha: elsewhereSha }); + assert.deepStrictEqual( + elsewhere.sources + .find((candidate) => candidate.kind === "commit") + ?.files?.map((file) => file.path), + ["elsewhere.txt"], + ); + + const preview = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + commitSha: mergeSha, + }); + const source = preview.sources.find((candidate) => candidate.kind === "commit")!; + assert.strictEqual(source.baseRef, firstParent); + assert.strictEqual(source.headRef, mergeSha); + assert.deepStrictEqual( + source.files?.map((file) => file.path), + ["README.md"], + ); + assert.include(source.diff, "+# side commit"); + assert.notInclude(source.diff, "dirty tree"); + assert.notInclude(source.diff, "feature.txt"); + + const patch = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + commitSha: mergeSha, + file: { path: "README.md", previousPath: null, sourceKind: "commit" }, + }); + assert.include( + patch.sources.find((candidate) => candidate.kind === "commit")!.diff, + "+# side commit", + ); + const contents = yield* driver.getReviewDiffFileContents( + makeReviewDiffFileContentsInput(cwd, { + sourceKind: "commit", + baseRef: source.baseRef, + headRef: source.headRef, + }), + ); + assert.deepStrictEqual(contents, { + oldContents: "# test\n", + newContents: "# side commit\n", + }); + }), + ); + it.effect("loads repository-relative files from a nested project directory", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3a7c99fbc3d31..c5bdf3d068ef9 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -26,6 +26,7 @@ import { GitCommandError, type GitCommandFailureReason, T3_PROJECT_FILE_NAME, + type ReviewBranchCommitsInput, type ReviewDiffFileContentsInput, type ReviewDiffPreviewInput, type ReviewDiffFileStat, @@ -74,6 +75,9 @@ const REVIEW_METADATA_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; // Temporary-index git add scales poorly (50k files took ~30 s); report incomplete totals above this cap. const REVIEW_UNTRACKED_MAX_FILES = 5_000; const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; +const REVIEW_BRANCH_COMMIT_LIMIT = 100; +const REVIEW_BRANCH_SUBJECT_LIMIT = 300; +const REVIEW_BRANCH_COMMIT_MAX_OUTPUT_BYTES = 128 * 1024; // Client patch parsers require a/ and b/ prefixes; override user diff prefix settings. export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; const REVIEW_DIFF_ARGS = [ @@ -3115,6 +3119,141 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { baseRef, insertions, deletions }; }, Effect.scoped); + const reviewCommitError = (cwd: string, detail: string) => + new GitCommandError({ + operation: "GitVcsDriver.reviewCommit", + command: "git cat-file", + cwd, + detail, + }); + + const resolveReviewCommit = Effect.fn("resolveReviewCommit")(function* ( + cwd: string, + sha: string, + ) { + if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) { + return yield* reviewCommitError(cwd, "Selected commit is invalid or no longer available."); + } + const present = yield* executeGit( + "GitVcsDriver.reviewCommit.present", + cwd, + ["cat-file", "-e", `${sha}^{commit}`], + { allowNonZeroExit: true, env: { GIT_NO_LAZY_FETCH: "1" } }, + ); + if (present.exitCode !== 0) { + return yield* reviewCommitError(cwd, "Selected commit is invalid or no longer available."); + } + // The raw object keeps the parents a shallow clone grafts away, so a boundary is not a root. + const object = yield* runGitStdout("GitVcsDriver.reviewCommit.parents", cwd, [ + "cat-file", + "commit", + sha, + ]); + const secondLine = object.split("\n", 2)[1]; + const firstParent = secondLine?.startsWith("parent ") ? secondLine.slice(7) : undefined; + if (firstParent !== undefined) { + const parent = yield* executeGit( + "GitVcsDriver.reviewCommit.parent", + cwd, + ["cat-file", "-e", `${firstParent}^{commit}`], + { allowNonZeroExit: true, env: { GIT_NO_LAZY_FETCH: "1" } }, + ); + if (parent.exitCode !== 0) { + return yield* reviewCommitError(cwd, "Selected commit's parent is not available locally."); + } + } + return { sha: sha.toLowerCase(), parent: firstParent ?? (yield* readEmptyTreeHash(cwd)) }; + }); + + const readReviewCommitMessage = Effect.fn("GitVcsDriver.readReviewCommitMessage")(function* ( + cwd: string, + sha: string, + ) { + const result = yield* executeGit( + "GitVcsDriver.readReviewCommitMessage", + cwd, + ["show", "-s", "--no-notes", "--no-show-signature", "--format=%B", sha], + { maxOutputBytes: 8_192, appendTruncationMarker: true }, + ); + const endOfSubject = result.stdout.indexOf("\n"); + return { + subject: result.stdout.slice(0, endOfSubject < 0 ? undefined : endOfSubject).slice(0, 300), + }; + }); + + const getReviewBranchCommits = Effect.fn("getReviewBranchCommits")(function* ( + input: ReviewBranchCommitsInput, + ) { + const repository = yield* resolveRepositoryPathsUncached(input.cwd); + if (!repository?.worktreeRoot) return { commits: [], truncated: false }; + const cwd = repository.worktreeRoot; + const head = yield* executeGit( + "GitVcsDriver.getReviewBranchCommits.head", + cwd, + ["rev-parse", "--verify", "--quiet", "HEAD"], + { allowNonZeroExit: true }, + ); + if (head.exitCode !== 0) { + if (head.exitCode === 1 && head.stderr.trim() === "") { + return { commits: [], truncated: false }; + } + return yield* reviewCommitError(cwd, "Could not resolve the current branch commit."); + } + const { mergeBase } = yield* resolveReviewMergeBase( + cwd, + repository.currentBranch, + input.baseRef, + ); + const result = yield* executeGit( + "GitVcsDriver.getReviewBranchCommits", + cwd, + [ + "log", + "-z", + "--no-notes", + "--no-show-signature", + "--format=%H%x00%s%x00%cI", + `--max-count=${REVIEW_BRANCH_COMMIT_LIMIT + 1}`, + `${mergeBase}..HEAD`, + ], + { + maxOutputBytes: REVIEW_BRANCH_COMMIT_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + }, + ); + if (!result.stdoutTruncated && result.stdout.length > 0 && !result.stdout.endsWith("\0")) { + return yield* reviewCommitError(cwd, "Could not read branch commit metadata."); + } + const fields = result.stdout.split("\0"); + const completeFieldCount = fields.length - 1; + const commits: Array<{ sha: string; subject: string; committedAt: string }> = []; + for ( + let index = 0; + index + 2 < completeFieldCount && commits.length <= REVIEW_BRANCH_COMMIT_LIMIT; + index += 3 + ) { + const sha = fields[index]; + const subject = fields[index + 1]; + const committedAt = fields[index + 2]; + if ( + !sha || + subject === undefined || + !committedAt || + !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha) + ) { + return yield* reviewCommitError(cwd, "Could not read branch commit metadata."); + } + commits.push({ sha, subject: subject.slice(0, REVIEW_BRANCH_SUBJECT_LIMIT), committedAt }); + } + if (result.stdoutTruncated && commits.length === 0) { + return yield* reviewCommitError(cwd, "Branch commit metadata exceeds the response limit."); + } + return { + commits: commits.slice(0, REVIEW_BRANCH_COMMIT_LIMIT), + truncated: result.stdoutTruncated || commits.length > REVIEW_BRANCH_COMMIT_LIMIT, + }; + }); + const getReviewDiffPreview = Effect.fn("getReviewDiffPreview")(function* ( input: ReviewDiffPreviewInput, ) { @@ -3141,6 +3280,64 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const cwd = repository.worktreeRoot; + const hashDiff = (diff: string, files: ReadonlyArray) => + crypto.digest("SHA-256", new TextEncoder().encode(JSON.stringify([diff, files]))).pipe( + Effect.map(Hex.encode), + Effect.mapError( + (cause) => + new GitCommandError({ + operation: "GitVcsDriver.getReviewDiffPreview.hash", + command: "crypto.digest SHA-256", + cwd, + detail: "Failed to hash review diff.", + cause, + }), + ), + ); + if (input.commitSha !== undefined || input.file?.sourceKind === "commit") { + if (!input.commitSha || (input.file && input.file.sourceKind !== "commit")) { + return yield* reviewCommitError(cwd, "Selected commit request is invalid."); + } + const commit = yield* resolveReviewCommit(cwd, input.commitSha); + const { subject } = yield* readReviewCommitMessage(cwd, commit.sha); + const diffArgs = [ + ...REVIEW_DIFF_ARGS, + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + ]; + const stat = yield* executeGit( + "GitVcsDriver.getReviewDiffPreview.commitStat", + cwd, + [...diffArgs, "--numstat", "-z", commit.parent, commit.sha, "--", ...pathArgs], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ); + const files = parseReviewNumstat(stat.stdout); + const patch = yield* executeGit( + "GitVcsDriver.getReviewDiffPreview.commitPatch", + cwd, + [...diffArgs, "--patch", commit.parent, commit.sha, "--", ...pathArgs], + { maxOutputBytes: patchLimit, appendTruncationMarker: true }, + ); + const diffHash = yield* hashDiff(patch.stdout, files); + return { + cwd: input.cwd, + generatedAt: yield* DateTime.now, + sources: [ + { + id: commit.sha, + kind: "commit" as const, + title: `Commit ${commit.sha.slice(0, 7)}`, + ...(subject.trim() ? { commitSubject: subject.trim() } : {}), + baseRef: commit.parent, + headRef: commit.sha, + diff: patch.stdout, + files, + diffHash, + truncated: patch.stdoutTruncated, + }, + ], + }; + } + const dirtyRef = input.file?.sourceKind === "branch-range" ? null : "HEAD"; const review = input.file?.sourceKind === "working-tree" @@ -3220,20 +3417,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const baseFiles = baseResult.files; const dirtyDiff = dirtyTrackedResult.stdout; const baseDiff = baseResult.stdout; - const hashDiff = (diff: string, files: ReadonlyArray) => - crypto.digest("SHA-256", new TextEncoder().encode(JSON.stringify([diff, files]))).pipe( - Effect.map(Hex.encode), - Effect.mapError( - (cause) => - new GitCommandError({ - operation: "GitVcsDriver.getReviewDiffPreview.hash", - command: "crypto.digest SHA-256", - cwd, - detail: "Failed to hash review diff.", - cause, - }), - ), - ); const [dirtyDiffHash, baseDiffHash] = yield* Effect.all([ hashDiff(dirtyDiff, dirtyFiles ?? []), hashDiff(baseDiff, baseFiles ?? []), @@ -3385,6 +3568,30 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (repositoryRoot.length === 0) { return yield* reviewDiffFileError(input, "Could not resolve the Git repository root."); } + if (input.sourceKind === "commit") { + if (!input.headRef) { + return yield* reviewDiffFileError(input, "Selected commit is no longer available."); + } + const commit = yield* resolveReviewCommit(input.cwd, input.headRef); + if (input.baseRef !== commit.parent) { + return yield* reviewDiffFileError( + input, + "Selected commit changed. Refresh the comparison.", + ); + } + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" + ? Effect.succeed("") + : readReviewFileAtRevision(input, commit.parent, input.oldPath), + input.changeType === "deleted" + ? Effect.succeed("") + : readReviewFileAtRevision(input, commit.sha, input.newPath), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + } const oldRevision = input.sourceKind === "working-tree" ? (input.baseRef ?? "HEAD") @@ -4649,6 +4856,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* withListRefsInvalidation(cwd, pullCurrentBranch(cwd)); }), readRangeContext, + getReviewBranchCommits, getReviewDiffPreview, getReviewDiffFileContents, readConfigValue, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 32737aa771df6..befad768da3e5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3001,6 +3001,7 @@ export const layerWsRpc = ( gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsInit]: (input) => vcsProvisioning.initRepository(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + [WS_METHODS.reviewGetBranchCommits]: (input) => review.getBranchCommits(input), [WS_METHODS.reviewGetDiffPreview]: (input) => review.getDiffPreview(input), [WS_METHODS.reviewGetDiffFileContents]: (input) => review.getDiffFileContents(input), [WS_METHODS.terminalOpen]: (input) => terminalManager.open(input), diff --git a/apps/web/src/browser/browserLinkTarget.ts b/apps/web/src/browser/browserLinkTarget.ts index 50ff1a4e351fc..f1c4e65a99913 100644 --- a/apps/web/src/browser/browserLinkTarget.ts +++ b/apps/web/src/browser/browserLinkTarget.ts @@ -1,6 +1,11 @@ -import type { BrowserLinkTarget, ScopedThreadRef } from "@t3tools/contracts"; +import { + AuthPreviewOperateScope, + type BrowserLinkTarget, + type ScopedThreadRef, +} from "@t3tools/contracts"; import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings"; +import { readEnvironmentScope } from "~/state/session"; import { isPreviewAvailableFor } from "./previewRuntime"; export interface ResolveLinkTargetInput { @@ -38,5 +43,9 @@ export async function resolveBrowserLinkTargetPreference(): Promise { const explicitThreadRef = explicitDiffOpenRef.current; explicitDiffOpenRef.current = null; - if (diffOpen && activeThreadRef && explicitThreadRef !== activeThreadRef) { + if ( + diffOpen && + activeThreadRef && + explicitThreadRef !== activeThreadRef && + !useDiffPanelStore.getState().consumeKeepSelectionOnOpen(activeThreadRef) + ) { useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); } }, [activeThreadRef, diffOpen]); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index ddce50db3cfe3..ef95c24d578a2 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -6,7 +6,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; -import type { ScopedThreadRef, RunId } from "@t3tools/contracts"; +import type { ReviewBranchCommitsResult, ScopedThreadRef, RunId } from "@t3tools/contracts"; import { ArrowRightIcon, CheckIcon, @@ -20,7 +20,7 @@ import { } from "lucide-react"; import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide"; import * as Schema from "effect/Schema"; -import { useEffect, useRef, useState } from "react"; +import { type MouseEvent, useEffect, useRef, useState } from "react"; import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; import { useFilesystemReadAccess } from "~/state/filesystem"; import { useOpenInPreferredEditor } from "../editorPreferences"; @@ -33,6 +33,7 @@ import { revisionedQuery } from "~/state/revisionedQuery"; import { cn } from "~/lib/utils"; import { resolveToolCallDiffTarget, + selectThreadBranchBaseRef, selectThreadDiffPanelSelection, useDiffPanelSessionStore, useDiffPanelStore, @@ -60,7 +61,7 @@ import { useQuietThreadVisibleTurnItems, } from "../state/entities"; import { useClientSettings, updateClientSettings } from "../hooks/useSettings"; -import { formatShortTimestamp } from "../timestampFormat"; +import { formatRelativeTimeLabel, formatShortTimestamp } from "../timestampFormat"; import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { DiffStatLabel } from "./chat/DiffStatLabel"; @@ -83,6 +84,7 @@ import { import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub, @@ -103,6 +105,8 @@ import { useReviewFilePatches } from "./diffs/useReviewFilePatches"; import { DiffFileLoadingBoundary } from "./diffs/DiffFileLoadingBoundary"; import { DiffFileStatus } from "./diffs/DiffFileStatus"; import { useThreadRouteRef } from "../hooks/useThreadRouteTarget"; +import { projectCommitUrl } from "@t3tools/client-runtime/change-request-links"; +import { useOpenLink } from "../browser/useOpenLink"; type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; @@ -223,6 +227,8 @@ export default function DiffPanel({ ); const [hideTestFiles, setHideTestFiles] = useHideTestFiles(); const [baseRefQuery, setBaseRefQuery] = useState(""); + // The open commit menu keeps the list it opened with, so rows do not move under the pointer. + const [openCommitMenu, setOpenCommitMenu] = useState(null); const [codeViewRevision, setCodeViewRevision] = useState(0); const routeThreadRef = useThreadRouteRef(); @@ -263,8 +269,14 @@ export default function DiffPanel({ const diffSelection = useDiffPanelStore((state) => selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef), ); + const rememberedBaseRef = useDiffPanelStore((state) => + selectThreadBranchBaseRef(state, routeThreadRef), + ); const toolCallSelection = diffSelection.kind === "tool-call" ? diffSelection : null; - const isGitSelection = diffSelection.kind === "branch" || diffSelection.kind === "unstaged"; + const isGitSelection = + diffSelection.kind === "branch" || + diffSelection.kind === "unstaged" || + diffSelection.kind === "commit"; const toolCallItems = useQuietThreadVisibleTurnItems(toolCallSelection ? routeThreadRef : null); const toolCallDetail = useTurnItemDetail( toolCallSelection && routeThreadRef @@ -294,8 +306,15 @@ export default function DiffPanel({ }, [diffSelection, orderedTurnDiffSummaries, routeThreadRef]); const selectedRunId = diffSelection.kind === "turn" ? diffSelection.turnId : null; - const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch"; - const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null; + const selectedGitScope = + diffSelection.kind === "unstaged" || diffSelection.kind === "commit" + ? diffSelection.kind + : "branch"; + const selectedCommitSha = diffSelection.kind === "commit" ? diffSelection.sha : null; + const selectedBaseRef = + diffSelection.kind === "branch" || diffSelection.kind === "commit" + ? diffSelection.baseRef + : null; const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null; const selectedFileRevealRequestId = diffSelection.kind === "turn" ? diffSelection.revealRequestId : 0; @@ -306,31 +325,17 @@ export default function DiffPanel({ orderedTurnDiffSummaries[0]); const selectedCheckpointTurnCount = selectedTurn?.checkpointTurnCount; const latestTurn = orderedTurnDiffSummaries[0]; - const selectedScopeLabel = toolCallSelection - ? "Tool call" - : selectedRunId === null - ? selectedGitScope === "unstaged" - ? "Uncommitted" - : "Changes" - : selectedTurn?.runId === latestTurn?.runId - ? "Latest turn" - : `Turn ${selectedCheckpointTurnCount ?? "?"}`; const reviewSectionId = toolCallSelection ? `tool-call:${toolCallSelection.threadId}:${toolCallSelection.itemId}` : selectedTurn ? `turn:${selectedTurn.runId}` - : selectedGitScope; + : selectedCommitSha !== null + ? `commit:${selectedCommitSha}` + : selectedGitScope; const collapseScopeKey = routeThreadRef ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` : null; const codeViewMountKey = `${collapseScopeKey ?? reviewSectionId}:${codeViewRevision}`; - const reviewSectionTitle = toolCallSelection - ? "Tool call" - : selectedTurn - ? `Turn ${selectedCheckpointTurnCount ?? "?"}` - : selectedGitScope === "unstaged" - ? "Uncommitted" - : "Changes"; const selectedCheckpointRange = typeof selectedCheckpointTurnCount === "number" ? { @@ -354,6 +359,7 @@ export default function DiffPanel({ ? { cwd: activeCwd, ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), + ...(selectedCommitSha !== null ? { commitSha: selectedCommitSha } : {}), ignoreWhitespace: diffIgnoreWhitespace, } : undefined; @@ -372,13 +378,69 @@ export default function DiffPanel({ ) : null, ); + const branchCommitsQuery = useEnvironmentQuery( + visible && isGitRepo && canReadFiles && activeThread && activeCwd + ? reviewEnvironment.branchCommits({ + environmentId: activeThread.environmentId, + input: { + cwd: activeCwd, + ...(rememberedBaseRef ? { baseRef: rememberedBaseRef } : {}), + }, + }) + : null, + ); + const branchCommits = openCommitMenu ?? branchCommitsQuery.data; const activeThreadRefreshKey = routeThreadRef ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}` : null; const selectedGitSource = branchDiffPreview.data?.sources.find( - (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"), + (source) => + source.kind === + (selectedGitScope === "unstaged" + ? "working-tree" + : selectedGitScope === "commit" + ? "commit" + : "branch-range"), ); + const selectedCommitSubject = + selectedCommitSha !== null && selectedGitSource?.headRef === selectedCommitSha + ? selectedGitSource.commitSubject + : undefined; + const selectedGitScopeLabel = + selectedCommitSha !== null + ? (selectedCommitSubject ?? `Commit ${selectedCommitSha.slice(0, 7)}`) + : selectedGitScope === "unstaged" + ? "Uncommitted" + : "Changes"; + const selectedScopeLabel = toolCallSelection + ? "Tool call" + : selectedRunId === null + ? selectedCommitSha !== null + ? (selectedCommitSubject ?? "Commit") + : selectedGitScopeLabel + : selectedTurn?.runId === latestTurn?.runId + ? "Latest turn" + : `Turn ${selectedCheckpointTurnCount ?? "?"}`; + const reviewSectionTitle = toolCallSelection + ? "Tool call" + : selectedTurn + ? `Turn ${selectedCheckpointTurnCount ?? "?"}` + : selectedGitScopeLabel; + const headerCommitSha = selectedRunId === null ? selectedCommitSha : null; + const selectedCommitUrl = + headerCommitSha !== null + ? projectCommitUrl(activeProject?.repositoryIdentity, headerCommitSha) + : null; + const openLink = useOpenLink(routeThreadRef); + // A SHA link goes to the code host; it must not route back into this pane's commit selection. + const openCommitUrl = (event: MouseEvent, url: string) => { + event.stopPropagation(); + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button === 1) + return; + event.preventDefault(); + void openLink(url, { event }); + }; const refreshPreviewQuery = branchDiffPreview.refresh; const refreshDiffFromUserAction = refreshPreviewQuery; @@ -512,7 +574,8 @@ export default function DiffPanel({ environmentId: activeThread?.environmentId, cwd: branchDiffPreview.data?.cwd, source: lazySource, - baseRef: lazySource?.baseRef ?? selectedBaseRef, + baseRef: + selectedCommitSha !== null ? selectedBaseRef : (lazySource?.baseRef ?? selectedBaseRef), ignoreWhitespace: diffIgnoreWhitespace, theme: resolvedTheme, previewInput: branchDiffPreviewInput, @@ -693,16 +756,23 @@ export default function DiffPanel({ useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef); }; const selectedTurnValue = selectedTurn ? `turn:${selectedTurn.runId}` : ""; + const selectedCommitValue = selectedCommitSha !== null ? `commit:${selectedCommitSha}` : ""; const selectedScopeValue = toolCallSelection ? "" : selectedRunId === null - ? selectedGitScope + ? selectedCommitValue || selectedGitScope : selectedTurn?.runId === latestTurn?.runId ? "latest" : selectedTurnValue; const selectScopeValue = (value: string) => { if (value === "unstaged" || value === "branch") { selectGitScope(value); + } else if (value === selectedCommitValue) { + selectGitScope("branch"); + } else if (value.startsWith("commit:")) { + if (routeThreadRef) { + useDiffPanelStore.getState().selectCommit(routeThreadRef, value.slice("commit:".length)); + } } else if (value === "latest") { if (latestTurn) selectTurn(latestTurn.runId); } else { @@ -717,13 +787,37 @@ export default function DiffPanel({ } - className="max-w-full" + className={cn("max-w-full", headerCommitSha !== null && "min-w-0 shrink")} aria-label={`Diff scope: ${selectedScopeLabel}`} data-workspace-focus-default > {selectedScopeLabel} + {headerCommitSha !== null ? ( + selectedCommitUrl ? ( + + openCommitUrl(event, selectedCommitUrl)} + /> + } + > + {headerCommitSha.slice(0, 7)} + + {selectedCommitUrl} + + ) : ( + + {headerCommitSha.slice(0, 7)} + + ) + ) : null} @@ -760,6 +854,99 @@ export default function DiffPanel({ + { + setOpenCommitMenu(open ? (branchCommitsQuery.data ?? null) : null); + if (!open) return; + // The menu keeps the list it opened with; the refreshed one shows next time. + branchCommitsQuery.refresh(); + }} + > + Commits + + + {branchCommits?.commits.map((commit) => { + const commitUrl = projectCommitUrl( + activeProject?.repositoryIdentity, + commit.sha, + ); + return ( +
+ + + } + > + + {commit.subject} + + {formatRelativeTimeLabel(commit.committedAt)} + + + + + {commit.subject} + + + {commitUrl ? ( + + } + onClick={(event) => openCommitUrl(event, commitUrl)} + > + + {commit.sha.slice(0, 7)} + + + ) : ( + + {commit.sha.slice(0, 7)} + + )} +
+ ); + })} +
+ {branchCommitsQuery.error ? ( + {branchCommitsQuery.error} + ) : !canReadFiles ? ( + + {fileAccess.isPending + ? "Checking file access..." + : (fileAccess.error ?? "This connection cannot read local diffs.")} + + ) : branchCommits === null ? ( + Loading commits... + ) : branchCommits.commits.length === 0 ? ( + No commits on this branch. + ) : branchCommits.truncated ? ( + + {`Showing latest ${branchCommits.commits.length} commits`} + + ) : null} +
+
{isGitSelection && selectedGitScope === "branch" && selectedGitSource?.baseRef && ( @@ -894,7 +1081,12 @@ export default function DiffPanel({
)}
-
+
{codeViewFiles.length > 0 || (isGitSelection && selectedGitSource?.files?.length) ? ( ) : ( diff --git a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx index a2a0f874d6e90..31ad2028c5f51 100644 --- a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx +++ b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.browser.test.tsx @@ -1,11 +1,16 @@ -import { EnvironmentId, ProjectId } from "@t3tools/contracts"; -import type { Atom } from "effect/reactivity"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { AsyncResult, type Atom } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; import { act, render, stubGlobal } from "../../../test/render"; const state = vi.hoisted(() => ({ - projects: [] as never[], + projects: [] as EnvironmentProject[], + routeParams: {} as Record, + preview: vi.fn(), + openLink: vi.fn(async () => undefined), navigate: vi.fn(async (): Promise => undefined), openScratchProject: vi.fn(async () => null), confirm: vi.fn(async (_message: string) => true), @@ -18,7 +23,10 @@ const state = vi.hoisted(() => ({ vi.mock(import("@tanstack/react-router"), async (original) => ({ ...(await original()), useNavigate: (() => state.navigate) as never, - useRouter: (() => ({ navigate: state.navigate, state: { matches: [] } })) as never, + useRouter: (() => ({ + navigate: state.navigate, + state: { matches: [{ params: state.routeParams }] }, + })) as never, })); vi.mock(import("../../state/environments"), async (original) => ({ ...(await original()), @@ -47,11 +55,17 @@ vi.mock(import("../../state/server"), async (original) => { }); vi.mock(import("../../state/shell"), async (original) => { const { Atom } = await import("effect/reactivity"); - return { ...(await original()), allReachableShellsLiveAtom: Atom.make(false) }; + return { + ...(await original()), + allReachableShellsLiveAtom: Atom.make(false).pipe(Atom.keepAlive), + }; }); vi.mock(import("../../state/entities"), async (original) => ({ ...(await original()), readProjects: () => state.projects, + readThreadShell: (ref: { threadId: string }) => + ref.threadId === "thread" ? ({ projectId: "effect", worktreePath: null } as never) : null, + readProject: () => state.projects[0] ?? null, })); vi.mock(import("../../hooks/useScratchProject"), async (original) => ({ ...(await original()), @@ -61,6 +75,10 @@ vi.mock(import("../../hooks/useScratchProject"), async (original) => ({ })) as never, })); +vi.mock("../../state/use-atom-query-runner", () => ({ + useAtomQueryRunner: () => state.preview, +})); +vi.mock("../../browser/useOpenLink", () => ({ useOpenLink: () => state.openLink })); vi.mock(import("../../confirmDialog"), async (original) => ({ ...(await original()), requestConfirmDialog: state.confirm, @@ -82,6 +100,12 @@ vi.mock(import("../../state/use-atom-command"), async (original) => { import { AppAtomRegistryProvider, appAtomRegistry } from "../../rpc/atomRegistry"; import { allReachableShellsLiveAtom } from "../../state/shell"; +import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../../diffPanelStore"; +import { + selectActiveRightPanel, + selectActiveRightPanelSurface, + useRightPanelStore, +} from "../../rightPanelStore"; import { DesktopWebLinkCoordinator } from "./DesktopWebLinkCoordinator"; const discoverySettled = allReachableShellsLiveAtom as Atom.Writable; @@ -89,6 +113,7 @@ const environmentId = EnvironmentId.make("local"); const projectId = ProjectId.make("effect"); const project = { id: projectId, + workspaceRoot: "/workspace/effect", environmentId, repositoryIdentity: { provider: "github", @@ -100,7 +125,7 @@ const project = { remoteUrl: "https://github.com/Effect-TS/effect.git", }, }, -} as never; +} as EnvironmentProject; const pullRequestUrl = "https://github.com/Effect-TS/effect/pull/9339"; const pullRequestView = { to: "/pull-requests", @@ -123,6 +148,12 @@ function setup() { state.confirm.mockResolvedValue(true); state.localEnvironmentDisabled = false; state.projects = []; + state.routeParams = {}; + state.preview + .mockReset() + .mockResolvedValue(AsyncResult.success({ sources: [{ kind: "commit", headRef: sha }] })); + useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {} }); + useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} }); appAtomRegistry.set(discoverySettled, false); } @@ -172,6 +203,80 @@ it("holds a launch PR link until project discovery settles, then opens the PR vi expect(state.openScratchProject).not.toHaveBeenCalled(); }); +const sha = "a".repeat(40); +const commitUrl = `https://github.com/Effect-TS/effect/pull/9339/commits/${sha}`; +const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread")); + +it.each([ + ["github.com", "pull/9339/commits"], + ["code.example.com:8443", "commit"], +])("opens a %s/%s commit in the active thread's Diff pane after discovery", async (host, path) => { + setup(); + state.routeParams = { environmentId: "local", threadId: "thread" }; + await using _coordinator = await mount(`https://${host}/Effect-TS/effect/${path}/${sha}`); + expect(state.preview).not.toHaveBeenCalled(); + + state.projects = [ + { + ...project, + repositoryIdentity: { + ...project.repositoryIdentity, + canonicalKey: `${host}/effect-ts/effect`, + locator: { + ...project.repositoryIdentity?.locator, + remoteUrl: `https://${host}/Effect-TS/effect.git`, + }, + }, + } as EnvironmentProject, + ]; + await act(async () => appAtomRegistry.set(discoverySettled, true)); + await vi.waitFor(() => { + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, threadRef)).toBe( + "diff", + ); + }); + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, threadRef), + ).toMatchObject({ kind: "commit", sha }); + expect(state.preview).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: expect.objectContaining({ cwd: "/workspace/effect", commitSha: sha }), + }); + expect(state.navigate).not.toHaveBeenCalled(); + expect(state.openLink).not.toHaveBeenCalled(); +}); + +it("opens a PR commit the workspace lacks in the thread's PR panel", async () => { + setup(); + state.routeParams = { environmentId: "local", threadId: "thread" }; + state.projects = [project]; + state.preview.mockResolvedValueOnce(AsyncResult.success({ sources: [] })); + appAtomRegistry.set(discoverySettled, true); + await using _coordinator = await mount(commitUrl); + await vi.waitFor(() => + expect( + selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, threadRef), + ).toMatchObject({ kind: "pull-request", repository: "effect-ts/effect", number: 9339 }), + ); + expect(useDiffPanelStore.getState().byThreadKey).toEqual({}); + expect(state.openLink).not.toHaveBeenCalled(); +}); + +it("opens a plain commit the workspace lacks like any other delivered link", async () => { + setup(); + state.routeParams = { environmentId: "local", threadId: "thread" }; + state.projects = [project]; + state.preview.mockResolvedValueOnce(AsyncResult.success({ sources: [] })); + appAtomRegistry.set(discoverySettled, true); + const plainCommitUrl = `https://github.com/Effect-TS/effect/commit/${sha}`; + await using _coordinator = await mount(plainCommitUrl); + await vi.waitFor(() => expect(state.openScratchProject).toHaveBeenCalledOnce()); + // Handing it to the OS browser would loop when T3 Code is the default browser. + expect(state.openLink).not.toHaveBeenCalled(); + expect(useDiffPanelStore.getState().byThreadKey).toEqual({}); + expect(useRightPanelStore.getState().byThreadKey).toEqual({}); +}); + it("adds the backend from a pairing link after the user confirms its host", async () => { setup(); await using _coordinator = await mount(pairLink); diff --git a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx index 4ae0b6cdb25b4..ddd892d02bd12 100644 --- a/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx @@ -27,6 +27,7 @@ import { selectActiveRightPanelSurface, useRightPanelStore } from "../../rightPa import { appAtomRegistry } from "../../rpc/atomRegistry"; import { resolveThreadRouteTarget } from "../../threadRoutes"; import { assetEnvironment } from "../../state/assets"; +import { readProject, readThreadShell } from "../../state/entities"; import { useEnvironmentHttpBaseUrl, usePrimaryEnvironment } from "../../state/environments"; import { previewEnvironment } from "../../state/preview"; import { allReachableShellsLiveAtom } from "../../state/shell"; @@ -163,25 +164,14 @@ export function DesktopWebLinkCoordinator() { return threadRef; }; - const openLink = useEffectEvent(async (url: string) => { - const pairingUrl = readPairingAppLink(url); - if (pairingUrl !== null) { - await pairFromLink(pairingUrl); - return; - } - const appPath = promptLinkAppPath(url); - if (appPath !== null) { - await router.navigate({ href: appPath }); - return; - } - if (openChangeRequest(externalClick, url)) return; + const openInBrowser = useEffectEvent(async (url: string) => { + const routeParams = router.state.matches.at(-1)?.params ?? {}; const isFile = url.startsWith("file:"); // Only this machine's environment serves files, so fail before starting a thread. if (isFile && httpBaseUrl === null) { reportFailure(url); return; } - const routeParams = router.state.matches.at(-1)?.params ?? {}; const threadRef = threadShowingBrowser( routeParams, @@ -210,15 +200,49 @@ export function DesktopWebLinkCoordinator() { } }); + const openLink = useEffectEvent(async (url: string) => { + const pairingUrl = readPairingAppLink(url); + if (pairingUrl !== null) { + await pairFromLink(pairingUrl); + return; + } + const appPath = promptLinkAppPath(url); + if (appPath !== null) { + await router.navigate({ href: appPath }); + return; + } + const routeParams = router.state.matches.at(-1)?.params ?? {}; + const routeTarget = resolveThreadRouteTarget(routeParams); + const activeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : undefined; + const activeThread = activeThreadRef ? readThreadShell(activeThreadRef) : null; + const activeProject = + activeThread && activeThreadRef + ? readProject(scopeProjectRef(activeThreadRef.environmentId, activeThread.projectId)) + : null; + const commitThreadRef = + activeThread && + sourceControlClients.parseCommitUrl(url, activeProject?.repositoryIdentity?.provider) !== null + ? activeThreadRef + : undefined; + // A commit the thread's workspace lacks continues here, after its lookup. + const openUnavailableCommit = () => { + queueRef.current = queueRef.current.then(() => openInBrowser(url)).catch(() => undefined); + }; + if (openChangeRequest(externalClick, url, commitThreadRef, undefined, openUnavailableCommit)) + return; + await openInBrowser(url); + }); + const receiveLink = useEffectEvent((url: string) => { - // A connected server publishes its config before its project snapshot, so a PR link - // delivered at launch would miss its project and fall through to the browser. Hold PR - // links, and only those, until discovery settles; an ordinary link never waits for it. + // A connected server publishes its config before its project snapshot, so a source control + // link delivered at launch would miss its project and fall through to the browser. Hold + // those links until discovery settles; an ordinary link never waits for it. // Discovery never settles without the local environment, so those links do not wait. const hold = openPullRequestsInApp && !localEnvironmentDisabled && - sourceControlClients.parseChangeRequestUrl(url) !== null + (sourceControlClients.parseChangeRequestUrl(url) !== null || + sourceControlClients.parseCommitUrl(url) !== null) ? projectsSettled() : Promise.resolve(); void hold.then(() => { diff --git a/apps/web/src/components/diffs/useReviewFilePatches.ts b/apps/web/src/components/diffs/useReviewFilePatches.ts index b683e13a8f6ee..69dafc2dd7823 100644 --- a/apps/web/src/components/diffs/useReviewFilePatches.ts +++ b/apps/web/src/components/diffs/useReviewFilePatches.ts @@ -193,6 +193,7 @@ function reviewFileQueries({ request: { cwd, ...(baseRef ? { baseRef } : {}), + ...(source.kind === "commit" ? { commitSha: source.id } : {}), ignoreWhitespace, file: { path: file.path, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index dc494caf5344d..aabfd0300eda5 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -923,7 +923,7 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Open pull requests in T3 Code", to: "/settings/diffs", searchTerms: [ - `pr links ${SOURCE_CONTROL_HOST_LABELS} browser external click default pull request view panel`, + `pr commit links ${SOURCE_CONTROL_HOST_LABELS} browser external click default pull request view panel`, ], }, { diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts index 11d96a3909ce7..6bd7eb0e01bdd 100644 --- a/apps/web/src/diffPanelStore.ts +++ b/apps/web/src/diffPanelStore.ts @@ -17,6 +17,7 @@ export type DiffPanelSelection = | ({ kind: "tool-call" } & ToolCallDiffTarget) | { kind: "branch"; baseRef: string | null } | { kind: "unstaged" } + | { kind: "commit"; sha: string; baseRef: string | null } | { kind: "turn"; turnId: RunId; filePath: string | null; revealRequestId: number }; const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; @@ -24,10 +25,14 @@ const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; interface DiffPanelStoreState { byThreadKey: Record; branchBaseRefByThreadKey: Record; + /** A link that selected a commit before opening the closed pane; opening keeps that selection. */ + keepSelectionOnOpenThreadKey: string | null; selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; selectToolCall: (ref: ScopedThreadRef, target: ToolCallDiffTarget) => void; selectTurn: (ref: ScopedThreadRef, turnId: RunId, filePath?: string) => void; + selectCommit: (ref: ScopedThreadRef, sha: string, keepOnOpen?: boolean) => void; + consumeKeepSelectionOnOpen: (ref: ScopedThreadRef) => boolean; reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray) => void; removeThread: (ref: ScopedThreadRef) => void; } @@ -37,19 +42,27 @@ function normalizeBaseRef(baseRef: string | null): string | null { return normalized ? normalized : null; } +function branchBaseRef( + state: Pick, + threadKey: string, +): string | null { + const previous = state.byThreadKey[threadKey]; + return previous?.kind === "branch" || previous?.kind === "commit" + ? previous.baseRef + : (state.branchBaseRefByThreadKey[threadKey] ?? null); +} + export const useDiffPanelStore = create()( persist( - (set) => ({ + (set, get) => ({ byThreadKey: {}, branchBaseRefByThreadKey: {}, + keepSelectionOnOpenThreadKey: null, selectGitScope: (ref, scope) => set((state) => { const threadKey = scopedThreadKey(ref); const previous = state.byThreadKey[threadKey]; - const previousBaseRef = - previous?.kind === "branch" - ? previous.baseRef - : (state.branchBaseRefByThreadKey[threadKey] ?? null); + const previousBaseRef = branchBaseRef(state, threadKey); return { byThreadKey: { ...state.byThreadKey, @@ -59,7 +72,7 @@ export const useDiffPanelStore = create()( : { kind: "unstaged" }, }, branchBaseRefByThreadKey: - previous?.kind === "branch" + previous?.kind === "branch" || previous?.kind === "commit" ? { ...state.branchBaseRefByThreadKey, [threadKey]: previous.baseRef } : state.branchBaseRefByThreadKey, }; @@ -102,6 +115,26 @@ export const useDiffPanelStore = create()( }, }; }), + selectCommit: (ref, sha, keepOnOpen = false) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const baseRef = branchBaseRef(state, threadKey); + return { + byThreadKey: { + ...state.byThreadKey, + [threadKey]: { kind: "commit", sha, baseRef }, + }, + branchBaseRefByThreadKey: { ...state.branchBaseRefByThreadKey, [threadKey]: baseRef }, + keepSelectionOnOpenThreadKey: keepOnOpen + ? threadKey + : state.keepSelectionOnOpenThreadKey, + }; + }), + consumeKeepSelectionOnOpen: (ref) => { + if (get().keepSelectionOnOpenThreadKey !== scopedThreadKey(ref)) return false; + set({ keepSelectionOnOpenThreadKey: null }); + return true; + }, reconcileTurnSelection: (ref, availableTurnIds) => set((state) => { const threadKey = scopedThreadKey(ref); @@ -178,6 +211,13 @@ export function selectThreadDiffPanelSelection( return byThreadKey[scopedThreadKey(ref)] ?? DEFAULT_SELECTION; } +export function selectThreadBranchBaseRef( + state: Pick, + ref: ScopedThreadRef | null | undefined, +): string | null { + return ref ? branchBaseRef(state, scopedThreadKey(ref)) : null; +} + export function resolveToolCallDiffTarget( target: ToolCallDiffTarget, items: ReadonlyArray, diff --git a/apps/web/src/lib/openPullRequestLink.browser.test.tsx b/apps/web/src/lib/openPullRequestLink.browser.test.tsx new file mode 100644 index 0000000000000..a93f32a8902f7 --- /dev/null +++ b/apps/web/src/lib/openPullRequestLink.browser.test.tsx @@ -0,0 +1,160 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { AsyncResult } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +import { act, render, stubGlobal } from "../../test/render"; + +const state = vi.hoisted(() => ({ preview: vi.fn(), openLink: vi.fn() })); +vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.preview })); +vi.mock("../browser/useOpenLink", () => ({ useOpenLink: () => state.openLink })); +vi.mock(import("../state/environments"), async (original) => ({ + ...(await original()), + usePrimaryEnvironmentId: () => EnvironmentId.make("local"), +})); +vi.mock("../state/entities", async (original) => ({ + ...(await original()), + readProjects: () => [project], + useProjects: () => [project], + readProject: () => project, + readThreadShell: () => ({ projectId: "project", worktreePath: null }), +})); +vi.mock("../state/server", async (original) => { + const { Atom } = await import("effect/reactivity"); + return { + ...(await original()), + environmentServerConfigsAtom: Atom.make(new Map([["local", {}]])), + }; +}); + +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { __setClientSettingsForTests } from "../hooks/useSettings"; +import { ToastProvider } from "../components/ui/toast"; +import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; +import { useRightPanelStore } from "../rightPanelStore"; +import { useDiffPanelStore } from "../diffPanelStore"; +import { useOpenChangeRequestLink } from "./openPullRequestLink"; + +const environmentId = EnvironmentId.make("local"); +const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread")); +const project = { + id: "project", + environmentId, + workspaceRoot: "/repo", + repositoryIdentity: { + provider: "github", + canonicalKey: "github.com/effect-ts/effect", + displayName: "Effect-TS/effect", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/Effect-TS/effect.git", + }, + }, +}; +const commitUrl = `https://github.com/Effect-TS/effect/commit/${"a".repeat(40)}?plain=1#diff-original`; +const pullRequestCommitUrl = `https://github.com/Effect-TS/effect/pull/1/commits/${"a".repeat(40)}`; + +function CommitLink({ url = commitUrl }: { url?: string }) { + const open = useOpenChangeRequestLink(threadRef); + return ( + open(event, url)}> + Commit + + ); +} + +function setup() { + state.preview.mockReset().mockResolvedValue(AsyncResult.success({ sources: [] })); + state.openLink.mockReset().mockResolvedValue(undefined); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + pullRequestLinksOpenInApp: true, + browserLinkTarget: "system", + }); + useDiffPanelStore.setState({ + byThreadKey: {}, + branchBaseRefByThreadKey: {}, + keepSelectionOnOpenThreadKey: null, + }); + useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} }); +} + +it.each([ + ["shiftKey", commitUrl], + ["altKey", commitUrl], + ["shiftKey", pullRequestCommitUrl], +] as const)("keeps %s clicks on %s native", async (modifier, url) => { + setup(); + await using screen = await render( + + + , + ); + const event = new MouseEvent("click", { bubbles: true, cancelable: true, [modifier]: true }); + const defaults: boolean[] = []; + screen.container.addEventListener( + "click", + (nativeEvent) => { + defaults.push(nativeEvent.defaultPrevented); + nativeEvent.preventDefault(); + }, + { once: true }, + ); + await act(() => screen.getByRole("link", { name: "Commit" }).element().dispatchEvent(event)); + expect(defaults).toEqual([false]); + expect(state.preview).not.toHaveBeenCalled(); + expect(useRightPanelStore.getState().byThreadKey).toEqual({}); +}); + +it.each(["system", "app"] as const)( + "offers the original unavailable commit URL when web popups are blocked (%s)", + async (browserLinkTarget) => { + setup(); + // Without preview access, the "app" preference opens a popup too. + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + pullRequestLinksOpenInApp: true, + browserLinkTarget, + }); + using _desktop = stubGlobal("desktopBridge", undefined); + await using screen = await render( + + + + + , + ); + using open = vi.spyOn(window, "open").mockReturnValue(null); + await screen.getByRole("link", { name: "Commit" }).click(); + await expect + .element(page.getByRole("button", { name: "Open link", exact: true })) + .toBeVisible(); + expect(open).not.toHaveBeenCalled(); + await page.getByRole("button", { name: "Open link", exact: true }).click(); + expect(open).toHaveBeenCalledExactlyOnceWith(commitUrl, "_blank", "noopener,noreferrer"); + expect(state.openLink).not.toHaveBeenCalled(); + }, +); + +it("opens an available commit in the closed Diff pane without the pane resetting it", async () => { + setup(); + const sha = "a".repeat(40); + state.preview.mockResolvedValue( + AsyncResult.success({ sources: [{ kind: "commit", headRef: sha }] }), + ); + await using screen = await render( + + + , + ); + await screen.getByRole("link", { name: "Commit" }).click(); + await vi.waitFor(() => + expect(useDiffPanelStore.getState().byThreadKey).toEqual({ + [scopedThreadKey(threadRef)]: { kind: "commit", sha, baseRef: null }, + }), + ); + // ChatView resets a pane opened without an explicit request to Changes unless this is set. + expect(useDiffPanelStore.getState().consumeKeepSelectionOnOpen(threadRef)).toBe(true); +}); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 014728f058a43..31c04b6263e5f 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -8,15 +8,25 @@ import { resolvePullRequestPreviewTarget, } from "@t3tools/client-runtime/change-request-links"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; + +import { canOpenLinksInApp } from "../browser/browserLinkTarget"; import { useOpenLink } from "../browser/useOpenLink"; import { useClientSettings } from "../hooks/useSettings"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; -import { useRightPanelStore } from "../rightPanelStore"; +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; +import { + selectThreadBranchBaseRef, + selectThreadDiffPanelSelection, + useDiffPanelStore, +} from "../diffPanelStore"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { readProjects, useProjects } from "../state/entities"; +import { readProject, readProjects, readThreadShell, useProjects } from "../state/entities"; import { environmentServerConfigsAtom, serverEnvironment } from "../state/server"; import { usePrimaryEnvironmentId } from "../state/environments"; +import { reviewEnvironment } from "../state/review"; +import { useAtomQueryRunner } from "../state/use-atom-query-runner"; export function usePullRequestPreviewTarget(environmentId: EnvironmentId | null, url: string) { const projects = useProjects(); @@ -43,71 +53,178 @@ export function useOpenChangeRequestLink( event: Pick< MouseEvent, "preventDefault" | "stopPropagation" | "metaKey" | "ctrlKey" - >, + > & + Partial, "button" | "shiftKey" | "altKey">>, targetUrl: string, targetThreadRef?: ScopedThreadRef, targetEnvironmentId?: EnvironmentId, + onCommitUnavailable?: () => void, ) => boolean { const navigate = useNavigate(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const openInApp = useClientSettings((settings) => settings.pullRequestLinksOpenInApp); - return (event, targetUrl, targetThreadRef, targetEnvironmentId) => { + const ignoreWhitespace = useClientSettings((settings) => settings.diffIgnoreWhitespace); + const browserLinkTarget = useClientSettings((settings) => settings.browserLinkTarget); + const readDiffPreview = useAtomQueryRunner(reviewEnvironment.diffPreview, { + reportFailure: false, + refresh: true, + }); + const openLink = useOpenLink(threadRef); + return (event, targetUrl, targetThreadRef, targetEnvironmentId, onCommitUnavailable) => { if (!openInApp || shouldOpenPullRequestExternally(event)) return false; - // Read at click time: every provider status push replaces the config map, and - // subscribing would hand every rendered link a new handler. Projects likewise, so a - // link can resolve against a snapshot that arrived after this handler was built. - const serverConfigs = appAtomRegistry.get(environmentServerConfigsAtom); const resolvedThreadRef = targetThreadRef ?? threadRef; const resolvedPanelRef = panelRef ?? resolvedThreadRef; - const target = resolveChangeRequestLinkTarget({ - url: targetUrl, - projects: readProjects(), - configLoaded: (environmentId) => serverConfigs.has(environmentId), - environmentId: resolvedThreadRef?.environmentId ?? targetEnvironmentId, - primaryEnvironmentId, - lendHostProject: resolvedPanelRef !== undefined, - }); - if (target === null) return false; - const { project, repository, number, host } = target; - event.preventDefault(); - event.stopPropagation(); - const pullRequestsSearch = { - repository, - number, - ...(host === undefined ? {} : { selectedHost: host }), - selectedProjectId: project.id, - selectedEnvironmentId: project.environmentId, - }; - if (resolvedPanelRef) { - useRightPanelStore.getState().openPullRequest(resolvedPanelRef, { - // The standalone PR panel has a synthetic ref; each tab keeps its real environment. - ...(resolvedPanelRef.environmentId === project.environmentId - ? {} - : { environmentId: project.environmentId }), - projectId: project.id, - ...(host === undefined ? {} : { host }), - repository, + const openPullRequest = () => { + // Read at click time: every provider status push replaces the config map, and + // subscribing would hand every rendered link a new handler. Projects likewise, so a + // link can resolve against a snapshot that arrived after this handler was built. + const serverConfigs = appAtomRegistry.get(environmentServerConfigsAtom); + const target = resolveChangeRequestLinkTarget({ url: targetUrl, - number, + projects: readProjects(), + configLoaded: (environmentId) => serverConfigs.has(environmentId), + environmentId: resolvedThreadRef?.environmentId ?? targetEnvironmentId, + primaryEnvironmentId, + lendHostProject: resolvedPanelRef !== undefined, }); - if (!resolvedThreadRef) { - void navigate({ - to: "/pull-requests", - search: (previous) => ({ - ...previous, - involvement: previous.involvement ?? "all", - state: previous.state ?? "all", - ...pullRequestsSearch, - }), - replace: true, + if (target === null) return false; + const { project, repository, number, host } = target; + const pullRequestsSearch = { + repository, + number, + ...(host === undefined ? {} : { selectedHost: host }), + selectedProjectId: project.id, + selectedEnvironmentId: project.environmentId, + }; + if (resolvedPanelRef) { + useRightPanelStore.getState().openPullRequest(resolvedPanelRef, { + // The standalone PR panel has a synthetic ref; each tab keeps its real environment. + ...(resolvedPanelRef.environmentId === project.environmentId + ? {} + : { environmentId: project.environmentId }), + projectId: project.id, + ...(host === undefined ? {} : { host }), + repository, + url: targetUrl, + number, }); + if (!resolvedThreadRef) { + void navigate({ + to: "/pull-requests", + search: (previous) => ({ + ...previous, + involvement: previous.involvement ?? "all", + state: previous.state ?? "all", + ...pullRequestsSearch, + }), + replace: true, + }); + } + return true; } + void navigate({ + to: "/pull-requests", + search: { involvement: "all", state: "all", ...pullRequestsSearch }, + }); + return true; + }; + const commitThread = resolvedThreadRef ? readThreadShell(resolvedThreadRef) : null; + const commitProject = + commitThread && resolvedThreadRef + ? readProject({ + environmentId: resolvedThreadRef.environmentId, + projectId: commitThread.projectId, + }) + : null; + const identity = commitProject?.repositoryIdentity; + const parsedCommit = sourceControlClients.parseCommitUrl(targetUrl, identity?.provider); + // Shift and Alt keep their browser meaning (new window, download) on commit links. + if (parsedCommit !== null && (event.shiftKey || event.altKey)) return false; + if ( + parsedCommit !== null && + commitThread && + resolvedThreadRef && + commitProject?.environmentId === resolvedThreadRef.environmentId && + identity && + identity.provider === parsedCommit.definition.kind && + parsedCommit.definition.isChangeRequestInRepository(identity, parsedCommit.link) + ) { + const commit = parsedCommit.link; + event.preventDefault(); + event.stopPropagation(); + const userActionRevision = useRightPanelStore.getState().markUserAction(resolvedThreadRef); + const baseRef = selectThreadBranchBaseRef(useDiffPanelStore.getState(), resolvedThreadRef); + const selection = selectThreadDiffPanelSelection( + useDiffPanelStore.getState().byThreadKey, + resolvedThreadRef, + ); + void readDiffPreview({ + environmentId: resolvedThreadRef.environmentId, + input: { + cwd: commitThread.worktreePath ?? commitProject.workspaceRoot, + ...(baseRef ? { baseRef } : {}), + commitSha: commit.sha, + ignoreWhitespace, + }, + }) + .then((result) => { + if ( + useRightPanelStore.getState().getUserActionRevision(resolvedThreadRef) !== + userActionRevision || + selectThreadDiffPanelSelection( + useDiffPanelStore.getState().byThreadKey, + resolvedThreadRef, + ) !== selection + ) + return; + if ( + result._tag === "Success" && + result.value.sources.some( + (source) => source.kind === "commit" && source.headRef === commit.sha, + ) + ) { + const diffOpen = + selectActiveRightPanel( + useRightPanelStore.getState().byThreadKey, + resolvedThreadRef, + ) === "diff"; + useDiffPanelStore.getState().selectCommit(resolvedThreadRef, commit.sha, !diffOpen); + useRightPanelStore.getState().open(resolvedThreadRef, "diff"); + return; + } + if (openPullRequest()) return; + if (onCommitUnavailable) return onCommitUnavailable(); + // A popup opened after the lookup has lost the click's activation, so browsers block it. + if ( + window.desktopBridge === undefined && + (browserLinkTarget !== "app" || !canOpenLinksInApp(resolvedThreadRef)) + ) { + const toastId = toastManager.add( + stackedThreadToast({ + type: "info", + title: "This commit is not in the thread's workspace", + actionProps: { + children: "Open link", + onClick: () => { + toastManager.close(toastId); + window.open(targetUrl, "_blank", "noopener,noreferrer"); + }, + }, + }), + ); + return; + } + return openLink(targetUrl, { + event: { metaKey: event.metaKey, ctrlKey: event.ctrlKey, button: event.button }, + threadRef: resolvedThreadRef, + }); + }) + .catch((error: unknown) => console.error(error)); return true; } - void navigate({ - to: "/pull-requests", - search: { involvement: "all", state: "all", ...pullRequestsSearch }, - }); + if (!openPullRequest()) return false; + event.preventDefault(); + event.stopPropagation(); return true; }; } diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 33c537a3cf942..ea043c0298bdb 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -109,6 +109,7 @@ interface RightPanelStoreState { userActionRevisionByThreadKey: Record; closeRevisionByThreadKey: Record; getUserActionRevision: (ref: ScopedThreadRef) => number; + markUserAction: (ref: ScopedThreadRef) => number; /** Refused if the user chose a panel after expectedUserActionRevision was read. */ openProactive: ( ref: ScopedThreadRef, @@ -598,6 +599,16 @@ export const useRightPanelStore = create()( closeRevisionByThreadKey: {}, getUserActionRevision: (ref) => get().userActionRevisionByThreadKey[scopedThreadKey(ref)] ?? 0, + markUserAction: (ref) => { + const revision = get().getUserActionRevision(ref) + 1; + set((state) => ({ + userActionRevisionByThreadKey: { + ...state.userActionRevisionByThreadKey, + [scopedThreadKey(ref)]: revision, + }, + })); + return revision; + }, openProactive: (ref, request, expectedUserActionRevision, activateExisting = false) => { let opened = false; set((state) => { diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 815e5dd0668fa..9eb2751f9315f 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -10,6 +10,16 @@ rebases, the files Git brought in are left out. A file stays in the list when th committed it, or fixed a conflict in it. Use the branch comparison to review everything that changed against your base branch. Restore still returns the complete saved workspace. +On web and desktop, open the Diff pane source picker and choose **Commits** to review one local +commit at a time. The list shows the newest 100 commits in the branch comparison. A merge commit +shows its changes against its first parent. + +A commit link from a supported code host opens in the Diff pane when the thread's repository has +that commit locally, on any branch. Otherwise a pull request commit link opens the pull request. Other +commit links follow your **Open links in** setting; on web, when that opens a new tab, T3 Code first +shows an **Open link** button. Command/Ctrl+Click always opens the link in your browser. Commit links follow the **Open pull requests in T3 Code** +setting in **Settings → Diffs**. + ## Connect an account Install Git and configure authentication on the machine running your T3 Code server. For a remote diff --git a/packages/client-runtime/src/changeRequestLinks.ts b/packages/client-runtime/src/changeRequestLinks.ts index 2983647f3e26f..76f60a5953f2b 100644 --- a/packages/client-runtime/src/changeRequestLinks.ts +++ b/packages/client-runtime/src/changeRequestLinks.ts @@ -150,3 +150,21 @@ export function projectChangeRequestUrl( ...(ownRepository && identity.webUrl !== undefined ? { webUrl: identity.webUrl } : {}), }); } + +export function projectCommitUrl( + identity: RepositoryIdentity | null | undefined, + sha: string, +): string | null { + if (identity?.provider === undefined) return null; + const [host, ...path] = identity.canonicalKey.split("/"); + if (!host) return null; + return ( + sourceControlClients.get(identity.provider).commitUrl?.({ + host, + repository: path.join("/"), + sha, + remoteUrl: identity.locator.remoteUrl.trim(), + ...(identity.webUrl !== undefined ? { webUrl: identity.webUrl } : {}), + }) ?? null + ); +} diff --git a/packages/client-runtime/src/state/review.ts b/packages/client-runtime/src/state/review.ts index 691212aedd501..6700be9af657a 100644 --- a/packages/client-runtime/src/state/review.ts +++ b/packages/client-runtime/src/state/review.ts @@ -51,6 +51,11 @@ export function createReviewEnvironmentAtoms( }); }); return { + branchCommits: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:review:branch-commits", + tag: WS_METHODS.reviewGetBranchCommits, + staleTimeMs: 5_000, + }), diffPreview, diffFilePatch: createEnvironmentQueryAtomFamily(runtime, { label: "environment-data:review:diff-file-patch", diff --git a/packages/contracts/src/review.ts b/packages/contracts/src/review.ts index 5a95442917c9a..a40bddfc1e33e 100644 --- a/packages/contracts/src/review.ts +++ b/packages/contracts/src/review.ts @@ -6,18 +6,41 @@ import { VcsError } from "./vcs.ts"; export const ReviewDiffPreviewInput = Schema.Struct({ cwd: TrimmedNonEmptyString, baseRef: Schema.optional(TrimmedNonEmptyString), + commitSha: Schema.optional(TrimmedNonEmptyString), ignoreWhitespace: Schema.optionalKey(Schema.Boolean), file: Schema.optionalKey( Schema.Struct({ path: Schema.NonEmptyString, previousPath: Schema.NullOr(Schema.NonEmptyString), - sourceKind: Schema.Literals(["working-tree", "branch-range"]), + sourceKind: Schema.Literals(["working-tree", "branch-range", "commit"]), }), ), }); export type ReviewDiffPreviewInput = typeof ReviewDiffPreviewInput.Type; -export const ReviewDiffPreviewSourceKind = Schema.Literals(["working-tree", "branch-range"]); +export const ReviewBranchCommitsInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + baseRef: Schema.optional(TrimmedNonEmptyString), +}); +export type ReviewBranchCommitsInput = typeof ReviewBranchCommitsInput.Type; + +export const ReviewBranchCommitsResult = Schema.Struct({ + commits: Schema.Array( + Schema.Struct({ + sha: Schema.String, + subject: Schema.String, + committedAt: Schema.String, + }), + ), + truncated: Schema.Boolean, +}); +export type ReviewBranchCommitsResult = typeof ReviewBranchCommitsResult.Type; + +export const ReviewDiffPreviewSourceKind = Schema.Literals([ + "working-tree", + "branch-range", + "commit", +]); export type ReviewDiffPreviewSourceKind = typeof ReviewDiffPreviewSourceKind.Type; export const ReviewDiffFileStat = Schema.Struct({ @@ -32,6 +55,7 @@ export const ReviewDiffPreviewSource = Schema.Struct({ id: TrimmedNonEmptyString, kind: ReviewDiffPreviewSourceKind, title: TrimmedNonEmptyString, + commitSubject: Schema.optionalKey(TrimmedNonEmptyString), baseRef: Schema.NullOr(TrimmedNonEmptyString), headRef: Schema.NullOr(TrimmedNonEmptyString), diff: Schema.String, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 445048eb2685d..edc7a9ab852a4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -149,6 +149,8 @@ import { VcsStatusStreamEvent, } from "./git.ts"; import { + ReviewBranchCommitsInput, + ReviewBranchCommitsResult, ReviewDiffFileContentsInput, ReviewDiffFileContentsResult, ReviewDiffPreviewError, @@ -455,6 +457,7 @@ export const WS_METHODS = { gitResolvePullRequest: "git.resolvePullRequest", gitPreparePullRequestThread: "git.preparePullRequestThread", + reviewGetBranchCommits: "review.getBranchCommits", reviewGetDiffPreview: "review.getDiffPreview", reviewGetDiffFileContents: "review.getDiffFileContents", @@ -1500,6 +1503,12 @@ const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { error: Schema.Union([VcsError, EnvironmentAuthorizationError]), }); +const WsReviewGetBranchCommitsRpc = Rpc.make(WS_METHODS.reviewGetBranchCommits, { + payload: ReviewBranchCommitsInput, + success: ReviewBranchCommitsResult, + error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]), +}); + const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPreview, { payload: ReviewDiffPreviewInput, success: ReviewDiffPreviewResult, @@ -2092,6 +2101,7 @@ export const WsRpcGroup = RpcGroup.make( WsVcsCreateRefRpc, WsVcsSwitchRefRpc, WsVcsInitRpc, + WsReviewGetBranchCommitsRpc, WsReviewGetDiffPreviewRpc, WsReviewGetDiffFileContentsRpc, WsTerminalOpenRpc, diff --git a/packages/source-control-azure-devops/src/shared/definition.ts b/packages/source-control-azure-devops/src/shared/definition.ts index b43adccedc846..2dd036674747b 100644 --- a/packages/source-control-azure-devops/src/shared/definition.ts +++ b/packages/source-control-azure-devops/src/shared/definition.ts @@ -1,15 +1,18 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { + buildCommitUrl, changeRequestUrlBelow, defineSourceControl, isChangeRequestAuthorityOfProject, parseChangeRequestPath, + parseCommitPath, remoteWebOrigin, } from "@t3tools/source-control-core/shared/definition"; import { icon } from "./icon.ts"; // Azure DevOps Server runs on any hostname; its `_git` route identifies it. const CHANGE_REQUEST_PATH = /^\/((?:[^/]+\/)*_git\/[^/]+)\/pullrequest\/(\d+)(?:\/|$)/u; +const COMMIT_PATH = /^\/((?:[^/]+\/)*_git\/[^/]+)\/commit\/([0-9a-f]+)\/?$/iu; const CHECKOUT_COMMAND = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i; const CHANGE_REQUEST_REFERENCE = /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i; @@ -88,6 +91,12 @@ export const definition = defineSourceControl({ const [canonicalHost = host, ...path] = (canonicalRepositoryKey(key) ?? key).split("/"); return `${remoteWebOrigin(canonicalHost, remoteUrl)}/${path.join("/")}/pullrequest/${number}`; }, + commitUrl: (input) => { + const key = `${input.host}/${input.repository}`.toLowerCase(); + const [host = input.host, ...path] = (canonicalRepositoryKey(key) ?? key).split("/"); + return buildCommitUrl({ ...input, host, repository: path.join("/") }, "commit"); + }, + parseCommitUrl: (url) => parseCommitPath(url, COMMIT_PATH), // Servers on different ports are different servers, so a port stays in the link. parseChangeRequestUrl: (url) => { const link = parseChangeRequestPath(url, CHANGE_REQUEST_PATH); diff --git a/packages/source-control-bitbucket/src/shared/definition.ts b/packages/source-control-bitbucket/src/shared/definition.ts index 946098b166d34..a8e786cb0ba6e 100644 --- a/packages/source-control-bitbucket/src/shared/definition.ts +++ b/packages/source-control-bitbucket/src/shared/definition.ts @@ -6,11 +6,13 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { + buildCommitUrl, changeRequestUrlBelow, defineSourceControl, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, parseChangeRequestPath, + parseCommitPath, projectChangeRequestHost, } from "@t3tools/source-control-core/shared/definition"; import { icon } from "./icon.ts"; @@ -19,6 +21,7 @@ const safeShellArgument = /^[A-Za-z0-9._/@+=,-]+$/; const repositoryName = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; const CHANGE_REQUEST_PATH = /^\/([^/]+\/[^/]+)\/pull-requests\/(\d+)(?:\/|$)/u; +const COMMIT_PATH = /^\/([^/]+\/[^/]+)\/commits\/([0-9a-f]+)\/?$/iu; const KIND = SourceControlProviderKind.make("bitbucket"); @@ -87,6 +90,9 @@ export const definition = defineSourceControl({ : `https://${host}/${repository}/pull-requests/${number}`, parseChangeRequestUrl: (url) => isBitbucketHost(url.hostname) ? parseChangeRequestPath(url, CHANGE_REQUEST_PATH) : null, + commitUrl: (input) => buildCommitUrl(input, "commits"), + parseCommitUrl: (url) => + isBitbucketHost(url.hostname) ? parseCommitPath(url, COMMIT_PATH) : null, changeRequestUrlFromReferenceAutolink: () => null, // No endpoint reopens a declined pull request, and nothing documented moves one in or out of // draft, so neither is offered rather than failing when pressed. diff --git a/packages/source-control-core/src/shared/definition.ts b/packages/source-control-core/src/shared/definition.ts index 6f99525260129..e9e68325b7418 100644 --- a/packages/source-control-core/src/shared/definition.ts +++ b/packages/source-control-core/src/shared/definition.ts @@ -30,6 +30,17 @@ export interface ParsedChangeRequestUrl extends ChangeRequestLink { readonly repositoryUrl: string; } +export interface CommitLink { + readonly host: string; + readonly repository: string; + readonly sha: string; + readonly authority?: string; +} + +export interface CommitUrlInput extends Omit { + readonly sha: string; +} + export interface ChangeRequestTerminology { readonly shortLabel: string; /** Spelled out because the article follows how the label is said: "an MR". */ @@ -238,6 +249,9 @@ export interface SourceControlDefinition { readonly newRepositoryOwner: (account: string | null) => { readonly owner: string | null } | null; readonly defaultCloneTransport: "https" | "ssh"; readonly changeRequestUrl: (input: ChangeRequestUrlInput) => string | null; + readonly commitUrl?: (input: CommitUrlInput) => string | null; + /** `known` is set when the caller already knows this host serves the URL's repository. */ + readonly parseCommitUrl?: (url: URL, known: boolean) => CommitLink | null; /** Null when the URL is not one of this host's change requests. */ readonly parseChangeRequestUrl: (url: URL) => ParsedChangeRequestUrl | null; /** @@ -290,7 +304,7 @@ export interface SourceControlDefinition { readonly isChangeRequestReference: (url: string) => boolean; readonly isChangeRequestInRepository: ( identity: ChangeRequestProjectIdentity, - link: ChangeRequestLink, + link: ChangeRequestLink | CommitLink, ) => boolean; readonly canReadChangeRequestOnHost: ( identity: ChangeRequestProjectIdentity, @@ -335,6 +349,42 @@ export type SourceControlHostSettingsSchema = { readonly fields: Readonly>; } & Schema.Decoder; +const FULL_COMMIT_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu; + +export function parseCommitPath(url: URL, route: RegExp): CommitLink | null { + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + const match = route.exec(url.pathname); + const repository = match?.[1]; + const sha = match?.[2]; + return repository && sha && FULL_COMMIT_SHA.test(sha) + ? { + host: url.hostname.toLowerCase(), + repository: repository.toLowerCase(), + sha: sha.toLowerCase(), + ...(url.port ? { authority: url.host.toLowerCase() } : {}), + } + : null; +} + +export function buildCommitUrl( + { host, repository, sha, remoteUrl, webUrl }: CommitUrlInput, + route: string, +): string | null { + if (!repository || !FULL_COMMIT_SHA.test(sha)) return null; + try { + const url = new URL(webUrl || `${remoteWebOrigin(host, remoteUrl)}/${repository}`); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + url.pathname = changeRequestUrlBelow(url.pathname, `${route}/${sha.toLowerCase()}`); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return null; + } +} + /** * Reads a change request from `url` with `route`, whose first group is the repository path and * second the number. @@ -383,7 +433,7 @@ export function defineSourceControl( export function isChangeRequestOnProjectHost( projectHost: string, identity: ChangeRequestProjectIdentity, - link: ChangeRequestLink, + link: ChangeRequestLink | CommitLink, ): boolean { if (!isChangeRequestAuthorityOfProject(identity, link)) return false; return projectHost === link.host.toLowerCase() || projectHost === link.authority; @@ -426,7 +476,7 @@ function projectRepositoryPath(identity: ChangeRequestProjectIdentity): string | export function isChangeRequestInProjectRepository( projectHost: string, identity: ChangeRequestProjectIdentity, - link: ChangeRequestLink, + link: ChangeRequestLink | CommitLink, ): boolean { const repository = projectRepositoryPath(identity); return ( @@ -533,6 +583,11 @@ export interface ParsedChangeRequest { readonly link: ParsedChangeRequestUrl; } +export interface ParsedCommit { + readonly definition: SourceControlDefinition; + readonly link: CommitLink; +} + export interface SourceControlMedia extends SourceControlMediaUrl { readonly definition: SourceControlDefinition; } @@ -549,6 +604,7 @@ export interface SourceControlDefinitionRegistry { readonly findByPublicDomain: (hostname: string) => SourceControlDefinition | undefined; /** The first host, in registry order, that reads `url` as one of its change requests. */ readonly parseChangeRequestUrl: (url: string) => ParsedChangeRequest | null; + readonly parseCommitUrl: (url: string, kind?: SourceControlProviderKind) => ParsedCommit | null; readonly findByChangeRequestUrl: (url: string) => SourceControlDefinition | undefined; readonly hostLabelForChangeRequestUrl: (url: string) => string; readonly isSameChangeRequest: (left: string, right: string) => boolean; @@ -648,6 +704,16 @@ export function makeSourceControlDefinitionRegistry( ); }, parseChangeRequestUrl, + parseCommitUrl: (value, kind) => { + const url = httpUrl(value); + if (url === null) return null; + const preferred = kind === undefined ? undefined : byKind.get(kind); + for (const definition of kind === undefined ? claimOrder : preferred ? [preferred] : []) { + const link = definition.parseCommitUrl?.(url, preferred !== undefined); + if (link != null) return { definition, link }; + } + return null; + }, findByChangeRequestUrl: (url) => parseChangeRequestUrl(url)?.definition, hostLabelForChangeRequestUrl: (url) => parseChangeRequestUrl(url)?.definition.label ?? "the host", diff --git a/packages/source-control-definitions/src/registry.ts b/packages/source-control-definitions/src/registry.ts index 0e42360855350..e44a6bb281717 100644 --- a/packages/source-control-definitions/src/registry.ts +++ b/packages/source-control-definitions/src/registry.ts @@ -23,7 +23,7 @@ export const builtInSourceControlDefinitions = makeSourceControlDefinitionRegist AzureDevOps.definition, ], { - claimChangeRequestUrlsFirst: [GitCafe.definition.kind], + claimChangeRequestUrlsFirst: [GitCafe.definition.kind, AzureDevOps.definition.kind], recognizeRemotesIn: [ GitCafe.definition.kind, Forgejo.definition.kind, diff --git a/packages/source-control-forgejo/src/shared/definition.ts b/packages/source-control-forgejo/src/shared/definition.ts index 32258854c79da..2cf8ac5bb5838 100644 --- a/packages/source-control-forgejo/src/shared/definition.ts +++ b/packages/source-control-forgejo/src/shared/definition.ts @@ -1,5 +1,6 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { + buildCommitUrl, changeRequestUrlBelow, type ChangeRequestHostIdentity, type ChangeRequestProjectIdentity, @@ -8,6 +9,7 @@ import { isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, parseChangeRequestPath, + parseCommitPath, projectChangeRequestHost, remoteWebOrigin, } from "@t3tools/source-control-core/shared/definition"; @@ -19,6 +21,7 @@ const CHANGE_REQUEST_REFERENCE = // Forgejo and Gitea run on any hostname, possibly mounted below a path. const CHANGE_REQUEST_PATH = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u; +const COMMIT_PATH = /^\/([^/]+(?:\/[^/]+)+)\/commit\/([0-9a-f]+)\/?$/iu; const KIND = SourceControlProviderKind.make("forgejo"); @@ -82,6 +85,8 @@ export const definition = defineSourceControl({ return link === null ? null : { ...link, authority: url.host.toLowerCase() }; }, changeRequestUrlFromReferenceAutolink: () => null, + commitUrl: (input) => buildCommitUrl(input, "commit"), + parseCommitUrl: (url) => parseCommitPath(url, COMMIT_PATH), changeRequestActions: new Set(["merge", "close", "reopen", "update-branch"] as const), // Neither `fj` nor `tea` checks out by number. checkoutCommand: ({ number, repositoryUrl }) => diff --git a/packages/source-control-gitcafe/src/shared/definition.ts b/packages/source-control-gitcafe/src/shared/definition.ts index dc352be748dc2..a7b22261b2b4b 100644 --- a/packages/source-control-gitcafe/src/shared/definition.ts +++ b/packages/source-control-gitcafe/src/shared/definition.ts @@ -7,11 +7,13 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { + buildCommitUrl, changeRequestUrlBelow, defineSourceControl, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, parseChangeRequestPath, + parseCommitPath, projectChangeRequestHost, } from "@t3tools/source-control-core/shared/definition"; import { icon } from "./icon.ts"; @@ -21,6 +23,7 @@ const GITCAFE_HOSTS = new Set(["git.cafe", "staging.git.cafe"]); /** GitCafe serves pull requests at `/pull/` and redirects the older `/pulls/` there. */ const CHANGE_REQUEST_PATH = /^\/([^/]+\/[^/]+)\/pulls?\/(\d+)(?:\/|$)/u; +const COMMIT_PATH = /^\/([^/]+\/[^/]+)\/commit\/([0-9a-f]+)\/?$/iu; function parseChangeRequestUrl(url: URL) { return GITCAFE_HOSTS.has(url.hostname.toLowerCase()) @@ -68,6 +71,9 @@ export const definition = defineSourceControl({ // `/pull/` and `/pulls/` are GitHub's and Forgejo's shapes too, so only GitCafe's own hosts // claim them. parseChangeRequestUrl, + commitUrl: (input) => buildCommitUrl(input, "commit"), + parseCommitUrl: (url) => + GITCAFE_HOSTS.has(url.hostname.toLowerCase()) ? parseCommitPath(url, COMMIT_PATH) : null, changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set([ "ready", diff --git a/packages/source-control-github/src/shared/definition.ts b/packages/source-control-github/src/shared/definition.ts index 965ed42814c19..6ac8aa305f3cd 100644 --- a/packages/source-control-github/src/shared/definition.ts +++ b/packages/source-control-github/src/shared/definition.ts @@ -1,11 +1,13 @@ import { makeProviderSettingsSchema, SourceControlProviderKind } from "@t3tools/contracts"; import { + buildCommitUrl, changeRequestUrlBelow, defineSourceControl, hostCredentialSettingsFields, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, parseChangeRequestPath, + parseCommitPath, projectChangeRequestHost, remoteWebOrigin, type SourceControlAppSignIn, @@ -22,15 +24,14 @@ const CHANGE_REQUEST_REFERENCE = // GitHub Enterprise Server runs on any hostname, so the `//pull/` route alone // identifies a GitHub pull request. const CHANGE_REQUEST_PATH = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u; +const COMMIT_PATH = /^\/([^/]+\/[^/]+)\/(?:commit|pull\/\d+\/commits)\/([0-9a-f]+)\/?$/iu; const ISSUE_PATH = /^\/([^/]+\/[^/]+)\/issues\/(\d+)(?:\/|$)/u; const KIND = SourceControlProviderKind.make("github"); function isGitHubHost(hostname: string): boolean { const host = hostname.toLowerCase(); - return ( - host === "github.com" || host.endsWith(".github.com") || host.split(".").includes("github") - ); + return host === "github.com" || host.endsWith(".ghe.com") || host.split(".").includes("github"); } function isRepositoryPath(repository: string): boolean { @@ -91,6 +92,11 @@ export const definition = defineSourceControl({ return null; } }, + commitUrl: (input) => + isRepositoryPath(input.repository) ? buildCommitUrl(input, "commit") : null, + // A known project's host can be any GitHub Enterprise Server name. + parseCommitUrl: (url, known) => + known || isGitHubHost(url.hostname) ? parseCommitPath(url, COMMIT_PATH) : null, parseChangeRequestUrl: (url) => parseChangeRequestPath(url, CHANGE_REQUEST_PATH), // GitHub writes every bare reference through `/issues/`, including pull requests. changeRequestUrlFromReferenceAutolink: (url) => { @@ -133,9 +139,7 @@ export const definition = defineSourceControl({ changeRequestHeadRef: (number) => `refs/pull/${number}/head`, // `*.ghe.com` is GitHub Enterprise Cloud with data residency, one subdomain per enterprise. recognizeRemote: ({ host, hostname }) => - hostname === "github.com" || - hostname.endsWith(".ghe.com") || - hostname.split(".").includes("github") + isGitHubHost(hostname) ? { kind: KIND, name: hostname === "github.com" ? "GitHub" : "GitHub Self-Hosted", diff --git a/packages/source-control-gitlab/src/shared/definition.ts b/packages/source-control-gitlab/src/shared/definition.ts index 437320c0d0419..2d280f01c62f2 100644 --- a/packages/source-control-gitlab/src/shared/definition.ts +++ b/packages/source-control-gitlab/src/shared/definition.ts @@ -1,10 +1,12 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { + buildCommitUrl, changeRequestUrlBelow, defineSourceControl, isChangeRequestInProjectRepository, isChangeRequestOnProjectHost, parseChangeRequestPath, + parseCommitPath, projectChangeRequestHost, } from "@t3tools/source-control-core/shared/definition"; import { icon } from "./icon.ts"; @@ -15,6 +17,7 @@ const CHANGE_REQUEST_REFERENCE = // Self-managed GitLab runs on any hostname and nests projects in groups. const CHANGE_REQUEST_PATH = /^\/([^/]+(?:\/[^/]+)+)\/-\/merge_requests\/(\d+)(?:\/|$)/u; +const COMMIT_PATH = /^\/([^/]+(?:\/[^/]+)+)\/-\/commit\/([0-9a-f]+)\/?$/iu; const KIND = SourceControlProviderKind.make("gitlab"); @@ -40,6 +43,8 @@ export const definition = defineSourceControl({ ? changeRequestUrlBelow(webUrl, `-/merge_requests/${number}`) : `https://${host}/${repository}/-/merge_requests/${number}`, parseChangeRequestUrl: (url) => parseChangeRequestPath(url, CHANGE_REQUEST_PATH), + commitUrl: (input) => buildCommitUrl(input, "-/commit"), + parseCommitUrl: (url) => parseCommitPath(url, COMMIT_PATH), changeRequestUrlFromReferenceAutolink: () => null, changeRequestActions: new Set([ "merge", From 305a90dce44b8b7813d951662f13ce8510a3c669 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 01:47:04 -0700 Subject: [PATCH 03/31] fix(relay): show the account's real tunnel cap in T3 Connect settings (#27442) Co-authored-by: Claude Opus 5.5 --- apps/web/src/cloud/managedRelayState.ts | 5 ++- .../ManagedEndpointProvider.test.ts | 1 + .../environments/ManagedTunnelLimits.test.ts | 23 +++++++++++ .../src/environments/ManagedTunnelLimits.ts | 41 +++++++++++-------- infra/relay/src/http/Api.test.ts | 4 ++ infra/relay/src/http/Api.ts | 20 ++++++--- packages/contracts/src/relay.ts | 2 + 7 files changed, 73 insertions(+), 23 deletions(-) diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index c5f7536305337..679f362fdf5a0 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -150,11 +150,14 @@ export function useManagedRelayDevices() { /** How this account can raise its tunnel cap: link T3 Chat, or upgrade the linked plan. */ export type ManagedTunnelUpgrade = "link" | "pro" | "premier" | null; -// Mirrors the relay's cap without manual overrides, which the relay does not expose. +// Mirrors the relay's cap: a manual override is exact, otherwise the T3 Chat plan raises the default. function managedTunnelCap(t3Chat: RelayT3ChatLinkResponse): { readonly limit: number; readonly upgrade: ManagedTunnelUpgrade; } { + if (t3Chat.maxTunnelsOverride !== undefined) { + return { limit: t3Chat.maxTunnelsOverride, upgrade: null }; + } if (!t3Chat.available) return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: null }; switch (t3Chat.link?.plan) { case "premier": diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index 01230171be9ba..96661fad42a61 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -343,6 +343,7 @@ function makeTunnelLimits( } return yield* reserve; }), + getOverride: () => Effect.die("unused"), }); } diff --git a/infra/relay/src/environments/ManagedTunnelLimits.test.ts b/infra/relay/src/environments/ManagedTunnelLimits.test.ts index 6aca885b85b87..6b8dd817ee01c 100644 --- a/infra/relay/src/environments/ManagedTunnelLimits.test.ts +++ b/infra/relay/src/environments/ManagedTunnelLimits.test.ts @@ -121,6 +121,27 @@ const reserve = ( ), ); +const getOverride = (override?: number) => + Effect.gen(function* () { + const limits = yield* ManagedTunnelLimits.ManagedTunnelLimits; + return yield* limits.getOverride({ userId: "user-1" }); + }).pipe( + Effect.provide( + ManagedTunnelLimits.layer.pipe( + Layer.provide( + Layer.succeed( + RelayDb.RelayDb, + makeFakeDb({ + otherSlots: () => 0, + ...(override === undefined ? {} : { override }), + }), + ), + ), + Layer.provide(t3ChatLayer("free")), + ), + ), + ); + describe("ManagedTunnelLimits", () => { it.effect("rejects concurrent reservations past the limit", () => { const reserved: Array = []; @@ -383,7 +404,9 @@ describe("ManagedTunnelLimits", () => { reserve({ override, activeTunnels: override, plan: "premier", lookups }), ); expect(error).toMatchObject({ _tag: "ManagedTunnelLimitExceeded", maxTunnels: override }); + expect(yield* getOverride(override)).toBe(override); } + expect(yield* getOverride()).toBeNull(); expect(lookups).toEqual([]); }), ); diff --git a/infra/relay/src/environments/ManagedTunnelLimits.ts b/infra/relay/src/environments/ManagedTunnelLimits.ts index aa373ad27ffb2..3af90f86c4163 100644 --- a/infra/relay/src/environments/ManagedTunnelLimits.ts +++ b/infra/relay/src/environments/ManagedTunnelLimits.ts @@ -106,6 +106,10 @@ export class ManagedTunnelLimits extends Context.Service< input: { readonly userId: string; readonly environmentId: string }, reserve: Effect.Effect, ) => Effect.Effect; + /** The user's manual cap, which replaces the default and any T3 Chat plan cap. */ + readonly getOverride: (input: { + readonly userId: string; + }) => Effect.Effect; } >()("t3code-relay/environments/ManagedTunnelLimits") {} @@ -126,7 +130,24 @@ const make = Effect.gen(function* () { ), ); + const loadOverride = (userId: string) => + db + .select({ maxTunnels: relayManagedTunnelLimits.maxTunnels }) + .from(relayManagedTunnelLimits) + .where(eq(relayManagedTunnelLimits.userId, userId)) + .limit(1) + .pipe( + Effect.map((rows) => rows[0]?.maxTunnels ?? null), + Effect.mapError( + (cause) => + new ManagedTunnelLimitPersistenceError({ operation: "load-limit", userId, cause }), + ), + ); + return ManagedTunnelLimits.of({ + getOverride: Effect.fn("relay.managed_tunnel_limits.get_override")(function* (input) { + return yield* loadOverride(input.userId); + }), withCapacity: Effect.fn("relay.managed_tunnel_limits.with_capacity")(function* ( input: { readonly userId: string; readonly environmentId: string }, reserve: Effect.Effect, @@ -148,22 +169,8 @@ const make = Effect.gen(function* () { ), ); - const overrides = yield* db - .select({ maxTunnels: relayManagedTunnelLimits.maxTunnels }) - .from(relayManagedTunnelLimits) - .where(eq(relayManagedTunnelLimits.userId, input.userId)) - .limit(1) - .pipe( - Effect.mapError( - (cause) => - new ManagedTunnelLimitPersistenceError({ - operation: "load-limit", - userId: input.userId, - cause, - }), - ), - ); - const baseMaxTunnels = overrides[0]?.maxTunnels ?? DEFAULT_MANAGED_TUNNEL_LIMIT; + const override = yield* loadOverride(input.userId); + const baseMaxTunnels = override ?? DEFAULT_MANAGED_TUNNEL_LIMIT; const pendingCutoff = DateTime.formatIso( DateTime.subtract(yield* DateTime.now, { @@ -196,7 +203,7 @@ const make = Effect.gen(function* () { // A manual override is the exact cap; a T3 Chat plan raises only the default. // Runs under the user lock, bounded by the T3 Chat request timeout. const maxTunnels = - t3Chat.available && overrides.length === 0 + t3Chat.available && override === null ? Math.max(baseMaxTunnels, yield* t3ChatTunnelLimit(input.userId)) : baseMaxTunnels; if (activeTunnels >= maxTunnels) { diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index a20989cd3c025..e6db7fc8b7aa0 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -64,6 +64,7 @@ import * as HookInbox from "../hooks/HookInbox.ts"; import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts"; import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts"; +import * as ManagedTunnelLimits from "../environments/ManagedTunnelLimits.ts"; import * as AgentActivityPublisher from "../agentActivity/AgentActivityPublisher.ts"; import * as EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts"; import * as T3ChatAccounts from "../t3chat/T3ChatAccounts.ts"; @@ -130,6 +131,7 @@ describe("managed endpoint provisioning errors", () => { Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}), Layer.mock(Devices.Devices, {}), Layer.mock(T3ChatAccounts.T3ChatAccounts, { available: false }), + Layer.mock(ManagedTunnelLimits.ManagedTunnelLimits, {}), ), ), Layer.provideMerge( @@ -241,6 +243,7 @@ describe("device listing compatibility", () => { }, }), Layer.mock(T3ChatAccounts.T3ChatAccounts, { available: false }), + Layer.mock(ManagedTunnelLimits.ManagedTunnelLimits, {}), ), ), Layer.provideMerge( @@ -322,6 +325,7 @@ describe("Cloudflare rate-limit retry hint", () => { Layer.mock(ManagedEndpointProvider.ManagedEndpointProvider, {}), Layer.mock(Devices.Devices, {}), Layer.mock(T3ChatAccounts.T3ChatAccounts, { available: false }), + Layer.mock(ManagedTunnelLimits.ManagedTunnelLimits, {}), ), ), Layer.provideMerge( diff --git a/infra/relay/src/http/Api.ts b/infra/relay/src/http/Api.ts index 5a4d77c28d3c2..390f3c18f785f 100644 --- a/infra/relay/src/http/Api.ts +++ b/infra/relay/src/http/Api.ts @@ -74,6 +74,7 @@ import * as AgentActivityPublisher from "../agentActivity/AgentActivityPublisher import * as EnvironmentConnector from "../environments/EnvironmentConnector.ts"; import * as EnvironmentLinker from "../environments/EnvironmentLinker.ts"; import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts"; +import * as ManagedTunnelLimits from "../environments/ManagedTunnelLimits.ts"; import * as ManagedEndpointAllocations from "../environments/ManagedEndpointAllocations.ts"; import * as EnvironmentPublishSignatures from "../environments/EnvironmentPublishSignatures.ts"; import * as MobileRegistrations from "../agentActivity/MobileRegistrations.ts"; @@ -825,6 +826,7 @@ export const layerClientApi = HttpApiBuilder.group( const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider; const devices = yield* Devices.Devices; const t3Chat = yield* T3ChatAccounts.T3ChatAccounts; + const tunnelLimits = yield* ManagedTunnelLimits.ManagedTunnelLimits; return handlers .handle( "listEnvironments", @@ -996,12 +998,20 @@ export const layerClientApi = HttpApiBuilder.group( "getT3ChatLink", Effect.fn("relay.api.client.getT3ChatLink")(function* () { yield* appendRelayCredentialResponseHeaders; - if (!t3Chat.available) return { available: false, link: null }; const { userId } = yield* RelayClientPrincipal; - const link = yield* t3Chat - .getLink({ userId }) - .pipe(Effect.catch(() => relayInternalErrorResponse("upstream_unavailable"))); - return { available: true, link }; + const link = t3Chat.available + ? yield* t3Chat + .getLink({ userId }) + .pipe(Effect.catch(() => relayInternalErrorResponse("upstream_unavailable"))) + : null; + const maxTunnelsOverride = yield* tunnelLimits + .getOverride({ userId }) + .pipe(Effect.catch(() => relayInternalErrorResponse("internal_error"))); + return { + available: t3Chat.available, + link, + ...(maxTunnelsOverride === null ? {} : { maxTunnelsOverride }), + }; }, mapRelayCommonApiErrors("not_authorized")), ) .handle( diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 4dbb6a199f472..8ebc9df23a0bb 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -981,6 +981,8 @@ export const RelayT3ChatLinkResponse = Schema.Struct({ // False when this relay has no T3 Chat secret, so clients hide the feature. available: Schema.Boolean, link: Schema.NullOr(RelayT3ChatLink), + // A manual cap that replaces the plan's; T3 Chat upgrades do not raise it. + maxTunnelsOverride: Schema.optionalKey(Schema.Number), }); export type RelayT3ChatLinkResponse = typeof RelayT3ChatLinkResponse.Type; From 5cbc207d8c8e92964b36e235740f22b49d938352 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 02:10:40 -0700 Subject: [PATCH 04/31] fix(web): streaming replies no longer re-render every visible timeline row (#27420) Co-authored-by: Claude Opus 5.5 --- .../src/components/chat/MessagesTimeline.tsx | 42 +++++++++++-------- apps/web/test/appScenarios.renders.tsx | 3 ++ 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a9cab1554a3d3..faa733c76e896 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -659,12 +659,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ ? new Set(paintedExpandedAttemptIds).add(activeFindAttemptId) : paintedExpandedAttemptIds; - const pendingApprovalsByRequestId = new Map( - pendingApprovals - .filter((approval) => !heldApprovalRequestIds?.has(approval.requestId)) - .map((approval) => [approval.requestId, approval]), + const isVisiblePendingApproval = (approval: PendingApproval) => + !heldApprovalRequestIds?.has(approval.requestId); + const pendingApprovalRequestIds = new Set( + pendingApprovals.filter(isVisiblePendingApproval).map((approval) => approval.requestId), ); - const pendingApprovalRequestIds = new Set(pendingApprovalsByRequestId.keys()); const rawRows = useIncrementalTimelineRows(`${listIdentityKey}\0${workspaceRoot ?? ""}`, { timelineEntries, pendingApprovalRequestIds, @@ -683,17 +682,19 @@ const ConversationTimeline = memo(function ConversationTimeline({ worktreeSetup, }); const rows = useStableRows(rawRows, listIdentityKey); - const unmatchedPendingApprovals = new Map(pendingApprovalsByRequestId); - if (unmatchedPendingApprovals.size > 0) { - for (const row of rows) { - const entries = - row.kind === "work" ? row.groupedEntries : row.kind === "work-live" ? [row.entry] : []; - for (const entry of entries) { - const item = entry.projectedItem?.item; - if (item?.type === "approval_request") unmatchedPendingApprovals.delete(item.requestId); - } + const matchedApprovalRequestIds = new Set(); + for (const row of pendingApprovalRequestIds.size > 0 ? rows : []) { + const entries = + row.kind === "work" ? row.groupedEntries : row.kind === "work-live" ? [row.entry] : []; + for (const entry of entries) { + const item = entry.projectedItem?.item; + if (item?.type === "approval_request") matchedApprovalRequestIds.add(item.requestId); } } + const unmatchedPendingApprovals = pendingApprovals.filter( + (approval) => + isVisiblePendingApproval(approval) && !matchedApprovalRequestIds.has(approval.requestId), + ); // Run status/timestamps change per stream event; keep them out of shared row context. const runs = useStableHandoffRuns(runsProp); const minimapItems = deriveTimelineMinimapItems(rows); @@ -706,7 +707,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ restoringThreadPosition && restoreRowIndex >= 0 ? { indices: [restoreRowIndex] } : undefined; const showsEmptyState = rows.length === 0 && - unmatchedPendingApprovals.size === 0 && + unmatchedPendingApprovals.length === 0 && !isWorking && parentThreadLink === null && historyControls === undefined && @@ -1000,6 +1001,13 @@ const ConversationTimeline = memo(function ConversationTimeline({ }; }; + // Built from props after the last hook so the compiler can memoize the row context; a map + // made before a hook would rebuild it, and re-render every row, on each streamed update. + const pendingApprovalsByRequestId = new Map( + pendingApprovals + .filter(isVisiblePendingApproval) + .map((approval) => [approval.requestId, approval]), + ); const sharedState: TimelineRowSharedState = { pendingApprovals: pendingApprovalsByRequestId, respondingRequestIds, @@ -1238,12 +1246,12 @@ const ConversationTimeline = memo(function ConversationTimeline({ onGrow={onFooterGrow} > {footer} - {unmatchedPendingApprovals.size > 0 ? ( + {unmatchedPendingApprovals.length > 0 ? (
- {[...unmatchedPendingApprovals.values()].map((approval) => ( + {unmatchedPendingApprovals.map((approval) => ( ))} diff --git a/apps/web/test/appScenarios.renders.tsx b/apps/web/test/appScenarios.renders.tsx index 9c1a7d520eeb1..cc7d67798ddd9 100644 --- a/apps/web/test/appScenarios.renders.tsx +++ b/apps/web/test/appScenarios.renders.tsx @@ -201,6 +201,9 @@ it("app-stream-assistant-turn", async () => { .element(page.getByText(`word${STREAM_DELTAS - 1}`, { exact: false })) .toBeVisible(); }); + // Only the streaming row changes; history rows must not re-render with it. + expect(counted.components.UserTimelineRow).toBeUndefined(); + expect(counted.components.AssistantTimelineRow).toBe(STREAM_DELTAS); await report("app-stream-assistant-turn", [counted], { commits: false }); }); From d8c926c395155be72b09be594cb498adda3dfed2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 02:10:41 -0700 Subject: [PATCH 05/31] perf(web): streaming replies re-render only their last Markdown block (#27417) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/ChatMarkdown.tsx | 129 ++++++++++++------ .../chat/markdownStreamBlocks.test.tsx | 61 +++++++++ .../components/chat/markdownStreamBlocks.ts | 43 ++++++ apps/web/test/appScenarios.renders.tsx | 4 +- 4 files changed, 194 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/components/chat/markdownStreamBlocks.test.tsx create mode 100644 apps/web/src/components/chat/markdownStreamBlocks.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 6a8f80d6d1071..99c2ebbc8f40f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -189,6 +189,7 @@ import { import { resolveLinkTarget } from "../browser/browserLinkTarget"; import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; import { MarkdownCodeBlock, SuspenseShikiCodeBlock } from "./chat/MarkdownCodeBlock"; +import { splitMarkdownBlocks } from "./chat/markdownStreamBlocks"; import { MarkdownMermaidCodeBlock } from "./chat/MarkdownMermaidCodeBlock"; import { MarkdownTable } from "./chat/MarkdownTable"; import { MarkdownDetails } from "./chat/MarkdownDetails"; @@ -434,6 +435,44 @@ const markdownServerConfigFieldsAtom = Atom.family((environmentId: EnvironmentId }).pipe(Atom.withLabel("markdown-server-config-fields")), ); +function fileLinkSourcesKey( + text: string, + cwd: string | undefined, + imageBaseDir: string | undefined, +): string { + const hrefs = extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(text)) + .filter((href) => !parseComposerContextHref(href)) + .map(normalizeMarkdownLinkHrefKey) + .filter((href) => resolveMarkdownFileLinkMeta(href, cwd, imageBaseDir ?? cwd)); + const spans = extractInlineCodeSpans(text).filter((span) => + resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd), + ); + return JSON.stringify([[...new Set(hrefs)], [...new Set(spans)]]); +} + +function resolveFileLinkSources( + key: string, + cwd: string | undefined, + imageBaseDir: string | undefined, +) { + const [hrefs, spans] = JSON.parse(key) as [string[], string[]]; + const markdownFileLinkMetaByHref = new Map(); + for (const href of hrefs) { + const meta = resolveMarkdownFileLinkMeta(href, cwd, imageBaseDir ?? cwd); + if (meta) markdownFileLinkMetaByHref.set(href, meta); + } + const inlineCodeFileLinkMetaByText = new Map(); + for (const span of spans) { + const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd); + if (meta) inlineCodeFileLinkMetaByText.set(span, meta); + } + const fileLinkParentSuffixByPath = buildFileLinkParentSuffixByPath([ + ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), + ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath), + ]); + return { markdownFileLinkMetaByHref, inlineCodeFileLinkMetaByText, fileLinkParentSuffixByPath }; +} + function useChatMarkdownState({ text, inline = false, @@ -605,40 +644,6 @@ function useChatMarkdownState({ }); }; const diffThemeName = resolveDiffThemeName(resolvedTheme); - const markdownFileLinkMetaByHref = (() => { - const metaByHref = new Map< - string, - NonNullable> - >(); - for (const href of extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(text))) { - if (parseComposerContextHref(href)) continue; - const normalizedHref = normalizeMarkdownLinkHrefKey(href); - if (metaByHref.has(normalizedHref)) continue; - const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd); - if (meta) { - metaByHref.set(normalizedHref, meta); - } - } - return metaByHref; - })(); - const inlineCodeFileLinkMetaByText = (() => { - const metaByText = new Map(); - for (const span of extractInlineCodeSpans(text)) { - if (metaByText.has(span)) continue; - const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd); - if (meta) { - metaByText.set(span, meta); - } - } - return metaByText; - })(); - const fileLinkParentSuffixByPath = (() => { - const filePaths = [ - ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), - ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath), - ]; - return buildFileLinkParentSuffixByPath(filePaths); - })(); const markdownUrlTransform = (href: string) => { if (parseAssistantCitationHref(href)) return href; if (parseComposerContextHref(href)) return href; @@ -666,6 +671,12 @@ function useChatMarkdownState({ // Subscribe so hydrated settings apply before the anchor synchronously decides whether to intercept _blank. const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget); const mathEnabled = useClientSettings((settings) => settings.mathRenderingEnabled); + // Keyed by the file links found, not the text, so streamed text that adds none keeps the + // renderer context, and every block reading it, unchanged. + // A template literal marks the key as a string, so the compiler memoizes the maps on its value. + const fileLinksKey = `${fileLinkSourcesKey(text, cwd, imageBaseDir)}`; + const { markdownFileLinkMetaByHref, inlineCodeFileLinkMetaByText, fileLinkParentSuffixByPath } = + resolveFileLinkSources(fileLinksKey, cwd, imageBaseDir); const resolveThreadPullRequest = ( href: string, ): (ThreadPullRequestKey & { readonly url: string }) | null => { @@ -1622,6 +1633,32 @@ const CHAT_MARKDOWN_COMPONENTS = { }, } satisfies Components; +const MarkdownSource = memo(function MarkdownSource({ + text, + remarkPlugins, + rehypePlugins, + urlTransform, +}: { + text: string; + remarkPlugins: NonNullable; + rehypePlugins: NonNullable; + urlTransform: ReactMarkdownOptions["urlTransform"]; +}) { + return ( + + + {text} + + + ); +}); + function ChatMarkdown({ text, className, @@ -1639,8 +1676,17 @@ function ChatMarkdown({ localMediaPreview, closeLocalMediaPreview, } = useChatMarkdownState({ text, inline, ...props }); + // Streaming re-renders only the last block; offsets for task edits need the whole text. + const streamingBlocks = + props.isStreaming === true && + !inline && + extraRemarkPlugins.length === 0 && + !props.onTaskListChange + ? splitMarkdownBlocks(text) + : null; const incrementalParsing = props.isStreaming === true && + streamingBlocks === null && extraRemarkPlugins.length === 0 && /(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text); const Root = inline ? "span" : "div"; @@ -1668,21 +1714,20 @@ function ChatMarkdown({ onCopy={handleCopy} > - - ( + - {text} - - + /> + ))} {localMediaPreview ? ( diff --git a/apps/web/src/components/chat/markdownStreamBlocks.test.tsx b/apps/web/src/components/chat/markdownStreamBlocks.test.tsx new file mode 100644 index 0000000000000..ae81316580bbd --- /dev/null +++ b/apps/web/src/components/chat/markdownStreamBlocks.test.tsx @@ -0,0 +1,61 @@ +import { + CHAT_MARKDOWN_REHYPE_PLUGINS, + CHAT_MARKDOWN_REMARK_PLUGINS, +} from "@t3tools/shared/markdownPipeline"; +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; +import { describe, expect, it } from "vite-plus/test"; + +import { splitMarkdownBlocks } from "./markdownStreamBlocks"; + +// Whitespace between top-level blocks collapses in the rendered message. +const html = (source: string) => + renderToStaticMarkup( + + {source} + , + ) + .replace(/>\s+<") + .trim(); + +describe("splitMarkdownBlocks", () => { + it.each([ + "# Title\n\nFirst paragraph with `code` and **bold**.\n\nSecond [link](https://example.com).", + "- one\n- two\n\n- loose three\n\n continued\n\nAfter the list.", + "1. first\n\n2. second\n\nText\n\n3) restarted", + "```ts\nconst a = 1;\n\n\nconst b = 2;\n```\n\nafter\n\n~~~\nx\n\n~~~~\n\ny\n~~~\n\nend", + "> quote\n\n> another quote\n\n> [!NOTE]\n> alert", + "| a | b |\n| - | - |\n| 1 | 2 |\n\nAfter the table.", + "para\n\n indented code\n\nnext", + "Setext\n===\n\nnext\n---\n\n- [ ] task\n- [x] done", + ])("renders each block as the whole text renders it, at every streamed prefix: %j", (text) => { + for (let end = 0; end <= text.length; end++) { + const source = text.slice(0, end); + const blocks = splitMarkdownBlocks(source); + expect(blocks?.join("")).toBe(source); + expect(blocks!.map(html).join("")).toBe(html(source)); + } + }); + + it("splits only between closed blocks", () => { + expect(splitMarkdownBlocks("a\n\n```\nb\n\nc\n```\n\n- d\n\n- e\n\nf")).toEqual([ + "a\n\n", + "```\nb\n\nc\n```\n\n- d\n\n- e\n\n", + "f", + ]); + }); + + it.each([ + "[ref]\n\n[ref]: /later", + "a[^x]\n\n[^x]: note", + "
\n\nbody\n\n
", + ":::note\n\ncontent\n\n:::", + "$$\na\n\nb\n$$", + "a\r\n\r\nb", + ])("keeps text with document-wide syntax whole: %j", (text) => { + expect(splitMarkdownBlocks(text)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/markdownStreamBlocks.ts b/apps/web/src/components/chat/markdownStreamBlocks.ts new file mode 100644 index 0000000000000..52415ae71977b --- /dev/null +++ b/apps/web/src/components/chat/markdownStreamBlocks.ts @@ -0,0 +1,43 @@ +// Definitions, footnotes, raw HTML, container directives and display math can tie blocks together. +const DOCUMENT_WIDE_SYNTAX = /^ {0,3}(?:\[[^\]\n]+\]:|<[A-Za-z!?/]|:::|\$\$)|\[\^/m; +const FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})/; +// After a blank line these still continue the previous block: a list item or indented content. +const CONTINUATION_PATTERN = /^(?:[ \t]|(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$))/; + +/** + * Cuts Markdown at blank lines that close every open block, so each piece renders exactly as it + * would inside the whole text. Returns null when the text uses syntax that spans blocks. + */ +export function splitMarkdownBlocks(text: string): string[] | null { + if (text.includes("\r") || DOCUMENT_WIDE_SYNTAX.test(text)) return null; + const blocks: string[] = []; + let blockStart = 0; + let lineStart = 0; + let fence: string | null = null; + let afterBlankLine = false; + for (const line of text.split("\n")) { + const opener = FENCE_PATTERN.exec(line)?.[1]; + if (fence !== null) { + if ( + opener && + opener[0] === fence[0] && + opener.length >= fence.length && + line.trim() === opener + ) { + fence = null; + } + } else if (line.trim() === "") { + afterBlankLine = true; + } else { + if (afterBlankLine && lineStart > blockStart && !CONTINUATION_PATTERN.test(line)) { + blocks.push(text.slice(blockStart, lineStart)); + blockStart = lineStart; + } + afterBlankLine = false; + fence = opener ?? null; + } + lineStart += line.length + 1; + } + blocks.push(text.slice(blockStart)); + return blocks; +} diff --git a/apps/web/test/appScenarios.renders.tsx b/apps/web/test/appScenarios.renders.tsx index cc7d67798ddd9..5bb6836dd04a7 100644 --- a/apps/web/test/appScenarios.renders.tsx +++ b/apps/web/test/appScenarios.renders.tsx @@ -182,7 +182,7 @@ it("app-stream-assistant-turn", async () => { const counted = await measure(async () => { let text = ""; for (let delta = 0; delta < STREAM_DELTAS; delta++) { - text += `word${delta} `; + text += `word${delta}\n\n`; await act(() => emitThreadEvent(LIVE, { type: "turn-item.updated", @@ -204,6 +204,8 @@ it("app-stream-assistant-turn", async () => { // Only the streaming row changes; history rows must not re-render with it. expect(counted.components.UserTimelineRow).toBeUndefined(); expect(counted.components.AssistantTimelineRow).toBe(STREAM_DELTAS); + // Each delta adds a paragraph; finished blocks keep their render. + expect(counted.components.MarkdownParagraph).toBe(STREAM_DELTAS); await report("app-stream-assistant-turn", [counted], { commits: false }); }); From aaee7743f813984cdf2d266e99f22cea4f28ca6e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 02:10:41 -0700 Subject: [PATCH 06/31] perf(web): the git actions panel no longer re-renders on every streamed update (#27418) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/GitActionsControl.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index cae352e6d6b26..a125ceacaed24 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -89,7 +89,7 @@ import { useVcsInitAction, useVcsPullAction, } from "../state/sourceControlActions"; -import { useThreadProjection, useQuietThreadShell } from "~/state/entities"; +import { useQuietThreadProjection, useQuietThreadShell } from "~/state/entities"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useEnvironmentQuery } from "~/state/query"; import { serverEnvironment } from "~/state/server"; @@ -958,7 +958,7 @@ export default function GitActionsControl({ ? store.getDraftThreadByRef(activeThreadRef) : null, ); - const activeServerThreadProjection = useThreadProjection( + const activeServerThreadProjection = useQuietThreadProjection( activeDraftThread !== null && activeServerThreadShell === null ? null : activeThreadRef, ); const activeServerThread = From 3cbc98241d33f202b78c1ebe7e5b8f35f5d6ac29 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 02:10:41 -0700 Subject: [PATCH 07/31] perf(web): streamed assistant text re-renders only its own row (#27419) Co-authored-by: Claude Opus 5.5 --- .../src/components/chat/ThreadTimeline.tsx | 7 +-- .../components/chat/TimelineAssistantRows.tsx | 10 +++- apps/web/src/state/entities.ts | 26 ++++++++++ apps/web/test/appScenarios.renders.tsx | 4 +- .../src/state/threadDetail.test.ts | 36 +++++++++++++ .../client-runtime/src/state/threadDetail.ts | 50 +++++++++++++++++++ 6 files changed, 127 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/ThreadTimeline.tsx b/apps/web/src/components/chat/ThreadTimeline.tsx index 7d1db86178cf4..a68d494d8b0a0 100644 --- a/apps/web/src/components/chat/ThreadTimeline.tsx +++ b/apps/web/src/components/chat/ThreadTimeline.tsx @@ -2,7 +2,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import { type ComponentProps, useLayoutEffect } from "react"; import { useIncrementalTimelineEntries } from "../../hooks/useIncrementalTimelineEntries"; import type { TimelineEntriesInput, TimelineEntry } from "../../session-logic"; -import { useThreadVisibleTurnItems } from "../../state/entities"; +import { useTimelineVisibleTurnItems } from "../../state/entities"; import { peekRememberedThreadTimeline, rememberReadyThreadTimeline, @@ -14,7 +14,8 @@ import { MessagesTimeline } from "./MessagesTimeline"; /** * The timeline with the streamed text. ChatView reads the thread through its quiet * atoms, which hold through text deltas; this reads the live turn items, so a - * delta re-renders the timeline without re-rendering ChatView. + * reasoning delta re-renders the timeline without re-rendering ChatView. Streamed + * assistant text reaches only its own row, through useAssistantMessageText. */ export function ThreadTimeline({ threadDetailRef, @@ -34,7 +35,7 @@ export function ThreadTimeline({ syncing: boolean; detailLoading: boolean; }) { - const visibleTurnItems = useThreadVisibleTurnItems(threadDetailRef); + const visibleTurnItems = useTimelineVisibleTurnItems(threadDetailRef); const serverEntries = useIncrementalTimelineEntries(activeThreadKey, { ...entriesInput, visibleTurnItems, diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index 228eb6f79b9c3..458d84b9e982b 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -12,7 +12,7 @@ import { McpAppFrame } from "./McpAppFrame"; import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { useFileContextMenuHandler } from "../../fileContextMenu"; -import { useProject, useThreadShell } from "../../state/entities"; +import { useAssistantMessageText, useProject, useThreadShell } from "../../state/entities"; import { MessageCopyButton } from "./MessageCopyButton"; import { AssistantCitationSource } from "./AssistantCitationSource"; import { resolveAssistantMessageCopyState } from "./MessagesTimeline.logic"; @@ -26,7 +26,13 @@ import { MESSAGE_HEADING_LEVEL, MessageAuthorHeading } from "./TimelineRowParts" export function AssistantTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + // The timeline holds a streaming message's row while only its text grows. + const liveText = useAssistantMessageText( + row.message.streaming ? ctx.threadRef : null, + row.projectedItem?.item.id, + ); + const text = liveText ?? row.message.text; + const messageText = text || (row.message.streaming ? "" : "(empty response)"); return ( <> diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 54b367df15b8b..0f94280768d4b 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -65,6 +65,9 @@ const EMPTY_VISIBLE_TURN_ITEMS_ATOM = Atom.make(EMPTY_VISIBLE_TURN_ITEMS).pipe( const EMPTY_CHECKPOINT_SUMMARIES_ATOM = Atom.make>( Object.freeze([]), ).pipe(Atom.withLabel("web-thread-checkpoint-summaries:empty")); +const EMPTY_ASSISTANT_MESSAGE_TEXT_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-assistant-message-text:empty"), +); const EMPTY_LAST_VISITED_AT_ATOM = Atom.make(null).pipe( Atom.withLabel("web-thread-last-visited:empty"), ); @@ -212,6 +215,29 @@ export function useThreadVisibleTurnItems( ); } +/** Visible turn items held through streamed assistant text; see useAssistantMessageText. */ +export function useTimelineVisibleTurnItems( + ref: ScopedThreadRef | null, +): ReadonlyArray { + return useAtomValue( + ref === null + ? EMPTY_VISIBLE_TURN_ITEMS_ATOM + : environmentThreadDetails.timelineVisibleTurnItemsAtom(ref), + ); +} + +/** One assistant message's live text, or null when the thread does not hold the item. */ +export function useAssistantMessageText( + ref: ScopedThreadRef | null, + itemId: string | undefined, +): string | null { + return useAtomValue( + ref === null || itemId === undefined + ? EMPTY_ASSISTANT_MESSAGE_TEXT_ATOM + : environmentThreadDetails.assistantMessageTextAtom(ref, itemId), + ); +} + /** Visible turn items without their streamed text; see ThreadStructure. */ export function useQuietThreadVisibleTurnItems( ref: ScopedThreadRef | null, diff --git a/apps/web/test/appScenarios.renders.tsx b/apps/web/test/appScenarios.renders.tsx index 5bb6836dd04a7..459f3b9791d7f 100644 --- a/apps/web/test/appScenarios.renders.tsx +++ b/apps/web/test/appScenarios.renders.tsx @@ -201,7 +201,9 @@ it("app-stream-assistant-turn", async () => { .element(page.getByText(`word${STREAM_DELTAS - 1}`, { exact: false })) .toBeVisible(); }); - // Only the streaming row changes; history rows must not re-render with it. + // Only the streaming row changes. The timeline renders for the new item and its layout, not + // per delta, and history rows not at all. + expect(counted.components.ConversationTimeline).toBeLessThanOrEqual(3); expect(counted.components.UserTimelineRow).toBeUndefined(); expect(counted.components.AssistantTimelineRow).toBe(STREAM_DELTAS); // Each delta adds a paragraph; finished blocks keep their render. diff --git a/packages/client-runtime/src/state/threadDetail.test.ts b/packages/client-runtime/src/state/threadDetail.test.ts index 8fc01839183fe..3e66202d0bb4b 100644 --- a/packages/client-runtime/src/state/threadDetail.test.ts +++ b/packages/client-runtime/src/state/threadDetail.test.ts @@ -197,6 +197,42 @@ describe("quietThreadAtom", () => { }); }); +describe("timelineVisibleTurnItemsAtom", () => { + const ref = { environmentId: EnvironmentId.make("environment-1"), threadId: v2ThreadId }; + + it("holds through streamed assistant text while the message reads its own text", () => { + const stateAtom = Atom.make( + AsyncResult.success({ + ...EMPTY_ENVIRONMENT_THREAD_STATE, + data: Option.some(withItem(assistant)), + }), + ); + const details = createEnvironmentThreadDetailAtoms(() => stateAtom); + const timelineAtom = details.timelineVisibleTurnItemsAtom(ref); + const textAtom = details.assistantMessageTextAtom(ref, assistant.id); + const registry = AtomRegistry.make(); + registry.mount(timelineAtom); + registry.mount(textAtom); + const push = (item: OrchestrationV2TurnItem) => + registry.set( + stateAtom, + AsyncResult.success({ + ...EMPTY_ENVIRONMENT_THREAD_STATE, + data: Option.some(withItem(item, later)), + }), + ); + const first = registry.get(timelineAtom); + + push({ ...assistant, text: "Hello", updatedAt: later }); + expect(registry.get(timelineAtom)).toBe(first); + expect(registry.get(textAtom)).toBe("Hello"); + + push({ ...assistant, text: "Hello", streaming: false, status: "completed" }); + expect(registry.get(timelineAtom)[0]?.item).toMatchObject({ text: "Hello", streaming: false }); + registry.dispose(); + }); +}); + it("only compares replaced entries in a streamed reducer delta", () => { const size = 100; let slotReads = 0; diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 3819c5d92ba5e..eeeda6a2f348f 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -148,6 +148,26 @@ function sameVisibleTurnItemStructure(before: VisibleTurnItem, after: VisibleTur ); } +function isStreamingAssistantMessage(visibleItem: VisibleTurnItem): boolean { + return visibleItem.item.type === "assistant_message" && visibleItem.item.streaming; +} + +/** Whether `next` only streams more text into assistant messages that were already streaming. */ +function sameApartFromStreamedAssistantText( + previous: OrchestrationV2ThreadProjection["visibleTurnItems"], + next: OrchestrationV2ThreadProjection["visibleTurnItems"], +): boolean { + return sameProjectionArray( + previous, + next, + (before, after) => + before === after || + (isStreamingAssistantMessage(before) && + isStreamingAssistantMessage(after) && + sameVisibleTurnItemStructure(before, after)), + ); +} + /** * Whether `previous` and `next` have the same ThreadStructure: they differ * only by text streamed into existing assistant and reasoning items or by a @@ -267,6 +287,32 @@ export function createEnvironmentThreadDetailAtoms( ); }); + // Holds while only streamed assistant text moves, so the timeline re-renders on structure; + // each streaming message reads its text through assistantMessageTextAtom. + const timelineVisibleTurnItemsAtomFamily = Atom.family((key: string) => { + let previous = EMPTY_VISIBLE_TURN_ITEMS; + return Atom.make((get) => { + const next = get(visibleTurnItemsAtomFamily(key)); + if (!sameApartFromStreamedAssistantText(previous, next)) previous = next; + return previous; + }).pipe( + Atom.setIdleTTL(0), + Atom.withLabel(`environment-thread-timeline-visible-turn-items:${key}`), + ); + }); + + const assistantMessageTextAtomFamily = Atom.family((key: string) => { + const separator = key.lastIndexOf("\u0000"); + const thread = key.slice(0, separator); + const itemId = key.slice(separator + 1); + return Atom.make((get) => { + const item = get(visibleTurnItemsAtomFamily(thread)).find( + (visibleItem) => visibleItem.item.id === itemId, + )?.item; + return item?.type === "assistant_message" ? item.text : null; + }).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-assistant-message-text:${key}`)); + }); + const queueWorkflowAtomFamily = Atom.family((key: string) => { let previous: Pick< OrchestrationV2ThreadProjection, @@ -472,6 +518,10 @@ export function createEnvironmentThreadDetailAtoms( visibleTurnItemsAtom: (ref: ScopedThreadRef) => visibleTurnItemsAtomFamily(threadKey(ref)), quietVisibleTurnItemsAtom: (ref: ScopedThreadRef) => quietVisibleTurnItemsAtomFamily(threadKey(ref)), + timelineVisibleTurnItemsAtom: (ref: ScopedThreadRef) => + timelineVisibleTurnItemsAtomFamily(threadKey(ref)), + assistantMessageTextAtom: (ref: ScopedThreadRef, itemId: string) => + assistantMessageTextAtomFamily(`${threadKey(ref)}\u0000${itemId}`), statusAtom: (ref: ScopedThreadRef) => statusAtomFamily(threadKey(ref)), sequenceAtom: (ref: ScopedThreadRef) => sequenceAtomFamily(threadKey(ref)), errorAtom: (ref: ScopedThreadRef) => errorAtomFamily(threadKey(ref)), From 32ecd71726fee16f335484c1f95cbce126d461f6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 02:17:08 -0700 Subject: [PATCH 08/31] fix(server): `t3 service protect` can ask for your sudo password (#27465) Co-authored-by: Claude Opus 5.5 --- apps/server/src/cli/service.ts | 10 +++++++++- apps/server/src/cloud/serviceProtection.test.ts | 13 +++++++++---- apps/server/src/cloud/serviceProtection.ts | 3 +++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 62edb31d14b62..884cd28c9df87 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -314,6 +314,14 @@ const serviceProtectCommand = Command.make("protect", { ); } + yield* Console.log( + [ + "Keeps T3 Code running when agents fill this machine's memory:", + " - reserves memory for the T3 Code server, so the kernel takes memory from agents first", + " - makes the out-of-memory killer pick agents before the server", + "It adds systemd drop-in files and changes nothing else. Undo with `t3 service protect --undo`.", + ].join("\n"), + ); const names = remote.map((unit) => unit.replace(/\.service$/, "")).join(" and "); const protectRemote = remote.length > 0 && @@ -328,7 +336,7 @@ const serviceProtectCommand = Command.make("protect", { }); yield* Console.log( [ - "As root, through sudo:", + "Drop-ins it writes, as root through sudo:", ...formatDropIns(plan.system), "As you:", ...formatDropIns([plan.managerDefaults, ...plan.user]), diff --git a/apps/server/src/cloud/serviceProtection.test.ts b/apps/server/src/cloud/serviceProtection.test.ts index b2e4a1506cb31..eef78cd3f82e7 100644 --- a/apps/server/src/cloud/serviceProtection.test.ts +++ b/apps/server/src/cloud/serviceProtection.test.ts @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import type * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; @@ -122,13 +123,13 @@ it.each([ expect(ServiceProtection.loadedRemoteAccessUnits(shown)).toEqual(expected); }); -const fakeSudo = (exitCode: number, onRun: () => void) => +const fakeSudo = (exitCode: number, onRun: (command: ChildProcess.StandardCommand) => void) => ChildProcessSpawner.make((command) => Effect.gen(function* () { if (command._tag !== "StandardCommand" || command.command !== "sudo") { return yield* Effect.die("Unexpected command"); } - onRun(); + onRun(command); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(123), stdout: Stream.empty, @@ -180,11 +181,13 @@ it.effect("lowers no score before the manager default is in place, and undoes in writesManagerDropIn = false, ) => { let atSudo: ReturnType | undefined; + let sudoOptions: ChildProcess.CommandOptions | undefined; return effect.pipe( Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, - fakeSudo(exitCode, () => { + fakeSudo(exitCode, (command) => { atSudo = present(); + sudoOptions = command.options; if (writesManagerDropIn) { NodeFS.mkdirSync(NodePath.dirname(managerDropIn), { recursive: true }); NodeFS.writeFileSync(managerDropIn, "[Service]\nOOMScoreAdjust=-900\n"); @@ -192,7 +195,7 @@ it.effect("lowers no score before the manager default is in place, and undoes in }), ), Effect.exit, - Effect.map((exit) => ({ exit, atSudo })), + Effect.map((exit) => ({ exit, atSudo, sudoOptions })), ); }; const refused = yield* withSudo(ServiceProtection.applyProtection(plan), 1); @@ -209,6 +212,8 @@ it.effect("lowers no score before the manager default is in place, and undoes in const applied = yield* withSudo(ServiceProtection.applyProtection(plan), 0); expect(applied.atSudo).toEqual({ managerDefaults: true, user: 0 }); + // sudo needs the controlling terminal to ask for a password. + expect(applied.sudoOptions).toMatchObject({ detached: false, stdin: "inherit" }); expect(present()).toEqual({ managerDefaults: true, user: plan.user.length }); const removed = yield* withSudo(ServiceProtection.removeProtection(plan), 0); diff --git a/apps/server/src/cloud/serviceProtection.ts b/apps/server/src/cloud/serviceProtection.ts index b23cd36e3a545..780aee83b4c48 100644 --- a/apps/server/src/cloud/serviceProtection.ts +++ b/apps/server/src/cloud/serviceProtection.ts @@ -240,7 +240,10 @@ const runPrivileged = Effect.fn("ServiceProtection.runPrivileged")(function* (sc const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const exitCode = yield* spawner .exitCode( + // Effect detaches children on POSIX by default; sudo then has no controlling + // terminal and cannot ask for a password. ChildProcess.make("sudo", ["/bin/sh", "-c", script], { + detached: false, stdin: "inherit", stdout: "inherit", stderr: "inherit", From 0f6de8c22c0bc694efe0926125b60b57e6ecf8cb Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:10:47 +0530 Subject: [PATCH 09/31] fix(web): preserve Ctrl+Arrow terminal key sequences (#23480) --- apps/web/src/keybindings.test.ts | 26 ++++++++++++++++++++++ apps/web/src/keybindings.ts | 4 ---- apps/web/src/terminal/ghostty/core.test.ts | 21 +++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index a0ba7fa7ee043..1f0a096fccaec 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -21,6 +21,7 @@ import { resolveShortcutCommand, shouldShowThreadJumpHintsForModifiers, shortcutLabelForCommand, + terminalNavigationShortcutData, type ShortcutEventLike, } from "./keybindings"; @@ -35,6 +36,31 @@ function event(overrides: Partial = {}): ShortcutEventLike { }; } +describe("terminal word navigation", () => { + it.each(["Win32", "Linux x86_64"])( + "leaves Ctrl+Arrow to the terminal encoder on %s", + (platform) => { + for (const key of ["ArrowLeft", "ArrowRight"]) { + assert.equal(terminalNavigationShortcutData(event({ key, ctrlKey: true }), platform), null); + } + }, + ); + + it.each(["Win32", "Linux x86_64", "MacIntel"])( + "preserves Alt+Arrow word navigation on %s", + (platform) => { + assert.equal( + terminalNavigationShortcutData(event({ key: "ArrowLeft", altKey: true }), platform), + "\u001bb", + ); + assert.equal( + terminalNavigationShortcutData(event({ key: "ArrowRight", altKey: true }), platform), + "\u001bf", + ); + }, + ); +}); + function modShortcut( key: string, overrides: Partial> = {}, diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index d3d3de58d3676..18c1b4c53c5a9 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -464,10 +464,6 @@ export function terminalNavigationShortcutData( return null; } - if (event.ctrlKey && !event.metaKey && !event.altKey) { - return moveWord; - } - if (event.altKey && !event.metaKey && !event.ctrlKey) { return moveWord; } diff --git a/apps/web/src/terminal/ghostty/core.test.ts b/apps/web/src/terminal/ghostty/core.test.ts index 69bf963ae34c4..7fc9661555e49 100644 --- a/apps/web/src/terminal/ghostty/core.test.ts +++ b/apps/web/src/terminal/ghostty/core.test.ts @@ -12,6 +12,7 @@ import { import { writeTerminalOutputUpdate } from "../../components/ThreadTerminalDrawer"; import { GHOSTTY_CELL_WIDE, GhosttyTerminalCore, ghosttyCellText } from "./core"; import { loadGhosttyRuntime } from "./runtime"; +import { terminalNavigationShortcutData } from "../../keybindings"; vi.mock("./vendor/ghostty-vt.wasm?url", async () => ({ default: (await import("./vendor/ghostty-vt.wasm?inline")).default, @@ -101,6 +102,26 @@ describe("GhosttyTerminalCore snapshots", () => { vi.restoreAllMocks(); }); + it.each([ + { key: "ArrowLeft", expected: "\x1b[1;5D" }, + { key: "ArrowRight", expected: "\x1b[1;5C" }, + ])("sends Ctrl+$key as a modified arrow instead of Alt+B/F", async ({ key, expected }) => { + const core = await createCore(); + const event = { + code: key, + key, + repeat: false, + shiftKey: false, + ctrlKey: true, + altKey: false, + metaKey: false, + isComposing: false, + getModifierState: () => false, + } as unknown as KeyboardEvent; + + expect(terminalNavigationShortcutData(event, "Win32") ?? core.encodeKey(event)).toBe(expected); + }); + it.each([ { code: "", key: "Backspace", expected: "\x7f" }, { code: "Unidentified", key: "Backspace", expected: "\x7f" }, From d1f1650f09bc51bad3146b572e0ec72730741c61 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:11:32 +0530 Subject: [PATCH 10/31] fix(web): keep PowerShell prompt paths with spaces in one link (#23527) --- apps/web/src/terminal-links.test.ts | 30 +++++++++++++++++++ apps/web/src/terminal-links.ts | 16 ++++++++-- apps/web/src/terminal/ghostty/surface.test.ts | 8 +++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index 05ed26959bee5..f6df5912ecdd1 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -16,6 +16,36 @@ function createBufferLine(text: string, isWrapped = false): TerminalBufferLineLi } describe("extractTerminalLinks", () => { + it.each([ + String.raw`C:\dev\ai-test\dir 1`, + String.raw`C:\dev\ai-test\dir 1\test`, + "C:\\", + String.raw`\\server\my share\dir 1`, + "/home/Jane Doe/project", + String.raw`C:\dev\folder (draft)\name!`, + ])("keeps the full PowerShell prompt path %s in one link", (path) => { + expect(extractTerminalLinks(`PS ${path}> `)).toEqual([ + { kind: "path", text: path, start: 3, end: 3 + path.length }, + ]); + }); + + it("keeps links in a command separate from the PowerShell prompt path", () => { + const path = String.raw`C:\dev\dir 1\test`; + const prompt = `PS ${path}> `; + const url = "https://example.com"; + expect(extractTerminalLinks(`${prompt}echo ${url}`)).toEqual([ + { kind: "path", text: path, start: 3, end: 3 + path.length }, + { kind: "url", text: url, start: prompt.length + 5, end: prompt.length + 5 + url.length }, + ]); + }); + + it("does not extend ordinary output paths through trailing prose", () => { + const path = String.raw`C:\dev\project`; + expect(extractTerminalLinks(`see ${path} for details`)).toEqual([ + { kind: "path", text: path, start: 4, end: 4 + path.length }, + ]); + }); + it.each([ String.raw`src\index.ts:12`, String.raw`.\src\index.ts:12:7`, diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 48a7c16edbc61..1c078d86cd20f 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -27,6 +27,8 @@ export interface WrappedTerminalLinkLine { } const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/giu; +// The default PowerShell prompt delimits its unquoted path with `PS ` and `>`. +const POWERSHELL_PROMPT_PATH_PATTERN = /^PS ((?:[A-Za-z]:[\\/]|\\\\|\/)[^>\r\n]*)>/u; // Bare paths start only at a segment boundary: hover detection runs this on // every frame, so separator-free output must not be rescanned at every suffix. const FILE_PATH_PATTERN = @@ -104,17 +106,25 @@ function collectMatches( } export function extractTerminalLinks(line: string): TerminalLinkMatch[] { + const promptPath = POWERSHELL_PROMPT_PATH_PATTERN.exec(line)?.[1]; + const promptMatches: TerminalLinkMatch[] = promptPath + ? [{ kind: "path", text: promptPath, start: 3, end: 3 + promptPath.length }] + : []; const requestRoutes = Array.from(line.matchAll(REQUEST_ROUTE_PATTERN), (match) => ({ start: match.index, end: match.index + match[0].length, })); - const urlMatches = collectMatches(line, "url", URL_PATTERN, []); - const quotedMatches = collectMatches(line, "path", QUOTED_FILE_PATH_PATTERN, urlMatches); + const urlMatches = collectMatches(line, "url", URL_PATTERN, promptMatches); + const quotedMatches = collectMatches(line, "path", QUOTED_FILE_PATH_PATTERN, [ + ...promptMatches, + ...urlMatches, + ]); const pathMatches = collectMatches(line, "path", FILE_PATH_PATTERN, [ + ...promptMatches, ...urlMatches, ...quotedMatches, ]); - return [...urlMatches, ...quotedMatches, ...pathMatches] + return [...promptMatches, ...urlMatches, ...quotedMatches, ...pathMatches] .filter( (match) => match.kind === "url" || !requestRoutes.some((route) => overlaps(match, route)), ) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 5473e3c4ffa1c..57018464df49f 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -606,12 +606,20 @@ describe("terminalLinkAtPositionWithRange", () => { row("com/reference", true), row("~/project/file", false), row("C:\\repo\\file.ts", false), + row("PS C:\\repo\\dir 1", false, true), + row("\\test> ", true), ]; expect(terminalLinkAtPositionWithRange(rows, 0, 8)?.text).toBe("https://example.com/reference"); expect(terminalLinkAtPositionWithRange(rows, 1, 4)?.text).toBe("https://example.com/reference"); expect(terminalLinkAtPositionWithRange(rows, 2, 2)?.text).toBe("~/project/file"); expect(terminalLinkAtPositionWithRange(rows, 3, 4)?.text).toBe("C:\\repo\\file.ts"); + expect(terminalLinkAtPositionWithRange(rows, 4, 13)).toEqual({ + text: "C:\\repo\\dir 1\\test", + range: { start: { x: 3, y: 4 }, end: { x: 4, y: 5 } }, + }); + expect(terminalLinkAtPositionWithRange(rows, 5, 2)?.text).toBe("C:\\repo\\dir 1\\test"); + expect(terminalLinkAtPositionWithRange(rows, 5, 5)).toBeNull(); expect(terminalLinkAtPositionWithRange(rows, 1, 4)).toEqual({ text: "https://example.com/reference", range: { From 8cb9182021bc4317f71b0f5e246d8ffb5b23d456 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:12:27 +0530 Subject: [PATCH 11/31] perf(client): find unread terminal chunks from the tail (#23779) --- .../src/state/terminalOutput.ts | 11 +++-- .../src/state/terminalSession.test.ts | 40 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/client-runtime/src/state/terminalOutput.ts b/packages/client-runtime/src/state/terminalOutput.ts index 5c4f8422c1f7d..df4d2ee03955c 100644 --- a/packages/client-runtime/src/state/terminalOutput.ts +++ b/packages/client-runtime/src/state/terminalOutput.ts @@ -286,9 +286,14 @@ export function readTerminalOutputUpdate( return { type: "reset", data: terminalOutputText(output), cursor: nextCursor }; } - const appended = output.chunks.filter( - (chunk) => chunk.startOffset + chunk.data.length > cursor.offset, - ); + // Appends and compaction preserve offset order, so unread chunks form a suffix. + let firstUnread = output.chunks.length; + while (firstUnread > 0) { + const chunk = output.chunks[firstUnread - 1]!; + if (!(chunk.startOffset + chunk.data.length > cursor.offset)) break; + firstUnread -= 1; + } + const appended = output.chunks.slice(firstUnread); if (appended.length === 0) { return { type: "none", cursor: nextCursor }; } diff --git a/packages/client-runtime/src/state/terminalSession.test.ts b/packages/client-runtime/src/state/terminalSession.test.ts index a04504e00ee3b..7cbcd14f819fb 100644 --- a/packages/client-runtime/src/state/terminalSession.test.ts +++ b/packages/client-runtime/src/state/terminalSession.test.ts @@ -166,6 +166,46 @@ describe("terminal session reducers", () => { expect(readTerminalOutputUpdate(state.output, update.cursor).type).toBe("none"); }); + it("bounds historical chunk inspection when reading new tail output", () => { + let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: { ...BASE_SNAPSHOT, history: "" }, + }); + for (let index = 0; index < 512; index += 1) { + state = applyTerminalAttachStreamEvent(state, { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: "history\r\n", + }); + } + const cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor; + const next = applyTerminalAttachStreamEvent(state, { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: "latest\r\n", + }); + let historicalChunkReads = 0; + const chunks = next.output.chunks.map((chunk, index) => + index < state.output.chunks.length + ? { + ...chunk, + get startOffset() { + historicalChunkReads += 1; + return chunk.startOffset; + }, + } + : chunk, + ); + + expect(readTerminalOutputUpdate({ ...next.output, chunks }, cursor)).toMatchObject({ + type: "append", + data: "latest\r\n", + }); + expect(historicalChunkReads).toBeLessThanOrEqual(2); + }); + it("preserves the byte-limited tail and resets a cursor before a partially trimmed chunk", () => { const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { type: "snapshot", From 6575ce32508bd7908444241f3719aa33aaf77f90 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:44:34 +0100 Subject: [PATCH 12/31] fix(pi): explain when Pi's default model has no credentials (#27513) Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT 6.1 Sol --- .../provider-pi/src/server/status.test.ts | 105 ++++++++++++++++-- packages/provider-pi/src/server/status.ts | 89 ++++++++++++++- 2 files changed, 177 insertions(+), 17 deletions(-) diff --git a/packages/provider-pi/src/server/status.test.ts b/packages/provider-pi/src/server/status.test.ts index cee6442fc9642..b8559d1ab7d8a 100644 --- a/packages/provider-pi/src/server/status.test.ts +++ b/packages/provider-pi/src/server/status.test.ts @@ -52,7 +52,15 @@ function piProbeSpawner(version: string) { }); } -const piDiscoverySpawner = (inventory: unknown) => +/** Fails the test if status runs `pi auth check` without `authCheck` or with other arguments. */ +const piDiscoverySpawner = ( + inventory: unknown, + options: { + readonly version?: string; + readonly state?: unknown; + readonly authCheck?: string | undefined; + } = {}, +) => Effect.gen(function* () { const stdout = yield* Queue.unbounded(); let stdinBuffer = ""; @@ -71,7 +79,9 @@ const piDiscoverySpawner = (inventory: unknown) => ? inventory : request.type === "get_commands" ? { commands: [] } - : {}; + : request.type === "get_state" + ? (options.state ?? {}) + : {}; yield* Queue.offer( stdout, encoder.encode( @@ -83,16 +93,23 @@ const piDiscoverySpawner = (inventory: unknown) => ); return ChildProcessSpawner.make((command) => { const args = ChildProcess.isStandardCommand(command) ? command.args : []; + if (args.includes("--version")) { + return Effect.succeed(processHandle({ stdout: `pi ${options.version ?? "0.84.3"}\n` })); + } + if (args[0] === "auth") { + const expected = ["auth", "check", "--provider", "openai", "--json", "--no-refresh"]; + return options.authCheck === undefined || args.join(" ") !== expected.join(" ") + ? Effect.die(`Unexpected pi ${args.join(" ")}`) + : Effect.succeed(processHandle({ stdout: options.authCheck, exitCode: 1 })); + } return Effect.succeed( - args.includes("--version") - ? processHandle({ stdout: "pi 0.84.3\n" }) - : ChildProcessSpawner.makeHandle({ - ...processHandle({}), - exitCode: Effect.never, - isRunning: Effect.succeed(true), - stdin, - stdout: Stream.fromQueue(stdout), - }), + ChildProcessSpawner.makeHandle({ + ...processHandle({}), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + stdin, + stdout: Stream.fromQueue(stdout), + }), ); }); }); @@ -146,6 +163,72 @@ describe("PiProvider", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect.each([ + { + label: "a pinned model without credentials", + version: "1.4.0", + activeModel: { provider: "openai", id: "gpt-5" }, + authCheck: '{"status":"not_ready","provider":"openai","reason":"credentials_not_configured"}', + message: "Pi default (openai/gpt-5) has no credentials configured for openai.", + }, + { + label: "a pinned model with a runtime --api-key", + version: "1.4.0", + launchArgs: "--provider openai --model gpt-5 --api-key fake-key", + activeModel: { provider: "openai", id: "gpt-5" }, + authCheck: undefined, + message: "Pi default (openai/gpt-5) uses openai, which has no available models in Pi.", + }, + { + label: "a pinned model whose provider auth check cannot find", + version: "1.4.0", + activeModel: { provider: "openai", id: "gpt-5" }, + authCheck: '{"status":"not_ready","provider":"openai","reason":"provider_not_found"}', + message: "Pi default (openai/gpt-5) uses openai, which has no available models in Pi.", + }, + { + label: "a pinned model on Pi without auth check", + version: MINIMUM_PI_VERSION, + activeModel: { provider: "openai", id: "gpt-5" }, + authCheck: undefined, + message: "Pi default (openai/gpt-5) uses openai, which has no available models in Pi.", + }, + { + label: "an uncatalogued model id of a provider with credentials", + version: "1.4.0", + activeModel: { provider: "anthropic", id: "claude-next" }, + authCheck: undefined, + message: undefined, + }, + { + label: "an extension model Pi lists as available", + version: "1.4.0", + activeModel: { provider: "router", id: "auto" }, + authCheck: undefined, + message: undefined, + }, + ])("diagnoses Pi's active model readiness for $label", (row) => + Effect.gen(function* () { + const spawner = yield* piDiscoverySpawner( + { + models: [ + { provider: "anthropic", id: "claude-sonnet-4" }, + { provider: "router", id: "auto" }, + ], + }, + { version: row.version, state: { model: row.activeModel }, authCheck: row.authCheck }, + ); + const snapshot = yield* checkPiProviderStatus({ + ...settings, + launchArgs: row.launchArgs ?? "", + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.auth.status, "authenticated"); + if (row.message === undefined) assert.isUndefined(snapshot.message); + else assert.include(snapshot.message ?? "", row.message); + }).pipe(Effect.provide(NodeServices.layer)), + ); + // regression: #18498 a malformed model inventory must not restore stale Pi models it.effect.each([ ["missing models", {}], diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index eef8bb5be3552..a180ab9c41a3b 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -13,6 +13,7 @@ import * as Stream from "effect/Stream"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import * as HttpClient from "effect/http/HttpClient"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; @@ -62,6 +63,12 @@ const PI_RPC_DISCOVERY_TIMEOUT_MS = 30_000; * Version 0.80.5 was the first published package containing both hooks. */ export const MINIMUM_PI_VERSION = "0.80.5"; +/** `pi auth check --json --no-refresh` arrived in 0.84.1. */ +const PI_AUTH_CHECK_VERSION = "0.84.1"; + +const decodePiAuthCheck = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ reason: Schema.optional(Schema.String) })), +); const PI_DEFAULT_MODEL: ServerProviderModel = { slug: "default", @@ -73,6 +80,8 @@ const PI_DEFAULT_MODEL: ServerProviderModel = { interface PiDiscovery extends PiDiscoveredCommands { readonly models: ReadonlyArray; readonly authenticated: boolean; + /** Pi's active model when Pi lists no model of its provider, so it cannot start a turn. */ + readonly unavailableActiveModel: { readonly provider: string; readonly id: string } | undefined; } function piModelsFromSettings( @@ -169,11 +178,22 @@ const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function .pipe(Effect.orElseSucceed(() => undefined)); const discoveredModels = yield* Effect.fromResult(parseDiscoveredModels(modelsData, stateData)); const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); + // Pi lists only models of providers it holds credentials for, including extension, virtual and + // --api-key ones. Turns need only the provider's credentials, so uncatalogued model ids still run. + const activeModel = recordField(stateData, "model"); + const activeProvider = recordString(activeModel, "provider"); + const activeModelId = recordString(activeModel, "id"); return { models: discoveredModels, slashCommands: withPiBuiltinSlashCommands(slashCommands), skills, authenticated: discoveredModels.length > 0, + unavailableActiveModel: + activeProvider !== undefined && + activeModelId !== undefined && + !discoveredModels.some((model) => model.subProvider === activeProvider) + ? { provider: activeProvider, id: activeModelId } + : undefined, } satisfies PiDiscovery; }, Effect.scoped); @@ -221,6 +241,56 @@ const runPiVersionCommand = Effect.fn("PiProviderStatus.runPiVersionCommand")(fu ); }); +/** + * Explains why Pi's active model cannot start a turn. On Pi versions with `auth check`, asks it + * whether credentials are missing, without refreshing or printing them. That command cannot see + * extension providers or a runtime --api-key, so it only refines Pi's own unavailable verdict, + * and is skipped when launch args supply the key. + */ +const describeUnavailablePiModel = Effect.fn("PiProviderStatus.describeUnavailablePiModel")( + function* ( + piSettings: PiSettings, + environment: NodeJS.ProcessEnv, + version: string, + launchArgs: ReadonlyArray, + model: { readonly provider: string; readonly id: string }, + ) { + const label = `Pi default (${model.provider}/${model.id})`; + const unavailable = `${label} uses ${model.provider}, which has no available models in Pi. Choose another model, or check that provider's setup and the Pi launch arguments.`; + if ( + compareSemverVersions(version, PI_AUTH_CHECK_VERSION) < 0 || + launchArgs.includes("--api-key") + ) { + return unavailable; + } + const command = piSettings.binaryPath || "pi"; + const output = yield* resolveSpawnCommand( + command, + ["auth", "check", "--provider", model.provider, "--json", "--no-refresh"], + { env: environment }, + ).pipe( + Effect.flatMap((spawnCommand) => + spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ), + ), + Effect.timeout(VERSION_PROBE_TIMEOUT_MS), + Effect.option, + ); + const missingCredentials = Option.exists( + Option.flatMap(output, (result) => decodePiAuthCheck(result.stdout)), + (check) => check.reason === "credentials_not_configured", + ); + return missingCredentials + ? `${label} has no credentials configured for ${model.provider}. Choose another model, or run \`pi\` in a terminal and use /login or configure its API key.` + : unavailable; + }, +); + export const buildInitialPiProviderSnapshot = Effect.fn( "PiProviderStatus.buildInitialPiProviderSnapshot", )(function* (piSettings: PiSettings): Effect.fn.Return { @@ -432,6 +502,17 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function const discovery = discoveryExit.value.value; const models = piModelsFromSettings(piSettings.customModels, discovery.models); + const message = !discovery.authenticated + ? "Pi has no usable models. Run `pi` in a terminal and use /login, or configure an API key in ~/.pi/agent." + : discovery.unavailableActiveModel === undefined + ? undefined + : yield* describeUnavailablePiModel( + piSettings, + environment, + version, + resolvedLaunchArgs.args, + discovery.unavailableActiveModel, + ); return buildServerProvider({ presentation: PI_PRESENTATION, enabled: piSettings.enabled, @@ -442,14 +523,10 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function probe: { installed: true, version, + // Other Pi models still work when only the default is unavailable, so keep the picker usable. status: discovery.authenticated ? "ready" : "warning", auth: { status: discovery.authenticated ? "authenticated" : "unauthenticated", type: "pi" }, - ...(discovery.authenticated - ? {} - : { - message: - "Pi has no usable models. Run `pi` in a terminal and use /login, or configure an API key in ~/.pi/agent.", - }), + ...(message === undefined ? {} : { message }), }, }); }); From 616c426f5f199865e34974777740dff678ffb7f5 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 30 Oct 2026 15:43:01 +0000 Subject: [PATCH 13/31] fix(shared): TypeScript files no longer show as videos (#27372) --- packages/shared/src/filePreview.test.ts | 3 +++ packages/shared/src/filePreview.ts | 5 +++-- packages/shared/src/image.ts | 3 +++ packages/shared/src/video.test.ts | 5 +++++ packages/shared/src/video.ts | 2 +- 5 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/filePreview.test.ts b/packages/shared/src/filePreview.test.ts index b6f4fc940874a..9024954ebf1ad 100644 --- a/packages/shared/src/filePreview.test.ts +++ b/packages/shared/src/filePreview.test.ts @@ -30,6 +30,9 @@ describe("workspace file previews", () => { expect(isWorkspacePreviewEntryPath(`${name}?download=1`)).toBe(false); expect(hostPreviewMimeTypeFromExtension(`.${extension}`)).toBe(mimeType); }); + it("previews a TypeScript file the OS labels as MPEG transport stream as text", () => { + expect(filePreviewKind({ name: "clamp.ts", mimeType: "video/mp2t" })).toBe("text"); + }); it("recognizes compound archives and MIME aliases", () => { expect(filePreviewKind({ name: "archive.tar.gz" })).toBe("archive"); expect(filePreviewKind({ name: "download", mimeType: "application/x-gzip" })).toBe("archive"); diff --git a/packages/shared/src/filePreview.ts b/packages/shared/src/filePreview.ts index 4155252a12a90..2cd77e2e59779 100644 --- a/packages/shared/src/filePreview.ts +++ b/packages/shared/src/filePreview.ts @@ -9,6 +9,7 @@ export { mediaKindFromPath, mediaMimeTypeFromExtension, } from "./mediaPreview.ts"; +import { GENERIC_MIME_TYPES } from "./image.ts"; import { videoMimeType } from "./video.ts"; export type FilePreviewKind = @@ -41,7 +42,7 @@ export function filePreviewKind( const mime = file.mimeType?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; const name = file.name.toLowerCase(); const extension = name.slice(name.lastIndexOf(".")); - const generic = !mime || mime === "application/octet-stream" || mime === "text/plain"; + const generic = !mime || GENERIC_MIME_TYPES.has(mime) || mime === "text/plain"; const richDocuments = options?.richDocuments !== false; const office = richDocuments ? officePreviewKind(file) : null; if (office) return office; @@ -61,7 +62,7 @@ export function filePreviewKind( ) return "font"; if (mime.startsWith("image/")) return "image"; - if (mime.startsWith("video/")) return "video"; + if (mime.startsWith("video/") && !generic) return "video"; if (mime.startsWith("audio/")) return "audio"; if (generic) { if (extension === ".pdf") return "pdf"; diff --git a/packages/shared/src/image.ts b/packages/shared/src/image.ts index 30615c24f967c..3c219286ac234 100644 --- a/packages/shared/src/image.ts +++ b/packages/shared/src/image.ts @@ -13,6 +13,9 @@ export const GENERIC_MIME_TYPES = new Set([ "application/octet-stream", "binary/octet-stream", "application/unknown", + // macOS and Windows label TypeScript `.ts` files as MPEG transport streams, which browsers can't play. + "video/mp2t", + "video/vnd.dlna.mpeg-tts", ]); export function isSvgAttachment(attachment: { diff --git a/packages/shared/src/video.test.ts b/packages/shared/src/video.test.ts index 24ea54b5da835..7f5b29db8155c 100644 --- a/packages/shared/src/video.test.ts +++ b/packages/shared/src/video.test.ts @@ -14,4 +14,9 @@ describe("videoMimeType", () => { "video/webm", ); }); + + it("does not treat a TypeScript file the OS labels as MPEG transport stream as video", () => { + expect(videoMimeType({ name: "clamp.ts", mimeType: "video/mp2t" })).toBeNull(); + expect(videoMimeType({ name: "clamp.ts", mimeType: "video/vnd.dlna.mpeg-tts" })).toBeNull(); + }); }); diff --git a/packages/shared/src/video.ts b/packages/shared/src/video.ts index 7979f73e6d537..7e66fe8b8bf1a 100644 --- a/packages/shared/src/video.ts +++ b/packages/shared/src/video.ts @@ -17,7 +17,7 @@ export function videoMimeType(attachment: { readonly mimeType: string; }): string | null { const mimeType = attachment.mimeType.split(";", 1)[0]?.trim().toLowerCase() ?? ""; - if (mimeType.startsWith("video/")) return mimeType; + if (mimeType.startsWith("video/") && !GENERIC_MIME_TYPES.has(mimeType)) return mimeType; if (mimeType !== "" && !GENERIC_MIME_TYPES.has(mimeType)) return null; const dotIndex = attachment.name.lastIndexOf("."); return dotIndex < 0 From e4ecaf629dbcf68d10a043944845cfa096b729a8 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 30 Oct 2026 15:58:44 +0000 Subject: [PATCH 14/31] feat(mobile): message media, file cards and grouped steers match web (#27373) --- .../src/components/MediaVideoPlayer.tsx | 6 +- .../src/features/threads/ThreadFeed.tsx | 655 +++++++++++++----- .../features/threads/ThreadMarkdownVideo.tsx | 2 + .../threads/pending-thread-feed.test.ts | 66 ++ .../features/threads/pending-thread-feed.ts | 45 ++ apps/mobile/src/lib/composerImageGallery.ts | 17 + .../mobile/src/lib/filePreviewGallery.test.ts | 67 +- .../client-runtime/src/userMessage.test.ts | 152 +++- packages/client-runtime/src/userMessage.ts | 59 +- 9 files changed, 902 insertions(+), 167 deletions(-) diff --git a/apps/mobile/src/components/MediaVideoPlayer.tsx b/apps/mobile/src/components/MediaVideoPlayer.tsx index c6dc31ca00ad2..37a7d0e236f19 100644 --- a/apps/mobile/src/components/MediaVideoPlayer.tsx +++ b/apps/mobile/src/components/MediaVideoPlayer.tsx @@ -140,6 +140,7 @@ interface MediaVideoPlayerProps { readonly paused?: boolean; readonly onPlaybackStart?: () => void; readonly actionsSource?: MediaActionsSource; + readonly borderRadius?: number; } function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { @@ -152,7 +153,10 @@ function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { {playbackUri ? ( ["source"]; + readonly className: string; + readonly maxWidth?: number; + readonly height?: number; + readonly onLoad?: () => void; + readonly onError?: () => void; +}) { + const imageStyle = useResolveClassNames(props.className); + const [size, setSize] = useState<{ width: number; height: number } | null>(null); + const displaySize = + props.maxWidth !== undefined && size !== null + ? resolveMarkdownImageDisplaySize({ + sourceWidth: size.width, + sourceHeight: size.height, + availableWidth: props.maxWidth, + }) + : null; + return ( + { + if (props.maxWidth !== undefined) setSize(event.source); + props.onLoad?.(); + }} + onError={props.onError} + /> + ); +} + function MessageAttachmentImage(props: { readonly environmentId: EnvironmentId; readonly attachmentId: string; readonly name: string; readonly mimeType: string; readonly className: string; + readonly maxWidth?: number; + readonly height?: number; readonly onPressPreview: (source: FilePreviewSource) => void; }) { const withGallery = useFilePreviewGallery(); @@ -504,11 +555,19 @@ function MessageAttachmentImage(props: { const uri = useAssetUrl(props.environmentId, resource); const refreshAssetUrl = useRefreshAssetUrl(props.environmentId, resource); const retriedImage = useRef(false); - const imageStyle = useResolveClassNames(props.className); if (uri === null) { return ( - + ); @@ -537,11 +596,11 @@ function MessageAttachmentImage(props: { ) } > - { retriedImage.current = false; }} @@ -564,11 +623,21 @@ function isFileAttachment(attachment: ChatAttachment): attachment is ChatFileAtt return attachment.type === "file"; } +function isMediaAttachment(attachment: ChatAttachment): boolean { + return ( + isImageAttachment(attachment) || + (isFileAttachment(attachment) && videoMimeType(attachment) !== null) + ); +} + function MessageAttachmentFile(props: { readonly environmentId: EnvironmentId; readonly attachment: ChatFileAttachment; readonly onPressPreview: (source: FilePreviewSource) => void; readonly onPlaybackStart: () => void; + readonly standaloneMedia?: boolean; + readonly standaloneFile?: boolean; + readonly available?: boolean; }) { const sourceIdentifier = useId(); const navigation = useNavigation(); @@ -603,7 +672,7 @@ function MessageAttachmentFile(props: { ); const shareFile = (sourceIdentifier?: string) => { - if (httpBaseUrl === null || openingRef.current) return; + if (props.available === false || httpBaseUrl === null || openingRef.current) return; const controller = new AbortController(); openingRef.current = controller; setOpening(true); @@ -703,29 +772,34 @@ function MessageAttachmentFile(props: { if (videoType !== null) { return ( - + ); } + const disabled = props.available === false || opening || httpBaseUrl === null; return ( - <> - + + shareFile(sourceIdentifier)} onPress={openAttachment} > @@ -751,8 +825,205 @@ function MessageAttachmentFile(props: { type="monochrome" /> - - + {props.standaloneFile ? ( + shareFile(sourceIdentifier)} + > + + + ) : null} + + + ); +} + +function UserMessageMedia(props: { + readonly attachments: ReadonlyArray; + readonly pendingAttachments: QueuedThreadMessage["attachments"]; + readonly environmentId: EnvironmentId; + readonly maxWidth: number; + readonly onPressPreview: (source: FilePreviewSource) => void; + readonly onPlaybackStart: () => void; +}) { + const attachments = [...props.pendingAttachments, ...props.attachments]; + const collage = attachments.length > 1 && attachments.every(isImageAttachment); + const imageProps = (index: number) => ({ + className: "w-full bg-user-bubble-foreground/15", + maxWidth: collage ? undefined : props.maxWidth, + height: !collage + ? undefined + : attachments.length === 2 || (attachments.length === 3 && index === 0) + ? 192 + : attachments.length === 3 + ? (192 - StyleSheet.hairlineWidth) / 2 + : 96, + }); + const media = [ + ...props.pendingAttachments.map((attachment, index) => { + const image = isImageAttachment(attachment); + return { + id: attachment.id, + image, + content: + image && attachment.uploadedAttachmentId ? ( + + ) : !image && attachment.uploadedAttachmentId ? ( + + ) : ( + + ), + }; + }), + ...props.attachments.map((attachment, index) => ({ + id: attachment.id, + image: isImageAttachment(attachment), + content: isImageAttachment(attachment) ? ( + + ) : isFileAttachment(attachment) ? ( + + ) : null, + })), + ]; + return collage ? ( + + {media.length === 3 ? ( + + + {media[0]!.content} + + + {media[1]!.content} + {media[2]!.content} + + + ) : ( + media + .filter((_, index) => index % 2 === 0) + .map((item, index) => ( + + {item.content} + {media[index * 2 + 1] ? ( + {media[index * 2 + 1]!.content} + ) : null} + + )) + )} + + + ) : ( + + {media.map((item) => ( + + {item.content} + + + ))} + + ); +} + +function PendingMessageMedia(props: { + readonly attachment: QueuedThreadMessage["attachments"][number]; + readonly className: string; + readonly maxWidth?: number; + readonly height?: number; + readonly onPressPreview: (source: FilePreviewSource) => void; + readonly onPlaybackStart: () => void; +}) { + const withGallery = useFilePreviewGallery(); + const { attachment } = props; + const fileBacked = isFileBackedComposerAttachment(attachment); + const preview = useLocalAttachmentPreview(fileBacked ? attachment : null); + if (isImageAttachment(attachment)) { + const uri = fileBacked ? preview.uri : (attachment.dataUrl ?? attachment.previewUri); + return ( + { + const source = composerImageGallery([attachment])[0]; + if (source) props.onPressPreview(withGallery(source)); + }} + > + + + ); + } + if (preview.uri === null) { + return ( + + {preview.error ? ( + {preview.error.message} + ) : ( + + )} + + ); + } + return ( + ); } @@ -1338,6 +1609,7 @@ function renderFeedEntry( readonly contentWidth: number; readonly threadTitle: string; readonly workflowOpening: WorkflowOpening | null; + readonly userMessageGrouping: GroupedThreadFeedRow["userMessageGrouping"]; }, ) { const entry = info.item; @@ -1582,7 +1854,9 @@ function renderFeedEntry( const foldRowId = presentation.attribution === "agent" ? `agent:${message.id}` : null; const foldExpanded = foldRowId !== null && (props.expandedWorkRows[foldRowId] ?? false); const bubbleBodyVisible = foldRowId === null || foldExpanded; - const intentBadge = resolveUserMessageIntentBadge(message.inputIntent); + const intentBadge = props.userMessageGrouping.continuesSteer + ? null + : resolveUserMessageIntentBadge(message.inputIntent); const referenceIds = new Set( collectComposerContextReferences(message.text).map((reference) => reference.contextId), ); @@ -1593,22 +1867,36 @@ function renderFeedEntry( : [], ), ); + const standaloneMedia = foldRowId === null && presentation.attribution !== "guest"; + const mediaAttachments = standaloneMedia ? attachments.filter(isMediaAttachment) : []; const visibleAttachments = attachments.filter( - (attachment) => isImageAttachment(attachment) || !inlineAttachmentIds.has(attachment.id), + (attachment) => + !(standaloneMedia && isMediaAttachment(attachment)) && + (isImageAttachment(attachment) || !inlineAttachmentIds.has(attachment.id)), ); - // Images sent without words show as images, not as a bubble around them. - const mediaOnly = - foldRowId === null && - presentation.attribution !== "guest" && - presentation.text.trim() === "" && - visibleAttachments.length + (entry.pendingMessage?.attachments.length ?? 0) > 0 && - visibleAttachments.every(isImageAttachment) && - (entry.pendingMessage?.attachments ?? []).every( - (attachment) => attachment.type === "image", - ); + const pendingAttachments = entry.pendingMessage?.attachments ?? []; + const pendingMedia = standaloneMedia ? pendingAttachments.filter(isMediaAttachment) : []; + const pendingBubbleAttachments = standaloneMedia + ? pendingAttachments.filter( + (attachment) => + !isMediaAttachment(attachment) && !inlineAttachmentIds.has(attachment.id), + ) + : pendingAttachments; + const fileCards = standaloneMedia + ? resolveUserMessageFileCards({ + text: presentation.text, + attachments: attachments.length > 0 ? attachments : pendingAttachments, + context: message.context, + }) + : null; + const hasBubble = + fileCards === null && + (foldRowId !== null || + presentation.text.trim().length > 0 || + visibleAttachments.length + pendingBubbleAttachments.length > 0); return ( {presentation.attribution === "t3code" ? ( @@ -1619,136 +1907,174 @@ function renderFeedEntry( {presentation.guestName !== undefined ? ( ) : null} - 0) - ? { width: props.duoColumns ? "85%" : props.userBubbleMaxWidth } - : null), - }} - > - {presentation.attribution === "guest" ? : null} - {foldRowId !== null ? ( - 0 ? ( + + ref.label) - .split(/\r?\n/) - .find((line) => line.trim().length > 0) - ?.trim() ?? "" - } - expanded={foldExpanded} - onToggle={() => props.onToggleWorkRow(foldRowId, entry.id)} - iconSubtleColor={iconSubtleColor} + maxWidth={props.userBubbleMaxWidth} + onPressPreview={props.onPressPreview} + onPlaybackStart={props.onPlaybackStart} /> - ) : null} - {bubbleBodyVisible ? ( - - {entry.pendingMessage?.attachments.map((attachment) => - attachment.type === "image" && attachment.uploadedAttachmentId ? ( - - ) : attachment.type === "image" ? ( - - props.onPressPreview({ - kind: "image", - name: attachment.name, - uri: attachment.dataUrl ?? attachment.previewUri, - gallery: composerImageGallery(entry.pendingMessage?.attachments ?? []), - }) - } - > - + ) : null} + {fileCards ? ( + + {fileCards.map((file) => { + const pendingFile = pendingAttachments.find( + (attachment) => attachment.id === file.id, + ); + return ( + + ); + })} + + ) : null} + {hasBubble ? ( + + {presentation.attribution === "guest" ? : null} + {foldRowId !== null ? ( + ref.label) + .split(/\r?\n/) + .find((line) => line.trim().length > 0) + ?.trim() ?? "" + } + expanded={foldExpanded} + onToggle={() => props.onToggleWorkRow(foldRowId, entry.id)} + iconSubtleColor={iconSubtleColor} + /> + ) : null} + {bubbleBodyVisible ? ( + + {pendingBubbleAttachments.map((attachment) => + attachment.type === "image" && attachment.uploadedAttachmentId ? ( + - - ) : ( - - ), - )} - {/* An empty container still takes a gap, which pads every attachment-free bubble. */} - {visibleAttachments.length > 0 ? ( - - {visibleAttachments.map((attachment) => { - return isImageAttachment(attachment) ? ( - { + const source = composerImageGallery([attachment])[0]; + if (source) + props.onPressPreview({ + ...source, + gallery: composerImageGallery( + entry.pendingMessage?.attachments ?? [], + props.environmentId, + ), + }); + }} + > + - ) : isFileAttachment(attachment) ? ( - - ) : ( - - ); - })} - - ) : null} - {presentation.text.trim().length > 0 ? ( - - - - ) : null} - - ) : null} - + + ) : ( + + ), + )} + {/* An empty container still takes a gap, which pads every attachment-free bubble. */} + {visibleAttachments.length > 0 ? ( + + {visibleAttachments.map((attachment) => { + return isImageAttachment(attachment) ? ( + + ) : isFileAttachment(attachment) ? ( + + ) : ( + + ); + })} + + ) : null} + {presentation.text.trim().length > 0 ? ( + + + + ) : null} + + ) : null} + + ) : null} {bubbleBodyVisible ? ( {foldRowId !== null && message.senderThreadId ? ( @@ -2830,6 +3156,12 @@ export const ThreadFeed = memo(function ThreadFeed({ const setupAnchorIndex = presentedFeed.findIndex( (entry) => entry.type === "message" && entry.message.role === "user", ); + const [groupFeed] = useState(createThreadFeedGrouping); + const groupedFeed = groupFeed(presentedFeed, { + workflowOpeningMessageId: workflowOpening?.messageId, + setupAnchorIndex: + props.worktreeSetup || props.setupWorkingStartedAt ? setupAnchorIndex : undefined, + }); // The empty↔filled key below remounts the list and resets its imperative // content-inset override. Seed each fresh instance as it attaches with the // current overlay height before the scroll integration's next reaction; @@ -3149,7 +3481,7 @@ export const ThreadFeed = memo(function ThreadFeed({ } }, [textSizeKey, contentWidth, hasHtmlRenders, beginReflow, listRef]); - const renderItem = (info: { item: ThreadFeedRow; index: number }) => ( + const renderItem = (info: { item: GroupedThreadFeedRow; index: number }) => ( {renderFeedEntry(info, { + userMessageGrouping: info.item.userMessageGrouping, environmentId: props.environmentId, dispatchingMessageId: props.dispatchingMessageId, onEditPendingMessage: props.onEditPendingMessage, @@ -3284,7 +3617,7 @@ export const ThreadFeed = memo(function ThreadFeed({ maintainVisibleContentPosition={ followsEnd && !disclosureToggleSettling ? false : maintainVisibleContentPosition } - data={presentedFeed} + data={groupedFeed} extraData={listAppearanceData} renderItem={renderItem} viewabilityConfig={THREAD_MEDIA_VIEWABILITY_CONFIG} @@ -3422,7 +3755,7 @@ function MessageImageGalleryScope(props: { entry.type === "message" ? [ ...messageImageGallery(entry.message, { environmentId, threadId, workspaceRoot }), - ...composerImageGallery(entry.pendingMessage?.attachments ?? []), + ...composerImageGallery(entry.pendingMessage?.attachments ?? [], environmentId), ] : []; return {props.children}; diff --git a/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx index 5f4b5ec263d48..457b7878ea5e7 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownVideo.tsx @@ -13,6 +13,7 @@ export function ThreadMarkdownVideo(props: { readonly source: MediaVideoPreviewSource; readonly thumbnailVisible?: boolean; readonly onPlaybackStart?: () => void; + readonly borderRadius?: number; }) { const { source } = props; const feedVisible = useContext(ThreadMediaVisibleContext); @@ -43,6 +44,7 @@ export function ThreadMarkdownVideo(props: { onPlaybackStart={props.onPlaybackStart} unavailable={"resource" in source && asset._tag === "Failure"} actionsSource={source.actionsSource} + borderRadius={props.borderRadius} /> ); } diff --git a/apps/mobile/src/features/threads/pending-thread-feed.test.ts b/apps/mobile/src/features/threads/pending-thread-feed.test.ts index 3d9463c79bd06..b16fab3a20a90 100644 --- a/apps/mobile/src/features/threads/pending-thread-feed.test.ts +++ b/apps/mobile/src/features/threads/pending-thread-feed.test.ts @@ -10,6 +10,7 @@ import { import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { appendPendingThreadMessages, + createThreadFeedGrouping, isCompactionCommandFeedEntry, selectPendingAcknowledgedMessages, } from "./pending-thread-feed"; @@ -25,6 +26,71 @@ const pending = (id: string): QueuedThreadMessage => ({ }); describe("pending timeline messages", () => { + it("keeps unchanged row identities while text streams and updates adjacent grouping", () => { + const group = createThreadFeedGrouping(); + const [first, second, third] = appendPendingThreadMessages( + [], + [], + [pending("first"), pending("second"), pending("third")], + ); + if (first?.type !== "message" || second?.type !== "message" || third?.type !== "message") { + throw new Error("Expected message rows"); + } + const reply = { + ...third, + id: "reply", + message: { ...third.message, role: "assistant" as const, text: "start" }, + }; + const initial = group([first, second, reply], {}); + const streamed = group( + [first, second, { ...reply, message: { ...reply.message, text: "streamed" } }], + {}, + ); + expect(streamed[0]).toBe(initial[0]); + expect(streamed[1]).toBe(initial[1]); + expect(streamed[2]).not.toBe(initial[2]); + const appended = group([first, second, third, reply], {}); + expect(appended[0]).toBe(initial[0]); + expect(appended[1]).not.toBe(initial[1]); + expect(appended[1]?.userMessageGrouping.groupedWithNext).toBe(true); + expect(appended[2]?.userMessageGrouping.groupedWithPrevious).toBe(true); + const removed = group([first, second, reply], {}); + expect(removed[1]?.userMessageGrouping.groupedWithNext).toBe(false); + const steer = { + ...second, + message: { ...second.message, inputIntent: "promoted_queued_to_steer" as const }, + }; + const singleSteer = group([first, steer], {}); + const steers = group( + [{ ...first, message: { ...first.message, inputIntent: "steer" } }, steer], + {}, + ); + expect(steers[1]?.userMessageGrouping.continuesSteer).toBe(true); + expect(singleSteer[1]?.userMessageGrouping.continuesSteer).toBe(false); + expect(steers[1]).not.toBe(singleSteer[1]); + }); + + it("preserves setup, workflow, and compaction grouping boundaries", () => { + const group = createThreadFeedGrouping(); + const feed = appendPendingThreadMessages([], [], [pending("first"), pending("second")]); + expect(group(feed, {})[0]?.userMessageGrouping.groupedWithNext).toBe(true); + for (const options of [{ setupAnchorIndex: 0 }, { workflowOpeningMessageId: "first" }]) { + const rows = group(feed, options); + expect(rows[0]?.userMessageGrouping.groupedWithNext).toBe(false); + expect(rows[1]?.userMessageGrouping.groupedWithPrevious).toBe(false); + } + const compact = appendPendingThreadMessages( + [], + [], + [{ ...pending("compact"), text: "/compact" }], + )[0]!; + expect(group([feed[0]!, compact], {})[0]?.userMessageGrouping.groupedWithNext).toBe(true); + expect( + group([feed[0]!, { ...compact, pendingMessage: undefined }], {})[0]?.userMessageGrouping + .groupedWithNext, + ).toBe(false); + }); + it.each([false, true])( "keeps a queued compact command in the pending message row (attachment: %s)", (withAttachment) => { diff --git a/apps/mobile/src/features/threads/pending-thread-feed.ts b/apps/mobile/src/features/threads/pending-thread-feed.ts index d38cc026fdd62..586f8ff3b8820 100644 --- a/apps/mobile/src/features/threads/pending-thread-feed.ts +++ b/apps/mobile/src/features/threads/pending-thread-feed.ts @@ -6,6 +6,7 @@ import { import type { ThreadFeedEntry } from "../../lib/threadActivity"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { threadHasCaughtUpTo } from "@t3tools/client-runtime/state/threads"; +import { resolveUserMessageGrouping } from "@t3tools/client-runtime/user-message"; import type { AcknowledgedThreadMessage, QueuedThreadMessage, @@ -53,6 +54,50 @@ export function selectPendingAcknowledgedMessages( /** A row of the thread feed's list. */ export type ThreadFeedRow = PendingThreadFeedEntry | GuestPendingFeedEntry; +export type GroupedThreadFeedRow = ThreadFeedRow & { + readonly userMessageGrouping: ReturnType; +}; + +export function createThreadFeedGrouping() { + const rows = new WeakMap(); + const ungrouped = resolveUserMessageGrouping(undefined, undefined, undefined); + return ( + feed: ReadonlyArray, + options: { readonly workflowOpeningMessageId?: string; readonly setupAnchorIndex?: number }, + ): ReadonlyArray => { + const groupingMessage = (index: number) => { + const entry = feed[index]; + return entry?.type === "message" && + entry.message.role === "user" && + !isCompactionCommandFeedEntry(entry) && + entry.message.id !== options.workflowOpeningMessageId + ? entry.message + : undefined; + }; + return feed.map((entry, index) => { + const message = groupingMessage(index); + const grouping = + message === undefined + ? ungrouped + : resolveUserMessageGrouping( + message, + index - 1 === options.setupAnchorIndex ? undefined : groupingMessage(index - 1), + index === options.setupAnchorIndex ? undefined : groupingMessage(index + 1), + ); + const previous = rows.get(entry); + if ( + previous?.userMessageGrouping.groupedWithPrevious === grouping.groupedWithPrevious && + previous.userMessageGrouping.groupedWithNext === grouping.groupedWithNext && + previous.userMessageGrouping.continuesSteer === grouping.continuesSteer + ) + return previous; + const row = { ...entry, userMessageGrouping: grouping }; + rows.set(entry, row); + return row; + }); + }; +} + /** Waiting guest messages go after the transcript, oldest first. */ export function appendGuestPendingMessages( presentedFeed: ReadonlyArray, diff --git a/apps/mobile/src/lib/composerImageGallery.ts b/apps/mobile/src/lib/composerImageGallery.ts index 3df558ae5d4db..f308dac2ff19b 100644 --- a/apps/mobile/src/lib/composerImageGallery.ts +++ b/apps/mobile/src/lib/composerImageGallery.ts @@ -1,12 +1,29 @@ import { imageMimeType } from "@t3tools/shared/image"; +import type { EnvironmentId } from "@t3tools/contracts"; import type { FilePreviewItem } from "../components/FilePreviewModal.types"; import { isFileBackedComposerAttachment, type DraftComposerAttachment } from "./composerImages"; export function composerImageGallery( attachments: readonly DraftComposerAttachment[], + environmentId?: EnvironmentId, ): FilePreviewItem[] { return attachments.flatMap((attachment): FilePreviewItem[] => { if (attachment.type !== "image" && imageMimeType(attachment) === null) return []; + if (environmentId !== undefined && attachment.uploadedAttachmentId) { + return [ + { + kind: "image", + name: attachment.name, + environmentId, + resource: { + _tag: "attachment", + attachmentId: attachment.uploadedAttachmentId, + fileName: attachment.name, + mimeType: attachment.mimeType, + }, + }, + ]; + } if (isFileBackedComposerAttachment(attachment)) { return [{ kind: "image", name: attachment.name, attachment }]; } diff --git a/apps/mobile/src/lib/filePreviewGallery.test.ts b/apps/mobile/src/lib/filePreviewGallery.test.ts index 95d52e5a7fe6e..60576317b95c9 100644 --- a/apps/mobile/src/lib/filePreviewGallery.test.ts +++ b/apps/mobile/src/lib/filePreviewGallery.test.ts @@ -1,9 +1,72 @@ -import { describe, expect, it } from "vite-plus/test"; -import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { FilePreviewItem } from "../components/FilePreviewModal.types"; +import type { DraftComposerAttachment } from "./composerImages"; +import { composerImageGallery } from "./composerImageGallery"; +import { messageImageGallery } from "../features/threads/messageImageGallery"; import { filePreviewGallery, filePreviewKey } from "./filePreviewGallery"; +vi.mock("./uuid", () => ({})); + describe("image preview groups", () => { + it.each([false, true])( + "keeps one entry per image through upload and delivery (file: %s)", + (fileBacked) => { + const environmentId = EnvironmentId.make("test"); + const context = { environmentId, threadId: ThreadId.make("thread"), workspaceRoot: null }; + const attachments: DraftComposerAttachment[] = ["a", "b"].map((id) => ({ + id, + type: "image", + name: `${id}.png`, + mimeType: "image/png", + sizeBytes: 3, + previewUri: `data:image/png;base64,${id}`, + ...(fileBacked ? { fileUri: `file:///documents/${id}.png` } : {}), + })); + const delivered = (id: string) => + messageImageGallery( + { + text: "", + attachments: [ + { + type: "image", + id: `uploaded-${id}`, + name: `${id}.png`, + mimeType: "image/png", + sizeBytes: 3, + }, + ], + }, + context, + )[0]!; + const local = composerImageGallery(attachments, environmentId); + const uploading = composerImageGallery( + [{ ...attachments[0]!, uploadedAttachmentId: "uploaded-a" }, attachments[1]!], + environmentId, + ); + const uploaded = composerImageGallery( + attachments.map((attachment) => ({ + ...attachment, + uploadedAttachmentId: `uploaded-${attachment.id}`, + })), + environmentId, + ); + for (const [selected, gallery] of [ + [local[0]!, local], + [delivered("a"), uploading], + [uploading[1]!, uploading], + [delivered("a"), uploaded], + [delivered("a"), [...uploaded, delivered("a"), delivered("b")]], + [delivered("a"), [delivered("a"), delivered("b")]], + ] as const) { + const result = filePreviewGallery(selected, gallery); + expect(result).toHaveLength(2); + expect(result.map(filePreviewKey)).toEqual([...new Set(gallery.map(filePreviewKey))]); + } + expect(composerImageGallery(attachments)).toEqual(local); + }, + ); + it("matches remote originals despite metadata and property order differences", () => { const environmentId = EnvironmentId.make("test"); const a: FilePreviewItem = { diff --git a/packages/client-runtime/src/userMessage.test.ts b/packages/client-runtime/src/userMessage.test.ts index 207939cf2f39d..44529f6c14557 100644 --- a/packages/client-runtime/src/userMessage.test.ts +++ b/packages/client-runtime/src/userMessage.test.ts @@ -1,9 +1,157 @@ import { describe, expect, it } from "vite-plus/test"; -import { GuestMessageId } from "@t3tools/contracts"; +import { ComposerContextId, GuestMessageId, ScheduledTaskId } from "@t3tools/contracts"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; -import { resolveUserMessagePresentation } from "./userMessage.ts"; +import { + resolveUserMessageFileCards, + resolveUserMessageGrouping, + resolveUserMessagePresentation, +} from "./userMessage.ts"; + +describe("resolveUserMessageGrouping", () => { + const own = { role: "user", text: "hello" }; + + it("groups only adjacent own messages", () => { + expect(resolveUserMessageGrouping(own, own, own)).toEqual({ + groupedWithPrevious: true, + groupedWithNext: true, + continuesSteer: false, + }); + expect(resolveUserMessageGrouping(own, undefined, own)).toMatchObject({ + groupedWithPrevious: false, + groupedWithNext: true, + }); + for (const boundary of [ + undefined, + { ...own, role: "assistant" }, + { ...own, createdBy: "agent" as const }, + { ...own, createdBy: "agent" as const, creationSource: "server" as const }, + { ...own, scheduledTaskId: ScheduledTaskId.make("task") }, + { + ...own, + context: { + guest: { guestMessageId: GuestMessageId.make("guest"), name: "Sam", text: "hi" }, + }, + }, + ]) { + expect(resolveUserMessageGrouping(own, boundary, boundary)).toEqual({ + groupedWithPrevious: false, + groupedWithNext: false, + continuesSteer: false, + }); + expect(resolveUserMessageGrouping(boundary, own, own)).toEqual({ + groupedWithPrevious: false, + groupedWithNext: false, + continuesSteer: false, + }); + } + }); + + it("shows one label for consecutive steers, including promoted queued messages", () => { + for (const inputIntent of ["steer", "promoted_queued_to_steer"] as const) { + const steer = { ...own, inputIntent }; + expect( + resolveUserMessageGrouping(steer, { ...own, inputIntent: "steer" }, undefined) + .continuesSteer, + ).toBe(true); + expect( + resolveUserMessageGrouping( + steer, + { ...own, inputIntent: "promoted_queued_to_steer" }, + undefined, + ).continuesSteer, + ).toBe(true); + expect(resolveUserMessageGrouping(steer, own, undefined).continuesSteer).toBe(false); + expect(resolveUserMessageGrouping(own, steer, undefined).continuesSteer).toBe(false); + } + }); +}); + +describe("resolveUserMessageFileCards", () => { + const file = { + type: "file" as const, + id: "report", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 1024, + }; + const record = { + ...file, + version: 1 as const, + kind: "file" as const, + contextId: ComposerContextId.make("report"), + label: file.name, + attachmentId: file.id, + }; + const context = { version: 1 as const, records: [record] }; + const reference = "[report.pdf](t3-context://v1/file/report)"; + + it("shows file cards for bare attachments and bound file references", () => { + for (const text of ["", " \n", reference]) { + expect(resolveUserMessageFileCards({ text, attachments: [file], context })).toEqual([file]); + } + expect( + resolveUserMessageFileCards({ + text: `${reference}\n${reference}`, + attachments: [file], + context, + }), + ).toEqual([file]); + }); + + it("keeps words and unavailable or non-file references in the bubble", () => { + for (const text of [ + `read ${reference}`, + "[missing.pdf](t3-context://v1/file/missing)", + "[report.pdf](t3-context://v1/mention/report)", + ]) { + expect(resolveUserMessageFileCards({ text, attachments: [file], context })).toBeNull(); + } + expect(resolveUserMessageFileCards({ text: reference, attachments: [file] })).toBeNull(); + expect( + resolveUserMessageFileCards({ + text: reference, + attachments: [file], + context: { + version: 1, + records: [ + { + version: 1, + kind: "file", + contextId: record.contextId, + label: file.name, + payload: {}, + }, + ], + }, + }), + ).toBeNull(); + expect( + resolveUserMessageFileCards({ + text: reference, + attachments: [{ ...file, id: "other" }], + context, + }), + ).toBeNull(); + }); + + it("keeps media, unknown attachments, and empty messages out of the file cards", () => { + const media = [ + { ...file, type: "image" as const, name: "photo.png", mimeType: "image/png" }, + { ...file, name: "photo.png", mimeType: "application/octet-stream" }, + { ...file, name: "clip.mp4", mimeType: "video/mp4" }, + { ...file, name: "clip.mov", mimeType: "application/octet-stream" }, + { ...file, name: "voice.mp3", mimeType: "audio/mpeg" }, + { ...file, type: "future" }, + ]; + expect(resolveUserMessageFileCards({ text: "", attachments: [] })).toBeNull(); + for (const attachment of media) { + expect(resolveUserMessageFileCards({ text: "", attachments: [attachment] })).toBeNull(); + expect(resolveUserMessageFileCards({ text: "", attachments: [file, attachment] })).toBeNull(); + } + }); +}); describe("resolveUserMessagePresentation", () => { const legacyText = "[Triggered by schedule task: Daily audit]\n\nCheck for crashes.\n"; diff --git a/packages/client-runtime/src/userMessage.ts b/packages/client-runtime/src/userMessage.ts index 8a80f31b155d0..a860c0188b9d2 100644 --- a/packages/client-runtime/src/userMessage.ts +++ b/packages/client-runtime/src/userMessage.ts @@ -1,11 +1,21 @@ import { + type ChatAttachment, + type ChatFileAttachment, type GuestMessageAttribution, + type OrchestrationMessageContext, type OrchestrationV2Actor, type OrchestrationV2CreationSource, type OrchestrationV2IdleCompaction, + type OrchestrationV2UserMessageInputIntent, ScheduledTaskId, } from "@t3tools/contracts"; -import { isAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; +import { + isAttachmentOnlyBootstrapPrompt, + replaceComposerContextReferences, +} from "@t3tools/shared/composerContextReferences"; +import { filePreviewKind } from "@t3tools/shared/filePreview"; +import { imageMimeType } from "@t3tools/shared/image"; +import { videoMimeType } from "@t3tools/shared/video"; /** The working row while T3 Code's own compaction runs. */ export const IDLE_COMPACTING_LABELS = { @@ -22,6 +32,32 @@ export const IDLE_COMPACTION_TITLES = { const LEGACY_AUTOMATION_PREFIX = /^\[Triggered by schedule task: [^\r\n]+\]\r?\n\r?\n/; const LEGACY_AUTOMATION_MESSAGE_ID = /^scheduled-task-message:(.+):\d+:(?:scheduled|manual)$/; +export function resolveUserMessageFileCards(message: { + readonly text: string; + readonly attachments: ReadonlyArray; + readonly context?: OrchestrationMessageContext | undefined; +}): ReadonlyArray | null { + const files = message.attachments.filter( + (attachment): attachment is ChatFileAttachment => + attachment.type === "file" && + imageMimeType(attachment) === null && + videoMimeType(attachment) === null && + filePreviewKind(attachment) !== "audio", + ); + if (files.length === 0 || files.length !== message.attachments.length) return null; + const fileIds = new Set(files.map((file) => file.id)); + const remaining = replaceComposerContextReferences(message.text, (reference) => { + const record = message.context?.records.find((item) => item.contextId === reference.contextId); + return reference.kind === "file" && + record?.kind === "file" && + "attachmentId" in record && + fileIds.has(record.attachmentId) + ? "" + : reference.label || reference.kind; + }); + return remaining.trim() === "" ? files : null; +} + export function resolveUserMessagePresentation(message: { readonly id?: string; readonly role: string; @@ -80,3 +116,24 @@ export function resolveUserMessagePresentation(message: { scheduledTaskId: undefined, }; } + +type GroupableUserMessage = Parameters[0] & { + readonly inputIntent?: OrchestrationV2UserMessageInputIntent; +}; + +export function resolveUserMessageGrouping( + message: GroupableUserMessage | undefined, + previous: GroupableUserMessage | undefined, + next: GroupableUserMessage | undefined, +) { + const isOwnMessage = (value: GroupableUserMessage | undefined) => + value?.role === "user" && resolveUserMessagePresentation(value).attribution === null; + const isSteer = (value: GroupableUserMessage | undefined) => + value?.inputIntent === "steer" || value?.inputIntent === "promoted_queued_to_steer"; + const groupedWithPrevious = isOwnMessage(message) && isOwnMessage(previous); + return { + groupedWithPrevious, + groupedWithNext: isOwnMessage(message) && isOwnMessage(next), + continuesSteer: groupedWithPrevious && isSteer(message) && isSteer(previous), + }; +} From f012fa210dbf464a5ea68fe9071ee377682f8d66 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 30 Oct 2026 16:01:03 +0000 Subject: [PATCH 15/31] fix(web): back-to-back messages from you stack as one burst with a single steer label (#27305) --- .../chat/MessagesTimeline.logic.test.ts | 68 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 40 +++++++++-- .../src/components/chat/MessagesTimeline.tsx | 22 +++--- .../chat/TimelineUserMessageRow.tsx | 5 +- 4 files changed, 118 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index bd74694dc46af..c9aec993bcd86 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1390,6 +1390,74 @@ describe("deriveMessagesTimelineRows", () => { ]); }); + it("stacks back-to-back steers as one burst with a single steer label", () => { + const runId = RunId.make("double-steer-run"); + const time = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString(); + const message = ( + id: string, + second: number, + role: "user" | "assistant", + inputIntent?: "turn_start" | "steer", + ): TimelineEntry => ({ + kind: "message", + id, + createdAt: time(second), + message: { + id: MessageId.make(id), + role, + text: id, + runId, + ...(inputIntent ? { inputIntent } : {}), + createdAt: time(second), + updatedAt: time(second), + streaming: false, + }, + }); + const prompt = message("prompt", 0, "user", "turn_start"); + const work: TimelineEntry = { + kind: "work", + id: "work", + createdAt: time(4), + entry: { id: "work", createdAt: time(4), runId, label: "Ran command", tone: "tool" }, + }; + const firstSteer = message("first-steer", 10, "user", "steer"); + const deriveRows = (timelineEntries: TimelineEntry[]) => + deriveMessagesTimelineRows({ + timelineEntries, + latestRun: { runId, status: "completed", startedAt: time(0), completedAt: time(30) }, + isWorking: false, + expandedRunIds: new Set(), + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + // The second steer arrives while the first message object is reused, as live updates do. + const before = computeStableMessagesTimelineRows(deriveRows([prompt, work, firstSteer]), { + result: [], + byId: new Map(), + }); + const rows = computeStableMessagesTimelineRows( + deriveRows([ + prompt, + work, + firstSteer, + message("second-steer", 12, "user", "steer"), + message("final", 30, "assistant"), + ]), + before, + ).result; + + const byId = new Map(rows.map((row) => [row.id, row])); + expect(byId.get("prompt")).not.toHaveProperty("groupedWithNext"); + expect(byId.get("first-steer")).toMatchObject({ + groupedWithNext: true, + continuesSteer: false, + }); + expect(byId.get("second-steer")).toMatchObject({ + groupedWithNext: false, + continuesSteer: true, + }); + }); + it("keeps the previous turn folded while a newly sent message awaits its turn", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c26bcf51a718a..1f22bc1c14978 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,4 +1,5 @@ import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; +import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import * as DateTime from "effect/DateTime"; import * as Equal from "effect/Equal"; @@ -594,6 +595,10 @@ type MessagesTimelineRowContent = assistantCopyStreaming: boolean; assistantTurnDiffSummary?: TurnDiffSummary | undefined; revertTurnCount?: number | undefined; + /** Another of your messages follows directly, so the two sit closer together. */ + groupedWithNext?: boolean; + /** Set when the message above is a steer too, so the label shows once. */ + continuesSteer?: boolean; } | { kind: "assistant-meta"; @@ -2134,13 +2139,36 @@ export function deriveMessagesTimelineRows(input: { const result = attachTrailingToolGroupsToAssistant( attachCreatedThreadSummaries(nextRows, timelineEntries, collapsedFoldMemberIds), ); - return result.map((row, index) => - timelineRowIsWorkLog(row) && timelineRowIsWorkLog(result[index + 1]) - ? { ...row, continuesWorkLog: true } - : row, + return result.map((row, index) => { + if (timelineRowIsWorkLog(row) && timelineRowIsWorkLog(result[index + 1])) { + return { ...row, continuesWorkLog: true }; + } + if (!timelineRowIsOwnUserMessage(row)) return row; + const previous = result[index - 1]; + const groupedWithNext = timelineRowIsOwnUserMessage(result[index + 1]); + const continuesSteer = + timelineRowIsOwnUserMessage(previous) && + messageIsSteer(row.message) && + messageIsSteer(previous.message); + if (!groupedWithNext && !continuesSteer) return row; + return { ...row, groupedWithNext, continuesSteer }; + }); +} + +function timelineRowIsOwnUserMessage( + row: MessagesTimelineRow | undefined, +): row is Extract { + return ( + row?.kind === "message" && + row.message.role === "user" && + resolveUserMessagePresentation(row.message).attribution === null ); } +function messageIsSteer(message: ChatMessage): boolean { + return message.inputIntent === "steer" || message.inputIntent === "promoted_queued_to_steer"; +} + function timelineRowIsWorkLog(row: MessagesTimelineRow | undefined): boolean { return ( row !== undefined && @@ -2478,7 +2506,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.showAssistantCopyButton === bm.showAssistantCopyButton && a.assistantCopyStreaming === bm.assistantCopyStreaming && a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary && - a.revertTurnCount === bm.revertTurnCount + a.revertTurnCount === bm.revertTurnCount && + a.groupedWithNext === bm.groupedWithNext && + a.continuesSteer === bm.continuesSteer ); } } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index faa733c76e896..8c779ee950d67 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1441,16 +1441,18 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ? undefined : row.kind === "turn-fold" || row.kind === "working" ? "pb-1.5" - : (row.kind === "message" && - row.message.role === "assistant" && - !row.showAssistantMeta) || - row.kind === "worktree-setup" || - row.kind === "event" || - row.kind === "attempt-fold" || - row.kind === "html-render" || - row.kind === "mcp-app" - ? "pb-2" - : "pb-4", + : row.kind === "message" && row.groupedWithNext + ? "pb-1" + : (row.kind === "message" && + row.message.role === "assistant" && + !row.showAssistantMeta) || + row.kind === "worktree-setup" || + row.kind === "event" || + row.kind === "attempt-fold" || + row.kind === "html-render" || + row.kind === "mcp-app" + ? "pb-2" + : "pb-4", (row.kind === "message" && row.message.role === "assistant") || row.kind === "assistant-meta" ? "group/assistant" diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index c4363a1b45983..58785db77ca47 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -484,8 +484,9 @@ function UserTimelineRow({ row }: { row: Extract
) : null} - {row.message.inputIntent === "steer" || - row.message.inputIntent === "promoted_queued_to_steer" ? ( + {(row.message.inputIntent === "steer" || + row.message.inputIntent === "promoted_queued_to_steer") && + !row.continuesSteer ? ( ) : null}
Date: Fri, 30 Oct 2026 17:09:22 +0100 Subject: [PATCH 16/31] fix(pi): clear Pi's native queue before Stop aborts (#27514) Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT 6.1 Sol --- .../pi_transcript.ndjson | 10 ++- .../pi_transcript.ndjson | 10 ++- .../provider-pi/src/server/adapter.test.ts | 69 +++++++++++++++++ packages/provider-pi/src/server/adapter.ts | 75 +++++++++++++++---- 4 files changed, 141 insertions(+), 23 deletions(-) diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/pi_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/pi_transcript.ndjson index ffdecab232a55..a024d2db1416e 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/pi_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/thread_rollback_after_stop/pi_transcript.ndjson @@ -133,7 +133,9 @@ {"type":"emit_inbound","label":"tool_execution_start","frame":{"type":"tool_execution_start","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","args":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}} {"type":"emit_inbound","label":"tool_execution_update","frame":{"type":"tool_execution_update","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","args":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35},"partialResult":{"content":[]}}} {"type":"emit_inbound","label":"tool_execution_update","frame":{"type":"tool_execution_update","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","args":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35},"partialResult":{"content":[{"type":"text","text":"interrupt fixture tool started\n"}],"details":{}}}} -{"type":"expect_outbound","label":"abort","frame":{"type":"abort","id":"t3-9"}} +{"type":"expect_outbound","label":"clear_queue","frame":{"type":"clear_queue","id":"t3-9"}} +{"type":"emit_inbound","label":"response:clear_queue","frame":{"id":"t3-9","type":"response","command":"clear_queue","success":true,"data":{"steering":[],"followUp":[]}}} +{"type":"expect_outbound","label":"abort","frame":{"type":"abort","id":"t3-10"}} {"type":"emit_inbound","label":"tool_execution_end","frame":{"type":"tool_execution_end","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","result":{"content":[{"type":"text","text":"interrupt fixture tool started\n\n\nCommand aborted"}],"details":{}},"isError":true}} {"type":"emit_inbound","label":"message_start","frame":{"type":"message_start","message":{"role":"toolResult","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","content":[{"type":"text","text":"interrupt fixture tool started\n\n\nCommand aborted"}],"details":{},"isError":true,"timestamp":1767225606463}}} {"type":"emit_inbound","label":"message_end","frame":{"type":"message_end","message":{"role":"toolResult","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","content":[{"type":"text","text":"interrupt fixture tool started\n\n\nCommand aborted"}],"details":{},"isError":true,"timestamp":1767225606463}}} @@ -144,9 +146,9 @@ {"type":"emit_inbound","label":"turn_end","frame":{"type":"turn_end","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"This operation was aborted","timestamp":1767225606465,"thinkingLevel":"high"},"toolResults":[]}} {"type":"emit_inbound","label":"agent_end","frame":{"type":"agent_end","messages":[{"role":"user","content":[{"type":"text","text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally."}],"timestamp":1767225601969},{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to run a command that will wait for 30 seconds, and then after it completes, respond with a specific message. But they also say \"Do not answer until it completes\" - but this command will run for 30 seconds and then naturally finish. However, the response they want is \"interrupt fixture should not finish naturally\" which contradicts the fact that it did finish naturally.\n\nWait, let me re-read. They want me to run the command and not answer until it completes. The command is a node script that logs \"interrupt fixture tool started\" and then waits 30 seconds. If I wait for it to complete, it will finish naturally after 30 seconds. But the response they want is \"interrupt fixture should not finish naturally\" - implying that the fixture should have been interrupted before finishing naturally.\n\nThis is a test scenario. Let me just run it and see what happens. Actually, maybe they want me to just run it and respond accordingly. Let me execute the command.\n\nActually, I think this is part of a test where the system might interrupt/kill the process. Let me just run the command with a sufficient timeout.","thinkingSignature":""},{"type":"toolCall","id":"call_8ac3cb4177994abdb9aa1c57","name":"bash","arguments":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":77,"output":317,"cacheRead":1792,"cacheWrite":0,"reasoning":284,"totalTokens":2186,"cost":{"input":0.00000322322,"output":0.000026539239999999998,"cacheRead":0.000015002623999999998,"cacheWrite":0,"total":0.000044765084}},"stopReason":"toolUse","timestamp":1767225601972,"responseId":"gen-1790898593-E5hfYfRp6aKBI57pUKss","rawStopReason":"tool_calls","thinkingLevel":"high"},{"role":"toolResult","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","content":[{"type":"text","text":"interrupt fixture tool started\n\n\nCommand aborted"}],"details":{},"isError":true,"timestamp":1767225606463},{"role":"assistant","content":[],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"This operation was aborted","timestamp":1767225606465,"thinkingLevel":"high"}],"willRetry":false}} {"type":"emit_inbound","label":"agent_settled","frame":{"type":"agent_settled"}} -{"type":"emit_inbound","label":"response:abort","frame":{"id":"t3-9","type":"response","command":"abort","success":true}} -{"type":"expect_outbound","label":"get_entries","frame":{"type":"get_entries","since":"b184b5a8","id":"t3-10"}} -{"type":"emit_inbound","label":"response:get_entries","frame":{"id":"t3-10","type":"response","command":"get_entries","success":true,"data":{"entries":[{"type":"message","id":"ee73daae","parentId":"b184b5a8","timestamp":"2026-01-01T00:00:01.971Z","message":{"role":"user","content":[{"type":"text","text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally."}],"timestamp":1767225601969}},{"type":"message","id":"c3915df7","parentId":"ee73daae","timestamp":"2026-01-01T00:00:06.406Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to run a command that will wait for 30 seconds, and then after it completes, respond with a specific message. But they also say \"Do not answer until it completes\" - but this command will run for 30 seconds and then naturally finish. However, the response they want is \"interrupt fixture should not finish naturally\" which contradicts the fact that it did finish naturally.\n\nWait, let me re-read. They want me to run the command and not answer until it completes. The command is a node script that logs \"interrupt fixture tool started\" and then waits 30 seconds. If I wait for it to complete, it will finish naturally after 30 seconds. But the response they want is \"interrupt fixture should not finish naturally\" - implying that the fixture should have been interrupted before finishing naturally.\n\nThis is a test scenario. Let me just run it and see what happens. Actually, maybe they want me to just run it and respond accordingly. Let me execute the command.\n\nActually, I think this is part of a test where the system might interrupt/kill the process. Let me just run the command with a sufficient timeout.","thinkingSignature":""},{"type":"toolCall","id":"call_8ac3cb4177994abdb9aa1c57","name":"bash","arguments":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":77,"output":317,"cacheRead":1792,"cacheWrite":0,"reasoning":284,"totalTokens":2186,"cost":{"input":0.00000322322,"output":0.000026539239999999998,"cacheRead":0.000015002623999999998,"cacheWrite":0,"total":0.000044765084}},"stopReason":"toolUse","timestamp":1767225601972,"responseId":"gen-1790898593-E5hfYfRp6aKBI57pUKss","rawStopReason":"tool_calls","thinkingLevel":"high"}},{"type":"message","id":"641f3d3f","parentId":"c3915df7","timestamp":"2026-01-01T00:00:06.463Z","message":{"role":"toolResult","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","content":[{"type":"text","text":"interrupt fixture tool started\n\n\nCommand aborted"}],"details":{},"isError":true,"timestamp":1767225606463}},{"type":"message","id":"e3ab1495","parentId":"641f3d3f","timestamp":"2026-01-01T00:00:06.465Z","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"This operation was aborted","timestamp":1767225606465,"thinkingLevel":"high"}}],"leafId":"e3ab1495"}}} +{"type":"emit_inbound","label":"response:abort","frame":{"id":"t3-10","type":"response","command":"abort","success":true}} +{"type":"expect_outbound","label":"get_entries","frame":{"type":"get_entries","since":"b184b5a8","id":"t3-11"}} +{"type":"emit_inbound","label":"response:get_entries","frame":{"id":"t3-11","type":"response","command":"get_entries","success":true,"data":{"entries":[{"type":"message","id":"ee73daae","parentId":"b184b5a8","timestamp":"2026-01-01T00:00:01.971Z","message":{"role":"user","content":[{"type":"text","text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally."}],"timestamp":1767225601969}},{"type":"message","id":"c3915df7","parentId":"ee73daae","timestamp":"2026-01-01T00:00:06.406Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to run a command that will wait for 30 seconds, and then after it completes, respond with a specific message. But they also say \"Do not answer until it completes\" - but this command will run for 30 seconds and then naturally finish. However, the response they want is \"interrupt fixture should not finish naturally\" which contradicts the fact that it did finish naturally.\n\nWait, let me re-read. They want me to run the command and not answer until it completes. The command is a node script that logs \"interrupt fixture tool started\" and then waits 30 seconds. If I wait for it to complete, it will finish naturally after 30 seconds. But the response they want is \"interrupt fixture should not finish naturally\" - implying that the fixture should have been interrupted before finishing naturally.\n\nThis is a test scenario. Let me just run it and see what happens. Actually, maybe they want me to just run it and respond accordingly. Let me execute the command.\n\nActually, I think this is part of a test where the system might interrupt/kill the process. Let me just run the command with a sufficient timeout.","thinkingSignature":""},{"type":"toolCall","id":"call_8ac3cb4177994abdb9aa1c57","name":"bash","arguments":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":77,"output":317,"cacheRead":1792,"cacheWrite":0,"reasoning":284,"totalTokens":2186,"cost":{"input":0.00000322322,"output":0.000026539239999999998,"cacheRead":0.000015002623999999998,"cacheWrite":0,"total":0.000044765084}},"stopReason":"toolUse","timestamp":1767225601972,"responseId":"gen-1790898593-E5hfYfRp6aKBI57pUKss","rawStopReason":"tool_calls","thinkingLevel":"high"}},{"type":"message","id":"641f3d3f","parentId":"c3915df7","timestamp":"2026-01-01T00:00:06.463Z","message":{"role":"toolResult","toolCallId":"call_8ac3cb4177994abdb9aa1c57","toolName":"bash","content":[{"type":"text","text":"interrupt fixture tool started\n\n\nCommand aborted"}],"details":{},"isError":true,"timestamp":1767225606463}},{"type":"message","id":"e3ab1495","parentId":"641f3d3f","timestamp":"2026-01-01T00:00:06.465Z","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"This operation was aborted","timestamp":1767225606465,"thinkingLevel":"high"}}],"leafId":"e3ab1495"}}} {"type":"expect_outbound","label":"process_start@p2","frame":{"type":"process_start","args":["--mode","rpc","--provider","openrouter","--model","deepseek/deepseek-v4-flash","--no-extensions","--no-skills","--no-prompt-templates","--no-context-files","--approve","--extension",""]}} {"type":"expect_outbound","label":"switch_session@p2","frame":{"type":"switch_session","sessionPath":"/pi-sessions/session-1.jsonl","id":"t3-0"}} {"type":"emit_inbound","label":"extension_ui_request@p2","frame":{"type":"extension_ui_request","id":"00000000-0000-4000-8000-000000000003","method":"notify","message":"t3-code MCP unavailable: T3_MCP_URL or T3_MCP_BEARER_TOKEN is missing.","notifyType":"warning"}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/pi_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/pi_transcript.ndjson index d4db23f4fd8e3..072994e9d82a9 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/pi_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/pi_transcript.ndjson @@ -41,7 +41,9 @@ {"type":"emit_inbound","label":"message_end","frame":{"type":"message_end","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to run a specific command and wait for it to complete. Let me execute it.","thinkingSignature":""},{"type":"toolCall","id":"call_9726ad99cc114832996228eb","name":"bash","arguments":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":1830,"output":102,"cacheRead":0,"cacheWrite":0,"reasoning":23,"totalTokens":1932,"cost":{"input":0.0000766038,"output":0.00000853944,"cacheRead":0,"cacheWrite":0,"total":0.00008514324}},"stopReason":"toolUse","timestamp":1767225600401,"responseId":"gen-1790898592-Mzq78XbaTGiNE3YSafgy","rawStopReason":"tool_calls","thinkingLevel":"high"}}} {"type":"emit_inbound","label":"tool_execution_start","frame":{"type":"tool_execution_start","toolCallId":"call_9726ad99cc114832996228eb","toolName":"bash","args":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}} {"type":"emit_inbound","label":"tool_execution_update","frame":{"type":"tool_execution_update","toolCallId":"call_9726ad99cc114832996228eb","toolName":"bash","args":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35},"partialResult":{"content":[]}}} -{"type":"expect_outbound","label":"abort","frame":{"type":"abort","id":"t3-6"}} +{"type":"expect_outbound","label":"clear_queue","frame":{"type":"clear_queue","id":"t3-6"}} +{"type":"emit_inbound","label":"response:clear_queue","frame":{"id":"t3-6","type":"response","command":"clear_queue","success":true,"data":{"steering":[],"followUp":[]}}} +{"type":"expect_outbound","label":"abort","frame":{"type":"abort","id":"t3-7"}} {"type":"emit_inbound","label":"tool_execution_end","frame":{"type":"tool_execution_end","toolCallId":"call_9726ad99cc114832996228eb","toolName":"bash","result":{"content":[{"type":"text","text":"Command aborted"}],"details":{}},"isError":true}} {"type":"emit_inbound","label":"message_start","frame":{"type":"message_start","message":{"role":"toolResult","toolCallId":"call_9726ad99cc114832996228eb","toolName":"bash","content":[{"type":"text","text":"Command aborted"}],"details":{},"isError":true,"timestamp":1767225602282}}} {"type":"emit_inbound","label":"message_end","frame":{"type":"message_end","message":{"role":"toolResult","toolCallId":"call_9726ad99cc114832996228eb","toolName":"bash","content":[{"type":"text","text":"Command aborted"}],"details":{},"isError":true,"timestamp":1767225602282}}} @@ -52,6 +54,6 @@ {"type":"emit_inbound","label":"turn_end","frame":{"type":"turn_end","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"This operation was aborted","timestamp":1767225602287,"thinkingLevel":"high"},"toolResults":[]}} {"type":"emit_inbound","label":"agent_end","frame":{"type":"agent_end","messages":[{"role":"system","content":"","sections":{"preamble":"","tools":"","rules":"","docs":"","cwd":""},"timestamp":1790898591817,"toolsAdded":[{"name":"read","description":"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.","parameters":{"type":"object","required":["path"],"properties":{"path":{"type":"string","description":"Path to the file to read (relative or absolute)"},"offset":{"type":"number","description":"Line number to start reading from (1-indexed)"},"limit":{"type":"number","description":"Maximum number of lines to read"}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}},{"name":"bash","description":"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.","parameters":{"type":"object","required":["command"],"properties":{"command":{"type":"string","description":"Shell command to execute"},"timeout":{"type":"number","description":"Timeout in seconds (optional, no default timeout)"}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}},{"name":"edit","description":"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.","parameters":{"type":"object","required":["path","edits"],"properties":{"path":{"type":"string","description":"Path to the file to edit (relative or absolute)"},"edits":{"type":"array","items":{"type":"object","required":["oldText","newText"],"properties":{"oldText":{"type":"string","description":"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call."},"newText":{"type":"string","description":"Replacement text for this targeted edit."}}},"description":"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead."}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}},{"name":"write","description":"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.","parameters":{"type":"object","required":["path","content"],"properties":{"path":{"type":"string","description":"Path to the file to write (relative or absolute)"},"content":{"type":"string","description":"Content to write to the file"}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}}]},{"role":"user","content":[{"type":"text","text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally."}],"timestamp":1767225600375},{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to run a specific command and wait for it to complete. Let me execute it.","thinkingSignature":""},{"type":"toolCall","id":"call_9726ad99cc114832996228eb","name":"bash","arguments":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":1830,"output":102,"cacheRead":0,"cacheWrite":0,"reasoning":23,"totalTokens":1932,"cost":{"input":0.0000766038,"output":0.00000853944,"cacheRead":0,"cacheWrite":0,"total":0.00008514324}},"stopReason":"toolUse","timestamp":1767225600401,"responseId":"gen-1790898592-Mzq78XbaTGiNE3YSafgy","rawStopReason":"tool_calls","thinkingLevel":"high"},{"role":"toolResult","toolCallId":"call_9726ad99cc114832996228eb","toolName":"bash","content":[{"type":"text","text":"Command aborted"}],"details":{},"isError":true,"timestamp":1767225602282},{"role":"assistant","content":[],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"This operation was aborted","timestamp":1767225602287,"thinkingLevel":"high"}],"willRetry":false}} {"type":"emit_inbound","label":"agent_settled","frame":{"type":"agent_settled"}} -{"type":"emit_inbound","label":"response:abort","frame":{"id":"t3-6","type":"response","command":"abort","success":true}} -{"type":"expect_outbound","label":"get_entries","frame":{"type":"get_entries","since":"4405fe6a","id":"t3-7"}} -{"type":"emit_inbound","label":"response:get_entries","frame":{"id":"t3-7","type":"response","command":"get_entries","success":true,"data":{"entries":[{"type":"model_change","id":"b78c2012","parentId":"4405fe6a","timestamp":"2026-01-01T00:00:00.366Z","provider":"openrouter","modelId":"deepseek/deepseek-v4-flash"},{"type":"session_info","id":"9dea9c23","parentId":"b78c2012","timestamp":"2026-01-01T00:00:00.370Z","name":"Replay fixture: turn_interrupt_mid_tool"},{"type":"message","id":"8dc98fa4","parentId":"9dea9c23","timestamp":"2026-01-01T00:00:00.379Z","message":{"role":"system","content":"","sections":{"preamble":"","tools":"","rules":"","docs":"","cwd":""},"timestamp":1790898591817,"toolsAdded":[{"name":"read","description":"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.","parameters":{"type":"object","required":["path"],"properties":{"path":{"type":"string","description":"Path to the file to read (relative or absolute)"},"offset":{"type":"number","description":"Line number to start reading from (1-indexed)"},"limit":{"type":"number","description":"Maximum number of lines to read"}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}},{"name":"bash","description":"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.","parameters":{"type":"object","required":["command"],"properties":{"command":{"type":"string","description":"Shell command to execute"},"timeout":{"type":"number","description":"Timeout in seconds (optional, no default timeout)"}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}},{"name":"edit","description":"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.","parameters":{"type":"object","required":["path","edits"],"properties":{"path":{"type":"string","description":"Path to the file to edit (relative or absolute)"},"edits":{"type":"array","items":{"type":"object","required":["oldText","newText"],"properties":{"oldText":{"type":"string","description":"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call."},"newText":{"type":"string","description":"Replacement text for this targeted edit."}}},"description":"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead."}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}},{"name":"write","description":"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.","parameters":{"type":"object","required":["path","content"],"properties":{"path":{"type":"string","description":"Path to the file to write (relative or absolute)"},"content":{"type":"string","description":"Content to write to the file"}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}}]}},{"type":"message","id":"b36f8c4a","parentId":"8dc98fa4","timestamp":"2026-01-01T00:00:00.379Z","message":{"role":"user","content":[{"type":"text","text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally."}],"timestamp":1767225600375}},{"type":"message","id":"8ea47a23","parentId":"b36f8c4a","timestamp":"2026-01-01T00:00:02.246Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to run a specific command and wait for it to complete. Let me execute it.","thinkingSignature":""},{"type":"toolCall","id":"call_9726ad99cc114832996228eb","name":"bash","arguments":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":1830,"output":102,"cacheRead":0,"cacheWrite":0,"reasoning":23,"totalTokens":1932,"cost":{"input":0.0000766038,"output":0.00000853944,"cacheRead":0,"cacheWrite":0,"total":0.00008514324}},"stopReason":"toolUse","timestamp":1767225600401,"responseId":"gen-1790898592-Mzq78XbaTGiNE3YSafgy","rawStopReason":"tool_calls","thinkingLevel":"high"}},{"type":"message","id":"aa5aec89","parentId":"8ea47a23","timestamp":"2026-01-01T00:00:02.282Z","message":{"role":"toolResult","toolCallId":"call_9726ad99cc114832996228eb","toolName":"bash","content":[{"type":"text","text":"Command aborted"}],"details":{},"isError":true,"timestamp":1767225602282}},{"type":"message","id":"206b2637","parentId":"aa5aec89","timestamp":"2026-01-01T00:00:02.287Z","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"This operation was aborted","timestamp":1767225602287,"thinkingLevel":"high"}}],"leafId":"206b2637"}}} +{"type":"emit_inbound","label":"response:abort","frame":{"id":"t3-7","type":"response","command":"abort","success":true}} +{"type":"expect_outbound","label":"get_entries","frame":{"type":"get_entries","since":"4405fe6a","id":"t3-8"}} +{"type":"emit_inbound","label":"response:get_entries","frame":{"id":"t3-8","type":"response","command":"get_entries","success":true,"data":{"entries":[{"type":"model_change","id":"b78c2012","parentId":"4405fe6a","timestamp":"2026-01-01T00:00:00.366Z","provider":"openrouter","modelId":"deepseek/deepseek-v4-flash"},{"type":"session_info","id":"9dea9c23","parentId":"b78c2012","timestamp":"2026-01-01T00:00:00.370Z","name":"Replay fixture: turn_interrupt_mid_tool"},{"type":"message","id":"8dc98fa4","parentId":"9dea9c23","timestamp":"2026-01-01T00:00:00.379Z","message":{"role":"system","content":"","sections":{"preamble":"","tools":"","rules":"","docs":"","cwd":""},"timestamp":1790898591817,"toolsAdded":[{"name":"read","description":"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.","parameters":{"type":"object","required":["path"],"properties":{"path":{"type":"string","description":"Path to the file to read (relative or absolute)"},"offset":{"type":"number","description":"Line number to start reading from (1-indexed)"},"limit":{"type":"number","description":"Maximum number of lines to read"}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}},{"name":"bash","description":"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.","parameters":{"type":"object","required":["command"],"properties":{"command":{"type":"string","description":"Shell command to execute"},"timeout":{"type":"number","description":"Timeout in seconds (optional, no default timeout)"}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}},{"name":"edit","description":"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.","parameters":{"type":"object","required":["path","edits"],"properties":{"path":{"type":"string","description":"Path to the file to edit (relative or absolute)"},"edits":{"type":"array","items":{"type":"object","required":["oldText","newText"],"properties":{"oldText":{"type":"string","description":"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call."},"newText":{"type":"string","description":"Replacement text for this targeted edit."}}},"description":"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead."}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}},{"name":"write","description":"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.","parameters":{"type":"object","required":["path","content"],"properties":{"path":{"type":"string","description":"Path to the file to write (relative or absolute)"},"content":{"type":"string","description":"Content to write to the file"}}},"constrainedSampling":{"type":"json_schema","strict":"prefer"}}]}},{"type":"message","id":"b36f8c4a","parentId":"8dc98fa4","timestamp":"2026-01-01T00:00:00.379Z","message":{"role":"user","content":[{"type":"text","text":"Run this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally."}],"timestamp":1767225600375}},{"type":"message","id":"8ea47a23","parentId":"b36f8c4a","timestamp":"2026-01-01T00:00:02.246Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to run a specific command and wait for it to complete. Let me execute it.","thinkingSignature":""},{"type":"toolCall","id":"call_9726ad99cc114832996228eb","name":"bash","arguments":{"command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","timeout":35}}],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":1830,"output":102,"cacheRead":0,"cacheWrite":0,"reasoning":23,"totalTokens":1932,"cost":{"input":0.0000766038,"output":0.00000853944,"cacheRead":0,"cacheWrite":0,"total":0.00008514324}},"stopReason":"toolUse","timestamp":1767225600401,"responseId":"gen-1790898592-Mzq78XbaTGiNE3YSafgy","rawStopReason":"tool_calls","thinkingLevel":"high"}},{"type":"message","id":"aa5aec89","parentId":"8ea47a23","timestamp":"2026-01-01T00:00:02.282Z","message":{"role":"toolResult","toolCallId":"call_9726ad99cc114832996228eb","toolName":"bash","content":[{"type":"text","text":"Command aborted"}],"details":{},"isError":true,"timestamp":1767225602282}},{"type":"message","id":"206b2637","parentId":"aa5aec89","timestamp":"2026-01-01T00:00:02.287Z","message":{"role":"assistant","content":[],"api":"openai-completions","provider":"openrouter","model":"deepseek/deepseek-v4-flash","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"error","errorMessage":"This operation was aborted","timestamp":1767225602287,"thinkingLevel":"high"}}],"leafId":"206b2637"}}} diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 544ac5cce66a3..ad5b35995ebe2 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -169,6 +169,12 @@ interface FakePi { readonly vetoNextNewSession: () => void; readonly allRequests: () => ReadonlyArray; readonly queueStats: (data: unknown) => void; + /** Pi before 0.84.4 answers clear_queue as an unknown command. */ + readonly setClearQueue: ( + result: + | { readonly steering: ReadonlyArray; readonly followUp: ReadonlyArray } + | "unknown_command", + ) => void; readonly closeStdout: Effect.Effect; readonly lastSpawn: () => { readonly args: ReadonlyArray; @@ -208,6 +214,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { let sessionGeneration = 0; let models: ReadonlyArray = []; let isStreaming: boolean | null = null; + let clearQueue: Parameters[0] = { steering: [], followUp: [] }; let stdinBuffer = ""; const emit = (record: PiRpcRecord, chunkBytes?: number) => @@ -273,6 +280,10 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { return { ...base, data: statsQueue.shift() ?? {} }; case "fork": return { ...base, data: { text: "Hello pi", cancelled: false } }; + case "clear_queue": + return clearQueue === "unknown_command" + ? { ...base, success: false, error: "Unknown command: clear_queue" } + : { ...base, data: clearQueue }; default: return base; } @@ -386,6 +397,9 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { isStreaming = streaming; }, queueStats: (data) => statsQueue.push(data), + setClearQueue: (result) => { + clearQueue = result; + }, closeStdout: Queue.end(stdout), lastSpawn: () => lastSpawn, } satisfies FakePi; @@ -3876,6 +3890,61 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect.each([ + { + pi: "clears its native queue", + clearQueue: { steering: ["Use the other file", ""], followUp: ["Then summarize"] }, + notices: [ + "Stop cleared 3 queued messages Pi had not read:\n\nUse the other file\n\nThen summarize", + ], + }, + { pi: "predates clear_queue", clearQueue: "unknown_command", notices: [] }, + ] as const)("Stop aborts and retires Pi when it $pi", ({ clearQueue, notices }) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + fake.setClearQueue(clearQueue); + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + const runningTurn = yield* takeEvent( + (event) => + event.type === "provider_turn.updated" && event.providerTurn.status === "running", + ); + const providerTurnId = + runningTurn.type === "provider_turn.updated" ? runningTurn.providerTurn.id : undefined; + assert.isDefined(providerTurnId); + + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: providerTurnId!, + requestRuntimeRestart: true, + }); + yield* fake.takeRequest("abort"); + assert.deepEqual( + fake + .allRequests() + .map((request) => request["type"]) + .filter((type) => type === "clear_queue" || type === "abort"), + ["clear_queue", "abort"], + ); + const emitted: Array = []; + yield* takeEvent((event) => { + if (event.type === "turn_item.updated" && event.turnItem.type === "system_notice") + emitted.push(event.turnItem.message); + return ( + event.type === "provider_session.updated" && event.providerSession.status === "stopped" + ); + }); + assert.deepEqual>(emitted, notices); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect("stops active retry progress when the turn is interrupted", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index aca13e35643ed..94c0e3521e47d 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -1212,6 +1212,59 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }); }); + const emitSystemNotice = Effect.fnUntraced(function* ( + turn: ActivePiTurn, + nativeItemId: string, + message: string, + ) { + const emittedAt = yield* DateTime.now; + yield* emitItemNode(turn, nativeItemId, "system", "completed", emittedAt, emittedAt); + yield* emit({ + type: "turn_item.updated", + driver: PI_PROVIDER, + turnItem: { + ...baseItemFields(turn, nativeItemId, emittedAt, emittedAt), + status: "completed", + completedAt: emittedAt, + title: message, + type: "system_notice", + message, + }, + }); + }); + + // Abort leaves queued steers and follow-ups for Pi's next prompt, and Pi before 0.86 starts them + // as soon as abort lands, so Stop clears them first. Pi before 0.84.4 rejects clear_queue. + const clearNativeQueue = Effect.fnUntraced(function* (turn: ActivePiTurn) { + const cleared = yield* request({ type: "clear_queue" }, 2_000).pipe( + Effect.map((data) => + ["steering", "followUp"].flatMap((key) => { + const texts = recordField(data, key); + return Array.isArray(texts) + ? texts.filter((text): text is string => typeof text === "string") + : []; + }), + ), + Effect.orElseSucceed((): ReadonlyArray => []), + ); + if (cleared.length === 0) return; + yield* sessionEventPermit.withPermits(1)( + threadState?.activeTurn === turn + ? emitSystemNotice( + turn, + `queue-cleared:${turn.providerTurn.id}`, + [ + `Stop cleared ${cleared.length} queued ${cleared.length === 1 ? "message" : "messages"} Pi had not read:`, + // Image-only messages come back as empty text. + ...cleared.filter((text) => text.length > 0), + ] + .join("\n\n") + .slice(0, 2_000), + ) + : Effect.void, + ); + }); + const handleExtensionUiRequest = Effect.fnUntraced(function* (event: PiRpcRecord) { const method = recordString(event, "method"); const nativeRequestId = recordString(event, "id"); @@ -1221,21 +1274,11 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( const turn = state?.activeTurn ?? null; const message = recordString(event, "message") ?? ""; if (turn === null || message.length === 0) return; - const emittedAt = yield* DateTime.now; - const nativeItemId = `notify:${turn.providerTurn.id}:${turn.nextItemOrdinal}`; - yield* emitItemNode(turn, nativeItemId, "system", "completed", emittedAt, emittedAt); - yield* emit({ - type: "turn_item.updated", - driver: PI_PROVIDER, - turnItem: { - ...baseItemFields(turn, nativeItemId, emittedAt, emittedAt), - status: "completed", - completedAt: emittedAt, - title: message, - type: "system_notice", - message, - }, - }); + yield* emitSystemNotice( + turn, + `notify:${turn.providerTurn.id}:${turn.nextItemOrdinal}`, + message, + ); return; } if ( @@ -2641,6 +2684,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( // Generic abort misses manual compaction; terminate so Stop covers /compact and recovery. stopRequested = true; if (interruptInput.requestRuntimeRestart === true && !turn.settleWhenIdle) { + yield* clearNativeQueue(turn); yield* request({ type: "abort" }, 2_000).pipe(Effect.ignore); } // Read rollback tree refs before termination breaks requests; the permit lets in-flight capture finish. @@ -2654,6 +2698,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ); return; } + yield* clearNativeQueue(turn); yield* request({ type: "abort" }).pipe( Effect.tapError(() => Effect.sync(() => (turn.interrupted = false))), ); From 947916887cce53e5f4b4aa77725315fa6c382635 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:10:34 +0100 Subject: [PATCH 17/31] feat(pi): import terminal Pi sessions during setup (#27515) Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT 6.1 Sol --- .../src/project/AgentSessionImporter.test.ts | 48 +++-- .../src/project/AgentSessionImporter.ts | 13 +- .../src/project/AgentSessionScanner.test.ts | 143 ++++++++++++- .../server/src/project/AgentSessionScanner.ts | 191 ++++++++++++++++-- .../components/onboarding/WelcomeWizard.tsx | 46 ++--- docs/user/welcome-wizard.md | 10 +- packages/contracts/src/agentSessions.ts | 2 +- packages/provider-pi/src/server/usage.ts | 27 ++- 8 files changed, 393 insertions(+), 87 deletions(-) diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 69026f91202af..4248e51b55977 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -1,5 +1,6 @@ import { expect, it } from "@effect/vitest"; import { + type AgentSessionSource, EventId, ProjectId, ProviderDriverKind, @@ -50,7 +51,7 @@ const persistence = Layer.mergeAll( function candidate( modelSelection: typeof CODEX_MODEL_SELECTION, providerSessionId: string, - provider: "codex" | "claudeAgent" = "codex", + provider: AgentSessionSource = "codex", ) { const providerInstanceId = modelSelection.instanceId; return { @@ -82,6 +83,12 @@ function candidate( } satisfies AgentSessionScanner.AgentSessionRecentThread; } +const PI_MODEL_SELECTION = { instanceId: ProviderInstanceId.make("pi"), model: "default" }; + +// Pi threads are owned by their session file path, not the session UUID. +const nativeId = ({ source }: ReturnType) => + source.provider === "pi" ? source.filePath : source.providerSessionId; + function importId(source: ReturnType) { return ThreadId.make( `import:${source.source.providerInstanceId}:${source.source.providerSessionId}`, @@ -95,19 +102,17 @@ const seedOwner = Effect.fn(function* ( const sink = yield* EventSink.EventSinkV2; const ids = yield* IdAllocator.IdAllocatorV2; const { providerInstanceId, providerSessionId, provider } = source.source; + const nativeThreadId = nativeId(source); const threadId = ThreadId.make(`owner:${providerInstanceId}:${providerSessionId}`); const driver = ProviderDriverKind.make(provider); const providerThread: OrchestrationV2ProviderThread = { - id: ids.derive.providerThread({ - driver, - nativeThreadId: providerSessionId, - }), + id: ids.derive.providerThread({ driver, nativeThreadId }), driver, providerInstanceId, providerSessionId: null, appThreadId: threadId, ownerNodeId: null, - nativeThreadRef: { driver, nativeId: providerSessionId, strength: "strong" }, + nativeThreadRef: { driver, nativeId: nativeThreadId, strength: "strong" }, nativeConversationHeadRef: null, status: "idle", firstRunOrdinal: 1, @@ -252,10 +257,12 @@ it.effect( "claudeAgent", ); const deleted = candidate(CODEX_MODEL_SELECTION, "deleted"); + const ownedPi = candidate(PI_MODEL_SELECTION, "owned-pi", "pi"); const owners = [ yield* seedOwner(active), yield* seedOwner(archived, "archived"), yield* seedOwner(deleted, "deleted"), + yield* seedOwner(ownedPi), ]; const before = yield* Effect.forEach(owners, (id) => projections.getThreadRecords(id, ["providerThreads"]), @@ -266,10 +273,19 @@ it.effect( "claudeAgent", ); const unowned = candidate(CODEX_MODEL_SELECTION, "unowned"); - const importer = yield* makeImporter([active, archived, deleted, otherInstance, unowned]); + const unownedPi = candidate(PI_MODEL_SELECTION, "unowned-pi", "pi"); + const importer = yield* makeImporter([ + active, + archived, + deleted, + ownedPi, + otherInstance, + unowned, + unownedPi, + ]); - expect(yield* importer.run).toEqual({ importedCount: 2, skippedCount: 0 }); - for (const source of [active, archived, deleted]) { + expect(yield* importer.run).toEqual({ importedCount: 3, skippedCount: 0 }); + for (const source of [active, archived, deleted, ownedPi]) { const threadId = importId(source); expect(yield* Effect.option(projections.getThread(threadId))).toEqual(Option.none()); expect(yield* events.read({ threadId }).pipe(Stream.runCollect)).toEqual([]); @@ -286,8 +302,8 @@ it.effect( "event" in stored ? [stored.event.threadId] : [], ), ), - ).toEqual(new Set([importId(otherInstance), importId(unowned)])); - for (const source of [otherInstance, unowned]) { + ).toEqual(new Set([importId(otherInstance), importId(unowned), importId(unownedPi)])); + for (const source of [otherInstance, unowned, unownedPi]) { const threadId = importId(source); const records = yield* projections.getThreadRecords(threadId, [ "messages", @@ -296,15 +312,13 @@ it.effect( expect(records.messages.map((message) => message.text)).toEqual(["Fix it", "Fixed"]); expect(records.thread.historyOrigin).toBe("v1_import"); expect(records.thread.activeProviderThreadId).toBe(records.providerThreads[0]?.id); - expect(records.providerThreads[0]?.nativeThreadRef?.nativeId).toBe( - source.source.providerSessionId, - ); + expect(records.providerThreads[0]?.nativeThreadRef?.nativeId).toBe(nativeId(source)); expect(Option.getOrThrow(yield* runtimes.getByThreadId({ threadId }))).toMatchObject({ providerInstanceId: source.source.providerInstanceId, resumeCursor: - source.source.provider === "codex" - ? { threadId: source.source.providerSessionId } - : { threadId, resume: source.source.providerSessionId }, + source.source.provider === "claudeAgent" + ? { threadId, resume: source.source.providerSessionId } + : { threadId: nativeId(source) }, runtimePayload: { importedTranscripts: [source.source] }, }); } diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 11f168ff1d7be..038b488635053 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -250,6 +250,9 @@ const make = Effect.gen(function* () { }); } const driver = ProviderDriverKind.make(thread.source); + // Pi resumes by session file path; its session UUID only names the thread. + const nativeThreadId = + thread.source === "pi" ? source.filePath : thread.providerSessionId; const persistRuntime = runtimes.upsert( { threadId, @@ -260,9 +263,9 @@ const make = Effect.gen(function* () { status: "stopped", lastSeenAt: thread.updatedAt, resumeCursor: - thread.source === "codex" - ? { threadId: thread.providerSessionId } - : { threadId, resume: thread.providerSessionId }, + thread.source === "claudeAgent" + ? { threadId, resume: thread.providerSessionId } + : { threadId: nativeThreadId }, runtimePayload: { cwd: project.workspaceRoot }, }, { onConflict: "ignore" }, @@ -288,7 +291,7 @@ const make = Effect.gen(function* () { const providerThreadId = idAllocator.derive.providerThread({ driver, providerInstanceId: thread.providerInstanceId, - nativeThreadId: thread.providerSessionId, + nativeThreadId, }); const createdAt = dateTime(thread.createdAt); const updatedAt = dateTime(thread.updatedAt); @@ -337,7 +340,7 @@ const make = Effect.gen(function* () { ownerNodeId: null, nativeThreadRef: { driver, - nativeId: thread.providerSessionId, + nativeId: nativeThreadId, strength: "strong", }, nativeConversationHeadRef: null, diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index baf0f27f9edb3..3cbec21d8831d 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -46,6 +46,7 @@ const layerProjectStore = (importedWorkspaceRoots: ReadonlyArray) => interface ScannerTestInput { readonly claudeHomePath: string; readonly codexHomePath: string; + readonly piAgentDir?: string; readonly importedWorkspaceRoots?: ReadonlyArray; readonly configBaseDir?: string; readonly providerInstances?: ContractServerSettings["providerInstances"]; @@ -65,6 +66,20 @@ const layerScannerTest = (input: ScannerTestInput) => driver: ProviderDriverKind.make("codex"), config: { homePath: input.codexHomePath }, }, + // Keeps the default Pi instance off the developer's real ~/.pi. + [ProviderInstanceId.make("pi")]: { + driver: ProviderDriverKind.make("pi"), + enabled: true, + environment: [ + { + name: "PI_CODING_AGENT_DIR", + value: input.piAgentDir ?? `${input.claudeHomePath}/pi-agent`, + sensitive: false, + }, + { name: "PI_CODING_AGENT_SESSION_DIR", value: "", sensitive: false }, + ], + config: {}, + }, ...input.providerInstances, }, }), @@ -561,9 +576,32 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { const codexHomePath = yield* makeTempDir("t3code-codex-legacy-"); const claudeEnvironmentHome = yield* makeTempDir("t3code-claude-env-"); const codexEnvironmentHome = yield* makeTempDir("t3code-codex-env-"); + const piAgentDir = yield* makeTempDir("t3code-pi-agent-"); + const piSessionDir = yield* makeTempDir("t3code-pi-sessions-"); const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-"); const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-"); - + const piWorkspace = yield* makeTempDir("t3code-workspace-pi-"); + const piFlatWorkspace = yield* makeTempDir("t3code-workspace-pi-flat-"); + + // Pi nests sessions per cwd by default and keeps a custom session directory flat. + for (const [filePath, cwd, mtime] of [ + [ + path.join(piAgentDir, "sessions", "--slug--", "2026-01-03_pi.jsonl"), + piWorkspace, + "2026-01-03T00:00:00.000Z", + ], + [ + path.join(piSessionDir, "2026-01-04_pi-flat.jsonl"), + piFlatWorkspace, + "2026-01-04T00:00:00.000Z", + ], + ] as const) { + yield* writeTranscript({ + filePath, + contents: `${encodeTranscriptRecord({ type: "session", version: 3, id: "pi", cwd })}\n`, + mtimeMs: Date.parse(mtime), + }); + } yield* writeTranscript({ filePath: path.join(claudeEnvironmentHome, "projects", "-actual", "session.jsonl"), contents: claudeSessionLine(claudeWorkspace), @@ -598,12 +636,31 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { environment: [{ name: "CODEX_HOME", value: codexEnvironmentHome, sensitive: false }], config: {}, }, + [ProviderInstanceId.make("pi")]: { + driver: ProviderDriverKind.make("pi"), + enabled: true, + environment: [ + { name: "PI_CODING_AGENT_DIR", value: piAgentDir, sensitive: false }, + { name: "PI_CODING_AGENT_SESSION_DIR", value: "", sensitive: false }, + ], + config: {}, + }, + [ProviderInstanceId.make("pi-flat")]: { + driver: ProviderDriverKind.make("pi"), + enabled: true, + environment: [ + { name: "PI_CODING_AGENT_SESSION_DIR", value: piSessionDir, sensitive: false }, + ], + config: {}, + }, }, }); - expect(result.candidates.map((candidate) => candidate.path)).toEqual([ - codexWorkspace, - claudeWorkspace, + expect(result.candidates.map((candidate) => [candidate.path, candidate.sources])).toEqual([ + [piFlatWorkspace, ["pi"]], + [piWorkspace, ["pi"]], + [codexWorkspace, ["codex"]], + [claudeWorkspace, ["claudeAgent"]], ]); }), ); @@ -983,13 +1040,14 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }); describe("recentThreads", () => { - it.effect("imports recent Claude and Codex sessions for the selected project only", () => + it.effect("imports recent Claude, Codex, and Pi sessions for the selected project only", () => Effect.gen(function* () { const path = yield* Path.Path; const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); yield* TestClock.setTime(nowMs); const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const piAgentDir = yield* makeTempDir("t3code-pi-agent-"); const workspace = yield* makeTempDir("t3code-workspace-"); const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); @@ -1053,21 +1111,96 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { ].join("\n"), mtimeMs: nowMs - 60 * 60 * 1000, }); + const piMessage = ( + id: string, + parentId: string | null, + message: { readonly role: string; readonly content: unknown }, + ) => + encodeTranscriptRecord({ + type: "message", + id, + parentId, + timestamp: "2026-08-24T11:00:00.000Z", + message, + }); + // The user rewound to the first prompt; the abandoned branch stays in the file. + yield* writeTranscript({ + filePath: path.join(piAgentDir, "sessions", "--selected--", "2026-08-24_pi.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session", + version: 3, + id: "pi-recent", + cwd: workspace, + }), + piMessage("a1", null, { role: "user", content: "Plan the change" }), + piMessage("a2", "a1", { + role: "assistant", + content: [{ type: "text", text: "Abandoned answer" }], + }), + piMessage("a3", "a2", { role: "user", content: "Abandoned follow-up" }), + piMessage("b1", "a1", { + role: "assistant", + content: [ + { type: "thinking", thinking: "Hidden reasoning" }, + { type: "toolCall", id: "call-1", name: "bash", arguments: {} }, + ], + }), + piMessage("bn", "b1", { role: "toolResult", content: null }), + piMessage("b2", "bn", { + role: "toolResult", + content: [{ type: "text", text: "Tool output" }], + }), + piMessage("b3", "b2", { + role: "assistant", + content: [{ type: "text", text: "Active answer" }], + }), + encodeTranscriptRecord({ + type: "session_info", + id: "b4", + parentId: "b3", + name: "Pi refactor", + }), + ].join("\n"), + mtimeMs: nowMs - 30 * 60 * 1000, + }); + // Version 1 sessions predate the entry tree. + yield* writeTranscript({ + filePath: path.join(piAgentDir, "sessions", "--selected--", "2025-01-01_pi-v1.jsonl"), + contents: [ + encodeTranscriptRecord({ type: "session", id: "pi-v1", cwd: workspace }), + encodeTranscriptRecord({ + type: "message", + message: { role: "user", content: "Legacy prompt" }, + }), + encodeTranscriptRecord({ + type: "message", + message: { role: "assistant", content: [{ type: "text", text: "Legacy answer" }] }, + }), + ].join("\n"), + mtimeMs: nowMs - 2 * 24 * 60 * 60 * 1000, + }); const threads = yield* runRecentThreads({ claudeHomePath, codexHomePath, + piAgentDir, workspaceRoot: workspace, }); expect(threads.map((thread) => thread.providerSessionId)).toEqual([ + "pi-recent", "codex-recent", "claude-recent", + "pi-v1", ]); expect(threads.map((thread) => thread.messages.map((message) => message.text))).toEqual([ + ["Plan the change", "Active answer"], ["Review this code", "Looks good"], ["Fix the project", "Done"], + ["Legacy prompt", "Legacy answer"], ]); + expect(threads[0]).toMatchObject({ source: "pi", title: "Pi refactor" }); }), ); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 7ec79c38a38a9..bf026d9005c1a 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -32,7 +32,9 @@ import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server import * as ServerConfig from "../config.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import { resolvePiSessionsDir } from "@t3tools/provider-pi/server/usage"; import * as ServerSettings from "../serverSettings.ts"; import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import { @@ -78,6 +80,10 @@ const CodexTurnMetadata = Schema.Struct({ const TranscriptRecord = Schema.Struct({ type: Schema.optional(Schema.String), + // Pi entries form a tree through id/parentId; session_info entries carry its name. + id: Schema.optional(Schema.String), + parentId: Schema.optional(Schema.NullOr(Schema.String)), + name: Schema.optional(Schema.String), timestamp: Schema.optional(Schema.String), cwd: Schema.optional(Schema.String), sessionId: Schema.optional(Schema.String), @@ -106,6 +112,13 @@ const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord)); const decodeTranscriptValue = Schema.decodeUnknownOption(TranscriptRecord); +const decodePiEntryLinks = Schema.decodeUnknownOption( + Schema.Struct({ + type: TranscriptRecord.fields.type, + id: TranscriptRecord.fields.id, + parentId: TranscriptRecord.fields.parentId, + }), +); const selectTranscriptPath = createTranscriptJsonSelector(TranscriptRecord); const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata); @@ -287,6 +300,39 @@ function deriveTitle(source: AgentSessionSource, text: string): string | undefin return request.trim().split("\n")[0]?.slice(0, 100).trim() || undefined; } +// Pi keeps abandoned branches in the file; its active branch runs from the last entry to the root. +function activePiBranch( + records: ReadonlyArray, +): ReadonlyArray { + const entries = records.filter((record) => record.type !== "session"); + const leaf = entries.at(-1); + // Version 1 sessions predate the tree and are linear. + if (leaf?.id === undefined) return entries; + const byId = new Map( + entries.flatMap((entry) => (entry.id === undefined ? [] : [[entry.id, entry] as const])), + ); + const branch: Array = []; + for ( + let entry: DecodedTranscriptRecord | undefined = leaf; + entry !== undefined && branch.length < entries.length; + entry = entry.parentId ? byId.get(entry.parentId) : undefined + ) { + branch.push(entry); + } + return branch.toReversed(); +} + +function isPiConversationRecord( + record: DecodedTranscriptRecord, +): record is DecodedTranscriptRecord & { + readonly message: typeof TranscriptMessage.Type & { readonly role: "user" | "assistant" }; +} { + return ( + record.type === "message" && + (record.message?.role === "user" || record.message?.role === "assistant") + ); +} + export function parseAgentSessionTranscript( input: AgentSessionTranscriptMetadata & { readonly contents: string; @@ -311,8 +357,16 @@ function parseAgentSessionRecords( // Claude filenames are session IDs. Codex rollout filenames include extra // timestamp text, so only transcript metadata can provide a resumable ID. let providerSessionId = - input.source === "codex" ? "" : (claudeFields.sessionId ?? input.fallbackSessionId); - const title = claudeFields.title ?? null; + input.source === "codex" + ? "" + : input.source === "pi" + ? records.find((record) => record.type === "session")?.id?.trim() || input.fallbackSessionId + : (claudeFields.sessionId ?? input.fallbackSessionId); + // Pi titles a session by its latest session_info on any branch; an empty name clears it. + const title = + input.source === "pi" + ? records.findLast((record) => record.type === "session_info")?.name?.trim() || null + : (claudeFields.title ?? null); let model = claudeFields.model ?? null; let derivedTitle: string | undefined; let hasCodexSessionId = false; @@ -403,8 +457,20 @@ function parseAgentSessionRecords( }; let recordIndex = -1; - for (const record of records) { + for (const record of input.source === "pi" ? activePiBranch(records) : records) { recordIndex += 1; + if (input.source === "pi") { + if (!isPiConversationRecord(record)) continue; + const text = extractText(record.message.content); + if (text.length === 0) continue; + retainMessage({ + role: record.message.role, + text, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: false, + }); + continue; + } if (input.source === "claudeAgent") { if (isHiddenClaudeRecord(record)) continue; if (record.type !== "user" && record.type !== "assistant") continue; @@ -507,6 +573,8 @@ function shouldRetainDecodedRecord( source: AgentSessionSource, record: DecodedTranscriptRecord, ): boolean { + // Any Pi entry can be a link on the active branch. + if (source === "pi") return true; if (source === "claudeAgent") { if (isHiddenClaudeRecord(record) || (record.type !== "user" && record.type !== "assistant")) { return false; @@ -843,15 +911,29 @@ export const make = Effect.gen(function* () { reader.write(decoder.decode()); recordCount += 1; if (recordCount > recordLimit) return false; - const decoded = decodeTranscriptValue(reader.finish()); - if (Option.isSome(decoded)) { + const value = reader.finish(); + const decoded = decodeTranscriptValue(value); + if (Option.isNone(decoded) && source === "pi") { + // Pi accepts messages this schema rejects, such as null content; keep their tree links. + records.push(...Option.toArray(decodePiEntryLinks(value))); + } else if (Option.isSome(decoded)) { if (Option.isNone(cwd)) cwd = Option.fromNullOr(extractDecodedCwd(decoded.value)); if (source === "claudeAgent") { claudeFields = foldClaudeFields(claudeFields, decoded.value); } if (shouldRetainDecodedRecord(source, decoded.value)) { - records.push(decoded.value); - historyBytes += recordBytes; + if ( + source === "pi" && + decoded.value.type === "message" && + !isPiConversationRecord(decoded.value) + ) { + // Tool output can dwarf the conversation; keep only the tree links. + const { message: _message, ...links } = decoded.value; + records.push(links); + } else { + records.push(decoded.value); + historyBytes += recordBytes; + } } } recordBytes = 0; @@ -1030,6 +1112,59 @@ export const make = Effect.gen(function* () { }, ); + // Pi nests sessions in one directory per cwd, but a custom session directory holds them flat. + const discoverPiTranscripts = Effect.fn("AgentSessionScanner.discoverPiTranscripts")(function* ( + sessionsDir: string, + providerInstanceId: ProviderInstanceId, + operationBudget: number, + ) { + let operationsRemaining = operationBudget; + let truncated = false; + const readDirectory = (directory: string) => { + if (operationsRemaining <= 0) { + truncated = true; + return Effect.succeed>([]); + } + operationsRemaining -= 1; + return listDirectory(directory); + }; + const transcripts: Array = []; + for (const entry of yield* readDirectory(sessionsDir)) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const entryPath = path.join(sessionsDir, entry); + const filePaths = entry.endsWith(".jsonl") + ? [entryPath] + : (yield* readDirectory(entryPath)) + .filter((nested) => nested.endsWith(".jsonl")) + .map((nested) => path.join(entryPath, nested)); + + for (const filePath of filePaths) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + operationsRemaining -= 1; + const stats = yield* statOption(filePath); + if ( + Option.isSome(stats) && + stats.value.type === "File" && + Option.isSome(stats.value.mtime) + ) { + transcripts.push({ + filePath, + mtimeMs: stats.value.mtime.value.getTime(), + providerInstanceId, + size: Number(stats.value.size), + }); + } + } + } + return { transcripts, truncated }; + }); + const groupTranscriptsByCwd = Effect.fn("AgentSessionScanner.groupTranscriptsByCwd")(function* ( source: AgentSessionSource, transcripts: ReadonlyArray, @@ -1081,7 +1216,7 @@ export const make = Effect.gen(function* () { const raw: Array = []; let truncated = false; - for (const source of ["claudeAgent", "codex"] as const) { + for (const source of ["claudeAgent", "codex", "pi"] as const) { const instances: Array<{ readonly instanceId: ProviderInstanceId; readonly config: ProviderInstanceConfig; @@ -1111,26 +1246,36 @@ export const make = Effect.gen(function* () { const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = []; const seenHomes = new Set(); for (const { instanceId, config: instance } of instances) { - const homeVariable = source === "claudeAgent" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; - const configuredHome = instance.environment?.findLast( - (variable) => variable.name === homeVariable, - )?.value; - const environmentHome = - typeof configuredHome === "string" ? configuredHome : hostEnvironment[homeVariable]; + const environmentHome = (name: "CLAUDE_CONFIG_DIR" | "CODEX_HOME") => { + const configured = instance.environment?.findLast( + (variable) => variable.name === name, + )?.value; + return typeof configured === "string" ? configured : hostEnvironment[name]; + }; let homePath: string; - if (source === "claudeAgent") { + if (source === "pi") { + const environment = yield* mergeProviderInstanceEnvironment( + instance.environment, + hostEnvironment, + ).pipe(Effect.provideService(HostProcess.HomeDirectory, homeDir)); + homePath = resolvePiSessionsDir(environment, homeDir, path); + } else if (source === "claudeAgent") { const config = decodeClaudeSettings(instance.config ?? {}); if (Option.isNone(config)) continue; - homePath = resolveClaudeConfigDir(config.value.homePath, environmentHome); + homePath = resolveClaudeConfigDir( + config.value.homePath, + environmentHome("CLAUDE_CONFIG_DIR"), + ); } else { const config = decodeCodexSettings(instance.config ?? {}); if (Option.isNone(config)) continue; + const codexHome = environmentHome("CODEX_HOME"); const codexSettings = config.value.homePath.trim().length === 0 && config.value.shadowHomePath.trim().length === 0 && - environmentHome?.trim() - ? { ...config.value, homePath: environmentHome } + codexHome?.trim() + ? { ...config.value, homePath: codexHome } : config.value; const layout = yield* resolveCodexHomeLayout(codexSettings).pipe( Effect.provideService(Path.Path, path), @@ -1155,9 +1300,13 @@ export const make = Effect.gen(function* () { truncated = true; continue; } - const discovered = yield* source === "claudeAgent" - ? discoverClaudeTranscripts(home.homePath, home.providerInstanceId, operationBudget) - : discoverCodexTranscripts(home.homePath, home.providerInstanceId, operationBudget); + const discover = + source === "claudeAgent" + ? discoverClaudeTranscripts + : source === "codex" + ? discoverCodexTranscripts + : discoverPiTranscripts; + const discovered = yield* discover(home.homePath, home.providerInstanceId, operationBudget); truncated ||= discovered.truncated; transcriptCandidates.push(...discovered.transcripts); } diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 50d65192c0249..4c768eb5763a4 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -15,6 +15,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { + AgentSessionSource, AuthTerminalOperateScope, CommandId, defaultInstanceIdForDriver, @@ -1715,41 +1716,38 @@ function ImportCandidateRow({ ); } +const IMPORT_SOURCE_NAMES: Record = { + claudeAgent: "Claude Code", + codex: "Codex", + pi: "Pi", +}; + function ImportRowMeta({ sources, threadCount, lastActiveAt, }: { - readonly sources: ReadonlyArray<"claudeAgent" | "codex"> | null; + readonly sources: ReadonlyArray | null; readonly threadCount: number; readonly lastActiveAt: string | null; }) { const relative = lastActiveAt === null ? null : formatRelativeTime(lastActiveAt); const age = relative === null ? "" : relative.suffix === null ? "now" : relative.value; return ( - - - {sources?.includes("claudeAgent") ? ( - - - - ) : null} - - - {sources?.includes("codex") ? ( - - - - ) : null} - + + {AgentSessionSource.literals.map((source) => ( + + {sources?.includes(source) ? ( + + + + ) : null} + + ))} {threadCount} {age} diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md index d4604fa7e6668..2d4b589d922fa 100644 --- a/docs/user/welcome-wizard.md +++ b/docs/user/welcome-wizard.md @@ -59,7 +59,7 @@ terminal metadata while the terminal process can use them. ## Import your projects -T3 Code finds directories that Claude Code or Codex has used. Git repositories +T3 Code finds directories that Claude Code, Codex or Pi has used. Git repositories are listed first, newest activity on top. When the remote is on GitHub, the group shows the repository as `owner/name`. Clones with the same remote share one group. Directories that are not git repositories sit under "Other folders". @@ -74,8 +74,12 @@ offered. A large or malformed history can reach the scan limit. T3 Code keeps the projects it found and warns when projects or conversations may be missing. -Imported projects include Codex and Claude conversations active within the last -30 days. You can continue those conversations in T3 Code. +Imported projects include Claude, Codex and Pi conversations active within the last +30 days. You can continue those conversations in T3 Code. Pi is off by default: +enable it in Settings, then open `/welcome` to import its sessions. Pi sessions +are read from each enabled Pi provider's session directory, which follows +`PI_CODING_AGENT_SESSION_DIR` and `PI_CODING_AGENT_DIR` in its environment. A +Pi conversation imports its active branch, the one Pi reopens. Conversation import is best effort. T3 Code keeps the first user prompt and the newest remaining visible user and assistant messages, with 200 messages total. diff --git a/packages/contracts/src/agentSessions.ts b/packages/contracts/src/agentSessions.ts index a60d702d9a0e3..93e0e36e4fbd4 100644 --- a/packages/contracts/src/agentSessions.ts +++ b/packages/contracts/src/agentSessions.ts @@ -2,7 +2,7 @@ import * as Schema from "effect/Schema"; import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; -export const AgentSessionSource = Schema.Literals(["claudeAgent", "codex"]); +export const AgentSessionSource = Schema.Literals(["claudeAgent", "codex", "pi"]); export type AgentSessionSource = typeof AgentSessionSource.Type; export const AgentSessionImportSource = Schema.Struct({ diff --git a/packages/provider-pi/src/server/usage.ts b/packages/provider-pi/src/server/usage.ts index 5dadc93e101d9..961a73e838acd 100644 --- a/packages/provider-pi/src/server/usage.ts +++ b/packages/provider-pi/src/server/usage.ts @@ -131,16 +131,21 @@ export const piUsageReader: ProviderUsageReader = { directories: Effect.fn("piUsageReader.directories")(function* ({ environment }) { const path = yield* Path.Path; const home = yield* HostProcess.HomeDirectory; - const sessionDir = environment.PI_CODING_AGENT_SESSION_DIR?.trim(); - if (sessionDir) return [{ dir: path.resolve(expandHomePath(sessionDir, home)) }]; - const agentDir = environment.PI_CODING_AGENT_DIR?.trim(); - return [ - { - dir: path.resolve( - agentDir ? expandHomePath(agentDir, home) : path.join(home, ".pi", "agent"), - "sessions", - ), - }, - ]; + return [{ dir: resolvePiSessionsDir(environment, home, path) }]; }), }; + +/** Where Pi stores session JSONL files for a provider environment, matching Pi's own lookup. */ +export function resolvePiSessionsDir( + environment: NodeJS.ProcessEnv, + home: string, + path: Path.Path, +): string { + const sessionDir = environment.PI_CODING_AGENT_SESSION_DIR?.trim(); + if (sessionDir) return path.resolve(expandHomePath(sessionDir, home)); + const agentDir = environment.PI_CODING_AGENT_DIR?.trim(); + return path.resolve( + agentDir ? expandHomePath(agentDir, home) : path.join(home, ".pi", "agent"), + "sessions", + ); +} From 77336a54a75c593ceb6d9f1d97789d0ccd616460 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:11:07 +0100 Subject: [PATCH 18/31] fix(pi): nest tools called through another tool under their caller (#27521) Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT 6.1 Sol --- .../provider-pi/src/server/adapter.test.ts | 63 +++++++++++++++++++ packages/provider-pi/src/server/adapter.ts | 23 ++++++- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index ad5b35995ebe2..56754c30923bd 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -2041,6 +2041,69 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect("nests tools called through another tool under their caller", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + const takeToolEvents = Effect.fnUntraced(function* (toolCallId: string) { + const node = yield* takeEvent( + (event) => + event.type === "node.updated" && event.node.nativeItemRef?.nativeId === toolCallId, + ); + const item = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === toolCallId, + ); + if (node.type !== "node.updated" || item.type !== "turn_item.updated") + return yield* Effect.die("Expected tool node and item events"); + return { node: node.node, item: item.turnItem }; + }); + + yield* fake.emit({ + type: "tool_execution_start", + toolCallId: "call_code", + toolName: "codemode", + args: { code: "await read({ path: 'a.ts' })" }, + }); + const caller = yield* takeToolEvents("call_code"); + assert.equal(caller.node.parentNodeId, caller.node.rootNodeId); + assert.isNull(caller.item.parentItemId); + + // Pi tags every phase, so an update that arrives before its start still nests. + const phases = [ + { type: "tool_execution_update", partialResult: { content: [] }, status: "running" }, + { type: "tool_execution_start", args: { path: "a.ts" }, status: "running" }, + { + type: "tool_execution_end", + isError: true, + result: { content: [{ type: "text", text: "ENOENT" }] }, + status: "failed", + }, + ] as const; + for (const { status, ...phase } of phases) { + yield* fake.emit({ + ...phase, + toolCallId: "call_code/1", + toolName: "read", + parentToolCallId: "call_code", + }); + const nested = yield* takeToolEvents("call_code/1"); + assert.equal(nested.node.status, status); + assert.equal(nested.node.parentNodeId, caller.node.id); + assert.equal(nested.item.parentItemId, caller.item.id); + } + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect( "keeps native and MCP images available to the shared asset reader with typed results", () => diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 94c0e3521e47d..f1924e7639202 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -729,6 +729,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( nativeItemId: string, startedAt: DateTime.Utc, updatedAt: DateTime.Utc, + parentNativeItemId?: string, ) => ({ id: idAllocator.derive.turnItemFromProviderItem({ driver: PI_PROVIDER, @@ -743,7 +744,13 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( providerThreadId: turn.turnInput.providerThread.id, providerTurnId: turn.providerTurn.id, nativeItemRef: providerRef(nativeItemId), - parentItemId: null, + parentItemId: + parentNativeItemId === undefined + ? null + : idAllocator.derive.turnItemFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId: parentNativeItemId, + }), ordinal: itemOrdinal(turn, nativeItemId), startedAt, updatedAt, @@ -756,6 +763,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( status: OrchestrationV2ExecutionNode["status"], startedAt: DateTime.Utc, completedAt: DateTime.Utc | null, + parentNativeItemId?: string, ) => emit({ type: "node.updated", @@ -767,7 +775,13 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }), threadId: turn.turnInput.threadId, runId: turn.turnInput.runId, - parentNodeId: turn.turnInput.rootNodeId, + parentNodeId: + parentNativeItemId === undefined + ? turn.turnInput.rootNodeId + : idAllocator.derive.nodeFromProviderItem({ + driver: PI_PROVIDER, + nativeItemId: parentNativeItemId, + }), rootNodeId: turn.turnInput.rootNodeId, kind, status, @@ -980,6 +994,8 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ) { const toolCallId = recordString(event, "toolCallId"); const toolName = recordString(event, "toolName") ?? "tool"; + // Pi tags every phase of a call made through ctx.executeTool, such as from a codemode script. + const parentToolCallId = recordString(event, "parentToolCallId"); if (toolCallId === undefined) return; if (phase === "start") { turn.toolArgs.set(toolCallId, event["args"]); @@ -999,7 +1015,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( : "failed" : "completed" : "running"; - const base = baseItemFields(turn, toolCallId, startedAt, emittedAt); + const base = baseItemFields(turn, toolCallId, startedAt, emittedAt, parentToolCallId); yield* emitItemNode( turn, toolCallId, @@ -1007,6 +1023,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( status, startedAt, completed ? emittedAt : null, + parentToolCallId, ); const shared = { ...base, From e00ebb6e2641a2681d7612bccc5d8810403d99b8 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:11:50 +0100 Subject: [PATCH 19/31] fix(pi): explain when an untrusted folder makes Pi skip project resources (#27539) Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT 6.1 Sol --- docs/user/providers-pi.md | 15 ++++++--- .../src/server/mcpExtensionSource.test.ts | 33 +++++++++++++++++-- .../src/server/mcpExtensionSource.ts | 14 ++++++++ 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/docs/user/providers-pi.md b/docs/user/providers-pi.md index 123258f6ccb43..e106d0afdd387 100644 --- a/docs/user/providers-pi.md +++ b/docs/user/providers-pi.md @@ -47,9 +47,10 @@ reporting while a response streams and after it settles. Pi skills appear in the composer's `$` menu. This includes user skills and project skills that Pi loads for the current workspace; selecting one uses Pi's native skill expansion. -Pi loads its normal user and project extensions. Blocking `select`, `confirm`, `input`, and `editor` -dialogs work in T3 Code. Notifications appear in the work log. Pi terminal decoration such as -titles, status lines, and widgets does not have a T3 Code equivalent. +Pi loads its normal user extensions, and project extensions once the folder is trusted in Pi. +Blocking `select`, `confirm`, `input`, and `editor` dialogs work in T3 Code. Notifications appear in +the work log. Pi terminal decoration such as titles, status lines, and widgets does not have a T3 +Code equivalent. ## Permission Modes @@ -79,7 +80,11 @@ threads. - If no models appear, open Pi directly and confirm its authentication and model configuration. - If discovery cannot complete, T3 Code keeps Pi available with the `Pi default` model. Start a thread to let the interactive Pi session handle any startup prompt. -- If a project extension is missing, approve the project in Pi, then start a fresh provider session. -- If a project skill is missing from the `$` menu, approve the project in Pi and refresh the provider. +- If Pi skips project settings, extensions, skills, prompts, MCP servers, or system prompt files + because the folder is not trusted, the thread shows a notice on the first turn of the Pi session. + Run the configured Pi binary in that folder on the server machine, using the same provider + environment, including `PI_CODING_AGENT_DIR` if set. Trust the folder or its parent with `/trust`, + then start a new thread using that same worktree. Launch arguments with `--no-approve` keep + project files off without the notice. - The context meter appears once Pi reports usage for the thread. Some model providers only report usage when a response completes, so the meter can wait for the first reply. diff --git a/packages/provider-pi/src/server/mcpExtensionSource.test.ts b/packages/provider-pi/src/server/mcpExtensionSource.test.ts index b1306f701e27a..cedf3974f535d 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.test.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.test.ts @@ -25,7 +25,10 @@ type InputHook = ( ctx: { ui: { notify: (message: string, level: string) => void } }, ) => Promise<{ action: string; text: string; images?: ReadonlyArray } | undefined>; -async function loadHooks(commands: ReadonlyArray = []) { +async function loadHooks( + commands: ReadonlyArray = [], + argv: ReadonlyArray = [], +) { const handlers = new Map(); const source = NodeModule.stripTypeScriptTypes( PI_T3_MCP_EXTENSION_SOURCE.replace(/^import .*;$/gm, "").replace( @@ -34,7 +37,7 @@ async function loadHooks(commands: ReadonlyArray = []) { ), ); await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { - process: { env: {} }, + process: { env: {}, argv }, NodeFSP, NodePath, stripFrontmatter: (content: string) => content, @@ -77,6 +80,32 @@ describe("Pi orchestration prompt section", () => { }); }); +describe("Pi project trust", () => { + it.each([ + { name: "a trusted folder", trusted: true, argv: [], notices: 0 }, + { name: "an untrusted folder", trusted: false, argv: [], notices: 1 }, + { name: "--no-approve", trusted: false, argv: ["--approve", "-na"], notices: 0 }, + ])("sends $notices trust notice per session for $name", async ({ trusted, argv, notices }) => { + const sent: string[] = []; + const ctx = { + cwd: "/repo", + isProjectTrusted: () => trusted, + ui: { notify: (message: string) => sent.push(message) }, + }; + // Pi reruns extension factories on session replacement without asking about trust again. + for (const _session of [1, 2]) { + const handlers = (await loadHooks([], ["node", "pi", ...argv])) as Map< + string, + (event: unknown, ctx: unknown) => unknown + >; + for (const _turn of [1, 2]) await handlers.get("before_agent_start")!({}, ctx); + } + assert.equal(sent.length, notices * 2); + for (const message of sent) + assert.include(message, "for /repo because the folder is not trusted"); + }); +}); + describe("Pi MCP tool results", () => { it("returns mixed screenshot blocks and typed script output without repeating structured text", async () => { const content = [ diff --git a/packages/provider-pi/src/server/mcpExtensionSource.ts b/packages/provider-pi/src/server/mcpExtensionSource.ts index dcc4a0230a8db..ee72c38f75966 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.ts @@ -241,6 +241,20 @@ function createMcpClient(endpoint: string, token: string) { } export default async function t3McpExtension(pi: ExtensionAPI) { + // RPC has no trust prompt, so Pi skips an untrusted folder's protected resources. Session + // replacement reruns this factory without project_trust, and T3 drops notices outside a turn, + // so check each session's first turn. An explicit --no-approve is deliberate and stays quiet. + let projectTrustChecked = false; + pi.on("before_agent_start", (_event, ctx) => { + if (projectTrustChecked) return; + projectTrustChecked = true; + if (ctx.isProjectTrusted() || process.argv.some((arg) => arg === "--no-approve" || arg === "-na")) return; + ctx.ui.notify( + \`Pi did not load project settings, extensions, skills, prompts, MCP servers or system prompt files for \${ctx.cwd} because the folder is not trusted. To load them, run the configured Pi binary there with the same PI_CODING_AGENT_DIR (if set), trust the folder or its parent with /trust, then start a new thread in that same worktree. To trust every project, add --approve to Pi's launch arguments in T3 Code settings; to keep them off without this notice, add --no-approve.\`, + "warning", + ); + }); + // Keep skill chips in the request. Pi expands only one leading /skill: // command, so use its own frontmatter loader for every selected chip here. pi.on("input", async (event, ctx) => { From a077fdd2aa4890554ef1646c58ecf079beae966a Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:43:34 +0530 Subject: [PATCH 20/31] fix(mobile): Android Thread settings rows no longer vanish with animations removed (#21757) Co-authored-by: Pipyakas Co-authored-by: Claude Opus 5.5 --- .../src/features/threads/ThreadSettingsSheet.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 11d8710470d66..1f17fd3bb842e 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -49,7 +49,12 @@ import { type ReactNode, } from "react"; import { Platform, Pressable, RefreshControl, ScrollView, TextInput, View } from "react-native"; -import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; +import Animated, { + FadeIn, + FadeOut, + LinearTransition, + useReducedMotion, +} from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SymbolView } from "../../components/ui/AppSymbol"; @@ -938,7 +943,10 @@ function ThreadSettingsMainContent(props: { if (error) Dialog.alert("Could not refresh models", error); }); }; - const [animationsReady, setAnimationsReady] = useState(false); + const [listLoaded, setListLoaded] = useState(false); + // Android can drop the first frame of a reduced-motion fade, leaving rows invisible. + const reducedMotion = useReducedMotion(); + const animationsReady = listLoaded && !reducedMotion; const catalogItems = buildThreadSettingsCatalogItems(session); const hasActiveCatalogFilter = session.providerFilter !== null || session.searchQuery.trim().length > 0; @@ -1064,7 +1072,7 @@ function ThreadSettingsMainContent(props: { ) : undefined } - onLoad={() => setAnimationsReady(true)} + onLoad={() => setListLoaded(true)} renderItem={renderCatalogItem} showsVerticalScrollIndicator={false} /> From 35dcdd601e11d821e0df5fd3435b350e92d15bf6 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:44:47 +0530 Subject: [PATCH 21/31] fix(server): phone taps, typing and scrolling reach desktop browser tabs again (#19456) Co-authored-by: PR Batch Tester Co-authored-by: Max Nylander Co-authored-by: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 211 +++++++++++++++++- apps/server/src/preview/ServerBrowser.ts | 107 +++++++-- 2 files changed, 293 insertions(+), 25 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 99df18c10cf5a..2f09e2b084613 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -24,6 +24,7 @@ import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import type { BrowserContext, Page } from "playwright-core"; import { beforeEach, expect, vi } from "vite-plus/test"; @@ -56,6 +57,7 @@ vi.mock("./ServerBrowserContexts.ts", () => ({ } async connectDesktopPage(endpoint: string) { const context = makeContext(); + context.page.viewportSize.mockReturnValue(null); desktopConnections.push({ endpoint, context }); return { browser: { close: async () => {} }, page: context.page as unknown as Page }; } @@ -83,11 +85,16 @@ const PNG_HEADER = (() => { return header.toString("base64"); })(); -function makeSession(frame?: ReturnType) { +function makeSession( + frame: ReturnType | undefined, + viewport: { width: number; height: number }, +) { const events = new NodeEvents.EventEmitter(); return { - on: vi.fn((name: string, listener: () => void) => events.on(name, listener)), - emit: (name: string) => events.emit(name), + on: vi.fn((name: string, callback: (...args: unknown[]) => void) => events.on(name, callback)), + emit: (name: string, ...args: unknown[]) => events.emit(name, ...args), + emitAsync: (name: string, ...args: unknown[]) => + Promise.all(events.listeners(name).map((listener) => listener(...args))), detach: vi.fn(async () => {}), send: vi.fn(async (method: string, input?: unknown): Promise> => { if (method === "Page.getNavigationHistory") return { currentIndex: 0, entries: [{}] }; @@ -101,7 +108,12 @@ function makeSession(frame?: ReturnType) { if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; if (method === "Page.createIsolatedWorld") { return { - executionContextId: (input as { worldName?: string }).worldName === "t3-preview" ? 1 : 7, + executionContextId: + (input as { worldName?: string }).worldName === "t3-preview-viewport" + ? 9 + : (input as { worldName?: string }).worldName === "t3-preview" + ? 1 + : 7, }; } if (method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 1) { @@ -118,6 +130,8 @@ function makeSession(frame?: ReturnType) { else pageCopy = ""; return { result: { value } }; } + if (method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 9) + return desktopSizeAnswer?.() ?? { result: { value: { ...viewport } } }; return { result: { value: "evaluated" } }; }), }; @@ -129,7 +143,8 @@ function makeContext(onClose?: (context: BrowserContext) => void) { const setChooserFiles = vi.fn(async (_files: unknown) => {}); const sessions: ReturnType[] = []; let url = "about:blank"; - let viewport = { width: 1280, height: 800 }; + let viewport: ReturnType = { width: 1280, height: 800 }; + const cssViewport = { width: 1920, height: 1080 }; let closed = false; let contextClosed = false; const page = { @@ -159,7 +174,7 @@ function makeContext(onClose?: (context: BrowserContext) => void) { frames: () => [page], url: () => url, title: vi.fn(async () => "test page"), - viewportSize: () => viewport, + viewportSize: vi.fn(() => viewport), setViewportSize: vi.fn(async (size: typeof viewport) => { viewport = size; }), @@ -197,11 +212,13 @@ function makeContext(onClose?: (context: BrowserContext) => void) { const context = { page, sessions, + cssViewport, newPage: async () => page as unknown as Page, grantPermissions: vi.fn(async () => {}), newCDPSession: async (target: unknown) => { const session = makeSession( target === page ? undefined : (target as ReturnType), + cssViewport, ); sessions.push(session); return session; @@ -268,7 +285,11 @@ let focusedFrame: ReturnType | null = null; let contextFailure: Error | null = null; let initialNavigation: (() => Promise) | null = null; let desktopRendersNext = false; +/** Replaces the answer to the desktop page's size reads. */ +let desktopSizeAnswer: (() => Promise>) | null = null; const desktopDetaches = new NodeEvents.EventEmitter(); +let desktopDetachReady = Promise.withResolvers(); +let desktopReleased = Promise.withResolvers(); const desktopTabs = new Set(); const desktopRenders = (tabId: string) => { if (desktopRendersNext) { @@ -317,6 +338,7 @@ const dependencies = Layer.mergeAll( const onDetach = (key: { threadId: string; tabId: string }) => Queue.offerUnsafe(queue, key); desktopDetaches.on("detach", onDetach); + desktopDetachReady.resolve(); return onDetach; }), (onDetach) => Effect.sync(() => desktopDetaches.off("detach", onDetach)), @@ -336,7 +358,10 @@ const dependencies = Layer.mergeAll( isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), endpoint: (key) => Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () => - Effect.sync(() => releasedDesktopTabs.push(key.tabId)), + Effect.sync(() => { + releasedDesktopTabs.push(key.tabId); + desktopReleased.resolve(); + }), ), pointer: () => Effect.void, turn: (threadId, active) => Effect.sync(() => desktopTurns.emit("turn", threadId, active)), @@ -367,6 +392,33 @@ const viewerInput = (tabId: string, canOperate: boolean) => ({ maxHeight: 800, quality: 70, }); +/** Fills a viewer's output with frames; resolves once every one of them is acknowledged. */ +const stallViewer = (viewer: ServerBrowser.ServerBrowserViewer) => { + const output = viewer.output as unknown as Queue.Queue; + const acked = Promise.withResolvers(); + let unacked = 0; + const ack = Effect.sync(() => { + if (--unacked === 0) acked.resolve(); + }); + while (Queue.offerUnsafe(output, { _tag: "frame", data: Buffer.alloc(0), ack })) unacked++; + return Effect.promise(() => acked.promise); +}; +const viewportSizes = (items: ReadonlyArray) => + items.flatMap((item) => (item._tag === "viewport" ? [`${item.width}x${item.height}`] : [])); +const openDesktopViewer = Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + desktopRendersNext = true; + const { tabId } = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + const context = desktopConnections[0]!.context; + const cdp = context.sessions[0]!; + const resize = (width: number, height: number) => { + Object.assign(context.cssViewport, { width, height }); + return Effect.promise(() => cdp.emitAsync("Page.frameResized")); + }; + return { tabId, viewer, context, cdp, resize }; +}); beforeEach(() => { contexts.length = 0; @@ -380,6 +432,9 @@ beforeEach(() => { focusedFrame = null; desktopTabs.clear(); desktopRendersNext = false; + desktopSizeAnswer = null; + desktopDetachReady = Promise.withResolvers(); + desktopReleased = Promise.withResolvers(); releasedDesktopTabs.length = 0; desktopConnections.length = 0; }); @@ -1144,6 +1199,148 @@ it.live("only the current controller can answer a file picker", () => ).pipe(Effect.provide(layer)), ); +it.effect( + "desktop viewers get the page's CSS size and keep the last one a read cannot measure", + () => + Effect.scoped( + Effect.gen(function* () { + // Each page size read waits here for its answer. + const reads = yield* Queue.unbounded>>(); + const deferReads = () => { + desktopSizeAnswer = () => { + const read = Promise.withResolvers>(); + Queue.offerUnsafe(reads, read); + return read.promise; + }; + }; + deferReads(); + const { viewer, cdp, context } = yield* openDesktopViewer; + const answer = (width: number, height: number) => ({ + result: { value: { width, height } }, + }); + const resized = () => cdp.emitAsync("Page.frameResized"); + // The read on attach never answers and gives up with no size to send. + const blocked = yield* Queue.take(reads); + yield* TestClock.adjust("1 second"); + // The next page load measures again. + desktopSizeAnswer = null; + yield* Effect.promise(() => cdp.emitAsync("Page.loadEventFired")); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual(["1920x1080"]); + // While a dialog is open nothing reads. The host closing it clears it, and since + // closing resizes nothing, the close itself remeasures. + deferReads(); + context.page.emit("dialog", { + type: () => "alert", + message: () => "", + defaultValue: () => "", + }); + yield* Effect.promise(resized); + expect(yield* Queue.size(reads)).toBe(0); + desktopSizeAnswer = null; + yield* Effect.promise(() => cdp.emitAsync("Page.javascriptDialogClosed")); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual([ + "1920x1080", + "1920x1080", + ]); + // The late answer, a read a navigation breaks and a hidden webview keep the last size. + blocked.resolve(answer(100, 100)); + deferReads(); + const navigated = resized(); + (yield* Queue.take(reads)).reject(new Error("Cannot find context with specified id")); + yield* Effect.promise(() => navigated); + const hidden = resized(); + (yield* Queue.take(reads)).resolve(answer(0, 0)); + yield* Effect.promise(() => hidden); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual([ + "1920x1080", + "1920x1080", + ]); + // Resizes during a read share one more read, so the newest size arrives last. + const first = resized(); + const firstRead = yield* Queue.take(reads); + const burst = Promise.all([resized(), resized()]); + firstRead.resolve(answer(800, 500)); + (yield* Queue.take(reads)).resolve(answer(1000, 600)); + expect(yield* Queue.size(reads)).toBe(0); + yield* Effect.promise(() => Promise.all([first, burst])); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual([ + "800x500", + "1000x600", + "1000x600", + ]); + }), + ).pipe(Effect.provide(layer)), +); + +it.effect("a stalled desktop viewer still gets its size, control and reconnect", () => + Effect.scoped( + Effect.gen(function* () { + const { tabId, viewer, context, resize } = yield* openDesktopViewer; + const output = + viewer.output as unknown as Queue.Queue; + // Ready once the sizes measured on attach and on a first resize both arrived. + yield* resize(1920, 1080); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual([ + "1920x1080", + "1920x1080", + ]); + yield* Effect.promise(() => desktopDetachReady.promise); + yield* viewer.input({ type: "takeControl" }); + context.page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ getAttribute: async () => ".csv" }), + setFiles: vi.fn(async () => {}), + }); + let chooser = yield* Queue.take(viewer.output); + while (chooser._tag !== "fileChooser") chooser = yield* Queue.take(viewer.output); + yield* Queue.clear(viewer.output); + // A resize compacts a full backlog but keeps control, the open picker and + // one-time notifications in order. + yield* viewer.input({ type: "takeControl" }); + const notifications: Array = [ + { _tag: "fileChooserClosed", id: "chooser-0" }, + { _tag: "popup", tabId: "popup-1" }, + { _tag: "download", id: "download-1", fileName: "report.csv", sizeBytes: 3 }, + { _tag: "clipboard", text: "copied" }, + ]; + for (const item of notifications) Queue.offerUnsafe(output, item); + let acked = stallViewer(viewer); + yield* resize(1000, 600); + expect(yield* Queue.takeAll(viewer.output)).toEqual([ + chooser, + ...notifications, + expect.objectContaining({ _tag: "control", controller: "you" }), + { _tag: "viewport", width: 1000, height: 600 }, + ]); + yield* acked; + // A control change keeps only the newest size. + yield* resize(900, 500); + yield* resize(1200, 700); + acked = stallViewer(viewer); + yield* viewer.input({ type: "releaseControl" }); + expect(yield* Queue.takeAll(viewer.output)).toEqual([ + expect.objectContaining({ _tag: "control", controller: "unclaimed" }), + { _tag: "viewport", width: 1200, height: 700 }, + ]); + yield* acked; + // Notifications that no longer fit go oldest first, never the size. + let copies = 0; + while (Queue.offerUnsafe(output, { _tag: "clipboard", text: `copy ${copies}` })) copies++; + yield* resize(1000, 600); + const compacted = yield* Queue.takeAll(viewer.output); + expect(compacted.at(0)).toEqual({ _tag: "clipboard", text: "copy 1" }); + expect(compacted.at(-1)).toEqual({ _tag: "viewport", width: 1000, height: 600 }); + // The desktop takes the page back; a resize after it cannot bury the reconnect. + acked = stallViewer(viewer); + desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId }); + yield* Effect.promise(() => desktopReleased.promise); + yield* resize(1000, 600); + expect(yield* Queue.takeAll(viewer.output)).toEqual([{ _tag: "reconnect" }]); + yield* acked; + }), + ).pipe(Effect.provide(layer)), +); + it.live("agents read a human's tab with no arguments and act on it only while nobody drives", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 6f3e86a7501ea..57e35be778634 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -102,6 +102,8 @@ const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } a const decodeViewportSetting = Schema.decodeUnknownSync(PreviewViewportSettingSchema); const AGENT_CURSOR_MOVE_MS = 160; const AGENT_CURSOR_CLICK_LEAD_MS = 40; +/** A dialog blocks page script, so a desktop size read gives up after this. */ +const DESKTOP_VIEWPORT_READ_TIMEOUT = "1 second"; const sleepUntil = (deadline: number) => new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now()))); @@ -309,6 +311,11 @@ interface ServerTab { setting: PreviewViewportSetting; colorScheme: PreviewAppearancePreference; zoomFactor: number; + /** Last usable CSS size measured on the desktop. */ + desktopViewport: { readonly width: number; readonly height: number } | null; + desktopViewportRead: Promise; + /** A read queued behind `desktopViewportRead`, shared by every caller that waits for it. */ + desktopViewportNext: Promise | null; loading: boolean; closing: boolean; recording: Recording | null; @@ -605,9 +612,48 @@ const make = Effect.gen(function* () { ); }; - const layoutSize = (tab: ServerTab) => { + // Desktop pages have no Playwright viewport; an isolated world keeps page script + // from overriding the CSS size, and failed reads preserve the last usable size. + const readDesktopViewport = async (tab: ServerTab) => { + const readSize = async () => { + const { frameTree } = await tab.cdp.send("Page.getFrameTree"); + const { executionContextId } = await tab.cdp.send("Page.createIsolatedWorld", { + frameId: frameTree.frame.id, + worldName: "t3-preview-viewport", + }); + const { result } = await tab.cdp.send("Runtime.evaluate", { + expression: "({ width: innerWidth, height: innerHeight })", + contextId: executionContextId, + returnByValue: true, + }); + return asRecord(result.value); + }; + const size = await runPromise( + // A navigation can destroy the isolated world mid-read. + Effect.promise(() => readSize().catch(() => null)).pipe( + Effect.timeoutOption(DESKTOP_VIEWPORT_READ_TIMEOUT), + Effect.map(Option.getOrNull), + ), + ); + const width = size?.width; + const height = size?.height; + if (typeof width === "number" && width > 0 && typeof height === "number" && height > 0) + tab.desktopViewport = { width, height }; + return tab.desktopViewport; + }; + + // Reads run one at a time, and callers that arrive during a read share the next one. + const measureDesktopViewport = (tab: ServerTab) => + (tab.desktopViewportNext ??= tab.desktopViewportRead.then(() => { + tab.desktopViewportNext = null; + return (tab.desktopViewportRead = readDesktopViewport(tab)); + })); + + const layoutSize = async (tab: ServerTab) => { + // A dialog blocks page script; its close measures again. + if (tab.desktop) return tab.dialog ? tab.desktopViewport : measureDesktopViewport(tab); const size = tab.page.viewportSize(); - if (!size || tab.desktop) return size; + if (!size) return size; return { width: Math.max(1, Math.round(size.width / tab.zoomFactor)), height: Math.max(1, Math.round(size.height / tab.zoomFactor)), @@ -622,7 +668,7 @@ const make = Effect.gen(function* () { await tab.page.setViewportSize(size); return; } - const layout = layoutSize(tab)!; + const layout = (await layoutSize(tab))!; await tab.cdp.send("Emulation.setDeviceMetricsOverride", { width: layout.width, height: layout.height, @@ -631,9 +677,9 @@ const make = Effect.gen(function* () { }); }; - const broadcastViewport = (tab: ServerTab) => { - const size = layoutSize(tab); - if (!size) return; + const broadcastViewport = async (tab: ServerTab) => { + const size = await layoutSize(tab); + if (!size || tab.closing) return; for (const viewer of tab.viewers) viewer.push({ _tag: "viewport", ...size }); }; @@ -650,7 +696,7 @@ const make = Effect.gen(function* () { UNATTACHED_FILL_VIEWPORT; await tab.page.setViewportSize({ width: size.width, height: size.height }); await applyZoom(tab); - broadcastViewport(tab); + await broadcastViewport(tab); }; const applyRendering = async (tab: ServerTab, snapshot: PreviewSessionSnapshot) => { @@ -663,7 +709,7 @@ const make = Effect.gen(function* () { if (zoomFactor !== tab.zoomFactor && !tab.desktop) { tab.zoomFactor = zoomFactor; await applyZoom(tab); - broadcastViewport(tab); + await broadcastViewport(tab); } }; @@ -789,6 +835,9 @@ const make = Effect.gen(function* () { setting: snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, colorScheme: "system", zoomFactor: 1, + desktopViewport: null, + desktopViewportRead: Promise.resolve(null), + desktopViewportNext: null, loading: false, closing: false, recording: null, @@ -803,6 +852,21 @@ const make = Effect.gen(function* () { await page.setViewportSize(fixedViewportSize(tab.setting) ?? UNATTACHED_FILL_VIEWPORT); } await applyRendering(tab, snapshot); + if (desktop) { + // The desktop resizes and zooms its webview itself; viewers map input with the new size. + const remeasure = () => { + if (tab.viewers.size > 0) return broadcastViewport(tab).catch(constVoid); + }; + cdp.on("Page.frameResized", remeasure); + // A read a navigation broke has a fresh page to measure. + cdp.on("Page.loadEventFired", remeasure); + // Closing a dialog resizes nothing, and the host can close it without us. + cdp.on("Page.javascriptDialogClosed", () => { + tab.dialog = null; + return remeasure(); + }); + await cdp.send("Page.enable"); + } const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) => request.isNavigationRequest() && request.frame() === page.mainFrame(); const navigationGenerations = new WeakMap(); @@ -1383,7 +1447,7 @@ const make = Effect.gen(function* () { }; } const url = tab.page.url(); - const viewport = tab.page.viewportSize(); + const viewport = tab.desktop ? await layoutSize(tab) : tab.page.viewportSize(); const status = { available: true, visible: tab.viewers.size > 0, @@ -2213,7 +2277,7 @@ const make = Effect.gen(function* () { const current = tab.page.viewportSize(); if (current?.width === width && current.height === height) return; await tab.page.setViewportSize({ width, height }); - broadcastViewport(tab); + await broadcastViewport(tab); return; } case "viewport": { @@ -2313,15 +2377,22 @@ const make = Effect.gen(function* () { // Dropped frames must still release Chromium. if (Queue.offerUnsafe(output, next)) return; if (next._tag === "frame") runFork(next.ack); - // State a stalled viewer cannot miss replaces its backlog. It runs - // synchronously so an older replacement can never land after a newer one. - else if (next._tag === "gone" || next._tag === "control" || next._tag === "fileChooser") { + // State a stalled viewer cannot miss compacts its backlog. It runs + // synchronously so an older compaction can never land after a newer one. + else if (["gone", "reconnect", "control", "viewport", "fileChooser"].includes(next._tag)) { const dropped = Effect.runSyncExit(Queue.clear(output)); if (dropped._tag === "Failure") return; - Queue.offerUnsafe(output, next); - - const chooser = next._tag === "control" ? fileChooserMessage(tab) : null; - if (chooser && tab.control.controller === viewer.id) Queue.offerUnsafe(output, chooser); + const items = [...dropped.value, next]; + // Without its latest size and control the viewer cannot map input at all. + const state = (["control", "viewport"] as const).flatMap( + (tag) => items.findLast((item) => item._tag === tag) ?? [], + ); + // Everything else is one-time; if it no longer fits, the oldest goes first. + const rest = items.filter( + (item) => item._tag !== "frame" && item._tag !== "control" && item._tag !== "viewport", + ); + for (const item of [...rest.slice(state.length - VIEWER_OUTPUT_LIMIT), ...state]) + Queue.offerUnsafe(output, item); for (const item of dropped.value) if (item._tag === "frame") runFork(item.ack); } }, @@ -2410,7 +2481,7 @@ const make = Effect.gen(function* () { ), }); }); - broadcastViewport(tab); + void broadcastViewport(tab).catch(constVoid); yield* Effect.promise(() => startScreencast(1)); // An idle page does not repaint for a new screencast, so the viewer // starts from a still unless a live frame beat it. From b8397e5caf2544233f599d72942fb33e6d37458a Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 30 Oct 2026 16:17:17 +0000 Subject: [PATCH 22/31] feat(web): images and videos sent with text sit above the bubble as their own shape (#27306) --- .../chat/TimelineUserMessageRow.tsx | 264 +++++++++++------- 1 file changed, 158 insertions(+), 106 deletions(-) diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 58785db77ca47..56f4ef8217ff6 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -88,7 +88,13 @@ const MEDIA_EDGE_CLASS_NAME = const BUBBLE_CHIP_CLASS_NAME = "[&_[data-slot=context-chip]]:[--context-chip-ink:var(--context-chip-message,var(--context-chip-accent))]"; -function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) { +function UserVideoAttachment({ + file, + standalone = false, +}: { + readonly file: ChatFileAttachment; + readonly standalone?: boolean; +}) { const ctx = use(TimelineRowCtx); // Row updates rebuild attachment objects, so the asset follows only the // fields it reads. @@ -105,13 +111,20 @@ function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) { if (asset === null && src === null) { return ( -
+
{file.name}
); } - return ( + const player = ( ); + return standalone ? ( +
{player}
+ ) : ( + player + ); } export function UserMessageRow({ row }: { row: Extract }) { @@ -351,15 +375,22 @@ function UserTimelineRow({ row }: { row: Extract 0; + // Media sent with words sits above the bubble as its own shape, like a chat app. + const mediaAboveText = + !folded && + userMessage.attribution !== "guest" && + !mediaOnly && + regularImages.length + userVideos.length > 0; + const standaloneMedia = mediaOnly || mediaAboveText; // A lone image keeps its own shape; a 4:3 crop hides most of a wide screenshot. const loneImage = - mediaOnly && + standaloneMedia && userVideos.length === 0 && regularImages.length === 1 && regularImages[0]!.source?.kind !== "snap-shot"; - // Several images sent without words share one rounded shape, split by hairlines. + // Several images share one rounded shape, split by hairlines. const collage = - mediaOnly && + standaloneMedia && userVideos.length === 0 && regularImages.length > 1 && regularImages.every((image) => image.source?.kind !== "snap-shot"); @@ -465,6 +496,107 @@ function UserTimelineRow({ row }: { row: Extract 0 || userVideos.length > 0 ? ( +
+ {regularImages.map((image, index) => ( +
3 && + regularImages.length % 2 === 1 && + index === regularImages.length - 1 && + "col-span-2", + ) + : standaloneMedia + ? cn(MEDIA_EDGE_CLASS_NAME, "aspect-[4/3] overflow-hidden rounded-2xl") + : "aspect-[4/3] overflow-hidden rounded-lg border border-border/80", + )} + > + {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {image.previewUrl && image.source?.kind === "snap-shot" ? ( + + ) : null} +
+ ))} + {userVideos.map((file) => ( + + ))} +
+ ) : null; + return (
) : null} + {mediaAboveText ? ( +
+ {/* The heading leads the media so screen readers reach it before the images. */} + You + {mediaBlock} +
+ ) : null}
{/* Outside the fold so a collapsed message still has a heading to navigate to. */} - - {folded - ? senderLabel - : userMessage.guestName === undefined - ? "You" - : `${userMessage.guestName} (guest)`} - + {mediaAboveText ? null : ( + + {folded + ? senderLabel + : userMessage.guestName === undefined + ? "You" + : `${userMessage.guestName} (guest)`} + + )} {folded ? ( - {(regularImages.length > 0 || userVideos.length > 0) && ( -
- {regularImages.map((image, index) => ( -
3 && - regularImages.length % 2 === 1 && - index === regularImages.length - 1 && - "col-span-2", - ) - : "aspect-[4/3] overflow-hidden rounded-lg border border-border/80", - )} - > - {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {image.previewUrl && image.source?.kind === "snap-shot" ? ( - - ) : null} -
- ))} - {userVideos.map((file) => ( - - ))} -
- )} + {!mediaAboveText && mediaBlock} {userAudio.length > 0 ? (
{userAudio.map((file) => ( From 1da41f949950bda57f45dc73effb7543037c844f Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 30 Oct 2026 16:23:24 +0000 Subject: [PATCH 23/31] feat(web): files sent without text show as file cards (#27324) --- .../chat/TimelineUserMessageRow.tsx | 137 ++++++++++++++++-- 1 file changed, 121 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 56f4ef8217ff6..aee30e27d51ec 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -54,6 +54,7 @@ import { GuestMessageName, guestBubbleClassName } from "./guestMessageStyle"; import { MessageCopyButton } from "./MessageCopyButton"; import { promptLinkFor } from "../promptLinks/copyPromptLink"; import { PierreEntryIcon } from "./PierreEntryIcon"; +import { formatBytes } from "../../lib/formatBytes"; import { shouldCollapseUserMessage } from "./MessagesTimeline.logic"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { @@ -88,6 +89,68 @@ const MEDIA_EDGE_CLASS_NAME = const BUBBLE_CHIP_CLASS_NAME = "[&_[data-slot=context-chip]]:[--context-chip-ink:var(--context-chip-message,var(--context-chip-accent))]"; +function UserFileCard({ + file, + copyMarkdown, +}: { + readonly file: ChatFileAttachment; + readonly copyMarkdown: string | undefined; +}) { + const ctx = use(TimelineRowCtx); + const extension = /\.([a-z0-9]{1,8})$/i.exec(file.name)?.[1]?.toUpperCase() ?? "File"; + const identity = ( + <> + + + + + + {file.name} + + + {extension} · {formatBytes(file.sizeBytes)} + + + + ); + return ( +
+ {file.downloadable === false ? ( +
{identity}
+ ) : ( + <> + + + ctx.onFileDownload(file)} + /> + } + > + + + Download {file.name} + + + )} +
+ ); +} + function UserVideoAttachment({ file, standalone = false, @@ -358,11 +421,22 @@ function UserTimelineRow({ row }: { row: Extract image.name.startsWith("preview-annotation-")); const revertTurnCount = row.revertTurnCount; - const chippedAttachmentIds = new Set( - collectComposerContextReferences(resolvedContext.text).flatMap((occurrence) => { + const attachmentReferences = collectComposerContextReferences(resolvedContext.text).flatMap( + (occurrence) => { const record = asKnownContextRecord(resolvedContext.recordsById.get(occurrence.contextId)); - return record?.kind === "file" || record?.kind === "image" ? [record.attachmentId] : []; - }), + return record?.kind === "file" || record?.kind === "image" + ? [ + { + attachmentId: record.attachmentId, + contextId: occurrence.contextId, + source: occurrence.source, + }, + ] + : []; + }, + ); + const chippedAttachmentIds = new Set( + attachmentReferences.map((reference) => reference.attachmentId), ); const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); const unchippedFiles = otherUserFiles.filter((file) => !chippedAttachmentIds.has(file.id)); @@ -394,6 +468,21 @@ function UserTimelineRow({ row }: { row: Extract 1 && regularImages.every((image) => image.source?.kind !== "snap-shot"); + // Files sent without words show as cards, not as a bubble around a name chip. + const fileCardsOnly = + !folded && + userMessage.attribution !== "guest" && + otherUserFiles.length > 0 && + regularImages.length + userVideos.length + userAudio.length + unknownAttachments.length === 0 && + replaceComposerContextReferences(resolvedContext.text, (reference) => + attachmentReferences.some( + (attached) => + attached.contextId === reference.contextId && + otherUserFiles.some((file) => file.id === attached.attachmentId), + ) + ? "" + : reference.label, + ).trim() === ""; const annotationRecordIds = resolvedContext.records .filter((record) => record.kind === "preview-annotation") .map((record) => record.contextId); @@ -635,8 +724,8 @@ function UserTimelineRow({ row }: { row: Extract @@ -689,7 +778,21 @@ function UserTimelineRow({ row }: { row: Extract ) : null} - {unchippedFiles.length > 0 || unknownAttachments.length > 0 ? ( + {fileCardsOnly ? ( +
+ {otherUserFiles.map((file) => ( + reference.attachmentId === file.id) + ?.source + } + /> + ))} +
+ ) : null} + {!fileCardsOnly && (unchippedFiles.length > 0 || unknownAttachments.length > 0) ? (
{unchippedFiles.map((file) => { const fileIdentity = ( @@ -751,15 +854,17 @@ function UserTimelineRow({ row }: { row: Extract ) : null} -
- -
+ {fileCardsOnly ? null : ( +
+ +
+ )}
) : null}
From e57ae5a0bf38a4a38018df57d250fce323702401 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 30 Oct 2026 16:39:52 +0000 Subject: [PATCH 24/31] feat(web): pasted text, voice notes, snapshots and sending files get their own shapes (#27377) --- apps/web/src/components/ContextChip.tsx | 9 +- .../chat/MessagesTimeline.browser.test.tsx | 75 +++++++++++ .../chat/TimelineUserMessageRow.logic.ts | 6 +- .../chat/TimelineUserMessageRow.tsx | 121 +++++++++++++----- .../chat/UserMessageContextChips.tsx | 11 +- .../web/src/components/files/AudioPreview.tsx | 4 +- .../components/files/InlineAudioPreview.tsx | 2 + 7 files changed, 189 insertions(+), 39 deletions(-) diff --git a/apps/web/src/components/ContextChip.tsx b/apps/web/src/components/ContextChip.tsx index fcd4c9e4fed30..a536f28ccfb12 100644 --- a/apps/web/src/components/ContextChip.tsx +++ b/apps/web/src/components/ContextChip.tsx @@ -35,6 +35,10 @@ const contextChipVariants = cva( "doc-slides": "[--context-chip-accent:var(--context-chip-doc-slides)]", "doc-epub": "[--context-chip-accent:var(--context-chip-doc-epub)]", }, + shape: { + inline: "", + bubble: "w-64 gap-2 rounded-2xl bg-message px-3 py-2 text-sm text-message-foreground", + }, state: { unresolved: "data-[state=unresolved]:[--context-chip-accent:currentColor]", invalid: @@ -49,11 +53,12 @@ type ContextChipKind = NonNullable["kin interface ContextChipProps extends useRender.ComponentProps<"span"> { kind?: ContextChipKind; state?: "unresolved" | "invalid"; + shape?: "inline" | "bubble"; } -function ContextChip({ className, kind, state, render, ...props }: ContextChipProps) { +function ContextChip({ className, kind, state, shape, render, ...props }: ContextChipProps) { const defaultProps = { - className: cn(contextChipVariants({ kind, state }), className), + className: cn(contextChipVariants({ kind, state, shape }), className), "data-slot": "context-chip", "data-state": state, }; diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 2c7aa332756cb..24e15957192b1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -3,6 +3,7 @@ import { onTestFinished } from "vite-plus/test"; import { page } from "vite-plus/test/browser"; import { stubGlobal } from "../../../test/render"; import { + ComposerContextId, EnvironmentId, MessageId, RunAttemptId, @@ -32,6 +33,11 @@ import { toastManager } from "../ui/toast"; import type { CitedHistory } from "./citationProcedure"; import type { TimelineNavigation } from "./timelineViewport"; import { rememberTimelinePosition } from "./timelineScrollAnchoring"; +import { + COMPOSER_CONTEXT_CLIPBOARD_MIME, + decodeComposerContextFragment, +} from "@t3tools/shared/composerContextClipboard"; +import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; const environmentQuery = vi.hoisted(() => ({ error: "Pull request not found", @@ -642,6 +648,75 @@ describe("MessagesTimeline agent messages", () => { }); describe("MessagesTimeline attachments", () => { + it.each(["image", "file"] as const)( + "copies only matching context records for a wordless %s attachment", + async (kind) => { + const attachment = { + type: kind, + id: "media", + name: kind === "image" ? "photo.png" : "clip.mp4", + mimeType: kind === "image" ? "image/png" : "video/mp4", + sizeBytes: 100, + downloadable: false, + }; + const record = { + version: 1 as const, + ...(kind === "image" ? { kind: "image" as const } : { kind: "file" as const }), + contextId: ComposerContextId.make("media-context"), + label: attachment.name, + attachmentId: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }; + await using screen = await renderTimeline([message("", { attachments: [attachment] })]); + expect(screen.buttons("Copy message")).toHaveLength(0); + await screen.rerender( + , + ); + expect(screen.buttons("Copy message")).toHaveLength(0); + await screen.rerender( + , + ); + using write = vi.spyOn(navigator.clipboard, "write").mockResolvedValue(); + await click(screen.button("Copy message")); + await vi.waitFor(() => expect(write).toHaveBeenCalledOnce()); + const [item] = write.mock.calls[0]![0]; + const text = await (await item!.getType("text/plain")).text(); + const fragment = await (await item!.getType(COMPOSER_CONTEXT_CLIPBOARD_MIME)).text(); + expect( + collectComposerContextReferences(text).map((reference) => reference.contextId), + ).toEqual([record.contextId]); + expect(decodeComposerContextFragment(fragment)?.records).toEqual([record]); + }, + ); + it.each(["loadedmetadata", "loadeddata", "canplay"])( "plays audio with unknown duration after %s and updates the total later", async (readyEvent) => { diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.logic.ts b/apps/web/src/components/chat/TimelineUserMessageRow.logic.ts index 9738e29d7ccaa..2bb6d0470fa18 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.logic.ts +++ b/apps/web/src/components/chat/TimelineUserMessageRow.logic.ts @@ -1,7 +1,11 @@ -import { type ChatImageAttachment } from "../../types"; +import { type ChatFileAttachment, type ChatImageAttachment } from "../../types"; import type { ComposerContextRecord, KnownComposerContextRecord } from "@t3tools/contracts"; import { asKnownContextRecord } from "~/lib/composerContextRecords"; +export function userMessageFileLabel(file: Pick) { + return file.source?._tag === "pasted-text" ? "Pasted text" : file.name; +} + export function resolvePreviewAnnotationImage(input: { record: Extract; recordsById: ReadonlyMap; diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index aee30e27d51ec..48086d40b4bf3 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -62,7 +62,10 @@ import { resolveUserMessageContext, selectedMessageContextFragment, } from "~/lib/composerContextRecords"; -import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { + collectComposerContextReferences, + formatComposerContextReference, +} from "@t3tools/shared/composerContextReferences"; import { COMPOSER_CONTEXT_CLIPBOARD_MIME, encodeComposerContextClipboardHtml, @@ -80,7 +83,10 @@ import { TimelineRowTimestamp, } from "./TimelineRowParts"; import { UserMessageContextReferenceChip } from "./UserMessageContextChips"; -import { resolvePreviewAnnotationImage } from "./TimelineUserMessageRow.logic"; +import { + resolvePreviewAnnotationImage, + userMessageFileLabel, +} from "./TimelineUserMessageRow.logic"; // Drawn inside the media so dark screenshots keep an edge on the dark background. const MEDIA_EDGE_CLASS_NAME = @@ -97,6 +103,8 @@ function UserFileCard({ readonly copyMarkdown: string | undefined; }) { const ctx = use(TimelineRowCtx); + const pastedText = file.source?._tag === "pasted-text"; + const label = userMessageFileLabel(file); const extension = /\.([a-z0-9]{1,8})$/i.exec(file.name)?.[1]?.toUpperCase() ?? "File"; const identity = ( <> @@ -105,10 +113,17 @@ function UserFileCard({ - {file.name} + {label} - {extension} · {formatBytes(file.sizeBytes)} + {file.downloadable === false ? ( + "Sending…" + ) : ( + <> + {!pastedText && `${extension} · `} + {formatBytes(file.sizeBytes)} + + )} @@ -119,12 +134,17 @@ function UserFileCard({ className="flex w-64 max-w-full items-center gap-1 rounded-2xl bg-message py-2 ps-2 pe-1 text-message-foreground" > {file.downloadable === false ? ( -
{identity}
+ <> +
{identity}
+ + ) : ( <> @@ -724,8 +773,8 @@ function UserTimelineRow({ row }: { row: Extract @@ -760,11 +809,12 @@ function UserTimelineRow({ row }: { row: Extract {!mediaAboveText && mediaBlock} {userAudio.length > 0 ? ( -
+
{userAudio.map((file) => ( 0 || unknownAttachments.length > 0) ? (
{unchippedFiles.map((file) => { + const pastedText = file.source?._tag === "pasted-text"; + const label = userMessageFileLabel(file); const fileIdentity = ( <> - {file.name} + + {label} + {file.downloadable === false || pastedText ? ( + + {file.downloadable === false ? "Sending…" : formatBytes(file.sizeBytes)} + + ) : null} + ); if (file.downloadable !== false) { @@ -810,7 +869,7 @@ function UserTimelineRow({ row }: { row: Extract
); @@ -854,7 +913,7 @@ function UserTimelineRow({ row }: { row: Extract ) : null} - {fileCardsOnly ? null : ( + {fileCardsOnly || audioOnly ? null : (
)} - {resolvedContext.text && ( + {copyText && ( ; @@ -372,12 +373,14 @@ const userMessageContextPresentationRegistry = createContextPresentationRegistry } const attachment = context.attachment; const isVideo = isVideoAttachment(attachment); + const label = userMessageFileLabel(attachment); const disabled = attachment.downloadable === false && (!isVideo || attachment.previewUrl === undefined); - const size = formatAttachmentSize(record.sizeBytes); + const size = + attachment.downloadable === false ? "Sending…" : formatAttachmentSize(record.sizeBytes); return ( isVideo ? context.onExpandVideo(attachment) : context.onOpenFile(attachment) } - tooltip={`${record.name}\n${size}`} + tooltip={`${label}\n${size}`} /> ); }, diff --git a/apps/web/src/components/files/AudioPreview.tsx b/apps/web/src/components/files/AudioPreview.tsx index 0a8f4bcfe16c9..abd05015a9414 100644 --- a/apps/web/src/components/files/AudioPreview.tsx +++ b/apps/web/src/components/files/AudioPreview.tsx @@ -15,6 +15,7 @@ type AudioPreviewProps = { readonly revision?: string | null; readonly onError?: () => void; readonly compact?: boolean; + readonly variant?: "chip" | "bubble"; readonly failed?: boolean; readonly onOpen?: (() => void) | undefined; readonly onRetry?: () => void; @@ -111,6 +112,7 @@ function AudioChip(props: AudioPreviewProps) { @@ -140,7 +142,7 @@ function AudioChip(props: AudioPreviewProps) { > - + {open ? (