From ab3b55e29ac20a6212f09e38f1983b856fd77695 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:55:00 -0400 Subject: [PATCH 001/326] fix(web): clarify auto permission fallback (#5431) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> --- apps/web/src/components/chat/ChatComposer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 201fb79c5669..f6d34315daca 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -245,7 +245,7 @@ const runtimeModeConfig: Record< }, auto: { label: "Auto", - description: "An AI reviewer approves routine actions; risky ones still ask.", + description: "Supported providers approve routine actions; others still ask.", icon: SparklesIcon, }, "full-access": { From 99d91ddaa4996203b675cbd086d5acaa642d4827 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:55:31 -0400 Subject: [PATCH 002/326] fix(acp): keep unknown approvals actionable (#5430) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> --- .../provider/acp/AcpCoreRuntimeEvents.test.ts | 40 +++++++++++++++++++ .../src/provider/acp/AcpCoreRuntimeEvents.ts | 4 +- apps/web/src/session-logic.test.ts | 26 ++++++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts index 7fe25699bbc3..394ada83f763 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts @@ -68,6 +68,46 @@ describe("AcpCoreRuntimeEvents", () => { }); }); + it("maps generic ACP permission kinds to dynamic tool approvals", () => { + const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; + + for (const kind of ["search", "fetch", "other", "unknown", "future-tool-kind"]) { + const permissionRequest = { kind }; + const request = { + stamp, + provider: ProviderDriverKind.make("cursor"), + threadId: "thread-1" as never, + turnId: TurnId.make("turn-1"), + requestId: RuntimeRequestId.make(`request-${kind}`), + permissionRequest, + }; + + expect( + makeAcpRequestOpenedEvent({ + ...request, + detail: kind, + args: {}, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: { sessionId: "session-1" }, + }), + ).toMatchObject({ + type: "request.opened", + payload: { requestType: "dynamic_tool_call" }, + }); + + expect( + makeAcpRequestResolvedEvent({ + ...request, + decision: "accept", + }), + ).toMatchObject({ + type: "request.resolved", + payload: { requestType: "dynamic_tool_call" }, + }); + } + }); + it("maps ACP core plan, tool-call, and content updates", () => { const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" }; const turnId = TurnId.make("turn-1"); diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts index c93e61dc37b6..bd25e9815aef 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -26,7 +26,7 @@ interface AcpEventStamp { type AcpCanonicalRequestType = Extract< CanonicalRequestType, - "exec_command_approval" | "file_read_approval" | "file_change_approval" | "unknown" + "exec_command_approval" | "file_read_approval" | "file_change_approval" | "dynamic_tool_call" >; function canonicalRequestTypeFromAcpKind(kind: string | "unknown"): AcpCanonicalRequestType { @@ -40,7 +40,7 @@ function canonicalRequestTypeFromAcpKind(kind: string | "unknown"): AcpCanonical case "move": return "file_change_approval"; default: - return "unknown"; + return "dynamic_tool_call"; } } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index cdb2119ac8d6..3732afbd3387 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -130,6 +130,32 @@ describe("derivePendingApprovals", () => { ]); }); + it("derives dynamic tool requests as actionable generic approvals", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open-dynamic-tool", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "approval.requested", + summary: "Approval requested", + tone: "approval", + payload: { + requestId: "req-dynamic-tool", + requestType: "dynamic_tool_call", + detail: "Search the web", + }, + }), + ]; + + expect(derivePendingApprovals(activities)).toEqual([ + { + requestId: "req-dynamic-tool", + requestKind: "command", + createdAt: "2026-02-23T00:00:01.000Z", + detail: "Search the web", + }, + ]); + }); + it("clears stale pending approvals when provider reports unknown pending request", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ From 470d4eb993eccd7d33cc9ad6f282c684d6760ac8 Mon Sep 17 00:00:00 2001 From: Carlos Rico-Ospina Date: Thu, 6 Aug 2026 12:56:01 -0400 Subject: [PATCH 003/326] fix(mobile): stop thread messages reading through pending cards (#5450) Co-authored-by: Claude Opus 5 --- apps/mobile/src/features/threads/PendingApprovalCard.tsx | 4 +++- apps/mobile/src/features/threads/PendingUserInputCard.tsx | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index caf9bd4cd0cc..377ae82aba8b 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -14,8 +14,10 @@ export interface PendingApprovalCardProps { } export function PendingApprovalCard(props: PendingApprovalCardProps) { + // Opaque for the same reason as PendingUserInputCard: nothing blurs the feed + // behind this card, so a translucent surface bleeds messages through it. return ( - + Approval needed diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 4d2c2c84159b..c3c9b4e7ce83 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -24,8 +24,11 @@ export interface PendingUserInputCardProps { } export function PendingUserInputCard(props: PendingUserInputCardProps) { + // The surface is opaque on purpose: the card floats over the thread feed + // with no blur behind it, so a translucent background renders the questions + // on top of whatever message happens to sit underneath. return ( - + User input needed From aa16c180e5850ab94c022d3e41e18350c2117deb Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:57:02 +0200 Subject: [PATCH 004/326] fix(web): align composer inline chips with prompt text (#5495) --- apps/web/src/components/ComposerPromptEditor.tsx | 10 +++++----- apps/web/src/components/composerInlineChip.ts | 4 ++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index f64bdedaa59c..67b82388bbcb 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -72,8 +72,8 @@ import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; import { COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, - COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, + COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME, SKILL_CHIP_ICON_SVG, } from "./composerInlineChip"; import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; @@ -188,7 +188,7 @@ class ComposerMentionNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-[-0.125em] leading-none"; return dom; } @@ -256,7 +256,7 @@ function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: s className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME} dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }} /> - {props.skillLabel} + {props.skillLabel} ); @@ -326,7 +326,7 @@ class ComposerSkillNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-[-0.125em] leading-none"; return dom; } @@ -397,7 +397,7 @@ class ComposerTerminalContextNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-[-0.125em] leading-none"; return dom; } diff --git a/apps/web/src/components/composerInlineChip.ts b/apps/web/src/components/composerInlineChip.ts index f08f9285da94..c17b3ddab3c0 100644 --- a/apps/web/src/components/composerInlineChip.ts +++ b/apps/web/src/components/composerInlineChip.ts @@ -14,6 +14,10 @@ export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight"; export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`; +// The skill label is smaller than the surrounding prompt text; offset its +// glyphs without moving the pill box or changing the editor's line height. +export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = `${COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME} relative top-[0.15em]`; + export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = "inline-flex max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] py-[0.08em] font-medium text-[0.86em] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; From 4f5834ba72c5905a318c00456dd21271b2fa9d6f Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:08:35 +0200 Subject: [PATCH 005/326] fix(web): clear woke state on explicit thread actions (#5486) --- apps/web/src/components/ChatView.tsx | 44 ++++----- apps/web/src/components/SidebarV2.tsx | 131 +++++++++++++++++-------- apps/web/src/hooks/useThreadActions.ts | 22 ++++- 3 files changed, 129 insertions(+), 68 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c260c9e91184..7b59530c9559 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,7 +26,11 @@ import { connectionStatusTitle, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; -import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { + effectiveSettled, + effectiveSnoozed, + threadWokeAt, +} from "@t3tools/client-runtime/state/thread-settled"; import { parseScopedThreadKey, scopedThreadKey, @@ -1231,9 +1235,6 @@ function ChatViewContent(props: ChatViewProps) { ); const activeServerThread = serverThread ?? loadingServerThread; const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const activeThreadLastVisitedAt = useUiStateStore( - (store) => store.threadLastVisitedAtById[routeThreadKey], - ); const settings = useEnvironmentSettings(environmentId); // New-thread defaults live in the primary environment's settings.json (the // settings UI never writes to remote environments), so read them from the @@ -1850,25 +1851,6 @@ function ChatViewContent(props: ChatViewProps) { [openOrReuseProjectDraftThread], ); - useEffect(() => { - if (!serverThread?.id) return; - const threadUpdatedAt = Date.parse(serverThread.updatedAt); - if (Number.isNaN(threadUpdatedAt)) return; - const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; - if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= threadUpdatedAt) return; - - markThreadVisited( - scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id)), - serverThread.updatedAt, - ); - }, [ - activeThreadLastVisitedAt, - markThreadVisited, - serverThread?.environmentId, - serverThread?.id, - serverThread?.updatedAt, - ]); - const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? @@ -4006,13 +3988,18 @@ function ChatViewContent(props: ChatViewProps) { const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; const nowMinute = useNowMinute(); + const snoozeNow = new Date().toISOString(); const activeThreadSnoozed = activeThreadShell !== null && supportsSnooze && - effectiveSnoozed(activeThreadShell, { now: new Date().toISOString() }); + effectiveSnoozed(activeThreadShell, { now: snoozeNow }); const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); + void snoozeWakeTick; + const activeThreadWokeAt = + activeThreadShell !== null && supportsSnooze + ? threadWokeAt(activeThreadShell, { now: snoozeNow }) + : null; useEffect(() => { - void snoozeWakeTick; if (!activeThreadSnoozed) return; const wakeAtMs = Date.parse(activeThreadShell?.snoozedUntil ?? ""); if (!Number.isFinite(wakeAtMs)) return; @@ -4022,6 +4009,10 @@ function ChatViewContent(props: ChatViewProps) { ); return () => window.clearTimeout(id); }, [activeThreadShell?.snoozedUntil, activeThreadSnoozed, snoozeWakeTick]); + const acknowledgeActiveThreadWoke = useCallback(() => { + if (activeThreadRef === null || activeThreadWokeAt === null) return; + markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); + }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); const activeThreadSettled = useMemo(() => { if (activeThreadShell === null || !supportsSettlement) return false; return effectiveSettled(activeThreadShell, { @@ -5063,6 +5054,7 @@ function ChatViewContent(props: ChatViewProps) { failure = startResult; } else { turnStartSucceeded = true; + acknowledgeActiveThreadWoke(); } } @@ -5422,6 +5414,7 @@ function ChatViewContent(props: ChatViewProps) { } if (failure === null) { + acknowledgeActiveThreadWoke(); // Optimistically open the plan sidebar when implementing (not refining). // "default" mode here means the agent is executing the plan, which produces // step-tracking activities that the sidebar will display. @@ -5451,6 +5444,7 @@ function ChatViewContent(props: ChatViewProps) { [ activeThread, activeProposedPlan, + acknowledgeActiveThreadWoke, beginLocalDispatch, isConnecting, isSendBusy, diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 1444d72e60c6..462bfde13b72 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -431,6 +431,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onUnsettle: (threadRef: ScopedThreadRef) => void; onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; + onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; }) { const { @@ -439,6 +440,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onCancelRename, onCommitRename, onContextMenu, + onAcknowledgeWoke, onRenameTitleChange, onSettle, onSnooze, @@ -542,6 +544,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { className: "text-emerald-700 dark:text-emerald-300", } : null; + const isWokeStatus = topStatus?.icon === "woke"; const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( @@ -605,6 +608,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { }, [onThreadClick, threadRef], ); + const handleAcknowledgeWokeClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (props.wokeAt === null) return; + onAcknowledgeWoke(threadRef, props.wokeAt); + }, + [onAcknowledgeWoke, props.wokeAt, threadRef], + ); const handleContextMenu = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); @@ -853,7 +865,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { the time/jump label yields to the settle affordance. */} {prBadge} - + {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( // Snoozed rows show when they come BACK, not when they were // last touched — the return ticket is the row's whole story. @@ -863,14 +880,16 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : isWoke ? ( // A wake can land straight in the settled tail (e.g. PR // merged while snoozed); the signal must survive the trip. - - Woke - + Woke + ) : ( {variantAction === "unsettle" @@ -885,7 +904,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Wake thread now" onClick={handleUnsnoozeClick} - className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" + className={cn( + "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100", + isWoke && "group-hover/v2-row:static", + )} > @@ -895,7 +917,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Un-settle thread" onClick={handleUnsettleClick} - className="absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" + className={cn( + "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100", + isWoke && "group-hover/v2-row:static", + )} > @@ -904,7 +929,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} - className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" + className={cn( + "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:opacity-100", + isWoke && "group-hover/v2-row:static", + )} > @@ -972,39 +1000,56 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { the hidden state out of flow lets the project label reclaim space without either state overlapping it. */} - {/* pointer-events-none: while hovered this label is absolute - + opacity-0, which paints it ABOVE the in-flow settle/snooze - buttons; without it the invisible label eats their clicks. */} + {/* Read-only status labels yield to the hover actions. Woke is + itself an action, so it stays pointer-enabled and visible + while the other controls appear beside it. */} {topStatus ? ( - - {topStatus.icon === "working" ? ( - - ) : topStatus.icon === "done" ? ( - - ) : topStatus.icon === "woke" ? ( + isWokeStatus ? ( + + ) : ( + + {topStatus.icon === "working" ? ( + + ) : topStatus.icon === "done" ? ( + + ) : null} + {/* The label alone is the live region: a role="status" + wrapper around the ticking duration would make + screen readers announce every second. */} + {topStatus.label} + {status === "working" ? ( + + + + ) : null} + + ) ) : ( threadTimeLabel(thread) )} @@ -1017,8 +1062,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // would keep the controls pinned over the status label // once the pointer moves away (e.g. after a failed // settle) instead of cross-fading back. - "absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:static group-hover/v2-row:opacity-100", - snoozeMenuOpen && "static opacity-100", + "pointer-events-none absolute inset-y-0 right-0 flex items-stretch opacity-0 transition-opacity has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:static has-[:focus-visible]:opacity-100 group-hover/v2-row:pointer-events-auto group-hover/v2-row:static group-hover/v2-row:opacity-100", + snoozeMenuOpen && "pointer-events-auto static opacity-100", )} > {showSnoozeButton ? ( @@ -1288,6 +1333,13 @@ export default function SidebarV2() { const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); const markThreadUnread = useUiStateStore((s) => s.markThreadUnread); + const markThreadVisited = useUiStateStore((s) => s.markThreadVisited); + const acknowledgeWoke = useCallback( + (threadRef: ScopedThreadRef, visitedAt: string) => { + markThreadVisited(scopedThreadKey(threadRef), visitedAt); + }, + [markThreadVisited], + ); const routeTarget = useParams({ strict: false, select: (params) => resolveThreadRouteTarget(params), @@ -3022,6 +3074,7 @@ export default function SidebarV2() { onUnsettle={attemptUnsettle} onSnooze={attemptSnooze} onUnsnooze={attemptUnsnooze} + onAcknowledgeWoke={acknowledgeWoke} onChangeRequestState={handleChangeRequestState} /> ); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 96ec645551aa..e47fce1d3bc9 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -2,9 +2,10 @@ import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef, + scopedThreadKey, } from "@t3tools/client-runtime/environment"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { canSettle, canSnooze } from "@t3tools/client-runtime/state/thread-settled"; +import { canSettle, canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; @@ -29,6 +30,7 @@ import { readThreadShell, } from "../state/entities"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; +import { useUiStateStore } from "../uiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; @@ -150,6 +152,7 @@ export function useThreadActions() { (store) => store.clearProjectDraftThreadById, ); const clearTerminalUiState = useTerminalUiStateStore((state) => state.clearTerminalUiState); + const markThreadVisited = useUiStateStore((state) => state.markThreadVisited); const router = useRouter(); const handleNewThread = useNewThreadHandler(); // Keep a ref so archiveThread can call handleNewThread without appearing in @@ -201,6 +204,10 @@ export function useThreadActions() { if (archiveResult._tag === "Failure") { return archiveResult; } + const wokeAt = threadWokeAt(thread, { now: new Date().toISOString() }); + if (wokeAt !== null) { + markThreadVisited(scopedThreadKey(threadRef), wokeAt); + } refreshArchivedThreadsForEnvironment(threadRef.environmentId); opts.onArchived?.(); @@ -216,7 +223,7 @@ export function useThreadActions() { return archiveResult; }, - [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget], + [archiveThreadMutation, getCurrentRouteThreadRef, markThreadVisited, resolveThreadTarget], ); const unarchiveThread = useCallback( @@ -459,14 +466,21 @@ export function useThreadActions() { ), ); } + const wokeAt = resolved + ? threadWokeAt(resolved.thread, { now: new Date().toISOString() }) + : null; // Settle is a high-frequency lifecycle action and stays silent — no // toast. - return settleThreadMutation({ + const result = await settleThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, }); + if (result._tag === "Success" && wokeAt !== null) { + markThreadVisited(scopedThreadKey(target), wokeAt); + } + return result; }, - [resolveThreadTarget, settleThreadMutation], + [markThreadVisited, resolveThreadTarget, settleThreadMutation], ); const unsettleThread = useCallback( From 331c6dce7f6745863752b1b423fc76d24014deec Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 6 Aug 2026 20:13:10 -0400 Subject: [PATCH 006/326] fix(server): skip origin fetch when creating worktrees in repos without an origin remote (#5556) Co-authored-by: Claude Fable 5 --- apps/server/src/git/GitWorkflowService.ts | 8 ++ apps/server/src/server.test.ts | 113 ++++++++++++++++++++++ apps/server/src/vcs/GitVcsDriver.ts | 6 ++ apps/server/src/vcs/GitVcsDriverCore.ts | 8 +- apps/server/src/ws.ts | 11 ++- 5 files changed, 143 insertions(+), 3 deletions(-) diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 100b9beadbad..da22794951fb 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -69,6 +69,10 @@ export class GitWorkflowService extends Context.Service< readonly cwd: string; readonly remoteName: string; }) => Effect.Effect; + readonly remoteExists: (input: { + readonly cwd: string; + readonly remoteName: string; + }) => Effect.Effect; readonly resolveRemoteTrackingCommit: (input: { readonly cwd: string; readonly refName: string; @@ -303,6 +307,10 @@ export const make = Effect.gen(function* () { ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe( Effect.andThen(git.fetchRemote(input)), ), + remoteExists: (input) => + ensureGitCommand("GitWorkflowService.remoteExists", input.cwd).pipe( + Effect.andThen(git.remoteExists(input)), + ), resolveRemoteTrackingCommit: (input) => ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe( Effect.andThen(git.resolveRemoteTrackingCommit(input)), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 8628aeef314d..a403e228b060 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -7134,6 +7134,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { pr: null, }), ); + const remoteExists = vi.fn( + (_: Parameters[0]) => + Effect.sync(() => { + bootstrapGitOperations.push("remote-exists"); + return true; + }), + ); const fetchRemote = vi.fn( (_: Parameters[0]) => Effect.sync(() => { @@ -7181,6 +7188,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { gitVcsDriver: { + remoteExists, fetchRemote, resolveRemoteTrackingCommit, createWorktree, @@ -7271,6 +7279,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { fallbackRemoteName: "origin", }); assert.deepEqual(bootstrapGitOperations, [ + "remote-exists", "fetch", "resolve-remote-commit", "create-worktree", @@ -7299,6 +7308,110 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "falls back to the local base branch when startFromOrigin is set but no origin remote exists", + () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const remoteExists = vi.fn( + (_: Parameters[0]) => + Effect.succeed(false), + ); + const fetchRemote = vi.fn( + (_: Parameters[0]) => Effect.void, + ); + const resolveRemoteTrackingCommit = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + commitSha: "0123456789abcdef0123456789abcdef01234567", + remoteRefName: "origin/main", + }), + ); + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + remoteExists, + fetchRemote, + resolveRemoteTrackingCommit, + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-turn-start-no-origin"), + threadId: ThreadId.make("thread-bootstrap-no-origin"), + message: { + messageId: MessageId.make("msg-bootstrap-no-origin"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + startFromOrigin: true, + }, + }, + createdAt, + }), + ), + ); + + assert.deepEqual(remoteExists.mock.calls[0]?.[0], { + cwd: "/tmp/project", + remoteName: "origin", + }); + assert.equal(fetchRemote.mock.calls.length, 0); + assert.equal(resolveRemoteTrackingCommit.mock.calls.length, 0); + assert.deepEqual(createWorktree.mock.calls[0]?.[0], { + cwd: "/tmp/project", + refName: "main", + newRefName: "t3code/bootstrap-refName", + baseRefName: "main", + path: null, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 192efe5a7d00..f256a7dd4e13 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -168,6 +168,11 @@ export interface GitFetchRemoteInput { remoteName: string; } +export interface GitRemoteExistsInput { + cwd: string; + remoteName: string; +} + export interface GitResolveRemoteTrackingCommitInput { cwd: string; refName: string; @@ -243,6 +248,7 @@ export class GitVcsDriver extends Context.Service< readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; + readonly remoteExists: (input: GitRemoteExistsInput) => Effect.Effect; readonly resolveRemoteTrackingCommit: ( input: GitResolveRemoteTrackingCommitInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index abcb10a8c9ab..d39817c0ee1d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1284,11 +1284,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ).pipe(Effect.map((result) => result.exitCode === 0)); - const originRemoteExists = (cwd: string): Effect.Effect => - executeGit("GitVcsDriver.originRemoteExists", cwd, ["remote", "get-url", "origin"], { + const remoteExists: GitVcsDriver.GitVcsDriver["Service"]["remoteExists"] = (input) => + executeGit("GitVcsDriver.remoteExists", input.cwd, ["remote", "get-url", input.remoteName], { allowNonZeroExit: true, }).pipe(Effect.map((result) => result.exitCode === 0)); + const originRemoteExists = (cwd: string): Effect.Effect => + remoteExists({ cwd, remoteName: "origin" }); + const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( Effect.map(parseRemoteNamesInGitOrder), @@ -3071,6 +3074,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), resolvePrimaryRemoteName, fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), + remoteExists, resolveRemoteTrackingCommit, fetchRemoteBranch: (input) => withListRefsInvalidation(input.cwd, fetchRemoteBranch(input)), fetchRemoteTrackingBranch: (input) => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fc65602679a9..a04fce3fd2c7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -908,7 +908,16 @@ const makeWsRpcLayer = ( if (bootstrap?.prepareWorktree) { let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; - if (bootstrap.prepareWorktree.startFromOrigin) { + // "Start from origin" is a stored default; repos without an + // origin remote fall back to the local base branch instead of + // failing the whole bootstrap on `git fetch origin`. + const startFromOrigin = + bootstrap.prepareWorktree.startFromOrigin === true && + (yield* gitWorkflow.remoteExists({ + cwd: bootstrap.prepareWorktree.projectCwd, + remoteName: "origin", + })); + if (startFromOrigin) { yield* gitWorkflow.fetchRemote({ cwd: bootstrap.prepareWorktree.projectCwd, remoteName: "origin", From ea50b695a749d6a0d44ef96b479b6dfceb8881e3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 6 Aug 2026 20:31:12 -0400 Subject: [PATCH 007/326] fix(web): update tooltip no longer dismisses when scrolling release notes (#5547) Co-authored-by: Claude Fable 5 --- apps/web/src/components/sidebar/SidebarUpdatePill.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 06a0e714a6ea..89120850f202 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -44,7 +44,7 @@ function SidebarUpdateReleaseNotesTooltip({
{tooltip}
-
+
{state.releaseNotes.map((releaseNote, index) => (
{index > 0 && } @@ -203,7 +203,9 @@ export function SidebarUpdatePill() { align="start" className={ state?.channel === "nightly" && state.releaseNotes.length > 0 - ? "max-w-none text-balance" + ? // pointer-events-auto overrides the positioner's pointer-events-none so the + // release notes stay open (and scrollable) when the cursor moves into them. + "pointer-events-auto max-w-none text-balance" : undefined } side="top" From 0ec4fbc4a376cbf6465e59b05506ce9d89b3d078 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 6 Aug 2026 20:52:29 -0400 Subject: [PATCH 008/326] fix(server): stop showing commit/push/PR notices as errors in the work log (#5559) Co-authored-by: Claude Fable 5 --- .../src/provider/Layers/ClaudeAdapter.test.ts | 18 ++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 12 +++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 8697505ef246..24b8429a391a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2021,6 +2021,24 @@ describe("ClaudeAdapterLive", () => { session_id: "session", uuid: "roster", }, + { + type: "system", + subtype: "vcs_state_changed", + kind: "push", + cwd: "/tmp/worktree", + session_id: "session", + uuid: "vcs", + }, + { + type: "system", + subtype: "code_change_published", + provider: "github", + url: "https://github.com/pingdotgg/t3code/pull/1", + repo: "pingdotgg/t3code", + identifier: "1", + session_id: "session", + uuid: "ccp", + }, { type: "system", subtype: "task_updated", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 812a73109289..27acedc383a6 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -3034,9 +3034,15 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // error rows in client work logs. `background_tasks_changed` is a roster // snapshot ({tasks: [...]}) — the task_* lifecycle events carry the // authoritative per-agent data and the typed background_tasks control - // request is the reconciliation source. - if ((message.subtype as string) === "background_tasks_changed") { - return; + // request is the reconciliation source. `vcs_state_changed` + // ({kind: commit|push|rebase}) and `code_change_published` + // ({provider, url, repo}) are informational CLI notices; the work log + // already shows the underlying git/gh tool calls. + switch (message.subtype as string) { + case "background_tasks_changed": + case "vcs_state_changed": + case "code_change_published": + return; } switch (message.subtype) { From 64a991ad455234e6fdd808a5a3caadc51f18a152 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 6 Aug 2026 20:55:00 -0400 Subject: [PATCH 009/326] fix(web): show remote environment for non-Git projects (#5555) --- .../components/BranchToolbar.logic.test.ts | 33 +++++++++++ .../web/src/components/BranchToolbar.logic.ts | 8 +++ apps/web/src/components/BranchToolbar.tsx | 56 +++++++++++-------- apps/web/src/components/ChatView.tsx | 22 +++++++- 4 files changed, 93 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 76336f1ef1f2..36d42a60fa81 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -17,6 +17,7 @@ import { resolvePreviousWorktreeLabel, resolvePreviousWorktreeSeed, shouldIncludeBranchPickerItem, + shouldShowComposerContextStrip, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; @@ -421,6 +422,38 @@ describe("shouldShowEnvironmentIndicator", () => { }); }); +describe("shouldShowComposerContextStrip", () => { + it("keeps the environment indicator visible for a non-Git project", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: false, + showEnvironmentIndicator: true, + }), + ).toBe(true); + }); + + it("hides the strip when a non-Git project has no environment indicator", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: false, + showEnvironmentIndicator: false, + }), + ).toBe(false); + }); + + it("shows Git controls without requiring an environment indicator", () => { + expect( + shouldShowComposerContextStrip({ + hasActiveProject: true, + isGitRepo: true, + showEnvironmentIndicator: false, + }), + ).toBe(true); + }); +}); + describe("resolveEffectiveEnvMode", () => { it("treats draft threads already attached to a worktree as current-checkout mode", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index d9737f17a323..485ffbf8d37f 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -54,6 +54,14 @@ export function shouldShowEnvironmentIndicator(input: { return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary; } +export function shouldShowComposerContextStrip(input: { + hasActiveProject: boolean; + isGitRepo: boolean; + showEnvironmentIndicator: boolean; +}): boolean { + return input.hasActiveProject && (input.isGitRepo || input.showEnvironmentIndicator); +} + export function resolveEnvModeLabel(mode: EnvMode): string { return mode === "worktree" ? "New worktree" : "Current checkout"; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index a3f043c65368..440f48d7c90a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -44,6 +44,7 @@ import { Separator } from "./ui/separator"; interface BranchToolbarProps { environmentId: EnvironmentId; threadId: ThreadId; + showGitControls: boolean; draftId?: DraftId; onEnvModeChange: (mode: EnvMode) => void; effectiveEnvModeOverride?: EnvMode; @@ -309,6 +310,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { export const BranchToolbar = memo(function BranchToolbar({ environmentId, threadId, + showGitControls, draftId, onEnvModeChange, effectiveEnvModeOverride, @@ -403,7 +405,7 @@ export const BranchToolbar = memo(function BranchToolbar({ data-compact={labelsOverflow ? "" : undefined} className="chat-composer-context-strip group/composer-context -mt-4 mx-auto flex w-[calc(100%-2.75rem)] max-w-[calc(48rem-2.75rem)] items-center gap-2 ps-1 pe-2 pt-5 pb-1" > - {isMobile ? ( + {isMobile && showGitControls ? ( - + {showGitControls ? ( + + ) : null} )} - + {showGitControls ? ( + + ) : null}
)} - + {showGitControls ? ( + + ) : null}
); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7b59530c9559..6c2dc1478e67 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -244,7 +244,12 @@ import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; -import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; +import { + resolveEffectiveEnvMode, + resolveLocalCheckoutBranchMismatch, + shouldShowComposerContextStrip, + shouldShowEnvironmentIndicator, +} from "./BranchToolbar.logic"; import { getProviderStatusBannerKey, ProviderStatusBanner, @@ -1745,6 +1750,14 @@ function ChatViewContent(props: ChatViewProps) { return envs; }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; + const activeEnvironmentOption = + logicalProjectEnvironments.find( + (environment) => environment.environmentId === activeThread?.environmentId, + ) ?? null; + const showComposerEnvironmentIndicator = shouldShowEnvironmentIndicator({ + activeEnvironment: activeEnvironmentOption, + canPickEnvironment: hasMultipleEnvironments, + }); const openPullRequestDialog = useCallback( (reference?: string) => { @@ -2514,7 +2527,11 @@ function ChatViewContent(props: ChatViewProps) { terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; - const showComposerContextStrip = isGitRepo && activeProject !== null; + const showComposerContextStrip = shouldShowComposerContextStrip({ + hasActiveProject: activeProject !== null, + isGitRepo, + showEnvironmentIndicator: showComposerEnvironmentIndicator, + }); const initialDiffPanelGitScope = gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; const diffPanelGitStatusResolutionKey = gitStatusQuery.data ? "resolved" : "pending"; @@ -6180,6 +6197,7 @@ function ChatViewContent(props: ChatViewProps) { Date: Thu, 6 Aug 2026 20:55:20 -0400 Subject: [PATCH 010/326] fix(server): stopping a Claude thread no longer shows an ede_diagnostic error (#5557) Co-authored-by: Claude Fable 5 --- .../src/provider/Layers/ClaudeAdapter.test.ts | 61 +++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 24 +++++++- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 24b8429a391a..afa65ea39d61 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1450,6 +1450,67 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("treats aborted_tools results as interrupted and hides ede_diagnostic errors", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 6).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + + // Exact shape the CLI emits when Stop lands mid-tool-call: is_error + // is true and the only error is internal diagnostic telemetry. + harness.query.emit({ + type: "result", + subtype: "error_during_execution", + is_error: true, + errors: ["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use"], + stop_reason: "tool_use", + terminal_reason: "aborted_tools", + session_id: "sdk-session-abort-tools", + uuid: "result-abort-tools", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + [ + "session.started", + "session.configured", + "session.state.changed", + "turn.started", + "thread.started", + "turn.completed", + ], + ); + + const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; + assert.equal(turnCompleted?.type, "turn.completed"); + if (turnCompleted?.type === "turn.completed") { + assert.equal(String(turnCompleted.turnId), String(turn.turnId)); + assert.equal(turnCompleted.payload.state, "interrupted"); + assert.equal(turnCompleted.payload.errorMessage, undefined); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("interruptTurn stops every live task before interrupting the turn", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 27acedc383a6..f6f1c14420de 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -348,7 +348,29 @@ function resultErrorsText(result: SDKResultMessage): string { : ""; } +/** + * First user-facing error from a non-success result. "[ede_diagnostic] ..." + * entries are CLI-internal telemetry (the CLI hides them from its own UI too), + * so they must never become the error banner. + */ +function resultUserFacingError(result: SDKResultMessage): string | undefined { + if (result.subtype === "success" || !Array.isArray(result.errors)) { + return undefined; + } + return result.errors.find((error) => !error.startsWith("[ede_diagnostic]")); +} + function isInterruptedResult(result: SDKResultMessage): boolean { + // The CLI stamps user aborts explicitly: interrupting mid-tool-call yields + // "aborted_tools" (with an internal "[ede_diagnostic] ..." error and + // is_error: true), interrupting mid-stream yields "aborted_streaming". + if ( + result.terminal_reason === "aborted_tools" || + result.terminal_reason === "aborted_streaming" + ) { + return true; + } + const errors = resultErrorsText(result); if (errors.includes("interrupt")) { return true; @@ -2919,7 +2941,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const status = turnStatusFromResult(message); - const errorMessage = message.subtype === "success" ? undefined : message.errors[0]; + const errorMessage = resultUserFacingError(message); if (status === "failed") { yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); From 6da92244cc2a7438703be95a0fcfaca0b73502a7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 6 Aug 2026 21:16:36 -0400 Subject: [PATCH 011/326] fix(web): show one toast when snoozing threads in bulk (#5560) --- apps/web/src/components/SidebarV2.tsx | 150 ++++++++++++++++++-------- 1 file changed, 106 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 462bfde13b72..003bec64d0f1 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -2182,6 +2182,40 @@ export default function SidebarV2() { ); // One snooze per thread at a time — same double-dispatch guard as settle. const snoozingThreadKeysRef = useRef(new Set()); + const performSnooze = useCallback( + async ( + threadRef: ScopedThreadRef, + preset: SnoozePreset, + opts: { coSnoozingKeys?: ReadonlySet } = {}, + ) => { + const threadKey = scopedThreadKey(threadRef); + if (snoozingThreadKeysRef.current.has(threadKey)) { + return { status: "skipped" } as const; + } + snoozingThreadKeysRef.current.add(threadKey); + try { + // Snoozing the open thread moves you forward, same as settle — + // both park the thread you're done with for now. + const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); + const result = await snoozeThread(threadRef, preset.snoozedUntil); + if (result._tag === "Failure") { + // Never navigate away from a thread that did not snooze. + return isAtomCommandInterrupted(result) + ? ({ status: "interrupted" } as const) + : ({ status: "failure", error: squashAtomCommandFailure(result) } as const); + } + // Only move forward if the user is still on the snoozed thread — + // a navigation made during the await wins over ours. + if (routeThreadKeyRef.current === threadKey) { + navigateAfterSnooze?.(); + } + return { status: "success" } as const; + } finally { + snoozingThreadKeysRef.current.delete(threadKey); + } + }, + [planForwardNavigation, snoozeThread], + ); const attemptSnooze = useCallback( ( threadRef: ScopedThreadRef, @@ -2189,52 +2223,35 @@ export default function SidebarV2() { opts: { coSnoozingKeys?: ReadonlySet } = {}, ) => { void (async () => { - const threadKey = scopedThreadKey(threadRef); - if (snoozingThreadKeysRef.current.has(threadKey)) return; - snoozingThreadKeysRef.current.add(threadKey); - try { - // Snoozing the open thread moves you forward, same as settle — - // both park the thread you're done with for now. - const navigateAfterSnooze = planForwardNavigation(threadKey, opts.coSnoozingKeys); - const result = await snoozeThread(threadRef, preset.snoozedUntil); - if (result._tag === "Failure") { - // Never navigate away from a thread that did not snooze. - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to snooze thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; - } - // Snooze hides the row, so the toast is the only confirmation — - // and the Undo is the escape hatch for a mis-click. + const outcome = await performSnooze(threadRef, preset, opts); + if (outcome.status === "failure") { toastManager.add( stackedThreadToast({ - type: "success", - title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, - timeout: 5_000, - actionProps: { - children: "Undo", - onClick: () => attemptUnsnooze(threadRef), - }, + type: "error", + title: "Failed to snooze thread", + description: + outcome.error instanceof Error ? outcome.error.message : "An error occurred.", }), ); - // Only move forward if the user is still on the snoozed thread — - // a navigation made during the await wins over ours. - if (routeThreadKeyRef.current === threadKey) { - navigateAfterSnooze?.(); - } - } finally { - snoozingThreadKeysRef.current.delete(threadKey); + return; } + if (outcome.status !== "success") return; + // Snooze hides the row, so the toast is the only confirmation — + // and the Undo is the escape hatch for a mis-click. + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Snoozed until ${snoozeWakeDescription(preset.snoozedUntil, new Date(), timestampFormat)}`, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => attemptUnsnooze(threadRef), + }, + }), + ); })(); }, - [attemptUnsnooze, planForwardNavigation, snoozeThread, timestampFormat], + [attemptUnsnooze, performSnooze, timestampFormat], ); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); @@ -2308,12 +2325,55 @@ export default function SidebarV2() { // Post-snooze navigation must skip threads snoozing in this same // batch — they are all leaving the card block together. const coSnoozingKeys = new Set(threadKeys); - for (const thread of selectedThreads) { - attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, { - coSnoozingKeys, - }); - } clearSelection(); + const outcomes = await Promise.all( + selectedThreads.map(async (thread) => { + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const outcome = await performSnooze(threadRef, preset, { coSnoozingKeys }); + return { outcome, threadRef }; + }), + ); + const snoozedThreadRefs = outcomes.flatMap(({ outcome, threadRef }) => + outcome.status === "success" ? [threadRef] : [], + ); + const failures = outcomes.flatMap(({ outcome }) => + outcome.status === "failure" ? [outcome.error] : [], + ); + + if (snoozedThreadRefs.length > 0) { + const snoozedCount = snoozedThreadRefs.length; + const failedCount = failures.length; + toastManager.add( + stackedThreadToast({ + type: failedCount > 0 ? "warning" : "success", + title: + failedCount > 0 + ? `Snoozed ${snoozedCount} of ${selectedThreads.length} threads` + : `Snoozed ${snoozedCount} thread${snoozedCount === 1 ? "" : "s"}`, + description: + failedCount > 0 + ? `${failedCount} thread${failedCount === 1 ? "" : "s"} couldn't be snoozed.` + : undefined, + timeout: 5_000, + actionProps: { + children: "Undo", + onClick: () => { + for (const threadRef of snoozedThreadRefs) attemptUnsnooze(threadRef); + }, + }, + }), + ); + } else if (failures.length > 0) { + const firstError = failures[0]; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to snooze threads", + description: + firstError instanceof Error ? firstError.message : "An error occurred.", + }), + ); + } } return; } @@ -2409,8 +2469,10 @@ export default function SidebarV2() { confirmThreadDelete, deleteThread, markThreadUnread, + performSnooze, removeFromSelection, serverConfigs, + attemptUnsnooze, updateThreadMetadata, timestampFormat, ], From 7aad7911f66c2fecba1cfd6601ea783a3fe2bf31 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 6 Aug 2026 21:43:13 -0400 Subject: [PATCH 012/326] fix(server): let stopped threads settle immediately (#5553) --- .../src/orchestration/Layers/ProjectionPipeline.test.ts | 7 +++++++ apps/server/src/orchestration/Layers/ProjectionPipeline.ts | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 9c4caf4c97de..09d7573f5d8e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1430,6 +1430,13 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { assert.deepEqual(settledRows, [ { state: "completed", completedAt: "2026-01-01T00:01:00.000Z" }, ]); + + const threadRows = yield* sql<{ readonly latestTurnId: string | null }>` + SELECT latest_turn_id AS "latestTurnId" + FROM projection_threads + WHERE thread_id = ${threadId} + `; + assert.deepEqual(threadRows, [{ latestTurnId: turnId }]); }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index fe683b08a3c8..7776e374ee23 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -850,7 +850,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } yield* projectionThreadRepository.upsert({ ...existingRow.value, - latestTurnId: event.payload.session.activeTurnId, + // activeTurnId describes current work; a terminal session must not erase history. + latestTurnId: event.payload.session.activeTurnId ?? existingRow.value.latestTurnId, updatedAt: event.occurredAt, }); yield* refreshThreadShellSummary(event.payload.threadId); From 6b73b3defe1dfb365de3b7bbb97ca56a26b50a43 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 6 Aug 2026 22:33:49 -0400 Subject: [PATCH 013/326] feat: paginate thread loading with user-anchored turn windows (#5493) Co-authored-by: Claude Fable 5 --- .../src/connection/environment-cache-store.ts | 5 +- .../features/threads/ThreadDetailScreen.tsx | 3 + .../src/features/threads/ThreadFeed.tsx | 20 +- .../features/threads/ThreadRouteScreen.tsx | 19 + .../Layers/ProjectionSnapshotQuery.test.ts | 405 +++++++++++++ .../Layers/ProjectionSnapshotQuery.ts | 369 +++++++++++- .../Services/ProjectionSnapshotQuery.ts | 8 + apps/server/src/orchestration/http.ts | 12 +- .../orchestration/threadDetailCursor.test.ts | 44 ++ .../src/orchestration/threadDetailCursor.ts | 62 ++ apps/server/src/persistence/Migrations.ts | 2 + .../037_ProjectionTurnsKeysetIndex.ts | 17 + apps/server/src/ws.ts | 10 +- apps/web/src/components/ChatView.tsx | 24 +- .../src/components/chat/MessagesTimeline.tsx | 44 +- apps/web/src/connection/storage.ts | 9 +- .../client-runtime/src/state/entities.test.ts | 2 + .../src/state/threadSnapshotHttp.ts | 27 +- .../client-runtime/src/state/threadState.ts | 24 + .../src/state/threads-pagination.test.ts | 543 ++++++++++++++++++ packages/client-runtime/src/state/threads.ts | 406 ++++++++++++- packages/contracts/src/environmentHttp.ts | 11 + packages/contracts/src/orchestration.ts | 51 ++ packages/contracts/src/server.ts | 6 + 24 files changed, 2093 insertions(+), 30 deletions(-) create mode 100644 apps/server/src/orchestration/threadDetailCursor.test.ts create mode 100644 apps/server/src/orchestration/threadDetailCursor.ts create mode 100644 apps/server/src/persistence/Migrations/037_ProjectionTurnsKeysetIndex.ts create mode 100644 packages/client-runtime/src/state/threads-pagination.test.ts diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 6573c9e11879..ad5ef13b62d5 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -17,7 +17,10 @@ import * as Schema from "effect/Schema"; import * as MobileDatabase from "../persistence/mobile-database"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; -const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2; +// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump +// makes pre-pagination clients discard the record instead of decoding a +// partial thread as complete (rollback safety). +const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 3; const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1; const VCS_REFS_CACHE_SCHEMA_VERSION = 1; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 5cb04290f66d..3d83c8375006 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -61,6 +61,8 @@ export interface ThreadDetailScreenProps { readonly connectionStateLabel: EnvironmentConnectionPhase; /** Message sync status for the selected thread (drives the composer status pill). */ readonly threadSyncStatus?: EnvironmentThreadStatus; + /** Non-null when older turns exist beyond the loaded window. */ + readonly loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null; readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectWorkspaceRoot: string | null; @@ -371,6 +373,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} skills={selectedProviderSkills} + loadEarlier={props.loadEarlier ?? null} /> ) : ( diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 8ad117c86351..fd8ffb270cb1 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -164,6 +164,11 @@ export interface ThreadFeedProps { readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly skills?: ReadonlyArray; + /** Non-null when older turns exist beyond the loaded window. */ + readonly loadEarlier?: { + readonly loading: boolean; + readonly onLoadEarlier: () => void; + } | null; } function MessageAttachmentImage(props: { @@ -1893,7 +1898,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onScroll={handleScroll} scrollEventThrottle={16} ListHeaderComponent={ - usesNativeAutomaticInsets ? null : + <> + {usesNativeAutomaticInsets ? null : } + {props.loadEarlier != null ? ( + + + {props.loadEarlier.loading ? "Loading earlier turns…" : "Load earlier turns"} + + + ) : null} + } contentContainerStyle={{ paddingTop: 12, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 7fb4740ddcef..d7754b7d78f7 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -8,6 +8,10 @@ import { import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; +import { + requestOlderThreadTurns, + threadHasOlderTurns, +} from "@t3tools/client-runtime/state/threads"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -190,6 +194,20 @@ function ThreadRouteContent( useThreadSelection(); const selectedThreadDetailState = props.selectedThreadDetailState; const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); + // "Load earlier turns" header state for windowed (paginated) thread loads. + const loadEarlierTurns = useMemo(() => { + if (selectedThread === null || !threadHasOlderTurns(selectedThreadDetailState)) { + return null; + } + return { + loading: + selectedThreadDetailState.page._tag === "Some" && + selectedThreadDetailState.page.value.loadingOlder, + onLoadEarlier: () => { + requestOlderThreadTurns(selectedThread.environmentId, selectedThread.id); + }, + }; + }, [selectedThread, selectedThreadDetailState]); const { selectedThreadCwd } = useSelectedThreadWorktree(); const composer = useThreadComposerState(); const gitState = useSelectedThreadGitState(); @@ -766,6 +784,7 @@ function ThreadRouteContent( draftAttachments={composer.draftAttachments} connectionStateLabel={routeConnectionState} threadSyncStatus={selectedThreadDetailState.status} + loadEarlier={loadEarlierTurns} activeThreadBusy={composer.activeThreadBusy} environmentId={selectedThread.environmentId} projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index b7b630a16fd3..92c87ebdc044 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -19,6 +19,7 @@ import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); @@ -1917,3 +1918,407 @@ it.effect( }).pipe(Effect.provide(layer)); }, ); + +projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) => { + // A thread shaped like real fan-out usage: user turns interleaved with + // subagent turns (no user pending message), plus a turnless straggler user + // message and a turnless activity anchored between turns. + // + // row turn pending msg anchor (requested_at) + // 1 turn-1 user-msg-1 T00 + // 2 turn-2 (subagent) T01 + // 3 turn-3 (subagent) T02 + // 4 turn-4 user-msg-4 T03 + // 5 turn-5 user-msg-5 T04 + // + // Straggler user message at T03.5 (turn_id NULL, not any pending_message_id) + // and a turnless activity at T03.6 — both belong to the page containing T03+. + const seedFanOutThread = Effect.fnUntraced(function* () { + const sql = yield* SqlClient.SqlClient; + + // Tests in this block share one in-memory database; reset before seeding. + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_turns`; + yield* sql`DELETE FROM projection_thread_messages`; + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at + ) + VALUES ('project-w', 'Windowed', '/tmp/project-w', '[]', + '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z', NULL) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + latest_turn_id, pending_approval_count, pending_user_input_count, + has_actionable_proposed_plan, created_at, updated_at, deleted_at + ) + VALUES ('thread-w', 'project-w', 'Windowed thread', + '{"provider":"codex","model":"gpt-5-codex"}', 'full-access', 'default', + 'turn-5', 0, 0, 0, '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:10.000Z', NULL) + `; + + const turns: ReadonlyArray<{ + turn: string; + pendingMessage: string | null; + at: string; + }> = [ + { turn: "turn-1", pendingMessage: "user-msg-1", at: "2026-03-01T00:00:00.000Z" }, + { turn: "turn-2", pendingMessage: null, at: "2026-03-01T00:01:00.000Z" }, + { turn: "turn-3", pendingMessage: null, at: "2026-03-01T00:02:00.000Z" }, + { turn: "turn-4", pendingMessage: "user-msg-4", at: "2026-03-01T00:03:00.000Z" }, + { turn: "turn-5", pendingMessage: "user-msg-5", at: "2026-03-01T00:04:00.000Z" }, + ]; + for (const { turn, pendingMessage, at } of turns) { + yield* sql` + INSERT INTO projection_turns ( + thread_id, turn_id, pending_message_id, state, requested_at, started_at, completed_at, + checkpoint_files_json + ) + VALUES ('thread-w', ${turn}, ${pendingMessage}, 'completed', ${at}, ${at}, ${at}, '[]') + `; + if (pendingMessage !== null) { + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) + VALUES (${pendingMessage}, 'thread-w', NULL, 'user', ${"prompt for " + turn}, 0, ${at}, ${at}) + `; + } + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) + VALUES (${turn + "-reply"}, 'thread-w', ${turn}, 'assistant', ${"reply from " + turn}, 0, ${at}, ${at}) + `; + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, created_at + ) + VALUES (${turn + "-activity"}, 'thread-w', ${turn}, 'tool', 'tool.completed', + 'ran tool', '{"ok":true}', ${at}) + `; + } + + // Straggler user message sent while turn-4 ran: turn_id NULL and not any + // turn's pending_message_id. + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) + VALUES ('user-msg-straggler', 'thread-w', NULL, 'user', 'while you are at it', + 0, '2026-03-01T00:03:30.000Z', '2026-03-01T00:03:30.000Z') + `; + // Turnless activity in the same time range. + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, created_at + ) + VALUES ('turnless-activity', 'thread-w', NULL, 'info', 'context-window.updated', + 'usage', '{"usedTokens":1}', '2026-03-01T00:03:36.000Z') + `; + + for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) { + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (${projector}, 42, '2026-03-01T00:00:10.000Z') + `; + } + }); + + const threadW = ThreadId.make("thread-w"); + const messageIds = (snapshot: { thread: { messages: ReadonlyArray<{ id: string }> } }) => + snapshot.thread.messages.map((message) => message.id).toSorted(); + const activityIds = (snapshot: { thread: { activities: ReadonlyArray<{ id: string }> } }) => + snapshot.thread.activities.map((activity) => activity.id).toSorted(); + + it.effect("returns the full thread with no page metadata when no window is requested", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag === "Some") { + assert.equal(snapshot.value.page, undefined); + assert.equal(snapshot.value.thread.messages.length, 9); + assert.equal(snapshot.value.thread.activities.length, 6); + assert.equal(snapshot.value.snapshotSequence, 42); + } + }), + ); + + it.effect("windows to the last N user-anchored turns with subagent turns riding along", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + // turnLimit 2 walks back: turn-5 (user), turn-4 (user) -> window is + // rows 4..5. Subagent turns 2-3 are older than the 2nd user turn and + // stay out; the straggler message and turnless activity (T03.5/T03.6, + // after turn-4's anchor) ride along. + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 }); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag === "Some") { + assert.deepEqual(messageIds(snapshot.value), [ + "turn-4-reply", + "turn-5-reply", + "user-msg-4", + "user-msg-5", + "user-msg-straggler", + ]); + assert.deepEqual(activityIds(snapshot.value), [ + "turn-4-activity", + "turn-5-activity", + "turnless-activity", + ]); + assert.equal(snapshot.value.page?.hasMore, true); + assert.notEqual(snapshot.value.page?.beforeCursor, null); + assert.equal(snapshot.value.page?.snapshotSequence, 42); + } + }), + ); + + it.effect("subagent turns between user turns ride along inside the window", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + // turnLimit 3 reaches user turn-1, dragging subagent turns 2-3 along: + // the full thread, so no further pages. + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 3 }); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag === "Some") { + assert.equal(snapshot.value.thread.messages.length, 9); + assert.equal(snapshot.value.thread.activities.length, 6); + assert.equal(snapshot.value.page?.hasMore, false); + assert.equal(snapshot.value.page?.beforeCursor, null); + } + }), + ); + + it.effect("cursors survive a projection rewrite that reassigns turn row ids", () => + Effect.gen(function* () { + // The revert projector (and any projection rebuild) deletes and + // re-upserts projection_turns, assigning fresh autoincrement row ids. + // The keyset cursor is derived from event content, so a page cursor + // minted before the rewrite must keep working after it. + yield* seedFanOutThread(); + const sql = yield* SqlClient.SqlClient; + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const firstPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 }); + assert.equal(firstPage._tag, "Some"); + if (firstPage._tag !== "Some") return; + const cursor = firstPage.value.page?.beforeCursor; + assert.notEqual(cursor, null); + if (cursor === null || cursor === undefined) return; + + // Simulate the rewrite: delete and re-insert every turn row with the + // same content, which reassigns all row ids. + const turnRows = yield* sql` + SELECT thread_id, turn_id, pending_message_id, state, requested_at, started_at, + completed_at, checkpoint_files_json + FROM projection_turns WHERE thread_id = 'thread-w' ORDER BY row_id + `; + yield* sql`DELETE FROM projection_turns WHERE thread_id = 'thread-w'`; + for (const row of turnRows) { + yield* sql` + INSERT INTO projection_turns ( + thread_id, turn_id, pending_message_id, state, requested_at, started_at, + completed_at, checkpoint_files_json + ) + VALUES (${row.thread_id as string}, ${row.turn_id as string}, + ${row.pending_message_id as string | null}, ${row.state as string}, + ${row.requested_at as string}, ${row.started_at as string}, + ${row.completed_at as string}, ${row.checkpoint_files_json as string}) + `; + } + + const olderPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 1, + beforeCursor: cursor, + }); + assert.equal(olderPage._tag, "Some"); + if (olderPage._tag === "Some") { + // Identical older slice to what the pre-rewrite cursor would return. + assert.deepEqual(messageIds(olderPage.value), [ + "turn-1-reply", + "turn-2-reply", + "turn-3-reply", + "user-msg-1", + ]); + assert.equal(olderPage.value.page?.hasMore, false); + } + }), + ); + + it.effect("beforeCursor returns the disjoint adjacent older slice", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const firstPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 }); + assert.equal(firstPage._tag, "Some"); + if (firstPage._tag !== "Some") return; + const cursor = firstPage.value.page?.beforeCursor; + assert.notEqual(cursor, null); + assert.notEqual(cursor, undefined); + if (cursor === null || cursor === undefined) return; + + // Older page: user turn-1 plus subagent turns 2-3 riding along. Disjoint + // from the first page: no turn-4/5 rows, no straggler. + const olderPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 1, + beforeCursor: cursor, + }); + assert.equal(olderPage._tag, "Some"); + if (olderPage._tag === "Some") { + assert.deepEqual(messageIds(olderPage.value), [ + "turn-1-reply", + "turn-2-reply", + "turn-3-reply", + "user-msg-1", + ]); + assert.deepEqual(activityIds(olderPage.value), [ + "turn-1-activity", + "turn-2-activity", + "turn-3-activity", + ]); + assert.equal(olderPage.value.page?.hasMore, false); + assert.equal(olderPage.value.page?.beforeCursor, null); + } + }), + ); + + it.effect("a cursor for a different thread degrades to the first page", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const firstPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 }); + assert.equal(firstPage._tag, "Some"); + if (firstPage._tag !== "Some") return; + + const foreign = encodeThreadDetailPageCursor({ + threadId: ThreadId.make("thread-other"), + beforeAnchorAt: "2026-03-01T00:01:00.000Z", + beforeTurnId: "turn-2", + }); + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 2, + beforeCursor: foreign, + }); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag === "Some") { + assert.deepEqual(messageIds(snapshot.value), messageIds(firstPage.value)); + } + }), + ); + + it.effect("a malformed cursor degrades to the first page instead of failing", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 2, + beforeCursor: "not-a-cursor", + }); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag === "Some") { + assert.equal(snapshot.value.page?.hasMore, true); + assert.equal(snapshot.value.thread.messages.length, 5); + } + }), + ); + + it.effect("windows never split below the raw-turn ceiling boundary contiguously", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + // Page repeatedly with turnLimit 1 and assert the union of all pages is + // exactly the full thread with no duplicates (disjointness + coverage). + const seenMessages: string[] = []; + const seenActivities: string[] = []; + let cursor: string | undefined; + for (let page = 0; page < 10; page += 1) { + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 1, + ...(cursor !== undefined ? { beforeCursor: cursor } : {}), + }); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag !== "Some") return; + seenMessages.push(...snapshot.value.thread.messages.map((message) => message.id)); + seenActivities.push(...snapshot.value.thread.activities.map((activity) => activity.id)); + const next = snapshot.value.page?.beforeCursor; + if (next === null || next === undefined) break; + cursor = next; + } + assert.equal(new Set(seenMessages).size, seenMessages.length); + assert.equal(new Set(seenActivities).size, seenActivities.length); + assert.equal(seenMessages.length, 9); + assert.equal(seenActivities.length, 6); + }), + ); + + it.effect("a thread with no turns returns its content unwindowed on the first page", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const snapshotQuery = yield* ProjectionSnapshotQuery; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_turns`; + yield* sql`DELETE FROM projection_thread_messages`; + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at + ) + VALUES ('project-e', 'Empty', '/tmp/project-e', '[]', + '2026-03-02T00:00:00.000Z', '2026-03-02T00:00:00.000Z', NULL) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, + created_at, updated_at, deleted_at + ) + VALUES ('thread-e', 'project-e', 'Turnless thread', + '{"provider":"codex","model":"gpt-5-codex"}', 'full-access', 'default', + 0, 0, 0, '2026-03-02T00:00:00.000Z', '2026-03-02T00:00:00.000Z', NULL) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) + VALUES ('pre-turn-msg', 'thread-e', NULL, 'user', 'first prompt', 0, + '2026-03-02T00:00:01.000Z', '2026-03-02T00:00:01.000Z') + `; + for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) { + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (${projector}, 7, '2026-03-02T00:00:01.000Z') + `; + } + + const snapshot = yield* snapshotQuery.getThreadDetailSnapshot(ThreadId.make("thread-e"), { + turnLimit: 5, + }); + assert.equal(snapshot._tag, "Some"); + if (snapshot._tag === "Some") { + assert.deepEqual(messageIds(snapshot.value), ["pre-turn-msg"]); + assert.equal(snapshot.value.page?.hasMore, false); + assert.equal(snapshot.value.page?.beforeCursor, null); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 2d8a98d8c6fb..f036198fe495 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -51,6 +51,10 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; +import { + decodeThreadDetailPageCursor, + encodeThreadDetailPageCursor, +} from "../threadDetailCursor.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { @@ -130,6 +134,36 @@ const ProjectIdLookupInput = Schema.Struct({ const ThreadIdLookupInput = Schema.Struct({ threadId: ThreadId, }); +// Windowed reads order turns by the stable keyset (anchor, turn key), where +// anchor is requested_at and turn key is +// COALESCE(turn_id, ''). Both are event-derived, so cursors survive the +// revert projector's row-id rewrite and full projection rebuilds. +const ThreadTurnWindowLookupInput = Schema.Struct({ + threadId: ThreadId, + // Exclusive keyset upper bound. Sentinels "~"/"" mean unbounded ("~" sorts + // after every ISO timestamp). + beforeAnchorAt: Schema.String, + beforeTurnKey: Schema.String, + userTurnLimit: Schema.Number, + maxRawTurns: Schema.Number, +}); +const ProjectionTurnWindowRowSchema = Schema.Struct({ + // The turn's timeline anchor, used to bound rows that have no turn linkage + // (user messages and turnless activities) to the same page window. + anchorAt: Schema.String, + turnKey: Schema.String, +}); +const ThreadTurnRangeLookupInput = Schema.Struct({ + threadId: ThreadId, + // Turn-linked rows are bounded by the keyset range [min, before) over + // (anchor, turn key); turnless rows by the matching [minAnchorAt, + // beforeAnchorAt) time range. Unbounded ends use sentinels: "" for the + // lower bound, "~" (sorts after ISO dates) for the upper bound. + minAnchorAt: Schema.String, + minTurnKey: Schema.String, + beforeAnchorAt: Schema.String, + beforeTurnKey: Schema.String, +}); const ProjectionProjectLookupRowSchema = ProjectionProjectDbRowSchema; const ProjectionThreadIdLookupRowSchema = Schema.Struct({ threadId: ThreadId, @@ -1043,6 +1077,197 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + // Resolves a page of recent turns for a windowed thread detail read. Walks + // back from the exclusive (beforeAnchorAt, beforeTurnKey) keyset boundary + // (sentinels "~"/"" mean unbounded, i.e. the first page) until it has seen + // `userTurnLimit` user-anchored turns — turns whose pending message is a + // user message; subagent/fan-out turns between them ride along — or hits the + // `maxRawTurns` ceiling that bounds pathological fan-out. The `candidates` + // CTE applies the keyset bound and LIMIT before the window functions run; + // its ORDER BY uses raw columns so the migration-037 + // (thread_id, requested_at, turn_id) index serves both range and order with + // no temp B-tree — the scan is genuinely bounded by the LIMIT. (Raw + // turn_id DESC places NULLs exactly where COALESCE-to-'' would, below every + // real id.) The caller derives the continuation cursor from the oldest + // returned row. + // Highest thread-DETAIL event sequence for this thread that the projection + // has applied (bounded by the global snapshot sequence read in the same + // transaction). This is the thread-scoped watermark a windowed page carries + // so clients can defer merging until their live subscription has caught up; + // the global sequence is not waitable per-thread. The event_type filter + // must match ws.ts's isThreadDetailEvent exactly: the subscription only + // delivers these types, so a watermark counting any other event could + // never be reached by the client and would park the page forever. Served + // by the event store's (aggregate_kind, stream_id, sequence) index. + const getThreadEventWatermarkRow = SqlSchema.findOneOption({ + Request: Schema.Struct({ threadId: ThreadId, maxSequence: Schema.Number }), + Result: Schema.Struct({ threadSequence: Schema.NullOr(Schema.Number) }), + execute: ({ threadId, maxSequence }) => + sql` + SELECT MAX(sequence) AS "threadSequence" + FROM orchestration_events + WHERE aggregate_kind = 'thread' + AND stream_id = ${threadId} + AND sequence <= ${maxSequence} + AND event_type IN ( + 'thread.message-sent', + 'thread.proposed-plan-upserted', + 'thread.activity-appended', + 'thread.turn-diff-completed', + 'thread.reverted', + 'thread.session-set' + ) + `, + }); + + const listTurnWindowRows = SqlSchema.findAll({ + Request: ThreadTurnWindowLookupInput, + Result: ProjectionTurnWindowRowSchema, + execute: ({ threadId, beforeAnchorAt, beforeTurnKey, userTurnLimit, maxRawTurns }) => + sql` + WITH candidates AS ( + SELECT + turns.requested_at AS anchor_at, + COALESCE(turns.turn_id, '') AS turn_key, + turns.pending_message_id + FROM projection_turns AS turns + WHERE turns.thread_id = ${threadId} + AND ( + turns.requested_at < ${beforeAnchorAt} + OR ( + turns.requested_at = ${beforeAnchorAt} + AND COALESCE(turns.turn_id, '') < ${beforeTurnKey} + ) + ) + ORDER BY turns.requested_at DESC, turns.turn_id DESC + LIMIT ${maxRawTurns} + ), + walked AS ( + SELECT + candidates.anchor_at, + candidates.turn_key, + CASE WHEN messages.role = 'user' THEN 1 ELSE 0 END AS is_user_turn, + SUM(CASE WHEN messages.role = 'user' THEN 1 ELSE 0 END) OVER ( + ORDER BY candidates.anchor_at DESC, candidates.turn_key DESC + ) AS user_turns_seen + FROM candidates + LEFT JOIN projection_thread_messages AS messages + ON messages.message_id = candidates.pending_message_id + ) + SELECT + anchor_at AS "anchorAt", + turn_key AS "turnKey" + FROM walked + WHERE user_turns_seen < ${userTurnLimit} + OR (user_turns_seen = ${userTurnLimit} AND is_user_turn = 1) + ORDER BY anchor_at ASC, turn_key ASC + `, + }); + + // Windowed variants of the two heavy collections. Turn-linked rows are + // bounded by the page's (anchor, turn key) keyset range over + // projection_turns; rows with no turn linkage (user messages always, and + // turnless activities like pre-turn context-window updates) are bounded by + // the matching turn-anchor time range so they land on the same page as the + // turns around them. Proposed plans and checkpoints stay unwindowed: they + // are metadata-scale. + const listThreadMessageRowsByThreadWindow = SqlSchema.findAll({ + Request: ThreadTurnRangeLookupInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ threadId, minAnchorAt, minTurnKey, beforeAnchorAt, beforeTurnKey }) => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} + AND ( + turn_id IN ( + SELECT turn_id FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + AND ( + requested_at > ${minAnchorAt} + OR ( + requested_at = ${minAnchorAt} + AND turn_id >= ${minTurnKey} + ) + ) + AND ( + requested_at < ${beforeAnchorAt} + OR ( + requested_at = ${beforeAnchorAt} + AND turn_id < ${beforeTurnKey} + ) + ) + ) + OR ( + turn_id IS NULL + AND created_at >= ${minAnchorAt} + AND created_at < ${beforeAnchorAt} + ) + ) + ORDER BY created_at ASC, message_id ASC + `, + }); + + const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({ + Request: ThreadTurnRangeLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId, minAnchorAt, minTurnKey, beforeAnchorAt, beforeTurnKey }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + payload_json AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND ( + turn_id IN ( + SELECT turn_id FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + AND ( + requested_at > ${minAnchorAt} + OR ( + requested_at = ${minAnchorAt} + AND turn_id >= ${minTurnKey} + ) + ) + AND ( + requested_at < ${beforeAnchorAt} + OR ( + requested_at = ${beforeAnchorAt} + AND turn_id < ${beforeTurnKey} + ) + ) + ) + OR ( + turn_id IS NULL + AND created_at >= ${minAnchorAt} + AND created_at < ${beforeAnchorAt} + ) + ) + ORDER BY + sequence ASC, + created_at ASC, + activity_id ASC + `, + }); + const getFullThreadDiffContextRow = SqlSchema.findOneOption({ Request: FullThreadDiffContextLookupInput, Result: ProjectionFullThreadDiffContextRowSchema, @@ -2104,7 +2329,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { } satisfies OrchestrationThreadShell); }); - const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => + // Contiguous turn range bounding a windowed detail read; undefined loads the + // full thread. Resolved from a window request inside the snapshot + // transaction (see getThreadDetailSnapshot). + interface ThreadDetailBounds { + readonly minAnchorAt: string; + readonly minTurnKey: string; + readonly beforeAnchorAt: string; + readonly beforeTurnKey: string; + } + + const getThreadDetailByIdBounded = (threadId: ThreadId, bounds: ThreadDetailBounds | undefined) => Effect.gen(function* () { const [ threadRow, @@ -2123,7 +2358,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - listThreadMessageRowsByThread({ threadId }).pipe( + (bounds === undefined + ? listThreadMessageRowsByThread({ threadId }) + : listThreadMessageRowsByThreadWindow({ threadId, ...bounds }) + ).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getThreadDetailById:listMessages:query", @@ -2139,7 +2377,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - listThreadActivityRowsByThread({ threadId }).pipe( + (bounds === undefined + ? listThreadActivityRowsByThread({ threadId }) + : listThreadActivityRowsByThreadWindow({ threadId, ...bounds }) + ).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", @@ -2249,23 +2490,139 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ); }); + const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => + getThreadDetailByIdBounded(threadId, undefined); + + // Bounds pathological fan-out: one user turn that spawned hundreds of + // subagent turns still pages in bounded chunks, at the cost of splitting the + // fan-out group across pages (the cursor continues the same group). Also + // structurally bounds the window scan via the candidates CTE's LIMIT. + const THREAD_DETAIL_MAX_RAW_TURNS_PER_PAGE = 150; + // Sentinels for unbounded keyset ends; "~" sorts after any ISO timestamp. + const ANCHOR_UNBOUNDED = "~"; + const getThreadDetailSnapshot: ProjectionSnapshotQueryShape["getThreadDetailSnapshot"] = ( threadId, + window, ) => // Read the thread detail and the snapshot sequence within a single // transaction so the sequence is consistent with the returned state; a // projector update landing between two separate reads could otherwise return // a sequence ahead of the thread detail, causing the client to resume from - // too far and drop events. + // too far and drop events. Window resolution runs inside the same + // transaction so the page boundary is consistent with the returned rows. sql .withTransaction( Effect.gen(function* () { - const thread = yield* getThreadDetailById(threadId); + if (window?.turnLimit === undefined) { + const thread = yield* getThreadDetailById(threadId); + if (Option.isNone(thread)) { + return Option.none(); + } + const { snapshotSequence } = yield* getSnapshotSequence(); + return Option.some({ snapshotSequence, thread: thread.value }); + } + + // A malformed or foreign-thread cursor falls back to the first page + // rather than failing: the client's stale cursor after a revert or + // reconnect should degrade to "reload recent history", not error. + const decodedCursor = + window.beforeCursor === undefined + ? null + : decodeThreadDetailPageCursor(window.beforeCursor); + const cursor = decodedCursor?.threadId === threadId ? decodedCursor : null; + + const windowRows = yield* listTurnWindowRows({ + threadId, + beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED, + beforeTurnKey: cursor?.beforeTurnId ?? "", + userTurnLimit: window.turnLimit, + maxRawTurns: THREAD_DETAIL_MAX_RAW_TURNS_PER_PAGE, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailSnapshot:listTurnWindow:query", + "ProjectionSnapshotQuery.getThreadDetailSnapshot:listTurnWindow:decodeRows", + ), + ), + ); + + const oldest = windowRows[0]; + // An empty window (no turns before the cursor, or a thread with no + // turns at all) still returns thread metadata with empty collections + // for turn-linked rows; turnless rows are bounded to the same empty + // range. The first page of a turnless thread stays unwindowed so + // pre-turn content (e.g. a just-created thread) is not hidden. + const bounds: ThreadDetailBounds | undefined = + oldest === undefined && cursor === null + ? undefined + : { + minAnchorAt: oldest?.anchorAt ?? "", + minTurnKey: oldest?.turnKey ?? "", + beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED, + beforeTurnKey: cursor?.beforeTurnId ?? "", + }; + // Empty window behind a cursor: nothing older remains. + const emptyBounds = + oldest === undefined && cursor !== null + ? { minAnchorAt: "", minTurnKey: "", beforeAnchorAt: "", beforeTurnKey: "" } + : undefined; + + const thread = yield* getThreadDetailByIdBounded(threadId, emptyBounds ?? bounds); if (Option.isNone(thread)) { return Option.none(); } + + const hasMore = + oldest !== undefined && + (yield* listTurnWindowRows({ + threadId, + beforeAnchorAt: oldest.anchorAt, + beforeTurnKey: oldest.turnKey, + userTurnLimit: 1, + maxRawTurns: 1, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query", + "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows", + ), + ), + )).length > 0; + const { snapshotSequence } = yield* getSnapshotSequence(); - return Option.some({ snapshotSequence, thread: thread.value }); + const watermarkRow = yield* getThreadEventWatermarkRow({ + threadId, + maxSequence: snapshotSequence, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailSnapshot:threadWatermark:query", + "ProjectionSnapshotQuery.getThreadDetailSnapshot:threadWatermark:decodeRow", + ), + ), + ); + const threadSequence = Option.match(watermarkRow, { + onNone: () => 0, + onSome: (row) => row.threadSequence ?? 0, + }); + return Option.some({ + snapshotSequence, + thread: thread.value, + page: { + beforeCursor: + hasMore && oldest !== undefined + ? encodeThreadDetailPageCursor({ + threadId, + beforeAnchorAt: oldest.anchorAt, + beforeTurnId: oldest.turnKey, + }) + : null, + hasMore, + snapshotSequence, + threadSequence, + }, + }); }), ) .pipe( diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 64138fb75596..0a00253a2285 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -17,6 +17,7 @@ import type { OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadDetailSnapshot, + OrchestrationThreadDetailWindow, OrchestrationThreadShell, ProjectId, ThreadId, @@ -174,9 +175,16 @@ export interface ProjectionSnapshotQueryShape { * sequence in one consistent transaction, so the returned `snapshotSequence` * exactly matches the state reflected in `thread` (no interleaving projector * update between the two reads). + * + * When `window` is provided, the thread's messages, activities, proposed + * plans, and checkpoints are bounded to a page of recent turns and the + * response carries `page` metadata (see `OrchestrationThreadDetailWindow`). + * Without a window the full thread is returned with no `page` field — + * pagination is strictly opt-in. */ readonly getThreadDetailSnapshot: ( threadId: ThreadId, + window?: OrchestrationThreadDetailWindow, ) => Effect.Effect, ProjectionRepositoryError>; } diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 9a5c8c0be39d..04d54ea8effb 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -66,7 +66,17 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); const snapshot = yield* projectionSnapshotQuery - .getThreadDetailSnapshot(args.params.threadId) + .getThreadDetailSnapshot( + args.params.threadId, + args.payload.turnLimit === undefined + ? undefined + : { + turnLimit: args.payload.turnLimit, + ...(args.payload.beforeCursor !== undefined + ? { beforeCursor: args.payload.beforeCursor } + : {}), + }, + ) .pipe( Effect.catch((cause) => failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), diff --git a/apps/server/src/orchestration/threadDetailCursor.test.ts b/apps/server/src/orchestration/threadDetailCursor.test.ts new file mode 100644 index 000000000000..434d83e86b18 --- /dev/null +++ b/apps/server/src/orchestration/threadDetailCursor.test.ts @@ -0,0 +1,44 @@ +import { ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { + decodeThreadDetailPageCursor, + encodeThreadDetailPageCursor, +} from "./threadDetailCursor.ts"; + +describe("threadDetailCursor", () => { + it("round-trips a cursor", () => { + const cursor = { + threadId: ThreadId.make("thread-1"), + beforeAnchorAt: "2026-08-01T00:00:00.000Z", + beforeTurnId: "turn-9", + }; + expect(decodeThreadDetailPageCursor(encodeThreadDetailPageCursor(cursor))).toEqual(cursor); + }); + + it("round-trips empty boundary values", () => { + // The anchor is COALESCE(requested_at, started_at, '') and the turn key + // is COALESCE(turn_id, ''), so a server-minted cursor can legitimately + // carry empty strings; rejecting them would degrade a valid cursor to a + // first-page request that repeats recent history (review finding). + const cursor = { + threadId: ThreadId.make("thread-1"), + beforeAnchorAt: "", + beforeTurnId: "", + }; + expect(decodeThreadDetailPageCursor(encodeThreadDetailPageCursor(cursor))).toEqual(cursor); + }); + + it("rejects malformed input", () => { + expect(decodeThreadDetailPageCursor("not-base64-json")).toBeNull(); + expect(decodeThreadDetailPageCursor(Buffer.from("[]").toString("base64url"))).toBeNull(); + expect( + decodeThreadDetailPageCursor(Buffer.from(JSON.stringify({ t: "" })).toString("base64url")), + ).toBeNull(); + expect( + decodeThreadDetailPageCursor( + Buffer.from(JSON.stringify({ t: "thread-1", a: 5, i: "x" })).toString("base64url"), + ), + ).toBeNull(); + }); +}); diff --git a/apps/server/src/orchestration/threadDetailCursor.ts b/apps/server/src/orchestration/threadDetailCursor.ts new file mode 100644 index 000000000000..a7dcf231ee60 --- /dev/null +++ b/apps/server/src/orchestration/threadDetailCursor.ts @@ -0,0 +1,62 @@ +import type { ThreadId } from "@t3tools/contracts"; + +/** + * Opaque, exclusive cursor for windowed thread detail reads. Encodes the thread + * id and the keyset boundary of an already-delivered page: the boundary turn's + * anchor timestamp (`COALESCE(requested_at, started_at, '')`) and turn id. + * Passing it back requests the adjacent disjoint slice of strictly older turns + * under `(anchor, turn_id)` ordering. + * + * The boundary is deliberately NOT a `projection_turns.row_id`: row ids are + * rewritten by the revert projector (delete + re-upsert) and by projection + * rebuilds, which would silently invalidate every persisted cursor with no + * event emitted. The (anchor, turnId) pair is derived from event content, so + * cursors survive both and no client-side refresh machinery is needed. The + * anchor doubles as the time bound for rows with no turn linkage (straggler + * user messages, turnless activities). The thread id is embedded so a cursor + * can never be replayed against a different thread. Clients must treat the + * string as opaque. + */ +export interface ThreadDetailPageCursor { + readonly threadId: ThreadId; + readonly beforeAnchorAt: string; + /** Boundary turn id; "" for the rare turn row with a null turn_id. */ + readonly beforeTurnId: string; +} + +export function encodeThreadDetailPageCursor(cursor: ThreadDetailPageCursor): string { + return Buffer.from( + JSON.stringify({ t: cursor.threadId, a: cursor.beforeAnchorAt, i: cursor.beforeTurnId }), + ).toString("base64url"); +} + +/** + * Returns null for anything that is not a well-formed cursor. Callers degrade + * a malformed or foreign-thread cursor to a first-page request. + */ +export function decodeThreadDetailPageCursor(encoded: string): ThreadDetailPageCursor | null { + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); + } catch { + return null; + } + if (parsed === null || typeof parsed !== "object") { + return null; + } + const record = parsed as Record; + if (typeof record.t !== "string" || record.t.length === 0) { + return null; + } + // Empty strings are valid boundary values, not malformed input: the anchor + // is COALESCE(requested_at, started_at, ''), so a boundary turn with no + // timestamps encodes a: "" (and sorts before every real anchor, correctly + // ending the walk); the turn key is "" for a null turn_id. + if (typeof record.a !== "string") { + return null; + } + if (typeof record.i !== "string") { + return null; + } + return { threadId: record.t as ThreadId, beforeAnchorAt: record.a, beforeTurnId: record.i }; +} diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 1309cd7ef59f..1f335bdfda73 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -49,6 +49,7 @@ import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; +import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; /** * Migration loader with all migrations defined inline. @@ -97,6 +98,7 @@ export const migrationEntries = [ [34, "ProjectionThreadsSnoozed", Migration0034], [35, "ProjectionThreadTitleRegeneration", Migration0035], [36, "ProjectionThreadsPinned", Migration0036], + [37, "ProjectionTurnsKeysetIndex", Migration0037], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/037_ProjectionTurnsKeysetIndex.ts b/apps/server/src/persistence/Migrations/037_ProjectionTurnsKeysetIndex.ts new file mode 100644 index 000000000000..6b1ee7c03043 --- /dev/null +++ b/apps/server/src/persistence/Migrations/037_ProjectionTurnsKeysetIndex.ts @@ -0,0 +1,17 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Composite index for windowed thread detail reads. Pagination orders turns by + * the stable keyset (requested_at, turn_id); the pre-existing + * (thread_id, requested_at) index cannot serve the tiebreak order, forcing a + * temp B-tree over all of a thread's turns before the page LIMIT applies. + * With this index the candidates scan is genuinely bounded by the page size. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_turns_thread_keyset + ON projection_turns(thread_id, requested_at, turn_id) + `; +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a04fce3fd2c7..6bafb9ec3ba9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1019,6 +1019,7 @@ const makeWsRpcLayer = ( settings, shellResumeCompletionMarker: true, threadResumeCompletionMarker: true, + threadSnapshotPagination: true, }; }); @@ -1351,7 +1352,14 @@ const makeWsRpcLayer = ( } const snapshot = yield* projectionSnapshotQuery - .getThreadDetailSnapshot(input.threadId) + .getThreadDetailSnapshot( + input.threadId, + // Windowing the fallback snapshot is opt-in per subscription: + // clients that don't send turnLimit (including all + // pre-pagination clients) get the full thread, since they + // have no way to load older pages. + input.turnLimit === undefined ? undefined : { turnLimit: input.turnLimit }, + ) .pipe( Effect.mapError( (cause) => diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6c2dc1478e67..f17e7021c440 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -223,7 +223,11 @@ import { serverEnvironment, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; -import { threadEnvironment } from "../state/threads"; +import { threadEnvironment, useEnvironmentThread } from "../state/threads"; +import { + requestOlderThreadTurns, + threadHasOlderTurns, +} from "@t3tools/client-runtime/state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { @@ -1239,6 +1243,23 @@ function ChatViewContent(props: ChatViewProps) { [routeServerThreadShell, threadDetailLoading], ); const activeServerThread = serverThread ?? loadingServerThread; + // Pagination window state for the routed server thread: drives the + // "load earlier turns" header when the loaded window has older history. + const routeThreadState = useEnvironmentThread( + routeKind === "server" ? routeThreadRef.environmentId : null, + routeKind === "server" ? routeThreadRef.threadId : null, + ); + const loadEarlierTurns = useMemo(() => { + if (routeKind !== "server" || !threadHasOlderTurns(routeThreadState)) { + return null; + } + return { + loading: routeThreadState.page._tag === "Some" && routeThreadState.page.value.loadingOlder, + onLoadEarlier: () => { + requestOlderThreadTurns(routeThreadRef.environmentId, routeThreadRef.threadId); + }, + }; + }, [routeKind, routeThreadRef, routeThreadState]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const settings = useEnvironmentSettings(environmentId); // New-thread defaults live in the primary environment's settings.json (the @@ -6029,6 +6050,7 @@ function ChatViewContent(props: ChatViewProps) { onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} + loadEarlier={loadEarlierTurns} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c952eb3d128f..8e27b7b6962c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -158,6 +158,33 @@ const TimelineRowCtx = createContext(null!); const TimelineRowActivityCtx = createContext(null!); const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FADE_HEADER =
; + +// Header row shown when older turns exist beyond the loaded window. Plain +// button, no spinner animation; the label change is the loading indicator. +function TimelineLoadEarlierHeader({ + loading, + onLoadEarlier, + fade, +}: { + loading: boolean; + onLoadEarlier: () => void; + fade: boolean; +}) { + return ( +
+
+ +
+
+ ); +} const TIMELINE_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; @@ -196,6 +223,8 @@ interface MessagesTimelineProps { onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; + /** Non-null when older turns exist beyond the loaded window. */ + loadEarlier?: { readonly loading: boolean; readonly onLoadEarlier: () => void } | null; } // --------------------------------------------------------------------------- @@ -233,6 +262,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onManualNavigation, hideEmptyPlaceholder = false, topFadeEnabled = false, + loadEarlier = null, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -533,7 +563,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", topFadeEnabled && "chat-timeline-scroll-fade", )} - ListHeaderComponent={topFadeEnabled ? TIMELINE_LIST_FADE_HEADER : TIMELINE_LIST_HEADER} + ListHeaderComponent={ + loadEarlier !== null ? ( + + ) : topFadeEnabled ? ( + TIMELINE_LIST_FADE_HEADER + ) : ( + TIMELINE_LIST_HEADER + ) + } ListFooterComponent={TIMELINE_LIST_FOOTER} /> Effect.gen(function* () { const encoded = yield* encodeStoredThreadSnapshot({ - schemaVersion: 2, + schemaVersion: 3, environmentId, threadId: snapshot.thread.id, snapshot, diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index e08fd9e552f2..d3bb6680208a 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -333,6 +333,7 @@ describe("environment entity projections", () => { data: Option.some(detail), status: "live", error: Option.none(), + page: Option.none(), }), ); @@ -361,6 +362,7 @@ describe("environment entity projections", () => { }), status: "live", error: Option.none(), + page: Option.none(), }), ); diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts index 874bcc30ebdf..6acc3b5d8a4f 100644 --- a/packages/client-runtime/src/state/threadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts @@ -26,6 +26,16 @@ const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000; * WebSocket subscription's first frame. The response is gzip-compressible by * the transport and keeps the (potentially multi-KB) snapshot off the socket. */ +/** + * Optional turn window for a snapshot fetch. Only send a window to servers + * that advertise `threadSnapshotPagination`; older servers reject unknown + * query parameters. + */ +export interface ThreadSnapshotWindow { + readonly turnLimit: number; + readonly beforeCursor?: string; +} + export const fetchEnvironmentThreadSnapshot = Effect.fn( "clientRuntime.state.fetchEnvironmentThreadSnapshot", )(function* (input: { @@ -33,6 +43,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( readonly threadId: ThreadId; readonly signer: Option.Option; readonly timeoutMs?: number; + readonly window?: ThreadSnapshotWindow; }) { const requestUrl = environmentEndpointUrl( input.prepared.httpBaseUrl, @@ -52,6 +63,12 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( input.prepared.httpAuthorization, client.orchestration.threadSnapshot({ params: { threadId: input.threadId }, + payload: { + ...(input.window !== undefined ? { turnLimit: input.window.turnLimit } : {}), + ...(input.window?.beforeCursor !== undefined + ? { beforeCursor: input.window.beforeCursor } + : {}), + }, headers, }), ), @@ -72,6 +89,7 @@ export class ThreadSnapshotLoader extends Context.Service< readonly load: ( prepared: PreparedConnection, threadId: ThreadId, + window?: ThreadSnapshotWindow, ) => Effect.Effect>; } >()("@t3tools/client-runtime/state/threadSnapshotHttp/ThreadSnapshotLoader") {} @@ -89,8 +107,13 @@ export const threadSnapshotLoaderLayer: Layer.Layer< // connections work without one). const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); return ThreadSnapshotLoader.of({ - load: (prepared: PreparedConnection, threadId: ThreadId) => - fetchEnvironmentThreadSnapshot({ prepared, threadId, signer }).pipe( + load: (prepared: PreparedConnection, threadId: ThreadId, window?: ThreadSnapshotWindow) => + fetchEnvironmentThreadSnapshot({ + prepared, + threadId, + signer, + ...(window !== undefined ? { window } : {}), + }).pipe( Effect.map(Option.some), Effect.provideService(HttpClient.HttpClient, httpClient), // A genuinely missing thread (404) is expected — the socket diff --git a/packages/client-runtime/src/state/threadState.ts b/packages/client-runtime/src/state/threadState.ts index 89be139e9256..8ba9696ec576 100644 --- a/packages/client-runtime/src/state/threadState.ts +++ b/packages/client-runtime/src/state/threadState.ts @@ -3,14 +3,38 @@ import * as Option from "effect/Option"; export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; +/** + * Pagination state for a windowed thread. Present only when the loaded thread + * is a window (the server returned `page` metadata); absent means the thread is + * fully loaded — either the server predates pagination or the window reached + * the top. + */ +export interface EnvironmentThreadPageState { + /** Opaque exclusive cursor for the next older slice; null when fully loaded. */ + readonly beforeCursor: string | null; + readonly hasMore: boolean; + /** True while an older page fetch is in flight. */ + readonly loadingOlder: boolean; +} + export interface EnvironmentThreadState { readonly data: Option.Option; readonly status: EnvironmentThreadStatus; readonly error: Option.Option; + readonly page: Option.Option; } export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { data: Option.none(), status: "empty", error: Option.none(), + page: Option.none(), }; + +/** Whether the thread has older turns that can be loaded with more pages. */ +export function threadHasOlderTurns(state: EnvironmentThreadState): boolean { + return Option.match(state.page, { + onNone: () => false, + onSome: (page) => page.hasMore, + }); +} diff --git a/packages/client-runtime/src/state/threads-pagination.test.ts b/packages/client-runtime/src/state/threads-pagination.test.ts new file mode 100644 index 000000000000..62cad18f89e0 --- /dev/null +++ b/packages/client-runtime/src/state/threads-pagination.test.ts @@ -0,0 +1,543 @@ +import { + EnvironmentId, + EventId, + ORCHESTRATION_WS_METHODS, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationMessage, + type OrchestrationThread, + type OrchestrationThreadDetailSnapshot, + type OrchestrationThreadStreamItem, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; +import * as RpcSession from "../rpc/session.ts"; +import type { ThreadSnapshotWindow } from "./threadSnapshotHttp.ts"; +import { + INITIAL_THREAD_USER_TURN_LIMIT, + makeEnvironmentThreadState, + requestOlderThreadTurns, + ThreadSnapshotLoader, + type EnvironmentThreadState, +} from "./threads.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); +const THREAD_ID = ThreadId.make("thread-1"); +const PREPARED: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: TARGET.wsBaseUrl, + httpAuthorization: null, + target: TARGET, +}; + +function message(id: string, turnId: string, createdAt: string): OrchestrationMessage { + return { + id: id as OrchestrationMessage["id"], + role: "assistant", + text: `text of ${id}`, + turnId: TurnId.make(turnId), + streaming: false, + createdAt, + updatedAt: createdAt, + }; +} + +const OLDER_MESSAGE = message("message-old", "turn-1", "2026-04-01T00:00:00.000Z"); +const RECENT_MESSAGE = message("message-recent", "turn-2", "2026-04-01T01:00:00.000Z"); + +// Reverts retain turns via checkpoints with checkpointTurnCount <= the revert's +// turnCount, so both fixture turns carry one: reverting to turnCount 1 keeps +// turn-1 (the older page's turn) and discards turn-2 (the loaded window's). +function checkpoint(turnId: string, turnCount: number): OrchestrationThread["checkpoints"][number] { + return { + turnId: TurnId.make(turnId), + checkpointTurnCount: turnCount, + checkpointRef: + `checkpoint-${turnCount}` as OrchestrationThread["checkpoints"][number]["checkpointRef"], + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2026-04-01T01:00:00.000Z", + }; +} + +const BASE_THREAD: OrchestrationThread = { + id: THREAD_ID, + projectId: ProjectId.make("project-1"), + title: "Windowed thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [RECENT_MESSAGE], + proposedPlans: [], + activities: [], + checkpoints: [checkpoint("turn-2", 2)], + session: null, +}; + +const WINDOWED_SNAPSHOT: OrchestrationThreadDetailSnapshot = { + snapshotSequence: 10, + thread: BASE_THREAD, + page: { beforeCursor: "cursor-1", hasMore: true, snapshotSequence: 10 }, +}; + +const OLDER_PAGE: OrchestrationThreadDetailSnapshot = { + snapshotSequence: 10, + thread: { + ...BASE_THREAD, + messages: [OLDER_MESSAGE], + checkpoints: [checkpoint("turn-1", 1)], + }, + page: { beforeCursor: null, hasMore: false, snapshotSequence: 10 }, +}; + +type LoaderResponse = Option.Option; + +const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (options?: { + readonly paginationCapability?: boolean; + readonly initialResponse?: LoaderResponse; + /** Cached snapshot returned by the cache store (simulates a warm cache). */ + readonly cached?: OrchestrationThreadDetailSnapshot; +}) { + const inputs = yield* Queue.unbounded(); + const observed = yield* Queue.unbounded(); + const loaderWindows = yield* Ref.make>([]); + const lastSubscribeInput = yield* Ref.make | undefined>(undefined); + const savedThreads = yield* Ref.make>([]); + // Older-page responses resolve through deferreds so tests can interleave + // live events with an in-flight page fetch. + const pendingPageResponses = yield* Queue.unbounded>(); + const supervisorState = yield* SubscriptionRef.make( + AVAILABLE_CONNECTION_STATE, + ); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: Record) => + Stream.unwrap(Ref.set(lastSubscribeInput, input).pipe(Effect.as(Stream.fromQueue(inputs)))), + } as unknown as WsRpcProtocolClient; + const session: RpcSession.RpcSession = { + client, + initialConfig: Effect.succeed({ + threadSnapshotPagination: options?.paginationCapability !== false, + } as never), + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + const supervisorSession = yield* SubscriptionRef.make>( + Option.some(session), + ); + const prepared = yield* SubscriptionRef.make>( + Option.some(PREPARED), + ); + const snapshotLoader = ThreadSnapshotLoader.of({ + load: (_prepared, _threadId, window) => + Ref.update(loaderWindows, (current) => [...current, window]).pipe( + Effect.andThen( + window?.beforeCursor === undefined + ? Effect.succeed( + options?.initialResponse ?? Option.none(), + ) + : Deferred.make().pipe( + Effect.tap((deferred) => Queue.offer(pendingPageResponses, deferred)), + Effect.flatMap(Deferred.await), + ), + ), + ), + }); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: supervisorState, + session: supervisorSession, + prepared, + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => + Effect.succeed(options?.cached !== undefined ? Option.some(options.cached) : Option.none()), + saveThread: (_environmentId, thread) => + Ref.update(savedThreads, (current) => [...current, thread]), + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService(ThreadSnapshotLoader, snapshotLoader), + ); + yield* SubscriptionRef.changes(threadState).pipe( + Stream.runForEach((state) => Queue.offer(observed, state)), + Effect.forkScoped, + ); + + const awaitState = (predicate: (state: EnvironmentThreadState) => boolean) => + Queue.take(observed).pipe(Effect.repeat({ until: predicate })); + const resolveNextPage = (response: LoaderResponse) => + Queue.take(pendingPageResponses).pipe( + Effect.flatMap((deferred) => Deferred.succeed(deferred, response)), + ); + + return { + inputs, + observed, + awaitState, + resolveNextPage, + loaderWindows, + lastSubscribeInput, + savedThreads, + threadState, + }; +}); + +const hasMessage = (state: EnvironmentThreadState, id: string): boolean => + Option.match(state.data, { + onNone: () => false, + onSome: (thread) => thread.messages.some((entry) => entry.id === id), + }); + +const titleEvent = (title: string, sequence: number): OrchestrationThreadStreamItem => ({ + kind: "event", + event: { + eventId: EventId.make(`event-title-${sequence}`), + sequence, + occurredAt: "2026-04-01T01:30:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.meta-updated", + payload: { + threadId: THREAD_ID, + title, + updatedAt: "2026-04-01T01:30:00.000Z", + }, + }, +}); + +// Reverting to turnCount 1 retains only turns whose checkpoint count is <= 1: +// turn-1 survives, turn-2 (the loaded window's newest turn) is discarded. +const revertEvent = (sequence: number): OrchestrationThreadStreamItem => ({ + kind: "event", + event: { + eventId: EventId.make(`event-revert-${sequence}`), + sequence, + occurredAt: "2026-04-01T02:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.reverted", + payload: { + threadId: THREAD_ID, + turnCount: 1, + }, + }, +}); + +describe("thread pagination state", () => { + it.effect("windows the initial load when the server advertises pagination", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + const state = yield* harness.awaitState((value) => Option.isSome(value.page)); + expect(Option.getOrThrow(state.page)).toEqual({ + beforeCursor: "cursor-1", + hasMore: true, + loadingOlder: false, + }); + const windows = yield* Ref.get(harness.loaderWindows); + expect(windows[0]?.turnLimit).toBe(INITIAL_THREAD_USER_TURN_LIMIT); + const subscribeInput = yield* Ref.get(harness.lastSubscribeInput); + expect(subscribeInput?.turnLimit).toBe(INITIAL_THREAD_USER_TURN_LIMIT); + }), + ); + + it.effect("does not send a window to servers without the capability", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + paginationCapability: false, + initialResponse: Option.some({ snapshotSequence: 10, thread: BASE_THREAD }), + }); + const state = yield* harness.awaitState((value) => Option.isSome(value.data)); + expect(Option.isNone(state.page)).toBe(true); + const windows = yield* Ref.get(harness.loaderWindows); + expect(windows[0]).toBeUndefined(); + const subscribeInput = yield* Ref.get(harness.lastSubscribeInput); + expect(subscribeInput?.turnLimit).toBeUndefined(); + }), + ); + + it.effect("merges an older page below the loaded window and clears the cursor", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + expect(requestOlderThreadTurns(TARGET.environmentId, THREAD_ID)).toBe(true); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + yield* harness.resolveNextPage(Option.some(OLDER_PAGE)); + + const state = yield* harness.awaitState((value) => hasMessage(value, "message-old")); + const thread = Option.getOrThrow(state.data); + // Older rows land before the loaded window's rows. + expect(thread.messages.map((entry) => entry.id)).toEqual(["message-old", "message-recent"]); + expect(Option.getOrThrow(state.page)).toEqual({ + beforeCursor: null, + hasMore: false, + loadingOlder: false, + }); + }), + ); + + it.effect("discards an in-flight older page when a revert rewrites history", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + // Revert lands while the page fetch is in flight and removes turn-2. + yield* Queue.offer(harness.inputs, revertEvent(11)); + yield* harness.awaitState((value) => !hasMessage(value, "message-recent")); + yield* harness.resolveNextPage(Option.some(OLDER_PAGE)); + + const state = yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => !page.loadingOlder }), + ); + // The stale page was dropped: no resurrected rows, cursor unchanged. + expect(hasMessage(state, "message-old")).toBe(false); + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1"); + }), + ); + + it.effect("discards an in-flight older page when a fresh snapshot replaces the thread", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + yield* Queue.offer(harness.inputs, { + kind: "snapshot", + snapshot: { + snapshotSequence: 20, + thread: { ...BASE_THREAD, title: "Replaced thread" }, + page: { beforeCursor: "cursor-2", hasMore: true, snapshotSequence: 20 }, + }, + }); + yield* harness.awaitState((value) => + Option.match(value.data, { + onNone: () => false, + onSome: (thread) => thread.title === "Replaced thread", + }), + ); + yield* harness.resolveNextPage(Option.some(OLDER_PAGE)); + + const state = yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => !page.loadingOlder }), + ); + expect(hasMessage(state, "message-old")).toBe(false); + // The replacement snapshot's cursor wins over the discarded page's. + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-2"); + }), + ); + + it.effect("discards an older page read from a projection behind the loaded state", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + yield* harness.resolveNextPage(Option.some({ ...OLDER_PAGE, snapshotSequence: 5 })); + + const state = yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => !page.loadingOlder }), + ); + expect(hasMessage(state, "message-old")).toBe(false); + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1"); + }), + ); + + it.effect("a merged history page never advances the live-event dedupe sequence", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + // The page was captured at a newer projection sequence (12) than the + // loaded state (10); merging it must not swallow events 11-12. + yield* harness.resolveNextPage( + Option.some({ + ...OLDER_PAGE, + snapshotSequence: 12, + page: { beforeCursor: null, hasMore: false, snapshotSequence: 12 }, + }), + ); + yield* harness.awaitState((value) => hasMessage(value, "message-old")); + + // Event at sequence 11 must still apply after the merge: the revert + // discards turn-2, so the loaded window's row disappears while the + // merged older turn-1 row survives. If the merge had advanced the + // dedupe sequence to the page's 12, this event would be swallowed. + yield* Queue.offer(harness.inputs, revertEvent(11)); + const state = yield* harness.awaitState( + (value) => !hasMessage(value, "message-recent") && hasMessage(value, "message-old"), + ); + expect(hasMessage(state, "message-old")).toBe(true); + }), + ); + + it.effect("parks a page read ahead of the live state until events catch up", () => + Effect.gen(function* () { + // A page whose thread watermark is ahead of the loaded state may + // contain streaming content the subscription has not delivered yet + // (e.g. an out-of-window subagent turn mid-stream); merging it + // immediately and then replaying those deltas would duplicate text. + // The page parks until the live state reaches the watermark. + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + requestOlderThreadTurns(TARGET.environmentId, THREAD_ID); + yield* harness.awaitState((value) => + Option.match(value.page, { onNone: () => false, onSome: (page) => page.loadingOlder }), + ); + // Page watermark 11 > loaded sequence 10: must park, not merge. + yield* harness.resolveNextPage( + Option.some({ + ...OLDER_PAGE, + snapshotSequence: 11, + page: { beforeCursor: null, hasMore: false, snapshotSequence: 11, threadSequence: 11 }, + }), + ); + + // A live event at sequence 11 arrives; only then does the page merge. + yield* Queue.offer(harness.inputs, titleEvent("Advanced past watermark", 11)); + const state = yield* harness.awaitState((value) => hasMessage(value, "message-old")); + expect(hasMessage(state, "message-recent")).toBe(true); + expect(Option.getOrThrow(state.page).loadingOlder).toBe(false); + }), + ); + + it.effect("a revert keeps the page cursor and triggers no refresh fetch", () => + Effect.gen(function* () { + // Cursors are an (anchor, turnId) keyset derived from event content, so + // they survive the revert projector's row rewrite: the machine keeps + // the stored cursor and performs no snapshot re-fetch. The revert + // reducer's turn filtering alone handles loaded history. + const harness = yield* makeHarness({ initialResponse: Option.some(WINDOWED_SNAPSHOT) }); + yield* harness.awaitState((value) => Option.isSome(value.page)); + + yield* Queue.offer(harness.inputs, revertEvent(11)); + const state = yield* harness.awaitState((value) => !hasMessage(value, "message-recent")); + + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1"); + const windows = yield* Ref.get(harness.loaderWindows); + // Only the initial load hit the loader — no post-revert refresh fetch. + expect(windows.length).toBe(1); + }), + ); + + it.effect("drops a windowed cache when the server lacks the pagination capability", () => + Effect.gen(function* () { + // Resuming a windowed cache via afterSequence against a pre-pagination + // server would render only the window forever with no way to load the + // rest: the machine must discard the cache and take a full snapshot. + const fullSnapshot: OrchestrationThreadDetailSnapshot = { + snapshotSequence: 20, + thread: { ...BASE_THREAD, title: "Full reload" }, + }; + const harness = yield* makeHarness({ + paginationCapability: false, + cached: WINDOWED_SNAPSHOT, + initialResponse: Option.some(fullSnapshot), + }); + + const state = yield* harness.awaitState((value) => + Option.match(value.data, { + onNone: () => false, + onSome: (thread) => thread.title === "Full reload", + }), + ); + expect(Option.isNone(state.page)).toBe(true); + // The subscription resumed from the fresh full snapshot, not the + // discarded windowed cache's watermark, and sent no window fields. + const subscribeInput = yield* Ref.get(harness.lastSubscribeInput); + expect(subscribeInput?.turnLimit).toBeUndefined(); + expect(subscribeInput?.afterSequence).toBe(20); + }), + ); + + it.effect("keeps a windowed cache when the server supports pagination", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: WINDOWED_SNAPSHOT }); + const state = yield* harness.awaitState((value) => Option.isSome(value.page)); + expect(Option.getOrThrow(state.page).beforeCursor).toBe("cursor-1"); + // Wait for the subscription (recorded when the WS method is invoked) + // before asserting its input. + const subscribeInput = yield* Ref.get(harness.lastSubscribeInput).pipe( + Effect.repeat({ until: (input) => input !== undefined }), + ); + expect(subscribeInput?.afterSequence).toBe(10); + }), + ); +}); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 06b5428ca58d..4ba5a0e9df18 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -2,15 +2,18 @@ import { ORCHESTRATION_WS_METHODS, type EnvironmentId as EnvironmentIdType, type OrchestrationThread, + type OrchestrationThreadDetailPage, type OrchestrationThreadDetailSnapshot, type OrchestrationThreadStreamItem, type ThreadId as ThreadIdType, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom } from "effect/unstable/reactivity"; @@ -21,13 +24,14 @@ import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import * as ConnectionWakeups from "../connection/wakeups.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; -import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; +import { ThreadSnapshotLoader, type ThreadSnapshotWindow } from "./threadSnapshotHttp.ts"; import { parseThreadKey, threadKey } from "./entities.ts"; import { applyThreadDetailEvent } from "./threadReducer.ts"; import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE, + type EnvironmentThreadPageState, type EnvironmentThreadState, type EnvironmentThreadStatus, } from "./threadState.ts"; @@ -36,6 +40,85 @@ function statusWithoutLiveData(data: Option.Option): Enviro return Option.isSome(data) ? "cached" : "empty"; } +/** + * Turn window sizes for paginated thread loads: the initial page covers the + * last 10 user-anchored turns (subagent/fan-out turns ride along), each + * "load earlier" tap fetches 20 more. Sized so first paint on the heaviest + * observed threads stays around 100K gzipped while median threads load fully. + */ +export const INITIAL_THREAD_USER_TURN_LIMIT = 10; +export const OLDER_THREAD_PAGE_USER_TURN_LIMIT = 20; + +function pageStateFromSnapshot( + page: OrchestrationThreadDetailPage | undefined, +): Option.Option { + return page === undefined + ? Option.none() + : Option.some({ + beforeCursor: page.beforeCursor, + hasMore: page.hasMore, + loadingOlder: false, + }); +} + +interface ThreadOlderTurnRequestRegistry { + /** + * Registers the live state machine for a thread. Returns the deregistration + * cleanup; registration lives exactly as long as the machine's scope, and a + * successor machine for the same thread simply replaces the entry. + */ + readonly register: (key: string, handler: () => void) => () => void; + readonly request: (key: string) => boolean; +} + +function makeThreadOlderTurnRequestRegistry(): ThreadOlderTurnRequestRegistry { + const handlers = new Map void>(); + return { + register: (key, handler) => { + handlers.set(key, handler); + return () => { + if (handlers.get(key) === handler) { + handlers.delete(key); + } + }; + }, + request: (key) => { + const handler = handlers.get(key); + if (handler === undefined) { + return false; + } + handler(); + return true; + }, + }; +} + +const defaultOlderTurnRequestRegistry = makeThreadOlderTurnRequestRegistry(); + +/** + * Channel from UI actions to the live per-thread state machines. The machines + * resolve it from the Effect environment (overridable in tests); the default + * instance is shared with the sync `requestOlderThreadTurns` entry point so + * the apps get working wiring without providing anything. + */ +export class ThreadOlderTurnRequests extends Context.Reference( + "@t3tools/client-runtime/state/threads/ThreadOlderTurnRequests", + { defaultValue: () => defaultOlderTurnRequestRegistry }, +) {} + +/** + * Asks the live state machine for `threadId` to fetch the next older page. + * Returns false when no machine is live or no fetch was started (no cursor, + * already loading); callers render from `EnvironmentThreadState.page` and can + * treat false as "nothing to do". + */ +export function requestOlderThreadTurns( + environmentId: EnvironmentIdType, + threadId: ThreadIdType, +): boolean { + return defaultOlderTurnRequestRegistry.request(threadKey({ environmentId, threadId })); +} + function formatThreadError(cause: Cause.Cause): string { const error = Cause.squash(cause); return error instanceof Error && error.message.trim().length > 0 @@ -73,6 +156,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make data: cachedThread, status: statusWithoutLiveData(cachedThread), error: Option.none(), + // A cached windowed snapshot restores its page cursor so "load earlier" + // works while rendering from cache; a cached full snapshot has no page. + page: Option.flatMap(cached, (snapshot) => pageStateFromSnapshot(snapshot.page)), }); // Seed the resume cursor from the cached snapshot so a warm cache can catch up // via `afterSequence` instead of re-downloading the full thread body. @@ -80,6 +166,25 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }), ); const awaitingCompletion = yield* Ref.make(false); + // Bumped whenever loaded history may have been rewritten out from under an + // in-flight older-page fetch (snapshot replacement, revert, deletion). A + // page response captured under an older epoch is discarded, not merged. + const historyEpoch = yield* Ref.make(0); + // Serializes stream-item application against older-page staleness checks + + // merges. Without it, a revert or snapshot processed between loadOlderTurns' + // epoch check and its merge could still slip resurrected history in. + const applyLock = yield* Semaphore.make(1); + // Whether the connected server accepts windowed reads; set per subscription + // from the session config. Gates loadOlderTurns so a reconnect to a + // pre-pagination server never sends unsupported window parameters. + const paginationSupported = yield* Ref.make(false); + // An older page whose thread watermark is ahead of the live state, parked + // until the subscription catches up (see mergeOlderPage's caller). At most + // one can exist because loadOlderTurns no-ops while loadingOlder is true. + const pendingOlderPage = yield* Ref.make<{ + readonly snapshot: OrchestrationThreadDetailSnapshot; + readonly epoch: number; + } | null>(null); const persistence = yield* Queue.sliding(1); const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( @@ -124,6 +229,12 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); const setDisconnected = Effect.gen(function* () { yield* Ref.set(awaitingCompletion, false); + // The capability belongs to the session that advertised it. During a + // reconnect, a new prepared connection can exist before the new session's + // config arrives; leaving the old value would let loadOlderTurns send + // window parameters to a server that may not accept them (review + // finding). makeSubscribeInput re-sets it from the next session's config. + yield* Ref.set(paginationSupported, false); yield* SubscriptionRef.update(state, (current) => ({ ...current, status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), @@ -143,28 +254,51 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( thread: OrchestrationThread, + // "keep" preserves the current page state (live events touch only loaded + // recent turns); a snapshot or merged page passes its own page state. + page: Option.Option | "keep", ) { const waiting = yield* Ref.get(awaitingCompletion); - yield* SubscriptionRef.set(state, { + yield* SubscriptionRef.update(state, (current) => ({ data: Option.some(thread), - status: waiting ? "synchronizing" : "live", + status: waiting ? ("synchronizing" as const) : ("live" as const), error: Option.none(), - }); + page: page === "keep" ? current.page : page, + })); // Active threads can update many times per second and retain large tool // payloads. The server remains the source of truth while a turn is active; // persist once it settles so cache encoding stays off the streaming path. if (shouldPersistThread(thread)) { const snapshotSequence = yield* SubscriptionRef.get(lastSequence); - yield* Queue.offer(persistence, { snapshotSequence, thread }); + const currentPage = yield* SubscriptionRef.get(state).pipe(Effect.map((value) => value.page)); + yield* Queue.offer(persistence, { + snapshotSequence, + thread, + // Persist the window boundary with the window's content so a cache + // restore can keep paging from where the loaded history ends. + ...Option.match(currentPage, { + onNone: () => ({}), + onSome: (value) => + ({ + page: { + beforeCursor: value.beforeCursor, + hasMore: value.hasMore, + snapshotSequence, + }, + }) as const, + }), + }); } }); const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { yield* Ref.set(awaitingCompletion, false); + yield* Ref.update(historyEpoch, (epoch) => epoch + 1); yield* SubscriptionRef.set(state, { data: Option.none(), status: "deleted", error: Option.none(), + page: Option.none(), }); yield* cache.removeThread(environmentId, threadId).pipe( Effect.catch((error) => @@ -179,7 +313,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); }); - const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* ( + // Body of applyItem, running under applyLock. + const applyItemLocked = Effect.fn("EnvironmentThreadState.applyItemLocked")(function* ( item: OrchestrationThreadStreamItem, ) { if (item.kind === "synchronized") { @@ -193,8 +328,13 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make } if (item.kind === "snapshot") { + // A fresh snapshot replaces all loaded history, including older + // pages: a turn reverted while disconnected would otherwise survive + // in the preserved history with no event left to remove it. The + // epoch bump discards any older-page fetch racing this snapshot. + yield* Ref.update(historyEpoch, (epoch) => epoch + 1); yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); - yield* setThread(item.snapshot.thread); + yield* setThread(item.snapshot.thread, pageStateFromSnapshot(item.snapshot.page)); return; } @@ -211,12 +351,184 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make } return; } + if (item.event.type === "thread.reverted") { + // A revert rewrites loaded history (whole turns disappear), so an + // older-page fetch in flight may straddle the removed range; the epoch + // bump discards it. The stored page cursor stays valid: cursors are an + // (anchor, turnId) keyset derived from event content, which survives + // the revert projector's row rewrite, so no refresh is needed — the + // revert reducer's turn filtering fully handles loaded history. + yield* Ref.update(historyEpoch, (epoch) => epoch + 1); + } const result = applyThreadDetailEvent(current.data.value, item.event); if (result.kind === "updated") { - yield* setThread(result.thread); + yield* setThread(result.thread, "keep"); } else if (result.kind === "deleted") { yield* setDeleted(); } + // The event may have advanced the live state past a parked page's + // watermark; merge it as soon as that happens. + yield* tryMergePendingOlderPage(); + }); + + // Merges a parked older page once the live state has caught up to the + // page's thread watermark, or discards it if history was rewritten + // (epoch advanced) while it waited. Must run under applyLock. + const tryMergePendingOlderPage = Effect.fn("EnvironmentThreadState.tryMergePendingOlderPage")( + function* () { + const pending = yield* Ref.get(pendingOlderPage); + if (pending === null) { + return; + } + const epochNow = yield* Ref.get(historyEpoch); + if (epochNow !== pending.epoch) { + yield* Ref.set(pendingOlderPage, null); + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + page: Option.map(value.page, (existing) => ({ ...existing, loadingOlder: false })), + })); + return; + } + const watermark = pending.snapshot.page?.threadSequence; + const loadedSequence = yield* SubscriptionRef.get(lastSequence); + if (watermark !== undefined && watermark > loadedSequence) { + return; + } + yield* Ref.set(pendingOlderPage, null); + yield* mergeOlderPage(pending.snapshot); + }, + ); + + const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* ( + item: OrchestrationThreadStreamItem, + ) { + yield* applyLock.withPermits(1)(applyItemLocked(item)); + }); + + // Merges an older disjoint page below the currently loaded window. All four + // windowed collections prepend; identity dedupe guards the (server-bug or + // cursor-misuse) case of overlapping pages so a row never renders twice. + const mergeOlderPage = Effect.fn("EnvironmentThreadState.mergeOlderPage")(function* ( + snapshot: OrchestrationThreadDetailSnapshot, + ) { + // The merge is built inside the update callback so it composes with + // whatever thread value is current at commit time. The applyLock already + // serializes this against event application; the atomic build is defense + // in depth against future callers outside the lock. + let merged: OrchestrationThread | null = null; + yield* SubscriptionRef.update(state, (value) => { + if (Option.isNone(value.data)) { + return value; + } + const loaded = value.data.value; + const older = snapshot.thread; + const mergeById = ( + olderRows: ReadonlyArray, + loadedRows: ReadonlyArray, + ): ReadonlyArray => { + const seen = new Set(loadedRows.map((row) => row.id)); + return [...olderRows.filter((row) => !seen.has(row.id)), ...loadedRows]; + }; + const seenCheckpoints = new Set(loaded.checkpoints.map((row) => row.turnId)); + merged = { + // Thread metadata stays the loaded (newer) snapshot's; only the + // windowed collections gain rows from the older page. + ...loaded, + messages: mergeById(older.messages, loaded.messages), + activities: mergeById(older.activities, loaded.activities), + proposedPlans: mergeById(older.proposedPlans, loaded.proposedPlans), + checkpoints: [ + ...older.checkpoints.filter((row) => !seenCheckpoints.has(row.turnId)), + ...loaded.checkpoints, + ], + }; + return { + ...value, + data: Option.some(merged), + page: pageStateFromSnapshot(snapshot.page), + }; + }); + // Persist the widened window under the *loaded* watermark: the merged + // content is only known consistent with the state it merged into, not + // with the page's own (possibly newer) sequence. + if (merged !== null && shouldPersistThread(merged)) { + const snapshotSequence = yield* SubscriptionRef.get(lastSequence); + yield* Queue.offer(persistence, { + snapshotSequence, + thread: merged, + ...(snapshot.page === undefined ? {} : { page: { ...snapshot.page, snapshotSequence } }), + }); + } + }); + + const loadOlderTurns = Effect.fn("EnvironmentThreadState.loadOlderTurns")(function* () { + // Gated on the connected server's capability: a reconnect to a + // pre-pagination server must never receive window parameters. + if (!(yield* Ref.get(paginationSupported))) { + return; + } + const current = yield* SubscriptionRef.get(state); + const page = Option.getOrNull(current.page); + if (page === null || page.loadingOlder || !page.hasMore || page.beforeCursor === null) { + return; + } + const prepared = Option.getOrNull(yield* SubscriptionRef.get(supervisor.prepared)); + if (prepared === null) { + return; + } + const epochAtStart = yield* Ref.get(historyEpoch); + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + page: Option.map(value.page, (existing) => ({ ...existing, loadingOlder: true })), + })); + const window: ThreadSnapshotWindow = { + turnLimit: OLDER_THREAD_PAGE_USER_TURN_LIMIT, + beforeCursor: page.beforeCursor, + }; + const response = yield* snapshotLoader.load(prepared, threadId, window); + // Staleness check and merge run under the same lock as stream-item + // application, so a revert/snapshot cannot land between them (TOCTOU + // review finding) — anything that rewrites history bumps the epoch + // before this permit is acquired. + yield* applyLock.withPermits(1)( + Effect.gen(function* () { + const epochNow = yield* Ref.get(historyEpoch); + const loadedSequence = yield* SubscriptionRef.get(lastSequence); + // A page carrying a sequence older than the loaded state was read + // from a projection behind what we render; merging it could + // resurrect turns a newer snapshot or revert already removed. + const stale = + epochNow !== epochAtStart || + Option.match(response, { + onNone: () => false, + onSome: (snapshot) => snapshot.snapshotSequence < loadedSequence, + }); + if (Option.isNone(response) || stale) { + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + page: Option.map(value.page, (existing) => ({ ...existing, loadingOlder: false })), + })); + return; + } + // A page read AHEAD of the live state may include content (e.g. + // streaming deltas of an out-of-window turn) the subscription has + // not delivered yet; merging now and then replaying those events + // would duplicate them. Park the page until the live state reaches + // the page's thread-scoped watermark; loadingOlder stays true so + // the UI shows progress and no second fetch starts. Pages from + // pre-watermark servers (threadSequence absent) merge immediately, + // preserving the old behavior. + const watermark = response.value.page?.threadSequence; + if (watermark !== undefined && watermark > loadedSequence) { + yield* Ref.set(pendingOlderPage, { + snapshot: response.value, + epoch: epochNow, + }); + return; + } + yield* mergeOlderPage(response.value); + }), + ); }); yield* SubscriptionRef.changes(supervisor.state).pipe( @@ -244,14 +556,40 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make subscribeDynamic( ORCHESTRATION_WS_METHODS.subscribeThread, Effect.fn("EnvironmentThreadState.makeSubscribeInput")(function* (session) { - const supportsCompletionMarker = yield* session.initialConfig.pipe( - Effect.map((config) => config.threadResumeCompletionMarker === true), - Effect.orElseSucceed(() => false), + const config = yield* session.initialConfig.pipe( + Effect.orElseSucceed( + () => + ({}) as { + threadResumeCompletionMarker?: boolean; + threadSnapshotPagination?: boolean; + }, + ), ); + const supportsCompletionMarker = config.threadResumeCompletionMarker === true; + // Windowed loads are gated on the server capability: pre-pagination + // servers reject unknown query params, and a windowed WS fallback to + // such a server would silently hide history. + const supportsPagination = config.threadSnapshotPagination === true; + yield* Ref.set(paginationSupported, supportsPagination); yield* Ref.set(awaitingCompletion, supportsCompletionMarker); yield* setSynchronizing; let current = yield* SubscriptionRef.get(state); + // A windowed cache resuming against a server without pagination is a + // trap: afterSequence resume keeps only the window, and the missing + // older turns can never be loaded (the server has no cursor reads). + // Drop the window marker and treat the data as needing a full reload. + if (!supportsPagination && Option.isSome(current.page)) { + yield* Ref.update(historyEpoch, (epoch) => epoch + 1); + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + data: Option.none(), + status: value.status === "deleted" ? value.status : ("empty" as const), + page: Option.none(), + })); + yield* SubscriptionRef.set(lastSequence, 0); + current = yield* SubscriptionRef.get(state); + } if (Option.isNone(current.data) && current.status !== "deleted") { const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( Effect.flatMap( @@ -267,7 +605,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }), ), ); - const httpSnapshot = yield* snapshotLoader.load(prepared, threadId); + const httpSnapshot = yield* snapshotLoader.load( + prepared, + threadId, + supportsPagination ? { turnLimit: INITIAL_THREAD_USER_TURN_LIMIT } : undefined, + ); if (Option.isSome(httpSnapshot)) { yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); current = yield* SubscriptionRef.get(state); @@ -288,6 +630,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make threadId, ...(canResume ? { afterSequence: sequence } : {}), ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), + // The WS fallback snapshot (sent when afterSequence is missing or + // the gap is too large) should be windowed the same as the HTTP + // path; without this a resume failure re-downloads the full thread. + ...(supportsPagination ? { turnLimit: INITIAL_THREAD_USER_TURN_LIMIT } : {}), }; }), { @@ -298,13 +644,47 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ).pipe(Stream.runForEach(applyItem)), ); + // Expose loadOlderTurns to UI actions through the request registry. + // Requests funnel through a sliding queue drained serially, so mashing + // "load earlier" coalesces (loadOlderTurns itself no-ops while a fetch is + // in flight). + const olderTurnRequestRegistry = yield* ThreadOlderTurnRequests; + const olderTurnRequests = yield* Queue.sliding(1); + yield* Stream.fromQueue(olderTurnRequests).pipe( + Stream.runForEach(() => loadOlderTurns()), + Effect.forkScoped, + ); + const deregister = olderTurnRequestRegistry.register( + threadKey({ environmentId, threadId }), + () => { + Queue.offerUnsafe(olderTurnRequests, undefined); + }, + ); + yield* Effect.addFinalizer(() => Effect.sync(deregister)); + yield* Effect.addFinalizer(() => Effect.all([SubscriptionRef.get(state), SubscriptionRef.get(lastSequence)]).pipe( Effect.flatMap(([current, snapshotSequence]) => Option.match(current.data, { onNone: () => Effect.void, onSome: (thread) => - shouldPersistThread(thread) ? persist({ snapshotSequence, thread }) : Effect.void, + shouldPersistThread(thread) + ? persist({ + snapshotSequence, + thread, + ...Option.match(current.page, { + onNone: () => ({}), + onSome: (page) => + ({ + page: { + beforeCursor: page.beforeCursor, + hasMore: page.hasMore, + snapshotSequence, + }, + }) as const, + }), + }) + : Effect.void, }), ), ), diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 2d40dad60cc4..f385a2eff2c9 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -457,6 +457,16 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ threadId: ThreadId, }); +// Query-string window for windowed thread snapshots (GET payloads must encode +// to strings). Both fields optional: omitting them keeps the full-snapshot +// behavior, so pagination stays opt-in per request. +const EnvironmentOrchestrationThreadSnapshotQuery = { + turnLimit: Schema.optional( + Schema.FiniteFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)), + ), + beforeCursor: Schema.optional(TrimmedNonEmptyString), +}; + export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") .add( HttpApiEndpoint.get("snapshot", "/api/orchestration/snapshot", { @@ -476,6 +486,7 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr HttpApiEndpoint.get("threadSnapshot", "/api/orchestration/threads/:threadId", { headers: OptionalBearerHeaders, params: EnvironmentOrchestrationThreadSnapshotParams, + payload: EnvironmentOrchestrationThreadSnapshotQuery, success: OrchestrationThreadDetailSnapshot, error: EnvironmentOrchestrationThreadSnapshotErrors, }).middleware(EnvironmentAuthenticatedAuth), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index c9baa6ac6701..7ccb3dc7cac1 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -13,6 +13,7 @@ import { IsoDateTime, MessageId, NonNegativeInt, + PositiveInt, ProjectId, ProviderItemId, ThreadId, @@ -525,12 +526,62 @@ export const OrchestrationSubscribeThreadInput = Schema.Struct({ * snapshot or catch-up replay and before it begins emitting live events. */ requestCompletionMarker: Schema.optionalKey(Schema.Boolean), + /** + * When provided, the fallback snapshot frame (sent when `afterSequence` is + * missing or the catch-up gap is too large) is windowed to the last + * `turnLimit` user-anchored turns and carries `page` metadata. Absent means + * the fallback snapshot is the full thread, preserving pre-pagination client + * behavior. Live events are unaffected either way. + */ + turnLimit: Schema.optionalKey(PositiveInt), }); export type OrchestrationSubscribeThreadInput = typeof OrchestrationSubscribeThreadInput.Type; +/** + * Bounds a thread detail read to a window of recent turns. `turnLimit` counts + * turns with a user pending message (subagent/fan-out turns between them ride + * along), so the window always contains the last N user prompts. `beforeCursor` + * requests the disjoint page of older turns strictly before a previously + * returned cursor. Requests without a window get the full thread; pagination is + * strictly opt-in so older clients keep today's behavior on both HTTP and the + * WebSocket fallback snapshot. + */ +export const OrchestrationThreadDetailWindow = Schema.Struct({ + turnLimit: Schema.optionalKey(PositiveInt), + beforeCursor: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type OrchestrationThreadDetailWindow = typeof OrchestrationThreadDetailWindow.Type; + +/** + * Page metadata for a windowed thread detail read. `beforeCursor` is opaque and + * exclusive: passing it back returns the adjacent disjoint slice of older + * turns. `null` means the thread is fully loaded below this page. The + * `snapshotSequence` mirrors the top-level snapshot sequence so history pages + * can be sequence-checked against live state before merging. + */ +export const OrchestrationThreadDetailPage = Schema.Struct({ + beforeCursor: Schema.NullOr(TrimmedNonEmptyString), + hasMore: Schema.Boolean, + snapshotSequence: NonNegativeInt, + /** + * Highest event sequence applied to THIS thread at page read time. The + * global `snapshotSequence` advances with every thread's events, so a + * client cannot wait for it via its per-thread subscription; this + * thread-scoped watermark is reachable. A client merging an older page + * must first have applied live events up to it — otherwise a streaming + * turn outside the loaded window could have deltas replayed on top of + * page content that already includes them, duplicating text. + */ + threadSequence: Schema.optionalKey(NonNegativeInt), +}); +export type OrchestrationThreadDetailPage = typeof OrchestrationThreadDetailPage.Type; + export const OrchestrationThreadDetailSnapshot = Schema.Struct({ snapshotSequence: NonNegativeInt, thread: OrchestrationThread, + // Present only on windowed responses. Absent on full snapshots (and from + // pre-pagination servers), which clients treat as fully loaded. + page: Schema.optional(OrchestrationThreadDetailPage), }); export type OrchestrationThreadDetailSnapshot = typeof OrchestrationThreadDetailSnapshot.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 20b40dffa755..d7bc4c5c1898 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -434,6 +434,12 @@ export const ServerConfig = Schema.Struct({ shellResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), /** Whether thread subscriptions can emit an opt-in catch-up completion marker. */ threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), + /** + * Whether thread detail reads accept a turn window (`turnLimit`/ + * `beforeCursor`) and return `page` metadata. Clients must not send window + * fields to servers that don't advertise this. + */ + threadSnapshotPagination: Schema.optionalKey(Schema.Boolean), }); export type ServerConfig = typeof ServerConfig.Type; From ae7b27de824e890f2cdfc85018fc9301e7d45022 Mon Sep 17 00:00:00 2001 From: Gabe Fletcher Date: Fri, 7 Aug 2026 00:14:50 -0400 Subject: [PATCH 014/326] fix: prevent reconnect loops during server stalls (#5561) Co-authored-by: t3-turbo-simulation Co-authored-by: Claude Fable 5 Co-authored-by: Theo Browne --- .../src/process/externalLauncher.test.ts | 61 +++++ apps/server/src/process/externalLauncher.ts | 13 +- apps/web/src/components/ChatView.tsx | 11 +- .../src/connection/supervisor.test.ts | 112 ++++++-- .../src/connection/supervisor.ts | 22 +- .../client-runtime/src/rpc/session.test.ts | 27 ++ .../src/state/shell-sync.test.ts | 125 +++++---- packages/client-runtime/src/state/shell.ts | 77 ++++-- packages/shared/src/observability.test.ts | 26 ++ packages/shared/src/observability.ts | 63 ++++- packages/shared/src/shell.ts | 65 +++++ patches/effect@4.0.0-beta.103.patch | 23 +- pnpm-lock.yaml | 247 +++++++++--------- 13 files changed, 637 insertions(+), 235 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 43ca40e9c7c8..36ef82643280 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -7,6 +7,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -155,6 +156,66 @@ it.effect("discovers editors through the service API", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("memoizes editor discovery and refreshes after the cache window", () => { + let statCalls = 0; + const fileInfo = { type: "File" } as FileSystem.File.Info; + const launcherLayer = ExternalLauncher.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + stat: () => + Effect.sync(() => { + statCalls += 1; + return fileInfo; + }), + }), + Path.layer, + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())), + ), + ), + ), + ); + + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + + const first = yield* launcher.resolveAvailableEditors(); + assert.equal(first.includes("vscode"), true); + const statCallsAfterFirstScan = statCalls; + assert.isAbove(statCallsAfterFirstScan, 0); + + // Past the shared command-resolution cache TTL (30s) but within the + // discovery cache window: the memoized set is reused without any scan. + yield* TestClock.adjust("31 seconds"); + const second = yield* launcher.resolveAvailableEditors(); + assert.deepEqual([...second], [...first]); + assert.equal(statCalls, statCallsAfterFirstScan); + + // Past the discovery cache window the next call rescans. + yield* TestClock.adjust("30 seconds"); + yield* launcher.resolveAvailableEditors(); + assert.isAbove(statCalls, statCallsAfterFirstScan); + }).pipe( + Effect.provide( + Layer.mergeAll( + launcherLayer, + Layer.succeed(HostProcessPlatform, "win32"), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + PATH: "C:\\t3-editor-discovery-cache-test", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ), + TestClock.layer(), + ), + ), + ); +}); + it.effect("rejects unknown editors through the service API", () => Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 9c2f0e417d3d..2cac42f0fec6 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -298,6 +298,12 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit return yield* buildAvailableEditors(platform, env); }); +// Editor discovery walks PATH for every known editor and runs for every +// client connect (the server config embeds the available editors). Memoize +// the discovered set for a bounded window so repeat connects skip even the +// per-command cache lookups in @t3tools/shared/shell. +const EDITOR_DISCOVERY_CACHE_TTL = "60 seconds"; + /** * ExternalLauncher - Service tag for browser/editor launch operations. */ @@ -443,8 +449,13 @@ export const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); + const cachedAvailableEditors = yield* Effect.cachedWithTTL( + provideCommandResolutionServices(resolveAvailableEditors()), + EDITOR_DISCOVERY_CACHE_TTL, + ); + return ExternalLauncher.of({ - resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()), + resolveAvailableEditors: () => cachedAvailableEditors, launchBrowser: (target) => launchBrowser(target).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f17e7021c440..708a97be5451 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4732,12 +4732,21 @@ function ChatViewContent(props: ChatViewProps) { isSendBusy || isConnecting || threadDetailLoading || - activeEnvironmentUnavailable || sendInFlightRef.current ) { notifyDirectAnnotationAttached(); return; } + if (activeEnvironmentUnavailable) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Not connected: message not sent", + description: "Reconnecting to the environment. Try again once it is connected.", + }), + ); + return; + } if (activePendingProgress) { if (directAnnotation) { notifyDirectAnnotationAttached(); diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index a925859049ff..5e50c44d9610 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -248,7 +248,7 @@ describe("EnvironmentSupervisor", () => { const firstAttempt = spans.find((span) => span.name === "relay.connection.attempt"); expect(firstAttempt).toBeDefined(); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState(supervisor.state, (state) => state.phase === "connected"); const attempts = spans.filter((span) => span.name === "relay.connection.attempt"); @@ -358,7 +358,7 @@ describe("EnvironmentSupervisor", () => { ); expect(yield* Ref.get(harness.prepareCount)).toBe(1); - for (const [index, delay] of [1_000, 2_000, 4_000, 8_000, 16_000, 16_000].entries()) { + for (const [index, delay] of [3_000, 4_000, 8_000, 16_000, 16_000, 16_000].entries()) { yield* TestClock.adjust(delay); yield* eventuallyState( supervisor.state, @@ -384,7 +384,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); const retrying = yield* awaitState( supervisor.state, @@ -489,7 +489,7 @@ describe("EnvironmentSupervisor", () => { }, }); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState(supervisor.state, (state) => state.phase === "connected"); expect(yield* Ref.get(harness.prepareCount)).toBe(2); }).pipe(Effect.provide(TestClock.layer())), @@ -526,7 +526,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* eventuallyState( supervisor.state, (state) => state.phase === "backoff" && state.attempt === 2, @@ -539,7 +539,7 @@ describe("EnvironmentSupervisor", () => { ); expect(yield* Ref.get(harness.prepareCount)).toBe(3); - yield* TestClock.adjust("999 millis"); + yield* TestClock.adjust("2999 millis"); expect(yield* Ref.get(harness.prepareCount)).toBe(3); yield* TestClock.adjust("1 milli"); yield* eventuallyState( @@ -588,7 +588,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "blocked" && state.attempt === 2, @@ -703,7 +703,7 @@ describe("EnvironmentSupervisor", () => { ); expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, @@ -728,7 +728,7 @@ describe("EnvironmentSupervisor", () => { (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, @@ -741,7 +741,7 @@ describe("EnvironmentSupervisor", () => { expect(secondFailure.retryAt).not.toBeNull(); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); expect(yield* Ref.get(harness.sessionCount)).toBe(2); yield* TestClock.adjust("1 second"); @@ -766,7 +766,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, @@ -805,7 +805,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 2, @@ -834,7 +834,7 @@ describe("EnvironmentSupervisor", () => { supervisor.state, (state) => state.phase === "backoff" && state.attempt === 1, ); - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("3 seconds"); yield* awaitState( supervisor.state, (state) => state.phase === "connecting" && state.attempt === 2, @@ -925,9 +925,14 @@ describe("EnvironmentSupervisor", () => { }), ); - it.effect("reconnects when the foreground liveness probe fails", () => + it.effect("reconnects immediately when the foreground liveness probe fails", () => Effect.gen(function* () { + const allowReconnect = yield* Deferred.make(); const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 2 + ? Deferred.await(allowReconnect).pipe(Effect.as(PREPARED_CONNECTION)) + : Effect.succeed(PREPARED_CONNECTION), probe: (attempt) => attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void, }); @@ -937,15 +942,77 @@ describe("EnvironmentSupervisor", () => { yield* awaitState(supervisor.state, (state) => state.phase === "connected"); yield* harness.wake("application-active"); - yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); - yield* TestClock.adjust("1 second"); + const reconnecting = yield* awaitState( + supervisor.state, + (state) => state.phase === "connecting", + ); + expect(reconnecting.attempt).toBe(1); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + + // No TestClock advance: a failed wake probe skips the first backoff rung. + yield* Deferred.succeed(allowReconnect, undefined); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("keeps normal backoff when a reconnect after a failed wake probe also fails", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 2 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), + probe: (attempt) => + attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void, + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + // The immediate follow-up attempt fails: only the first attempt after + // the wake probe skips the ladder, so this failure backs off normally. + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + yield* TestClock.adjust("2999 millis"); + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + yield* TestClock.adjust("1 milli"); yield* eventuallyState( supervisor.state, (state) => state.phase === "connected" && state.generation === 2, ); + expect(yield* Ref.get(harness.prepareCount)).toBe(3); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("uses the full tolerance window for a stalled desktop foreground probe", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), + }); + const supervisor = yield* EnvironmentSupervisor.make(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + yield* TestClock.adjust("14999 millis"); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + yield* TestClock.adjust("1 milli"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1, + ); + expect(yield* Ref.get(harness.sessionCount)).toBe(2); - expect(yield* Ref.get(harness.releaseCount)).toBe(1); }).pipe(Effect.provide(TestClock.layer())), ); @@ -961,15 +1028,14 @@ describe("EnvironmentSupervisor", () => { yield* awaitState(supervisor.state, (state) => state.phase === "connected"); yield* harness.wake("application-active-probe"); yield* TestClock.adjust("3 seconds"); + // The timed-out wake probe reconnects immediately without a backoff + // sleep: no further clock advance is needed. yield* awaitState( supervisor.state, - (state) => state.phase === "backoff" && state.lastFailure?.reason === "timeout", - ); - yield* TestClock.adjust("1 second"); - yield* eventuallyState( - supervisor.state, - (state) => state.phase === "connected" && state.generation === 2, + (state) => state.phase === "connected" && state.generation === 2 && state.attempt === 1, ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); }).pipe(Effect.provide(TestClock.layer())), ); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 2a9c7519072b..85fda10ef1a7 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -29,7 +29,7 @@ import * as RpcSession from "../rpc/session.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import * as ConnectionWakeups from "./wakeups.ts"; -const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; +const RETRY_DELAYS_MS = [3_000, 4_000, 8_000, 16_000] as const; const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; const CONNECTION_PROBE_TIMEOUT = "15 seconds"; const MOBILE_CONNECTION_PROBE_TIMEOUT = "3 seconds"; @@ -232,6 +232,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const intent = yield* Ref.make(initialIntent); const signals = yield* Queue.unbounded(); const resetRetryState = yield* Ref.make(false); + // Set when a foreground wake probe fails or times out: the user is actively + // returning to the app on a dead transport, so the follow-up reconnect skips + // the first backoff rung instead of sleeping. + const wakeProbeFailed = yield* Ref.make(false); const state = yield* SubscriptionRef.make( !initialIntent.desired ? availableState(initialIntent, 0) @@ -441,6 +445,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( ), ); if (probeEvent._tag === "ProbeCompleted") { + if (Exit.isFailure(probeEvent.exit)) { + yield* Ref.set(wakeProbeFailed, true); + } yield* probeEvent.exit; break; } @@ -673,6 +680,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const outcome: AttemptOutcome = yield* Effect.scoped( runAttempt(attempt, nextGeneration, latestFailure, pendingRetry), ); + // Consumed on every iteration so a stale marker can never leak into a + // later, unrelated failure. + const failedWakeProbe = yield* Ref.getAndSet(wakeProbeFailed, false); if (outcome.established) { generation = nextGeneration; if (outcome.stable) { @@ -709,6 +719,16 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( continue; } + if (failedWakeProbe) { + // The wake probe found a dead transport while the user is returning to + // the app, so reconnect immediately instead of sleeping the first + // backoff rung. Only this first attempt skips the ladder; if it fails + // too, normal backoff resumes. + resetRetryLadder(); + yield* setState(connectingState(yield* Ref.get(intent), generation, 1, error)); + continue; + } + failureCount += 1; const delayMs = retryDelayMs(failureCount - 1); pendingRetry = Option.map(attemptSpan, (previousAttempt) => ({ diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index f7868834b57c..0af5850bf6c7 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -287,6 +287,33 @@ describe("RpcSessionFactory", () => { }), ); + it.effect("tolerates two missed pong windows before closing the session", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const closedFiber = yield* Effect.forkChild(Effect.flip(session.closed)); + const socket = yield* awaitSocket(sockets); + + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + yield* TestClock.adjust("15 seconds"); + expect(closedFiber.pollUnsafe()).toBeUndefined(); + expect(socket.sent.slice(1).map((request) => decodeJson(request))).toEqual([ + { _tag: "Ping" }, + { _tag: "Ping" }, + { _tag: "Ping" }, + ]); + + yield* TestClock.adjust("5 seconds"); + const error = yield* Fiber.join(closedFiber); + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error).toMatchObject({ reason: "transport" }); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + it.effect("reaches ready when a newer server sends unknown config members", () => Effect.gen(function* () { const { factory, sockets } = yield* makeFactory(); diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index e006fc3cd762..40e9bd80dc5b 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -150,34 +150,34 @@ describe("environment shell synchronization", () => { }), ); - it.effect("replaces a warm shell cache with an authoritative HTTP snapshot", () => + it.effect("requests a full socket snapshot when the HTTP refresh fails", () => Effect.gen(function* () { const cachedSnapshot: OrchestrationShellSnapshot = { snapshotSequence: 5, projects: [], - threads: [{ id: "stale-thread" } as never], + threads: [{ id: "cached-thread" } as never], updatedAt: "2026-06-06T00:00:00.000Z", }; - const httpSnapshot: OrchestrationShellSnapshot = { + const resetSnapshot: OrchestrationShellSnapshot = { ...cachedSnapshot, - snapshotSequence: 9, + snapshotSequence: 9_999, threads: [], updatedAt: "2026-06-07T00:00:00.000Z", }; const events = yield* Queue.unbounded(); - const capturedAfterSequence = yield* SubscriptionRef.make(undefined); - const capturedCompletionMarker = yield* Ref.make(undefined); - const loaderCalls = yield* SubscriptionRef.make(0); + const wakeups = yield* Queue.unbounded(); + const subscribeInputs = yield* Queue.unbounded<{ + readonly afterSequence?: number; + readonly requestCompletionMarker?: boolean; + }>(); + const loaderCalls = yield* Ref.make(0); const client = { [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; }) => Stream.unwrap( - Ref.set(capturedCompletionMarker, input.requestCompletionMarker).pipe( - Effect.andThen(SubscriptionRef.set(capturedAfterSequence, input.afterSequence)), - Effect.as(Stream.fromQueue(events)), - ), + Queue.offer(subscribeInputs, input).pipe(Effect.as(Stream.fromQueue(events))), ), } as unknown as WsRpcProtocolClient; const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); @@ -208,57 +208,66 @@ describe("environment shell synchronization", () => { clear: () => Effect.void, }); const snapshotLoader = ShellSnapshotLoader.of({ - load: () => - SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe( - Effect.as(Option.some(httpSnapshot)), - ), + load: () => Ref.update(loaderCalls, (count) => count + 1).pipe(Effect.as(Option.none())), }); const shellState = yield* makeEnvironmentShellState().pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.provideService(Persistence.EnvironmentCacheStore, cache), Effect.provideService(ShellSnapshotLoader, snapshotLoader), + Effect.provideService( + ConnectionWakeups.ConnectionWakeups, + ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.fromQueue(wakeups) }), + ), ); - // Wait until the subscription is established from the warm cache. - yield* SubscriptionRef.changes(capturedAfterSequence).pipe( - Stream.filter((value) => value !== undefined), - Stream.runHead, - ); - - expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(9); - expect(yield* Ref.get(capturedCompletionMarker)).toBe(true); - expect(yield* SubscriptionRef.get(loaderCalls)).toBe(1); + const subscribeInput = yield* Queue.take(subscribeInputs); + expect(subscribeInput.afterSequence).toBeUndefined(); + expect(subscribeInput.requestCompletionMarker).toBe(true); + expect(yield* Ref.get(loaderCalls)).toBe(1); const synchronizing = yield* SubscriptionRef.get(shellState); expect(synchronizing.status).toBe("synchronizing"); - expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(httpSnapshot); + expect(Option.getOrThrow(synchronizing.snapshot)).toEqual(cachedSnapshot); + yield* Queue.offer(events, { kind: "snapshot", snapshot: resetSnapshot }); yield* Queue.offer(events, { kind: "synchronized" }); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter((value) => value.status === "live"), Stream.runHead, ); + + const live = yield* SubscriptionRef.get(shellState); + expect(Option.getOrThrow(live.snapshot)).toEqual(resetSnapshot); + expect(yield* Ref.get(loaderCalls)).toBe(1); + + yield* Queue.offer(wakeups, "application-active"); + const resumedInput = yield* Queue.take(subscribeInputs); + expect(resumedInput.afterSequence).toBe(resetSnapshot.snapshotSequence); + expect(resumedInput.requestCompletionMarker).toBe(true); + expect(yield* Ref.get(loaderCalls)).toBe(1); }), ); - it.effect("refreshes the authoritative shell snapshot when the app becomes active", () => + it.effect("resubscribes from the in-memory shell cursor when the app becomes active", () => Effect.gen(function* () { const events = yield* Queue.unbounded(); const wakeups = yield* Queue.unbounded(); const loaderCalls = yield* Ref.make(0); - const subscriptionCount = yield* Ref.make(0); + const capturedAfterSequences = yield* Ref.make>([]); const client = { - [ORCHESTRATION_WS_METHODS.subscribeShell]: () => + [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number }) => Stream.unwrap( - Ref.update(subscriptionCount, (count) => count + 1).pipe( - Effect.as(Stream.fromQueue(events)), - ), + Ref.update(capturedAfterSequences, (captured) => [ + ...captured, + input.afterSequence, + ]).pipe(Effect.as(Stream.fromQueue(events))), ), } as unknown as WsRpcProtocolClient; const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); + const activeSession = yield* SubscriptionRef.make(Option.some(session(client))); const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, state: supervisorState, - session: yield* SubscriptionRef.make(Option.some(session(client))), + session: activeSession, prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), connect: Effect.void, disconnect: Effect.void, @@ -296,54 +305,60 @@ describe("environment shell synchronization", () => { ), ); - yield* SubscriptionRef.changes(shellState).pipe( - Stream.filter( - (value) => - value.status === "synchronizing" && - Option.isSome(value.snapshot) && - value.snapshot.value.snapshotSequence === 10, - ), - Stream.runHead, - ); + // A new session starts from an authoritative HTTP snapshot. + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(capturedAfterSequences)).length >= 1) break; + yield* Effect.yieldNow; + } + expect(yield* Ref.get(capturedAfterSequences)).toEqual([10]); yield* Queue.offer(events, { kind: "synchronized" }); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter((value) => value.status === "live"), Stream.runHead, ); - yield* Queue.offer(wakeups, "application-active"); + // A newer snapshot arrives on the stream and advances the cursor. + yield* Queue.offer(events, { + kind: "snapshot", + snapshot: { ...LIVE_SHELL_SNAPSHOT, snapshotSequence: 40 }, + }); yield* SubscriptionRef.changes(shellState).pipe( Stream.filter( - (value) => - value.status === "synchronizing" && - Option.isSome(value.snapshot) && - value.snapshot.value.snapshotSequence === 20, + (value) => Option.isSome(value.snapshot) && value.snapshot.value.snapshotSequence === 40, ), Stream.runHead, ); + yield* Queue.offer(wakeups, "application-active"); for (let attempt = 0; attempt < 100; attempt += 1) { - if ((yield* Ref.get(subscriptionCount)) >= 2) break; + if ((yield* Ref.get(capturedAfterSequences)).length >= 2) break; yield* Effect.yieldNow; } - - expect(yield* Ref.get(loaderCalls)).toBe(2); - expect(yield* Ref.get(subscriptionCount)).toBe(2); + expect(yield* Ref.get(capturedAfterSequences)).toEqual([10, 40]); + yield* Queue.offer(events, { kind: "synchronized" }); yield* Queue.offer(wakeups, "application-active-probe"); for (let attempt = 0; attempt < 100; attempt += 1) { - if ((yield* Ref.get(subscriptionCount)) >= 3) break; + if ((yield* Ref.get(capturedAfterSequences)).length >= 3) break; yield* Effect.yieldNow; } - expect(yield* Ref.get(loaderCalls)).toBe(3); - expect(yield* Ref.get(subscriptionCount)).toBe(3); + expect(yield* Ref.get(capturedAfterSequences)).toEqual([10, 40, 40]); yield* Queue.offer(wakeups, "application-active-reconnect"); for (let attempt = 0; attempt < 10; attempt += 1) { yield* Effect.yieldNow; } - expect(yield* Ref.get(loaderCalls)).toBe(3); - expect(yield* Ref.get(subscriptionCount)).toBe(3); + expect((yield* Ref.get(capturedAfterSequences)).length).toBe(3); + expect(yield* Ref.get(loaderCalls)).toBe(1); + + // Replacing the session performs another authoritative refresh. + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(capturedAfterSequences)).length >= 4) break; + yield* Effect.yieldNow; + } + expect(yield* Ref.get(capturedAfterSequences)).toEqual([10, 40, 40, 20]); + expect(yield* Ref.get(loaderCalls)).toBe(2); }), ); }); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index a266af5f5f4e..c150bbb75b8c 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -21,6 +21,7 @@ import * as ConnectionWakeups from "../connection/wakeups.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; +import type { RpcSession } from "../rpc/session.ts"; import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; import type { EnvironmentCatalogState } from "./connections.ts"; @@ -71,6 +72,8 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") error: Option.none(), }); const awaitingCompletion = yield* Ref.make(false); + const lastAuthoritativeSession = yield* Ref.make(null); + const activeSubscriptionSession = yield* Ref.make(null); const persistence = yield* Queue.sliding(1); const persist = Effect.fn("EnvironmentShellState.persist")(function* ( @@ -166,6 +169,12 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") status: waiting ? "synchronizing" : "live", error: Option.none(), }); + if (item.kind === "snapshot") { + const session = yield* Ref.get(activeSubscriptionSession); + if (session !== null) { + yield* Ref.set(lastAuthoritativeSession, session); + } + } yield* Queue.offer(persistence, nextSnapshot); }); @@ -180,6 +189,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") subscribeDynamic( ORCHESTRATION_WS_METHODS.subscribeShell, Effect.fn("EnvironmentShellState.makeSubscribeInput")(function* (session) { + yield* Ref.set(activeSubscriptionSession, session); const supportsCompletionMarker = yield* session.initialConfig.pipe( Effect.map((config) => config.shellResumeCompletionMarker === true), Effect.orElseSucceed(() => false), @@ -187,30 +197,53 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") yield* Ref.set(awaitingCompletion, supportsCompletionMarker); yield* setSynchronizing; - const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( - Effect.flatMap( - Option.match({ - onSome: Effect.succeed, - onNone: () => - SubscriptionRef.changes(supervisor.prepared).pipe( - Stream.filter(Option.isSome), - Stream.map((value) => value.value), - Stream.runHead, - Effect.map(Option.getOrThrow), - ), - }), - ), - ); - const httpSnapshot = yield* snapshotLoader.load(prepared); - if (Option.isSome(httpSnapshot)) { - yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); - return { - afterSequence: httpSnapshot.value.snapshotSequence, - ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), - }; + // Foreground resubscriptions on the same live session can resume from + // the in-memory cursor. A new session reloads the authoritative HTTP + // snapshot so a valid cursor cannot preserve incomplete cached data. + const hasAuthoritativeSnapshot = (yield* Ref.get(lastAuthoritativeSession)) === session; + let canResume = hasAuthoritativeSnapshot; + let current = yield* SubscriptionRef.get(state); + if (!hasAuthoritativeSnapshot || Option.isNone(current.snapshot)) { + const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + SubscriptionRef.changes(supervisor.prepared).pipe( + Stream.filter(Option.isSome), + Stream.map((value) => value.value), + Stream.runHead, + Effect.map(Option.getOrThrow), + ), + }), + ), + ); + const httpSnapshot = yield* snapshotLoader.load(prepared); + if (Option.isSome(httpSnapshot)) { + yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); + canResume = true; + current = yield* SubscriptionRef.get(state); + } } - return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}; + // If the authoritative refresh failed, omit the cached cursor so the + // socket fallback sends a complete snapshot for this new session. + if (!canResume || Option.isNone(current.snapshot)) { + return supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}; + } + if (!supportsCompletionMarker) { + // Without a completion marker there is no synchronized signal for a + // resumed subscription, so report live immediately, like threads. + yield* SubscriptionRef.update(state, (value) => ({ + ...value, + status: "live" as const, + error: Option.none(), + })); + } + return { + afterSequence: current.snapshot.value.snapshotSequence, + ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), + }; }), { onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)), diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index 4bd1070bf1f1..c58395393d37 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -21,6 +21,7 @@ import { makeTraceSink, type TraceRecord, type TraceSinkFlushStats, + truncateTraceAttributes, } from "./observability.ts"; describe("errorTag", () => { @@ -111,6 +112,31 @@ const makeTestLayer = (tracePath: string) => const nodeServicesIt = it.layer(NodeServices.layer); +describe("truncateTraceAttributes", () => { + it("clamps oversized strings at any depth without mutating the input", () => { + const stack = "s".repeat(2_000); + const attributes = { + "db.query.text": "q".repeat(2_000), + short: "ok", + error: { name: "Error", stack, nested: ["a".repeat(2_000)] }, + }; + const truncated = truncateTraceAttributes(attributes); + + assert.equal((truncated["db.query.text"] as string).length, 200 + "…[truncated]".length); + assert.equal(truncated["short"], "ok"); + const error = truncated["error"] as { stack: string; nested: Array }; + assert.equal(error.stack.length, 500 + "…[truncated]".length); + assert.equal(error.nested[0]?.length, 500 + "…[truncated]".length); + // Input is untouched: the live span's attributes are shared. + assert.equal(attributes.error.stack, stack); + }); + + it("returns the same reference when nothing exceeds the limits", () => { + const attributes = { short: "ok", nested: { fine: "also ok" } }; + assert.equal(truncateTraceAttributes(attributes), attributes); + }); +}); + describe("observability", () => { it("normalizes circular arrays, maps, and sets without recursing forever", () => { const array: Array = ["alpha"]; diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index e0a7595865d9..67057c548806 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -248,6 +248,61 @@ function formatTraceExit(exit: Exit.Exit): EffectTraceRecord[" }; } +const TRACE_ATTRIBUTE_MAX_LENGTH = 500; +const TRACE_ATTRIBUTE_TRUNCATED_LENGTH = 200; +const TRACE_ATTRIBUTE_TRUNCATION_SUFFIX = "…[truncated]"; +const ALWAYS_TRUNCATED_TRACE_ATTRIBUTES: ReadonlySet = new Set(["db.query.text"]); + +// Clamps strings nested inside already-normalized attribute values (arrays and +// plain objects from normalizeJsonValue, e.g. an Error's `stack`). Returns the +// input reference when nothing was clamped. +function truncateNestedValue(value: unknown): unknown { + if (typeof value === "string") { + return value.length <= TRACE_ATTRIBUTE_MAX_LENGTH + ? value + : `${value.slice(0, TRACE_ATTRIBUTE_MAX_LENGTH)}${TRACE_ATTRIBUTE_TRUNCATION_SUFFIX}`; + } + if (Array.isArray(value)) { + const truncated = value.map(truncateNestedValue); + return truncated.some((entry, index) => entry !== value[index]) ? truncated : value; + } + if (isPlainObject(value)) { + let truncated: Record | undefined; + for (const [key, entry] of Object.entries(value)) { + const next = truncateNestedValue(entry); + if (next === entry) continue; + truncated ??= { ...value }; + truncated[key] = next; + } + return truncated ?? value; + } + return value; +} + +/** + * Clamps oversized attribute values on the serialized trace record so the file + * sink stays small, including strings nested inside arrays and objects (e.g. + * error stacks). Returns a new record when anything was clamped; never + * mutates the input (the live span's attributes are shared with other tracers). + */ +export function truncateTraceAttributes(attributes: TraceAttributes): TraceAttributes { + let truncated: Record | undefined; + for (const [key, value] of Object.entries(attributes)) { + if (typeof value === "string" && ALWAYS_TRUNCATED_TRACE_ATTRIBUTES.has(key)) { + if (value.length <= TRACE_ATTRIBUTE_TRUNCATED_LENGTH) continue; + truncated ??= { ...attributes }; + truncated[key] = + `${value.slice(0, TRACE_ATTRIBUTE_TRUNCATED_LENGTH)}${TRACE_ATTRIBUTE_TRUNCATION_SUFFIX}`; + continue; + } + const next = truncateNestedValue(value); + if (next === value) continue; + truncated ??= { ...attributes }; + truncated[key] = next; + } + return truncated ?? attributes; +} + export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { const status = span.status as Extract; const parentSpanId = Option.getOrUndefined(span.parent)?.spanId; @@ -263,16 +318,18 @@ export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord { startTimeUnixNano: String(status.startTime), endTimeUnixNano: String(status.endTime), durationMs: Number(status.endTime - status.startTime) / 1_000_000, - attributes: compactTraceAttributes(Object.fromEntries(span.attributes)), + attributes: truncateTraceAttributes( + compactTraceAttributes(Object.fromEntries(span.attributes)), + ), events: span.events.map(([name, startTime, attributes]) => ({ name, timeUnixNano: String(startTime), - attributes: compactTraceAttributes(attributes), + attributes: truncateTraceAttributes(compactTraceAttributes(attributes)), })), links: span.links.map((link) => ({ traceId: link.span.traceId, spanId: link.span.spanId, - attributes: compactTraceAttributes(link.attributes), + attributes: truncateTraceAttributes(compactTraceAttributes(link.attributes)), })), exit: formatTraceExit(status.exit), }; diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index cf2f2417ff4b..efdd05683abc 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -3,6 +3,7 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; +import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -491,6 +492,54 @@ function resolveCommandCandidates( return Array.from(new Set(candidates)); } +// Session bootstrap resolves the same commands over and over, each PATH scan +// costing hundreds of 'shell.isExecutableFile' filesystem probes (tens of +// thousands per connect). Memoize the scan outcome per +// (platform, PATH, PATHEXT, command) for a short window: repeat scans hit the +// cache while any change to the search environment invalidates immediately. +// Explicit-path resolution is never cached - callers probe paths they have +// just written (e.g. managed binary installs). A "not-found" outcome is also +// cached for the TTL, so a just-installed binary can stay invisible for up to +// 30s unless resolved by explicit path. +// TTL expiry uses the monotonic clock (Clock.currentTimeNanos) so backward +// wall-clock adjustments cannot keep expired entries alive. +const COMMAND_RESOLUTION_CACHE_TTL_NANOS = 30_000_000_000n; +const COMMAND_RESOLUTION_CACHE_MAX_ENTRIES = 512; +const COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR = String.fromCharCode(0); + +interface CommandResolutionCacheEntry { + readonly resolvedPath: string | null; + readonly expiresAtNanos: bigint; +} + +// The cache lives in the Effect environment (like HostProcessPlatform above) +// so tests and embedders can provide an isolated instance; the default is a +// single process-wide map shared by all consumers. +export const CommandResolutionCache = Context.Reference>( + "@t3tools/shared/shell/CommandResolutionCache", + { + defaultValue: () => new Map(), + }, +); + +function cacheCommandResolution( + cache: Map, + cacheKey: string, + resolvedPath: string | null, + nowNanos: bigint, +): void { + if (cache.size >= COMMAND_RESOLUTION_CACHE_MAX_ENTRIES) { + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) { + cache.delete(oldestKey); + } + } + cache.set(cacheKey, { + resolvedPath, + expiresAtNanos: nowNanos + COMMAND_RESOLUTION_CACHE_TTL_NANOS, + }); +} + const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* ( filePath: string, platform: NodeJS.Platform, @@ -538,6 +587,20 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat if (pathValue.length === 0) { return yield* new CommandResolutionError({ command, reason: "not-found" }); } + + const cacheKey = [platform, pathValue, windowsPathExtensions.join(";"), command].join( + COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR, + ); + const cache = yield* CommandResolutionCache; + const nowNanos = yield* Clock.currentTimeNanos; + const cached = cache.get(cacheKey); + if (cached !== undefined && cached.expiresAtNanos > nowNanos) { + if (cached.resolvedPath === null) { + return yield* new CommandResolutionError({ command, reason: "not-found" }); + } + return cached.resolvedPath; + } + const pathEntries: string[] = []; for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { const pathEntry = stripWrappingQuotes(entry.trim()); @@ -550,10 +613,12 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat for (const candidate of commandCandidates) { const candidatePath = path.join(pathEntry, candidate); if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { + cacheCommandResolution(cache, cacheKey, candidatePath, nowNanos); return candidatePath; } } } + cacheCommandResolution(cache, cacheKey, null, nowNanos); return yield* new CommandResolutionError({ command, reason: "not-found" }); }); diff --git a/patches/effect@4.0.0-beta.103.patch b/patches/effect@4.0.0-beta.103.patch index 561db6f52630..a46ccf9c9764 100644 --- a/patches/effect@4.0.0-beta.103.patch +++ b/patches/effect@4.0.0-beta.103.patch @@ -278,32 +278,43 @@ index b536d0a..12ffac0 100644 }).pipe(Effect.flatMap(() => Effect.fail(new Socket.SocketError({ reason: new Socket.SocketCloseError({ code: 1000 -@@ -687,20 +716,20 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun +@@ -687,20 +716,28 @@ export const makeProtocolSocket = options => Protocol.make(Effect.fnUntraced(fun }; })); const defaultRetryPolicy = /*#__PURE__*/Schedule.min([/*#__PURE__*/Schedule.exponential(500, 1.5), /*#__PURE__*/Schedule.spaced(5000)]); -const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing) { +const makePinger = /*#__PURE__*/Effect.fnUntraced(function* (writePing, hooks) { let recievedPong = true; ++ let missedPongs = 0; const latch = Latch.makeUnsafe(); const reset = () => { recievedPong = true; ++ missedPongs = 0; latch.closeUnsafe(); }; - const onPong = () => { -+ const onPong = Effect.sync(() => { - recievedPong = true; +- recievedPong = true; - }; ++ const onPong = Effect.sync(() => { ++ recievedPong = true; ++ missedPongs = 0; + }).pipe(Effect.andThen(hooks?.onPong ?? Effect.void)); yield* Effect.suspend(() => { - if (!recievedPong) return latch.open; - recievedPong = false; +- if (!recievedPong) return latch.open; +- recievedPong = false; - return writePing; ++ if (!recievedPong) { ++ missedPongs += 1; ++ if (missedPongs >= 3) return latch.open; ++ return (hooks?.onPing ?? Effect.void).pipe(Effect.andThen(writePing)); ++ } ++ recievedPong = false; ++ missedPongs = 0; + return (hooks?.onPing ?? Effect.void).pipe(Effect.andThen(writePing)); }).pipe(Effect.delay("5 seconds"), Effect.ignore, Effect.forever, Effect.interruptible, Effect.forkScoped); return { timeout: latch.await, -@@ -843,6 +872,11 @@ export const makeProtocolWorker = options => Protocol.make(Effect.fnUntraced(fun +@@ -843,6 +880,11 @@ export const makeProtocolWorker = options => Protocol.make(Effect.fnUntraced(fun * @since 4.0.0 */ export const layerProtocolWorker = /*#__PURE__*/flow(makeProtocolWorker, /*#__PURE__*/Layer.effect(Protocol)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7c6f4cc1f5e..0be461aacbf1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,7 +76,7 @@ patchedDependencies: '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 - effect@4.0.0-beta.103: a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9 + effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 @@ -116,7 +116,7 @@ importers: version: 0.0.3 '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -134,7 +134,7 @@ importers: version: link:../../packages/tailscale effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) electron: specifier: 41.5.0 version: 41.5.0 @@ -153,7 +153,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -199,7 +199,7 @@ importers: version: 4.1.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) '@effect/atom-react': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.3)(scheduler@0.27.0) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.3)(scheduler@0.27.0) '@expo-google-fonts/dm-sans': specifier: ^0.4.2 version: 0.4.2 @@ -274,7 +274,7 @@ importers: version: 8.0.3 effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) expo: specifier: ~56.0.12 version: 56.0.12(8895228379997a2a064f9644cda56ed0) @@ -422,7 +422,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -446,16 +446,16 @@ importers: version: 0.3.170(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@effect/platform-bun': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/platform-node-shared': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) '@effect/sql-sqlite-bun': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) @@ -467,7 +467,7 @@ importers: version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) node-pty: specifier: ^1.1.0 version: 1.1.0 @@ -477,7 +477,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -531,7 +531,7 @@ importers: version: 3.2.2(react@19.2.6) '@effect/atom-react': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.6)(scheduler@0.27.0) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.6)(scheduler@0.27.0) '@formkit/auto-animate': specifier: ^0.9.0 version: 0.9.0 @@ -567,7 +567,7 @@ importers: version: 0.7.1 effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) jose: specifier: 'catalog:' version: 6.2.2 @@ -607,10 +607,10 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@rolldown/plugin-babel': specifier: ^0.2.0 version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5) @@ -658,7 +658,7 @@ importers: version: 3.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -676,23 +676,23 @@ importers: version: link:../../packages/shared alchemy: specifier: 2.0.0-beta.65 - version: 2.0.0-beta.65(a455401069e1fee89f31a277c51247f6) + version: 2.0.0-beta.65(75567d8add6e3362e26fe00f83a97bee) drizzle-orm: specifier: 1.0.0-rc.4 - version: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + version: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@cloudflare/workers-types': specifier: ^4.20260601.1 version: 4.20260604.1 '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -710,17 +710,17 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@oxlint/plugins': specifier: ^1.63.0 version: 1.68.0 effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -735,11 +735,11 @@ importers: version: link:../shared effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -748,11 +748,11 @@ importers: dependencies: effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -761,17 +761,17 @@ importers: dependencies: effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -783,17 +783,17 @@ importers: dependencies: effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -814,7 +814,7 @@ importers: version: link:../contracts effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) jose: specifier: 'catalog:' version: 6.2.2 @@ -824,10 +824,10 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -845,14 +845,14 @@ importers: version: link:../shared effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -864,17 +864,17 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/shared': specifier: workspace:* version: link:../shared effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -886,7 +886,7 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@t3tools/contracts': specifier: workspace:* version: link:../packages/contracts @@ -898,7 +898,7 @@ importers: version: link:../packages/tailscale effect: specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) pngjs: specifier: 7.0.0 version: 7.0.0 @@ -908,7 +908,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.103 - version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/pngjs': specifier: 6.0.5 version: 6.0.5 @@ -5947,6 +5947,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} @@ -11640,24 +11641,24 @@ snapshots: '@cloudflare/workers-types@5.20260726.1': {} - '@distilled.cloud/aws@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/aws@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/util': 5.2.0 '@aws-sdk/credential-providers': 3.1062.0 '@aws-sdk/types': 3.973.10 - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@smithy/shared-ini-file-loader': 4.5.6 '@smithy/types': 4.14.3 '@smithy/util-base64': 4.4.6 aws4fetch: 1.0.20 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) fast-xml-parser: 5.8.0 - '@distilled.cloud/axiom@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/axiom@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) '@distilled.cloud/cloudflare-rolldown-plugin@0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1)': dependencies: @@ -11670,48 +11671,48 @@ snapshots: transitivePeerDependencies: - workerd - '@distilled.cloud/cloudflare-runtime@0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/cloudflare-runtime@0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: '@alchemy.run/node-utils': 0.0.5 - '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) workerd: 1.20260704.1 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@distilled.cloud/cloudflare-vite-plugin@0.13.10(8bf551e378e9cbc11f8bf1003fbc14a8)': + '@distilled.cloud/cloudflare-vite-plugin@0.13.10(f97c3167f1a1990dddb83bff73e575e5)': dependencies: - '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) - '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - rolldown - workerd - '@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - '@distilled.cloud/core@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/core@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - '@distilled.cloud/neon@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/neon@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - '@distilled.cloud/planetscale@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@distilled.cloud/planetscale@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) '@dnd-kit/accessibility@3.1.1(react@19.2.6)': dependencies: @@ -11747,47 +11748,47 @@ snapshots: '@drizzle-team/brocli@0.12.0': {} - '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.3)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.3)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) react: 19.2.3 scheduler: 0.27.0 - '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(react@19.2.6)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.6)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) react: 19.2.6 scheduler: 0.27.0 - '@effect/openapi-generator@4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/openapi-generator@4.0.0-beta.103(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) swagger2openapi: 7.0.8 transitivePeerDependencies: - encoding - '@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6)': + '@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6)': + '@effect/platform-node-shared@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6)': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6)': + '@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + '@effect/platform-node-shared': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) ioredis: 5.11.0 mime: 4.1.0 undici: 8.9.0 @@ -11795,14 +11796,14 @@ snapshots: - bufferutil - utf-8-validate - '@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: '@cloudflare/workers-types': 5.20260726.1 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - '@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) pg: 8.22.0 pg-connection-string: 2.14.0 pg-cursor: 2.21.0(pg@8.22.0) @@ -11811,9 +11812,9 @@ snapshots: transitivePeerDependencies: - pg-native - '@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) '@effect/tsgo-darwin-arm64@0.13.2': optional: true @@ -11846,9 +11847,9 @@ snapshots: '@effect/tsgo-win32-arm64': 0.13.2 '@effect/tsgo-win32-x64': 0.13.2 - '@effect/vitest@4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))': + '@effect/vitest@4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) '@egjs/hammerjs@2.0.17': dependencies: @@ -15350,22 +15351,22 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.65(a455401069e1fee89f31a277c51247f6): + alchemy@2.0.0-beta.65(75567d8add6e3362e26fe00f83a97bee): dependencies: '@alchemy.run/node-utils': 0.0.5 '@aws-sdk/credential-providers': 3.1062.0 '@clack/prompts': 0.11.0 - '@distilled.cloud/aws': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/axiom': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@distilled.cloud/aws': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/axiom': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/cloudflare': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@distilled.cloud/cloudflare-rolldown-plugin': 0.13.10(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.5)(workerd@1.20260704.1) - '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/cloudflare-vite-plugin': 0.13.10(8bf551e378e9cbc11f8bf1003fbc14a8) - '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/neon': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@distilled.cloud/planetscale': 0.30.2(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@effect/sql-d1': 4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@effect/vitest': 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@distilled.cloud/cloudflare-runtime': 0.13.10(@distilled.cloud/cloudflare@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/platform-bun@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/cloudflare-vite-plugin': 0.13.10(f97c3167f1a1990dddb83bff73e575e5) + '@distilled.cloud/core': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/neon': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@distilled.cloud/planetscale': 0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/sql-d1': 4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/vitest': 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@octokit/rest': 22.0.1 '@octokit/webhooks': 14.2.0 @@ -15375,7 +15376,7 @@ snapshots: '@types/aws-lambda': 8.10.161 aws4fetch: 1.0.20 capnweb: 0.6.1 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) fast-glob: 3.3.3 fast-xml-parser: 5.8.0 ink: 6.8.0(@types/react@19.2.16)(bufferutil@4.1.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react@19.2.6)(utf-8-validate@6.0.6) @@ -15391,11 +15392,11 @@ snapshots: undici: 7.27.1 yaml: 2.9.0 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@effect/platform-bun': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(utf-8-validate@6.0.6) + '@effect/platform-node': 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) drizzle-kit: 1.0.0-rc.4 - drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + drizzle-orm: 1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -16418,15 +16419,15 @@ snapshots: get-tsconfig: 4.14.0 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): + drizzle-orm@1.0.0-rc.4(@cloudflare/workers-types@4.20260604.1)(@effect/sql-d1@4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-pg@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@effect/sql-sqlite-bun@4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): optionalDependencies: '@cloudflare/workers-types': 4.20260604.1 - '@effect/sql-d1': 4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) - '@effect/sql-sqlite-bun': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9)) + '@effect/sql-d1': 4.0.0-beta.101(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/sql-pg': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) + '@effect/sql-sqlite-bun': 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) bun-types: 1.3.14 - effect: 4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9) + effect: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) expo-sqlite: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) mysql2: 3.22.4(@types/node@24.12.4) pg: 8.21.0 @@ -16446,7 +16447,7 @@ snapshots: ee-first@1.1.1: {} - effect@4.0.0-beta.103(patch_hash=a18f963109656ddbeb2a99ca45f942ecf54830986259eb01ce6a970421c9c6a9): + effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6): dependencies: '@standard-schema/spec': 1.1.0 fast-check: 4.9.0 From 6fa457607886caf096e7871b67e447f76d3772f6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 00:15:59 -0400 Subject: [PATCH 015/326] fix(server): settle stopped Claude subagents (#5568) --- .../src/provider/Layers/ClaudeAdapter.test.ts | 19 ++++++++- .../src/provider/Layers/ClaudeAdapter.ts | 40 ++++++++++++++++--- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index afa65ea39d61..d3d768b53844 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1511,7 +1511,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("interruptTurn stops every live task before interrupting the turn", () => { + it.effect("interruptTurn settles every acknowledged live task before interrupting", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -1568,11 +1568,28 @@ describe("ClaudeAdapterLive", () => { yield* Fiber.join(taskEventsFiber); + const stoppedTaskEventFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "task.completed"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); yield* adapter.interruptTurn(session.threadId); // Only the still-live task is stopped; interrupt always fires after. assert.deepEqual(harness.query.stopTaskCalls, ["task-live"]); assert.equal(harness.query.interruptCalls.length, 1); + + const stoppedTaskEvents = Array.from(yield* Fiber.join(stoppedTaskEventFiber)); + assert.equal(stoppedTaskEvents.length, 1); + const stoppedTaskEvent = stoppedTaskEvents[0]; + assert.equal(stoppedTaskEvent?.type, "task.completed"); + if (stoppedTaskEvent?.type === "task.completed") { + assert.equal(String(stoppedTaskEvent.payload.taskId), "task-live"); + assert.equal(stoppedTaskEvent.payload.status, "stopped"); + assert.equal(stoppedTaskEvent.payload.taskType, "local_agent"); + assert.equal(stoppedTaskEvent.payload.title, "Agent A"); + } }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), Effect.provide(harness.layer), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index f6f1c14420de..92445522cc49 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -65,6 +65,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -4419,11 +4420,40 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* Effect.forEach( liveIds, (taskId) => - Effect.tryPromise({ - // Invoke through the query object: SDK methods rely on `this`. - try: () => context.query.stopTask!(taskId), - catch: () => undefined, - }).pipe(Effect.timeoutOption("3 seconds"), Effect.ignore), + Effect.gen(function* () { + const stopAcknowledged = yield* Effect.tryPromise({ + // Invoke through the query object: SDK methods rely on `this`. + try: () => context.query.stopTask!(taskId), + catch: () => undefined, + }).pipe( + Effect.timeoutOption("3 seconds"), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isNone(stopAcknowledged) || !context.liveTaskIds.delete(taskId)) { + return; + } + + // stopTask only acknowledges the control request. Its separate + // task_notification can lose the race with interrupt(), so make + // the acknowledged stop authoritative for the durable UI state. + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "task.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState + ? { turnId: asCanonicalTurnId(context.turnState.turnId) } + : {}), + payload: { + taskId: RuntimeTaskId.make(taskId), + status: "stopped", + ...taskLinkageFor(context.taskAgents, taskId), + }, + providerRefs: nativeProviderRefs(context), + }); + }).pipe(Effect.ignore), { concurrency: 8, discard: true }, ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); } From 1c7d059f550a53dd94d5b9802640ecd11b759d1a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 00:32:01 -0400 Subject: [PATCH 016/326] fix: scrolling up during a running thread no longer snaps back to the bottom (#5566) Co-authored-by: Claude Fable 5 --- .../src/features/threads/ThreadFeed.tsx | 71 ++++++++- apps/web/src/components/ChatView.tsx | 143 +++++++++++++++--- .../components/chat/MessagesTimeline.logic.ts | 32 +++- .../components/chat/MessagesTimeline.test.tsx | 33 +++- .../src/components/chat/MessagesTimeline.tsx | 14 +- 5 files changed, 258 insertions(+), 35 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index fd8ffb270cb1..28df94b529bc 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1335,6 +1335,24 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { ); const [viewportHeight, setViewportHeight] = useState(0); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); + // Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed + // whenever the viewport drifts back inside its geometric threshold, which + // yanked users off history they were reading every time a stream chunk grew + // a row. Follow breaks when the user scrolls up and away, and re-arms only + // when the list actually returns to the end (or on send / thread switch). + const [endFollowEnabled, setEndFollowEnabled] = useState(true); + const endFollowEnabledRef = useRef(true); + // A "user scroll session" spans from drag start through the end of its + // momentum; only motion inside a session can break follow, so MVCP + // compensations and programmatic scrolls never strand a follower. + const userScrollSessionRef = useRef(false); + const setEndFollow = useCallback((enabled: boolean) => { + if (endFollowEnabledRef.current === enabled) { + return; + } + endFollowEnabledRef.current = enabled; + setEndFollowEnabled(enabled); + }, []); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; @@ -1454,9 +1472,41 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; nearListEnd.value = contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height; + + // Latch bookkeeping. LegendList recomputes its inset-aware end distance + // before invoking this handler, so getState() is current. Returning to + // the end re-arms follow no matter who scrolled (the user, or our own + // scroll-to-end); moving away breaks it only during a user-initiated + // scroll session, so MVCP compensations and programmatic repositioning + // can never strand a follower. + const listState = props.listRef.current?.getState(); + if (listState) { + if (listState.isWithinMaintainScrollAtEndThreshold) { + setEndFollow(true); + } else if (userScrollSessionRef.current) { + setEndFollow(false); + } + } }, - [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd], + [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd, props.listRef, setEndFollow], ); + const handleScrollBeginDrag = useCallback(() => { + userScrollSessionRef.current = true; + }, []); + // The session must survive past finger-lift so momentum that carries the + // user away from the end still breaks follow; a drag released with no + // momentum ends its session at the release itself, otherwise at momentum + // end. Leaving a session open would let a later animated maintain-scroll + // read as user motion and break follow spuriously. + const handleScrollEndDrag = useCallback((event: NativeSyntheticEvent) => { + const velocity = event.nativeEvent.velocity?.y ?? 0; + if (Math.abs(velocity) < 0.05) { + userScrollSessionRef.current = false; + } + }, []); + const handleMomentumScrollEnd = useCallback(() => { + userScrollSessionRef.current = false; + }, []); // Gated variant of the 180ms feed layout slide. Instant while browsing // history: maintainVisibleContentPosition compensates the scroll offset in @@ -1496,6 +1546,20 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reportHeaderMaterialVisibility(false); }, [props.threadId, reportHeaderMaterialVisibility]); + // A thread switch opens pinned to the end; a send explicitly returns to the + // live edge (ThreadDetailScreen scrolls the new message into place). Both + // re-arm follow regardless of where the user had scrolled before. + useEffect(() => { + userScrollSessionRef.current = false; + setEndFollow(true); + }, [props.threadId, setEndFollow]); + useEffect(() => { + if (props.anchorMessageId !== null) { + userScrollSessionRef.current = false; + setEndFollow(true); + } + }, [props.anchorMessageId, setEndFollow]); + const expandedWorkGroupIds = useMemo(() => { const ids = new Set(); for (const [groupId, expanded] of Object.entries(expandedWorkGroups)) { @@ -1847,7 +1911,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // anchor scrolls also lets it correct a scroll that landed on a // stale end target once the anchor row finishes measuring. maintainScrollAtEnd={ - disclosureToggleSettling + disclosureToggleSettling || !endFollowEnabled ? false : { animated: true, @@ -1896,6 +1960,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { alignItemsAtEnd initialScrollAtEnd onScroll={handleScroll} + onScrollBeginDrag={handleScrollBeginDrag} + onScrollEndDrag={handleScrollEndDrag} + onMomentumScrollEnd={handleMomentumScrollEnd} scrollEventThrottle={16} ListHeaderComponent={ <> diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 708a97be5451..3c416b8f88aa 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -244,6 +244,7 @@ import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; +import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -3567,6 +3568,10 @@ function ChatViewContent(props: ChatViewProps) { new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), ); const timelineScrollModeRef = useRef("following-end"); + // State mirror of the follow mode refs. LegendList's maintainScrollAtEnd + // re-pins on its own (independent of the refs), so the timeline needs a + // render-visible flag to switch it off once the user scrolls away. + const [timelineLiveFollowEnabled, setTimelineLiveFollowEnabled] = useState(true); const pendingTimelineAnchorRef = useRef(null); const positionedTimelineAnchorRef = useRef(null); const settledTimelineAnchorRef = useRef(null); @@ -3583,6 +3588,7 @@ function ChatViewContent(props: ChatViewProps) { anchorUserScrollGenerationRef.current += 1; timelineScrollModeRef.current = "free-scrolling"; liveFollowUserScrollGenerationRef.current = null; + setTimelineLiveFollowEnabled(false); pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; @@ -3654,6 +3660,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -3662,37 +3669,120 @@ function ChatViewContent(props: ChatViewProps) { }, []); useEffect(() => { let removeListeners: (() => void) | null = null; - const frame = requestAnimationFrame(() => { - const scrollNode = legendListRef.current?.getScrollableNode(); - if (!scrollNode) { - return; - } - const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigationRef.current(); - }; - scrollNode.addEventListener("wheel", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("touchmove", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("pointerdown", handleManualNavigation, { - passive: true, + let frame: number | null = null; + const attach = (remainingAttempts: number) => { + frame = requestAnimationFrame(() => { + frame = null; + const scrollNode = legendListRef.current?.getScrollableNode(); + if (!scrollNode) { + // The list may not have mounted on the first frame after a thread + // switch — without a retry the opt-out listeners never attach and + // live-follow becomes impossible to escape for the whole thread. + if (remainingAttempts > 0) { + attach(remainingAttempts - 1); + } + return; + } + const handleManualNavigation = () => { + cancelTimelineLiveFollowForUserNavigationRef.current(); + }; + // The gestures below must only break follow when they can actually + // move the viewport away from the live edge. Follow now gates + // LegendList's maintainScrollAtEnd, so a spurious break while pinned + // at the end produces no scroll event, never re-arms, and streaming + // silently stops following. Underflowing content can't scroll at all, + // so nothing there should break follow. + const contentScrollsUp = () => timelineRealContentOverflowsViewport(); + // The follow re-arm band, not the strict flag: streaming growth makes + // isAtEnd flicker false for a frame before the follow scroll catches + // up, and a gesture landing in that window while still pinned would + // otherwise break follow with no scroll event left to re-arm it. + const viewportIsAwayFromEnd = () => + resolveTimelineIsAtEnd(legendListRef.current?.getState(), composerOverlayHeight) === + false; + // Only an upward wheel is a navigation intent; wheeling down while + // following either does nothing (at the end) or moves toward it. + const handleWheel = (event: WheelEvent) => { + if (event.deltaY < 0 && contentScrollsUp()) { + handleManualNavigation(); + } + }; + // Touch direction isn't observable here (touchmove fires on any + // finger motion, scrolling or not), so break only once the drag has + // actually carried the viewport out of the end band — an upward flick + // gets there within its first few events and later touchmoves break. + const handleTouchMove = () => { + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + }; + // Scrollbar drags produce no wheel/touch events; they are the only + // pointerdowns whose target is the scroll node itself rather than a + // message row. Content clicks break follow only away from the end + // (reading or selecting up there must hold position); clicking near + // the live edge keeps following. + const handlePointerDown = (event: PointerEvent) => { + if (event.target === scrollNode) { + if (contentScrollsUp()) { + handleManualNavigation(); + } + return; + } + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + }; + // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and + // pointer events entirely; without this the timeline yanks back to + // the end on the next stream chunk. + const handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case "PageUp": + case "Home": + case "ArrowUp": + if (contentScrollsUp()) { + handleManualNavigation(); + } + break; + default: + break; + } + }; + scrollNode.addEventListener("wheel", handleWheel, { + passive: true, + }); + scrollNode.addEventListener("touchmove", handleTouchMove, { + passive: true, + }); + scrollNode.addEventListener("pointerdown", handlePointerDown, { + passive: true, + }); + scrollNode.addEventListener("keydown", handleKeyDown); + removeListeners = () => { + scrollNode.removeEventListener("wheel", handleWheel); + scrollNode.removeEventListener("touchmove", handleTouchMove); + scrollNode.removeEventListener("pointerdown", handlePointerDown); + scrollNode.removeEventListener("keydown", handleKeyDown); + }; }); - removeListeners = () => { - scrollNode.removeEventListener("wheel", handleManualNavigation); - scrollNode.removeEventListener("touchmove", handleManualNavigation); - scrollNode.removeEventListener("pointerdown", handleManualNavigation); - }; - }); + }; + attach(12); return () => { - cancelAnimationFrame(frame); + if (frame !== null) { + cancelAnimationFrame(frame); + } removeListeners?.(); }; - }, [activeThread?.id]); + }, [activeThread?.id, composerOverlayHeight, timelineRealContentOverflowsViewport]); const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { + // Anchored-end space can be remeasured when the turn completes. Once the + // user has scrolled away (or returned to ordinary end-following), that + // remeasurement must not restart the send-time anchor positioning. + if (timelineScrollModeRef.current !== "anchoring-new-turn") { + return; + } if (pendingTimelineAnchorRef.current === messageId) { pendingTimelineAnchorRef.current = null; } @@ -3798,6 +3888,7 @@ function ChatViewContent(props: ChatViewProps) { if (isAtEnd) { timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); } else { @@ -3878,6 +3969,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; @@ -4945,6 +5037,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -5389,6 +5482,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); @@ -6055,6 +6149,7 @@ function ChatViewContent(props: ChatViewProps) { onAnchorReady={onTimelineAnchorReady} onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerOverlayHeight} + liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index e5ecdbd20045..c204499273ac 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -18,11 +18,37 @@ export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; export interface TimelineEndState { readonly isAtEnd?: boolean; - readonly isNearEnd?: boolean; + readonly contentLength?: number; + readonly scroll?: number; + readonly scrollLength?: number; } -export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined { - return state?.isNearEnd ?? state?.isAtEnd; +/** + * Follow re-arm band above the hard bottom. Strict on purpose: LegendList's + * isNearEnd fires within half a viewport, which re-armed live-follow while the + * user was reading history and yanked them back down on the next stream chunk. + * A small pixel band (instead of the 1px isAtEnd epsilon alone) keeps re-arming + * reliable while streaming content is still growing under the viewport. + */ +export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40; + +export function resolveTimelineIsAtEnd( + state: TimelineEndState | undefined, + endInset = 0, +): boolean | undefined { + if (!state) { + return undefined; + } + if (state.isAtEnd) { + return true; + } + const { contentLength, scroll, scrollLength } = state; + if (contentLength === undefined || scroll === undefined || scrollLength === undefined) { + return state.isAtEnd; + } + // contentLength includes the end inset (composer overlay), so subtract it to + // measure the distance to the real content bottom. + return contentLength - scroll - scrollLength - endInset <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX; } export function resolveTimelineMinimapHeightStyle(itemCount: number): string { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 83ca7d3e9527..cf055f05b742 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -194,6 +194,7 @@ function buildProps() { onAnchorReady: () => {}, onAnchorSizeChanged: () => {}, contentInsetEndAdjustment: 0, + liveFollowEnabled: true, onIsAtEndChange: () => {}, onManualNavigation: () => {}, }; @@ -296,7 +297,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain("1 changed file"); }); - it("uses LegendList isNearEnd when deciding whether the live edge is visible", async () => { + it("treats only the strict list end as the live edge", async () => { const { resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, @@ -307,10 +308,36 @@ describe("MessagesTimeline", () => { resolveTimelineMinimapTopPercent, } = await import("./MessagesTimeline.logic"); - expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true); - expect(resolveTimelineIsAtEnd({ isNearEnd: false, isAtEnd: true })).toBe(false); expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true); expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined(); + // Within the pixel band above the content bottom counts as the end... + expect( + resolveTimelineIsAtEnd({ + isAtEnd: false, + contentLength: 2000, + scroll: 1170, + scrollLength: 800, + }), + ).toBe(true); + // ...but half a viewport up (LegendList's isNearEnd territory) does not. + expect( + resolveTimelineIsAtEnd({ + isAtEnd: false, + contentLength: 2000, + scroll: 900, + scrollLength: 800, + }), + ).toBe(false); + // The composer inset is part of contentLength and must not count as + // distance-to-end. + expect( + resolveTimelineIsAtEnd( + { isAtEnd: false, contentLength: 2100, scroll: 1170, scrollLength: 800 }, + 100, + ), + ).toBe(true); + // Geometry missing (older state shape): fall back to the strict flag. + expect(resolveTimelineIsAtEnd({ isAtEnd: false })).toBe(false); expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))"); expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8e27b7b6962c..a5fb03602046 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -219,6 +219,13 @@ interface MessagesTimelineProps { onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; + /** + * Whether the timeline should keep pinning to the live edge as content + * grows. Off while the user is reading history; LegendList's own + * maintainScrollAtEnd would otherwise re-pin regardless of ChatView's + * scroll-mode refs whenever the user drifts near the bottom. + */ + liveFollowEnabled: boolean; onIsAtEndChange: (isAtEnd: boolean) => void; onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; @@ -258,6 +265,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onAnchorReady, onAnchorSizeChanged, contentInsetEndAdjustment, + liveFollowEnabled, onIsAtEndChange, onManualNavigation, hideEmptyPlaceholder = false, @@ -401,7 +409,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); - const isAtEnd = resolveTimelineIsAtEnd(state); + const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment); if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); } @@ -427,7 +435,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ strip.dataset.inView = inView ? "true" : "false"; } - }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]); + }, [contentInsetEndAdjustment, listRef, minimapItems, minimapStripMap, onIsAtEndChange]); useEffect(() => { const frame = requestAnimationFrame(handleScroll); @@ -543,7 +551,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ - anchoredEndSpace + anchoredEndSpace || !liveFollowEnabled ? false : { animated: false, From 9547cf24634ccafe5d381964449b722d33f812b8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 02:15:40 -0400 Subject: [PATCH 017/326] fix(server): one disconnecting client no longer blocks every reconnect (#5572) Co-authored-by: Claude --- .../src/process/externalLauncher.test.ts | 65 +++++++++++++++++++ apps/server/src/process/externalLauncher.ts | 41 ++++++++++-- 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 36ef82643280..1ab6166e92a1 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -216,6 +217,70 @@ it.effect("memoizes editor discovery and refreshes after the cache window", () = ); }); +// A client that disconnects mid-scan interrupts the shared discovery effect on +// the connection fiber. The cache must not retain that interrupt: doing so +// replayed it to every later connect for the whole TTL, so `server.getConfig` +// failed and no client could reconnect until the server restarted. +it.effect("rescans after an interrupted discovery instead of caching the interrupt", () => { + const fileInfo = { type: "File" } as FileSystem.File.Info; + let blockFirstScan = true; + let scans = 0; + const launcherLayer = ExternalLauncher.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + // The first scan parks inside `stat` so the interrupt lands while + // discovery is in flight, which is what a client disconnecting + // mid-connect does to the shared effect. + stat: () => + Effect.gen(function* () { + scans += 1; + if (blockFirstScan) { + return yield* Effect.never; + } + return fileInfo; + }), + }), + Path.layer, + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())), + ), + ), + ), + ); + + return Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + + const fiber = yield* Effect.forkChild(launcher.resolveAvailableEditors()); + yield* Effect.yieldNow; + yield* Fiber.interrupt(fiber); + + // The next connect must still get a real answer well inside the TTL. + blockFirstScan = false; + scans = 0; + const editors = yield* launcher.resolveAvailableEditors(); + assert.equal(editors.includes("vscode"), true); + assert.isAbove(scans, 0); + }).pipe( + Effect.provide( + Layer.mergeAll( + launcherLayer, + Layer.succeed(HostProcessPlatform, "win32"), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + PATH: "C:\\t3-editor-discovery-interrupt-test", + PATHEXT: ".COM;.EXE;.BAT;.CMD", + }, + }), + ), + ), + ), + ); +}); + it.effect("rejects unknown editors through the service API", () => Effect.gen(function* () { const launcher = yield* ExternalLauncher.ExternalLauncher; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 2cac42f0fec6..8ec928f26fc3 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -19,6 +19,7 @@ import { } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isCommandAvailable, resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as Clock from "effect/Clock"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -27,6 +28,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -302,7 +304,23 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit // client connect (the server config embeds the available editors). Memoize // the discovered set for a bounded window so repeat connects skip even the // per-command cache lookups in @t3tools/shared/shell. -const EDITOR_DISCOVERY_CACHE_TTL = "60 seconds"; +// +// This deliberately does not use `Effect.cachedWithTTL`: that memoizes the +// first caller's Exit whatever it is, including an interrupt. Callers run this +// on the connection fiber under a timeout (`resolveAvailableEditorsForConfig`), +// so one client disconnecting mid-scan would cache the interrupt and replay it +// to every later connect for the whole TTL, breaking `server.getConfig` +// permanently. Storing only on success means an interrupted scan leaves the +// cache untouched and the next connect simply rescans. +// Expiry uses the monotonic clock (Clock.currentTimeNanos), matching the +// command-resolution cache in @t3tools/shared/shell, so a backward wall-clock +// adjustment cannot keep an expired entry alive. +const EDITOR_DISCOVERY_CACHE_TTL_NANOS = 60_000_000_000n; + +interface EditorDiscoveryCacheEntry { + readonly editors: ReadonlyArray; + readonly expiresAtNanos: bigint; +} /** * ExternalLauncher - Service tag for browser/editor launch operations. @@ -449,10 +467,25 @@ export const make = Effect.gen(function* () { Effect.provideService(Path.Path, path), ); - const cachedAvailableEditors = yield* Effect.cachedWithTTL( - provideCommandResolutionServices(resolveAvailableEditors()), - EDITOR_DISCOVERY_CACHE_TTL, + const editorDiscoveryCache = yield* Ref.make>( + Option.none(), ); + const cachedAvailableEditors = Effect.gen(function* () { + const nowNanos = yield* Clock.currentTimeNanos; + const entry = yield* Ref.get(editorDiscoveryCache); + if (Option.isSome(entry) && entry.value.expiresAtNanos > nowNanos) { + return entry.value.editors; + } + const editors = yield* provideCommandResolutionServices(resolveAvailableEditors()); + yield* Ref.set( + editorDiscoveryCache, + Option.some({ + editors, + expiresAtNanos: nowNanos + EDITOR_DISCOVERY_CACHE_TTL_NANOS, + }), + ); + return editors; + }); return ExternalLauncher.of({ resolveAvailableEditors: () => cachedAvailableEditors, From ddfe45c66eccd93c0adf61db92a16cbbbcde23e1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 02:17:49 -0400 Subject: [PATCH 018/326] test(server): catch client transfer regressions in CI (#5350) --- .github/scripts/thread-transfer-report.cjs | 429 ++++++++++++++++++ .../scripts/thread-transfer-report.test.cjs | 292 ++++++++++++ .github/workflows/ci.yml | 21 + .github/workflows/thread-transfer-report.yml | 75 +++ .../NetworkTransferMeasurement.integration.ts | 177 ++++++++ .../OrchestrationEngineHarness.integration.ts | 13 + .../TestProviderAdapter.integration.ts | 23 +- .../TransferBudgetReport.integration.ts | 212 +++++++++ .../TransferBudgetScenario.integration.ts | 128 ++++++ .../integration/fixtures/transferBudget.ts | 372 +++++++++++++++ apps/server/src/server.test.ts | 271 ++++++++++- apps/server/src/ws.ts | 2 +- 12 files changed, 1973 insertions(+), 42 deletions(-) create mode 100644 .github/scripts/thread-transfer-report.cjs create mode 100644 .github/scripts/thread-transfer-report.test.cjs create mode 100644 .github/workflows/thread-transfer-report.yml create mode 100644 apps/server/integration/NetworkTransferMeasurement.integration.ts create mode 100644 apps/server/integration/TransferBudgetReport.integration.ts create mode 100644 apps/server/integration/TransferBudgetScenario.integration.ts create mode 100644 apps/server/integration/fixtures/transferBudget.ts diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs new file mode 100644 index 000000000000..94a02b7806dc --- /dev/null +++ b/.github/scripts/thread-transfer-report.cjs @@ -0,0 +1,429 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const ARTIFACT_NAME = "thread-transfer-results"; +const RESULT_FILE = "thread-transfer-result.json"; +const COMMENT_MARKER = ""; +const PROVIDERS = ["codex", "claudeAgent"]; +const OBSERVED_KEYS = [ + "totalWireBytes", + "threadSnapshotWireBytes", + "threadSnapshotDecodedBytes", + "measuredTurnWebSocketWireBytes", + "measuredTurnWebSocketDecodedBytes", + "measuredTurnWebSocketMessages", +]; +const CEILING_KEYS = [ + "totalWireBytes", + "threadSnapshotWireBytes", + "measuredTurnWebSocketWireBytes", + "measuredTurnWebSocketDecodedBytes", + "measuredTurnWebSocketMessages", +]; +const SCENARIO_KEYS = [ + "id", + "historyTurns", + "historyCommandToolsPerTurn", + "historyMcpResultBytes", + "measuredCommandTools", + "measuredMcpResultBytes", +]; + +function resultShaMarker(sha) { + return ``; +} + +function assertObject(value, label) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } +} + +function assertExactKeys(value, expected, label) { + assertObject(value, label); + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + throw new Error(`${label} has unexpected fields`); + } +} + +function assertMetric(value, label) { + if (!Number.isSafeInteger(value) || value < 0 || value > 1_000_000_000) { + throw new Error(`${label} must be a non-negative safe integer below 1,000,000,000`); + } +} + +function validateResult(value) { + assertExactKeys(value, ["schemaVersion", "scenario", "providers"], "result"); + if (value.schemaVersion !== 1) { + throw new Error("result.schemaVersion must be 1"); + } + + assertExactKeys(value.scenario, SCENARIO_KEYS, "result.scenario"); + if (value.scenario.id !== "thread-transfer-v1") { + throw new Error("result.scenario.id is not supported"); + } + for (const key of SCENARIO_KEYS.slice(1)) { + assertMetric(value.scenario[key], `result.scenario.${key}`); + } + + assertExactKeys(value.providers, PROVIDERS, "result.providers"); + for (const provider of PROVIDERS) { + const entry = value.providers[provider]; + assertExactKeys(entry, ["observed", "ceiling"], `result.providers.${provider}`); + assertExactKeys(entry.observed, OBSERVED_KEYS, `result.providers.${provider}.observed`); + assertExactKeys(entry.ceiling, CEILING_KEYS, `result.providers.${provider}.ceiling`); + for (const key of OBSERVED_KEYS) { + assertMetric(entry.observed[key], `result.providers.${provider}.observed.${key}`); + } + for (const key of CEILING_KEYS) { + assertMetric(entry.ceiling[key], `result.providers.${provider}.ceiling.${key}`); + } + } + + return value; +} + +function readResult(directory) { + if (!directory) return undefined; + const file = path.join(directory, RESULT_FILE); + if (!fs.existsSync(file)) return undefined; + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.size > 64 * 1_024) { + throw new Error("thread transfer result must be a regular file smaller than 64 KiB"); + } + return validateResult(JSON.parse(fs.readFileSync(file, "utf8"))); +} + +function formatBytes(bytes) { + if (bytes < 1_024) return `${bytes} B`; + if (bytes >= 1_024 * 1_024) return `${(bytes / 1_024 / 1_024).toFixed(2)} MiB`; + return `${(bytes / 1_024).toFixed(1)} KiB`; +} + +function formatValue(value, kind) { + return kind === "messages" ? value.toLocaleString("en-US") : formatBytes(value); +} + +function formatImpact(current, baseline, kind) { + if (baseline === undefined) return "—"; + const delta = current - baseline; + const prefix = delta > 0 ? "+" : delta < 0 ? "−" : ""; + const magnitude = formatValue(Math.abs(delta), kind); + const percent = + baseline === 0 ? "" : ` (${prefix}${Math.abs((delta / baseline) * 100).toFixed(1)}%)`; + return `${prefix}${magnitude}${percent}`; +} + +function sameScenario(left, right) { + return SCENARIO_KEYS.every((key) => left[key] === right[key]); +} + +const METRICS = [ + { key: "totalWireBytes", label: "Total thread wire", kind: "bytes" }, + { key: "threadSnapshotWireBytes", label: "Thread snapshot wire", kind: "bytes" }, + { + key: "measuredTurnWebSocketWireBytes", + label: "Live turn WebSocket wire", + kind: "bytes", + }, + { + key: "measuredTurnWebSocketDecodedBytes", + label: "Live turn WebSocket decoded", + kind: "bytes", + }, + { key: "measuredTurnWebSocketMessages", label: "Live turn messages", kind: "messages" }, +]; + +function renderComment(input) { + const current = input.current; + const baseline = input.baseline; + const comparable = baseline !== undefined && sameScenario(current.scenario, baseline.scenario); + const rows = []; + const ceilingChanges = []; + let failed = false; + + for (const provider of PROVIDERS) { + for (const metric of METRICS) { + const observed = current.providers[provider].observed[metric.key]; + const ceiling = current.providers[provider].ceiling[metric.key]; + const baselineObserved = comparable + ? baseline.providers[provider].observed[metric.key] + : undefined; + const pass = observed <= ceiling; + failed ||= !pass; + rows.push( + `| ${provider === "codex" ? "Codex" : "Claude"} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`, + ); + + if (baseline && baseline.providers[provider].ceiling[metric.key] !== ceiling) { + ceilingChanges.push( + `- ${provider === "codex" ? "Codex" : "Claude"} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`, + ); + } + } + } + + const baselineLink = input.baselineRun + ? `[\`${input.baselineRun.sha.slice(0, 7)}\`](${input.baselineRun.url})` + : "unavailable"; + const currentLink = `[\`${input.currentRun.sha.slice(0, 7)}\`](${input.currentRun.url})`; + const notices = []; + if (!baseline) { + notices.push( + "> ℹ️ No successful `main` baseline artifact is available yet. This run establishes the initial measurement.", + ); + } else if (!comparable) { + notices.push( + "> ⚠️ The thread fixture changed, so impact percentages are not directly comparable to the `main` baseline.", + ); + } else if (!input.baselineRun.matchesBase) { + notices.push( + "> ℹ️ The exact PR base did not have a successful artifact. Baseline uses the latest successful `main` measurement shown below.", + ); + } + if (ceilingChanges.length > 0) { + notices.push( + `> ⚠️ **This PR changes transfer ceilings:**\n>\n${ceilingChanges.map((line) => `> ${line}`).join("\n")}`, + ); + } + + return [ + COMMENT_MARKER, + resultShaMarker(input.currentRun.sha), + "## Thread transfer impact", + "", + failed + ? "❌ One or more thread transfer ceilings were exceeded." + : "✅ Thread transfer remains within every enforced ceiling.", + ...(notices.length > 0 ? ["", ...notices] : []), + "", + "| Provider | Metric | Main baseline | This PR | Impact | PR ceiling | |", + "| --- | --- | ---: | ---: | ---: | ---: | --- |", + ...rows, + "", + `Baseline: ${baselineLink} · PR result: ${currentLink} · Source CI: ${input.currentRun.conclusion}`, + "", + "
", + "Scenario and decoded snapshot size", + "", + `${current.scenario.historyTurns} historical turns, ${current.scenario.historyCommandToolsPerTurn} command tools per turn, ${formatBytes(current.scenario.historyMcpResultBytes)} retained MCP result per historical turn, and a ${formatBytes(current.scenario.measuredMcpResultBytes)} retained result in the measured turn.`, + "", + ...PROVIDERS.map( + (provider) => + `- ${provider === "codex" ? "Codex" : "Claude"} decoded thread snapshot: ${formatBytes(current.providers[provider].observed.threadSnapshotDecodedBytes)}`, + ), + "", + "
", + "", + "_Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed._", + ].join("\n"); +} + +async function artifactsForRun(github, owner, repo, runId) { + return github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + owner, + repo, + run_id: runId, + per_page: 100, + }); +} + +function findResultArtifact(artifacts) { + return artifacts.find((artifact) => artifact.name === ARTIFACT_NAME && !artifact.expired); +} + +async function resolve({ github, context, core }) { + const source = context.payload.workflow_run; + const { owner, repo } = context.repo; + if (source.event !== "pull_request") { + core.setOutput("publish", "false"); + return; + } + + let pullNumber = source.pull_requests?.[0]?.number; + if (!pullNumber) { + const associated = await github.paginate( + github.rest.repos.listPullRequestsAssociatedWithCommit, + { owner, repo, commit_sha: source.head_sha, per_page: 100 }, + ); + const matchingPulls = associated.filter( + (pull) => + pull.state === "open" && + pull.head.sha === source.head_sha && + pull.head.ref === source.head_branch, + ); + if (matchingPulls.length !== 1) { + core.info( + `Expected one open pull request for ${source.head_repository?.full_name ?? "unknown repository"}:${source.head_branch ?? "unknown branch"} at ${source.head_sha}; found ${matchingPulls.length}.`, + ); + core.setOutput("publish", "false"); + return; + } + pullNumber = matchingPulls[0].number; + } + if (!pullNumber) { + core.info("No open pull request is associated with the completed CI run."); + core.setOutput("publish", "false"); + return; + } + + const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: pullNumber }); + if (pull.head.sha !== source.head_sha) { + core.info(`Skipping stale CI result ${source.head_sha}; PR head is ${pull.head.sha}.`); + core.setOutput("publish", "false"); + return; + } + + const sourceArtifacts = await artifactsForRun(github, owner, repo, source.id); + const sourceArtifact = findResultArtifact(sourceArtifacts); + const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, { + owner, + repo, + workflow_id: source.workflow_id, + branch: pull.base.ref, + event: "push", + status: "success", + per_page: 100, + }); + const orderedRuns = [ + ...workflowRuns.filter((run) => run.head_sha === pull.base.sha), + ...workflowRuns.filter((run) => run.head_sha !== pull.base.sha), + ].slice(0, 20); + + let baselineRun; + for (const run of orderedRuns) { + const artifacts = await artifactsForRun(github, owner, repo, run.id); + if (findResultArtifact(artifacts)) { + baselineRun = run; + break; + } + } + + core.setOutput("publish", "true"); + core.setOutput("pull_number", String(pullNumber)); + core.setOutput("pr_artifact", sourceArtifact ? "true" : "false"); + core.setOutput("pr_run_id", String(source.id)); + core.setOutput("pr_sha", source.head_sha); + core.setOutput("pr_conclusion", source.conclusion ?? "unknown"); + core.setOutput("baseline_artifact", baselineRun ? "true" : "false"); + core.setOutput("baseline_run_id", baselineRun ? String(baselineRun.id) : ""); + core.setOutput("baseline_sha", baselineRun?.head_sha ?? ""); + core.setOutput( + "baseline_matches_base", + baselineRun?.head_sha === pull.base.sha ? "true" : "false", + ); +} + +async function upsertComment(github, context, pullNumber, body, options = {}) { + const { owner, repo } = context.repo; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pullNumber, + per_page: 100, + }); + const existing = comments.find( + (comment) => + comment.user?.login === "github-actions[bot]" && comment.body?.includes(COMMENT_MARKER), + ); + if ( + options.preserveResultSha && + existing?.body?.includes(resultShaMarker(options.preserveResultSha)) + ) { + return; + } + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number: pullNumber, body }); + } +} + +async function upsertCommentForCurrentHead( + github, + context, + core, + pullNumber, + expectedSha, + body, + options, +) { + const { owner, repo } = context.repo; + const { data: pull } = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if (pull.head.sha !== expectedSha) { + core.info(`Skipping stale CI result ${expectedSha}; PR head is ${pull.head.sha}.`); + return false; + } + + await upsertComment(github, context, pullNumber, body, options); + return true; +} + +async function publish({ github, context, core }) { + const pullNumber = Number(process.env.PR_NUMBER); + if (!Number.isSafeInteger(pullNumber) || pullNumber <= 0) { + throw new Error("PR_NUMBER is invalid"); + } + + const current = readResult(process.env.PR_RESULT_DIR); + const currentRun = { + sha: process.env.PR_SHA, + conclusion: process.env.PR_CONCLUSION, + url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.PR_RUN_ID}`, + }; + if (!current) { + await upsertCommentForCurrentHead( + github, + context, + core, + pullNumber, + currentRun.sha, + [ + COMMENT_MARKER, + "## Thread transfer impact", + "", + `⚠️ The latest [CI run](${currentRun.url}) did not produce a thread transfer result for \`${currentRun.sha.slice(0, 7)}\`.`, + "", + "_This comment will update automatically after the next completed run._", + ].join("\n"), + { preserveResultSha: currentRun.sha }, + ); + return; + } + + const baseline = readResult(process.env.BASELINE_RESULT_DIR); + const baselineRun = baseline + ? { + sha: process.env.BASELINE_SHA, + matchesBase: process.env.BASELINE_MATCHES_BASE === "true", + url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${process.env.BASELINE_RUN_ID}`, + } + : undefined; + const body = renderComment({ current, baseline, currentRun, baselineRun }); + const published = await upsertCommentForCurrentHead( + github, + context, + core, + pullNumber, + currentRun.sha, + body, + ); + if (published) { + core.info(`Updated thread transfer report on PR #${pullNumber}.`); + } +} + +module.exports = { + publish, + readResult, + renderComment, + resolve, + upsertCommentForCurrentHead, + validateResult, +}; diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs new file mode 100644 index 000000000000..4935864e46f0 --- /dev/null +++ b/.github/scripts/thread-transfer-report.test.cjs @@ -0,0 +1,292 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { + renderComment, + resolve, + upsertCommentForCurrentHead, + validateResult, +} = require("./thread-transfer-report.cjs"); + +function result(overrides = {}) { + const observed = { + totalWireBytes: 2_200_000, + threadSnapshotWireBytes: 1_950_000, + threadSnapshotDecodedBytes: 9_100_000, + measuredTurnWebSocketWireBytes: 250_000, + measuredTurnWebSocketDecodedBytes: 1_150_000, + measuredTurnWebSocketMessages: 15, + }; + const ceiling = { + totalWireBytes: 2_900_000, + threadSnapshotWireBytes: 2_600_000, + measuredTurnWebSocketWireBytes: 320_000, + measuredTurnWebSocketDecodedBytes: 1_550_000, + measuredTurnWebSocketMessages: 20, + }; + return { + schemaVersion: 1, + scenario: { + id: "thread-transfer-v1", + historyTurns: 10, + historyCommandToolsPerTurn: 5, + historyMcpResultBytes: 900_000, + measuredCommandTools: 20, + measuredMcpResultBytes: 1_100_000, + }, + providers: { + codex: { observed: { ...observed, ...overrides }, ceiling }, + claudeAgent: { observed, ceiling }, + }, + }; +} + +test("validates the fixed artifact schema", () => { + assert.equal(validateResult(result()).schemaVersion, 1); + assert.throws( + () => validateResult({ ...result(), injectedMarkdown: "@everyone" }), + /unexpected fields/, + ); + assert.throws( + () => validateResult(result({ totalWireBytes: "lots" })), + /non-negative safe integer/, + ); +}); + +test("renders baseline, impact, ceiling, and ceiling changes", () => { + const baseline = result(); + const current = result({ measuredTurnWebSocketWireBytes: 260_000 }); + current.providers.codex.ceiling = { + ...current.providers.codex.ceiling, + measuredTurnWebSocketWireBytes: 330_000, + }; + const comment = renderComment({ + current, + baseline, + currentRun: { + sha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + conclusion: "success", + url: "https://github.com/pingdotgg/t3code/actions/runs/2", + }, + baselineRun: { + sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + matchesBase: true, + url: "https://github.com/pingdotgg/t3code/actions/runs/1", + }, + }); + + assert.match(comment, /Main baseline \| This PR \| Impact \| PR ceiling/); + assert.match(comment, /\+9\.8 KiB \(\+4\.0%\)/); + assert.match(comment, /This PR changes transfer ceilings/); + assert.match(comment, /312\.5 KiB → 322\.3 KiB/); + assert.match(comment, //); + assert.match( + comment, + //, + ); +}); + +test("resolves a fallback PR with a redacted head repo and exact main baseline", async () => { + const outputs = {}; + const listWorkflowRunArtifacts = () => {}; + const listWorkflowRuns = () => {}; + const listPullRequestsAssociatedWithCommit = () => {}; + const github = { + paginate: async (method, input) => { + if (method === listPullRequestsAssociatedWithCommit) { + return [ + { + number: 5350, + state: "open", + head: { sha: "head-sha", ref: "feature-branch", repo: null }, + }, + ]; + } + if (method === listWorkflowRunArtifacts) { + return [ + { + name: "thread-transfer-results", + expired: false, + runId: input.run_id, + }, + ]; + } + if (method === listWorkflowRuns) { + return [{ id: 1, head_sha: "base-sha" }]; + } + throw new Error("unexpected pagination call"); + }, + rest: { + actions: { listWorkflowRunArtifacts, listWorkflowRuns }, + pulls: { + get: async () => ({ + data: { + head: { sha: "head-sha" }, + base: { sha: "base-sha", ref: "main" }, + }, + }), + }, + repos: { listPullRequestsAssociatedWithCommit }, + }, + }; + await resolve({ + github, + context: { + repo: { owner: "pingdotgg", repo: "t3code" }, + payload: { + workflow_run: { + id: 2, + event: "pull_request", + workflow_id: 3, + head_sha: "head-sha", + head_branch: "feature-branch", + head_repository: { full_name: "pingdotgg/t3code" }, + conclusion: "success", + pull_requests: [], + }, + }, + }, + core: { + info: () => {}, + setOutput: (key, value) => { + outputs[key] = value; + }, + }, + }); + + assert.equal(outputs.publish, "true"); + assert.equal(outputs.pull_number, "5350"); + assert.equal(outputs.pr_artifact, "true"); + assert.equal(outputs.baseline_run_id, "1"); + assert.equal(outputs.baseline_matches_base, "true"); +}); + +test("does not guess when a fallback commit belongs to multiple PRs", async () => { + const outputs = {}; + const listPullRequestsAssociatedWithCommit = () => {}; + let fetchedPull = false; + await resolve({ + github: { + paginate: async (method) => { + assert.equal(method, listPullRequestsAssociatedWithCommit); + return [5350, 5351].map((number) => ({ + number, + state: "open", + head: { + sha: "head-sha", + ref: "feature-branch", + repo: { full_name: "pingdotgg/t3code" }, + }, + })); + }, + rest: { + actions: {}, + pulls: { + get: async () => { + fetchedPull = true; + }, + }, + repos: { listPullRequestsAssociatedWithCommit }, + }, + }, + context: { + repo: { owner: "pingdotgg", repo: "t3code" }, + payload: { + workflow_run: { + id: 2, + event: "pull_request", + workflow_id: 3, + head_sha: "head-sha", + head_branch: "feature-branch", + head_repository: { full_name: "pingdotgg/t3code" }, + conclusion: "success", + pull_requests: [], + }, + }, + }, + core: { + info: () => {}, + setOutput: (key, value) => { + outputs[key] = value; + }, + }, + }); + + assert.equal(outputs.publish, "false"); + assert.equal(fetchedPull, false); +}); + +test("does not publish a stale result after the PR head advances", async () => { + let listedComments = false; + const info = []; + const published = await upsertCommentForCurrentHead( + { + paginate: async () => { + listedComments = true; + return []; + }, + rest: { + issues: { + listComments: () => {}, + createComment: () => { + throw new Error("must not create a stale comment"); + }, + updateComment: () => { + throw new Error("must not update a stale comment"); + }, + }, + pulls: { + get: async () => ({ data: { head: { sha: "new-head-sha" } } }), + }, + }, + }, + { repo: { owner: "pingdotgg", repo: "t3code" } }, + { info: (message) => info.push(message) }, + 5350, + "old-head-sha", + "stale body", + ); + + assert.equal(published, false); + assert.equal(listedComments, false); + assert.deepEqual(info, ["Skipping stale CI result old-head-sha; PR head is new-head-sha."]); +}); + +test("preserves a successful result when a same-SHA rerun has no artifact", async () => { + let updatedComment = false; + const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const published = await upsertCommentForCurrentHead( + { + paginate: async () => [ + { + id: 1, + user: { login: "github-actions[bot]" }, + body: `\n`, + }, + ], + rest: { + issues: { + listComments: () => {}, + createComment: () => { + updatedComment = true; + }, + updateComment: () => { + updatedComment = true; + }, + }, + pulls: { + get: async () => ({ data: { head: { sha } } }), + }, + }, + }, + { repo: { owner: "pingdotgg", repo: "t3code" } }, + { info: () => {} }, + 5350, + sha, + "missing artifact warning", + { preserveResultSha: sha }, + ); + + assert.equal(published, true); + assert.equal(updatedComment, false); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e51867cbe7d..052a8c20cf78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,8 +84,29 @@ jobs: run: vp run --filter @t3tools/desktop ensure:electron - name: Test + env: + T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md + T3CODE_TRANSFER_BUDGET_RESULT_PATH: ${{ runner.temp }}/thread-transfer-result.json run: vp run test + - name: Publish transfer budget report + if: always() + run: | + if test -f "${{ runner.temp }}/t3code-transfer-budget.md"; then + tee -a "$GITHUB_STEP_SUMMARY" < "${{ runner.temp }}/t3code-transfer-budget.md" + else + echo "Transfer budget report was not produced." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload thread transfer result + if: always() + uses: actions/upload-artifact@v7 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer-result.json + if-no-files-found: ignore + retention-days: 30 + - name: Test resource monitor run: cargo test --locked --manifest-path native/resource-monitor/Cargo.toml diff --git a/.github/workflows/thread-transfer-report.yml b/.github/workflows/thread-transfer-report.yml new file mode 100644 index 000000000000..23eec72923bd --- /dev/null +++ b/.github/workflows/thread-transfer-report.yml @@ -0,0 +1,75 @@ +name: Thread Transfer Report + +on: + workflow_run: + workflows: [CI] + types: [completed] + +permissions: + actions: read + contents: read + pull-requests: write + +jobs: + publish: + name: Publish PR comment + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-24.04 + concurrency: + group: thread-transfer-report-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }} + cancel-in-progress: true + steps: + # workflow_run has a write-capable token even for fork PRs. Only load the + # publisher from the trusted default branch and never execute PR code. + - name: Checkout trusted publisher + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + sparse-checkout: .github/scripts + + - name: Test trusted publisher + run: node --test .github/scripts/thread-transfer-report.test.cjs + + - id: resolve + name: Resolve PR and baseline artifacts + uses: actions/github-script@v8 + with: + script: | + const reporter = require("./.github/scripts/thread-transfer-report.cjs"); + await reporter.resolve({ github, context, core }); + + - name: Download PR result + if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.pr_artifact == 'true' + uses: actions/download-artifact@v8 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer/pr + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ steps.resolve.outputs.pr_run_id }} + + - name: Download main baseline + if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.baseline_artifact == 'true' + uses: actions/download-artifact@v8 + with: + name: thread-transfer-results + path: ${{ runner.temp }}/thread-transfer/main + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ steps.resolve.outputs.baseline_run_id }} + + - name: Update thread transfer comment + if: steps.resolve.outputs.publish == 'true' + uses: actions/github-script@v8 + env: + PR_NUMBER: ${{ steps.resolve.outputs.pull_number }} + PR_SHA: ${{ steps.resolve.outputs.pr_sha }} + PR_CONCLUSION: ${{ steps.resolve.outputs.pr_conclusion }} + PR_RUN_ID: ${{ steps.resolve.outputs.pr_run_id }} + PR_RESULT_DIR: ${{ runner.temp }}/thread-transfer/pr + BASELINE_SHA: ${{ steps.resolve.outputs.baseline_sha }} + BASELINE_MATCHES_BASE: ${{ steps.resolve.outputs.baseline_matches_base }} + BASELINE_RUN_ID: ${{ steps.resolve.outputs.baseline_run_id }} + BASELINE_RESULT_DIR: ${{ runner.temp }}/thread-transfer/main + with: + script: | + const reporter = require("./.github/scripts/thread-transfer-report.cjs"); + await reporter.publish({ github, context, core }); diff --git a/apps/server/integration/NetworkTransferMeasurement.integration.ts b/apps/server/integration/NetworkTransferMeasurement.integration.ts new file mode 100644 index 000000000000..75714d1519e2 --- /dev/null +++ b/apps/server/integration/NetworkTransferMeasurement.integration.ts @@ -0,0 +1,177 @@ +// @effect-diagnostics nodeBuiltinImport:off - Measures the real Node HTTP and WebSocket transports. +import * as NodeHttp from "node:http"; +import * as NodeZlib from "node:zlib"; + +import * as NodeSocket from "@effect/platform-node/NodeSocket"; +import { WsRpcGroup } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as Socket from "effect/unstable/socket/Socket"; + +export class TransferHttpRequestError extends Schema.TaggedErrorClass()( + "TransferHttpRequestError", + { + url: Schema.String, + cause: Schema.Defect(), + }, +) {} + +export interface HttpTransferMeasurement { + readonly status: number; + readonly contentEncoding: string | null; + readonly encodedBody: Uint8Array; + readonly encodedBodyBytes: number; + readonly decodedBody: Uint8Array; + readonly decodedBodyBytes: number; + /** HTTP response bytes read from the socket, including status line and headers. */ + readonly wireBytes: number; +} + +export const measureHttpGet = Effect.fn("TransferBudget.measureHttpGet")(function* (input: { + readonly url: string; + readonly headers?: Readonly>; +}) { + return yield* Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + let socketBytesBeforeResponse = 0; + const request = NodeHttp.get( + input.url, + { + agent: false, + headers: { + "accept-encoding": "gzip", + connection: "close", + ...input.headers, + }, + }, + (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)); + response.once("error", reject); + response.once("end", () => { + try { + const encodedBody = Buffer.concat(chunks); + const header = response.headers["content-encoding"]; + const contentEncoding = Array.isArray(header) + ? (header[0] ?? null) + : (header ?? null); + const decodedBody = + contentEncoding === "gzip" ? NodeZlib.gunzipSync(encodedBody) : encodedBody; + resolve({ + status: response.statusCode ?? 0, + contentEncoding, + encodedBody, + encodedBodyBytes: encodedBody.byteLength, + decodedBody, + decodedBodyBytes: decodedBody.byteLength, + wireBytes: Math.max(0, response.socket.bytesRead - socketBytesBeforeResponse), + }); + } catch (cause) { + reject(cause); + } + }); + }, + ); + request.once("socket", (socket) => { + socketBytesBeforeResponse = socket.bytesRead; + }); + request.once("error", reject); + request.setTimeout(10_000, () => { + request.destroy(new Error(`Timed out reading ${input.url}`)); + }); + }), + catch: (cause) => new TransferHttpRequestError({ url: input.url, cause }), + }); +}); + +export interface WebSocketTransferTotals { + readonly wireBytes: number; + readonly decodedBytes: number; + readonly messages: number; +} + +export interface WebSocketTransferRecorder { + readonly connect: ( + url: string, + protocols: string | string[] | undefined, + cookie: string, + ) => globalThis.WebSocket; + readonly totals: () => WebSocketTransferTotals; + readonly negotiatedExtensions: () => string; +} + +interface NodeWebSocketWithTransport extends NodeSocket.NodeWS.WebSocket { + readonly _socket?: { + readonly bytesRead: number; + }; +} + +function rawDataBytes(data: NodeSocket.NodeWS.RawData): number { + if (Array.isArray(data)) { + return data.reduce((total, chunk) => total + chunk.byteLength, 0); + } + return data.byteLength; +} + +export function makeWebSocketTransferRecorder(): WebSocketTransferRecorder { + let socket: NodeWebSocketWithTransport | null = null; + let decodedBytes = 0; + let messages = 0; + + return { + connect: (url, protocols, cookie) => { + const nextSocket = new NodeSocket.NodeWS.WebSocket(url, protocols, { + headers: { cookie }, + perMessageDeflate: true, + }) as NodeWebSocketWithTransport; + socket = nextSocket; + nextSocket.on("message", (data) => { + const bytes = rawDataBytes(data); + decodedBytes += bytes; + messages += 1; + }); + return nextSocket as unknown as globalThis.WebSocket; + }, + totals: () => ({ + wireBytes: socket?._socket?.bytesRead ?? 0, + decodedBytes, + messages, + }), + negotiatedExtensions: () => socket?.extensions ?? "", + }; +} + +export function transferDelta( + start: WebSocketTransferTotals, + end: WebSocketTransferTotals, +): WebSocketTransferTotals { + return { + wireBytes: Math.max(0, end.wireBytes - start.wireBytes), + decodedBytes: Math.max(0, end.decodedBytes - start.decodedBytes), + messages: Math.max(0, end.messages - start.messages), + }; +} + +export function countingWsRpcProtocolLayer(input: { + readonly url: string; + readonly cookie: string; + readonly recorder: WebSocketTransferRecorder; +}) { + const webSocketConstructorLayer = Layer.succeed(Socket.WebSocketConstructor, (url, protocols) => + input.recorder.connect(url, protocols, input.cookie), + ); + return RpcClient.layerProtocolSocket().pipe( + Layer.provide( + Socket.layerWebSocket(input.url, { openTimeout: "10 seconds" }).pipe( + Layer.provide(webSocketConstructorLayer), + ), + ), + Layer.provide(RpcSerialization.layerJson), + ); +} + +export const makeCountingWsRpcClient = RpcClient.make(WsRpcGroup); +export type CountingWsRpcClient = Effect.Success; diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index c3f77d677b1d..d192cbeac8e2 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -55,6 +55,8 @@ import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceip import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts"; +import { CheckpointReactor } from "../src/orchestration/Services/CheckpointReactor.ts"; +import { ProviderRuntimeIngestionService } from "../src/orchestration/Services/ProviderRuntimeIngestion.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape, @@ -218,6 +220,8 @@ export interface OrchestrationIntegrationHarness { timeoutMs?: number, ): Effect.Effect; }; + readonly drainProviderRuntime: Effect.Effect; + readonly drainCheckpointReactor: Effect.Effect; readonly dispose: Effect.Effect; } @@ -392,6 +396,13 @@ export const makeOrchestrationIntegrationHarness = ( const reactor = yield* tryRuntimePromise("load OrchestrationReactor service", () => runtime.runPromise(Effect.service(OrchestrationReactor)), ).pipe(Effect.orDie); + const providerRuntimeIngestion = yield* tryRuntimePromise( + "load ProviderRuntimeIngestion service", + () => runtime.runPromise(Effect.service(ProviderRuntimeIngestionService)), + ).pipe(Effect.orDie); + const checkpointReactor = yield* tryRuntimePromise("load CheckpointReactor service", () => + runtime.runPromise(Effect.service(CheckpointReactor)), + ).pipe(Effect.orDie); const snapshotQuery = yield* tryRuntimePromise("load ProjectionSnapshotQuery service", () => runtime.runPromise(Effect.service(ProjectionSnapshotQuery)), ).pipe(Effect.orDie); @@ -556,6 +567,8 @@ export const makeOrchestrationIntegrationHarness = ( waitForDomainEvent, waitForPendingApproval, waitForReceipt, + drainProviderRuntime: providerRuntimeIngestion.drain, + drainCheckpointReactor: checkpointReactor.drain, dispose, } satisfies OrchestrationIntegrationHarness; }); diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts index 0e64699de97d..095cca4e5e74 100644 --- a/apps/server/integration/TestProviderAdapter.integration.ts +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -11,7 +11,6 @@ import { ProviderDriverKind, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Crypto from "effect/Crypto"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; @@ -226,9 +225,9 @@ function missingSessionEffect( export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapterHarnessOptions) => Effect.gen(function* () { const provider = options?.provider ?? ProviderDriverKind.make("codex"); - const crypto = yield* Crypto.Crypto; const runtimeEvents = yield* Queue.unbounded(); let sessionCount = 0; + let eventCount = 0; const sessions = new Map(); const queuedResponsesForNextSession: TestTurnResponse[] = []; const interruptCallsBySession = new Map>(); @@ -242,18 +241,10 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter >(); const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event); - const randomUUIDv4 = (threadId: ThreadId) => - crypto.randomUUIDv4.pipe( - Effect.mapError( - (cause) => - new ProviderAdapterValidationError({ - provider, - operation: "crypto/randomUUIDv4", - issue: `Failed to generate test runtime identifier for thread '${threadId}'.`, - cause, - }), - ), - ); + const nextEventId = (threadId: ThreadId) => { + eventCount += 1; + return EventId.make(`test-provider:${provider}:${threadId}:${eventCount}`); + }; const startSession: ProviderAdapterShape["startSession"] = (input) => Effect.gen(function* () { @@ -322,7 +313,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter for (const fixtureEvent of response.events) { const rawEvent: Record = { ...(fixtureEvent as Record), - eventId: yield* randomUUIDv4(input.threadId), + eventId: nextEventId(input.threadId), provider, sessionId: RuntimeSessionId.make(String(input.threadId)), }; @@ -379,7 +370,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter if (deferredTurnCompletedEvents.length === 0) { yield* emit({ type: "turn.completed", - eventId: EventId.make(yield* randomUUIDv4(input.threadId)), + eventId: nextEventId(input.threadId), provider, createdAt: nowIso(), threadId: state.snapshot.threadId, diff --git a/apps/server/integration/TransferBudgetReport.integration.ts b/apps/server/integration/TransferBudgetReport.integration.ts new file mode 100644 index 000000000000..f773b5b8b844 --- /dev/null +++ b/apps/server/integration/TransferBudgetReport.integration.ts @@ -0,0 +1,212 @@ +import type { ProviderDriverKind } from "@t3tools/contracts"; + +import type { + HttpTransferMeasurement, + WebSocketTransferTotals, +} from "./NetworkTransferMeasurement.integration.ts"; +import { + TRANSFER_HISTORY_MCP_RESULT_BYTES, + TRANSFER_HISTORY_TOOLS_PER_TURN, + TRANSFER_HISTORY_TURN_COUNT, + TRANSFER_MEASURED_MCP_RESULT_BYTES, + TRANSFER_MEASURED_TOOLS, +} from "./fixtures/transferBudget.ts"; + +export interface TransferBudgetRun { + readonly provider: ProviderDriverKind; + readonly threadSnapshot: HttpTransferMeasurement; + readonly measuredTurnWebSocket: WebSocketTransferTotals; +} + +interface ProviderTransferBudget { + readonly totalWireBytes: number; + readonly threadSnapshotWireBytes: number; + readonly measuredTurnWebSocketWireBytes: number; + readonly measuredTurnWebSocketDecodedBytes: number; + readonly measuredTurnWebSocketMessages: number; +} + +// These caps leave roughly 30% headroom above the client projection of the +// deterministic 9 MB retained-result fixture. Full MCP results stay in +// persistence, so accidentally shipping them again exceeds these caps by +// orders of magnitude. The CI report preserves exact values for review. +const TRANSFER_BUDGET = { + totalWireBytes: 15_500, + threadSnapshotWireBytes: 7_500, + measuredTurnWebSocketWireBytes: 8_000, + measuredTurnWebSocketDecodedBytes: 68_000, + measuredTurnWebSocketMessages: 21, +} satisfies ProviderTransferBudget; + +export const TRANSFER_BUDGETS: Readonly> = { + codex: TRANSFER_BUDGET, + claudeAgent: TRANSFER_BUDGET, +}; + +function totalWireBytes(run: TransferBudgetRun): number { + return run.threadSnapshot.wireBytes + run.measuredTurnWebSocket.wireBytes; +} + +function observedTransfer(run: TransferBudgetRun) { + return { + totalWireBytes: totalWireBytes(run), + threadSnapshotWireBytes: run.threadSnapshot.wireBytes, + threadSnapshotDecodedBytes: run.threadSnapshot.decodedBodyBytes, + measuredTurnWebSocketWireBytes: run.measuredTurnWebSocket.wireBytes, + measuredTurnWebSocketDecodedBytes: run.measuredTurnWebSocket.decodedBytes, + measuredTurnWebSocketMessages: run.measuredTurnWebSocket.messages, + }; +} + +/** Machine-readable input for the trusted PR comment publisher. */ +export function formatTransferBudgetResult(runs: ReadonlyArray): string { + const providers = Object.fromEntries( + runs.flatMap((run) => { + const ceiling = TRANSFER_BUDGETS[run.provider]; + return ceiling ? [[run.provider, { observed: observedTransfer(run), ceiling }]] : []; + }), + ); + + return `${JSON.stringify( + { + schemaVersion: 1, + scenario: { + id: "thread-transfer-v1", + historyTurns: TRANSFER_HISTORY_TURN_COUNT, + historyCommandToolsPerTurn: TRANSFER_HISTORY_TOOLS_PER_TURN, + historyMcpResultBytes: TRANSFER_HISTORY_MCP_RESULT_BYTES, + measuredCommandTools: TRANSFER_MEASURED_TOOLS, + measuredMcpResultBytes: TRANSFER_MEASURED_MCP_RESULT_BYTES, + }, + providers, + }, + null, + 2, + )}\n`; +} + +function formatBytes(bytes: number): string { + if (bytes < 1_024) return `${bytes} B`; + if (bytes >= 1_024 * 1_024) { + return `${(bytes / 1_024 / 1_024).toFixed(2)} MiB (${bytes.toLocaleString("en-US")} B)`; + } + return `${(bytes / 1_024).toFixed(1)} KiB (${bytes.toLocaleString("en-US")} B)`; +} + +function row( + provider: ProviderDriverKind, + phase: string, + metric: string, + observed: number, + maximum: number, + format: (value: number) => string = formatBytes, +): string { + const status = observed <= maximum ? "PASS" : "FAIL"; + return `| ${provider} | ${phase} | ${metric} | ${format(observed)} | ${format(maximum)} | ${status} |`; +} + +export function transferBudgetViolations(runs: ReadonlyArray): string[] { + const violations: string[] = []; + for (const run of runs) { + const budget = TRANSFER_BUDGETS[run.provider]; + if (!budget) { + violations.push(`${run.provider}: no transfer budget is configured`); + continue; + } + const checks = [ + ["total thread wire bytes", totalWireBytes(run), budget.totalWireBytes], + ["thread snapshot wire bytes", run.threadSnapshot.wireBytes, budget.threadSnapshotWireBytes], + [ + "measured-turn WebSocket wire bytes", + run.measuredTurnWebSocket.wireBytes, + budget.measuredTurnWebSocketWireBytes, + ], + [ + "measured-turn WebSocket decoded bytes", + run.measuredTurnWebSocket.decodedBytes, + budget.measuredTurnWebSocketDecodedBytes, + ], + [ + "measured-turn WebSocket messages", + run.measuredTurnWebSocket.messages, + budget.measuredTurnWebSocketMessages, + ], + ] as const; + for (const [metric, observed, maximum] of checks) { + if (observed > maximum) { + violations.push(`${run.provider}: ${metric} was ${observed}, maximum ${maximum}`); + } + } + } + return violations; +} + +export function formatTransferBudgetReport(runs: ReadonlyArray): string { + const lines = [ + "# T3 Code thread transfer budget", + "", + "Wire values are thread data bytes read from local HTTP and WebSocket sockets. HTTP includes response headers; WebSocket measurement starts after the resumed thread subscription synchronizes. TCP/IP, TLS framing, and the WebSocket upgrade are excluded. WebSocket permessage-deflate is negotiated.", + `Scenario: ${TRANSFER_HISTORY_TURN_COUNT} historical turns with ${TRANSFER_HISTORY_TOOLS_PER_TURN} command tools and one retained ${formatBytes(TRANSFER_HISTORY_MCP_RESULT_BYTES)} MCP result each, followed by one measured turn with ${TRANSFER_MEASURED_TOOLS} command tools and a retained ${formatBytes(TRANSFER_MEASURED_MCP_RESULT_BYTES)} MCP result. Payload sizes are calibrated from heavy local Codex and Claude histories and contain no user data.`, + "", + "| Provider | Total thread wire | Budget | Result |", + "| --- | ---: | ---: | --- |", + ...runs.flatMap((run) => { + const budget = TRANSFER_BUDGETS[run.provider]; + if (!budget) return []; + const observed = observedTransfer(run).totalWireBytes; + return [ + `| ${run.provider} | ${formatBytes(observed)} | ${formatBytes(budget.totalWireBytes)} | ${observed <= budget.totalWireBytes ? "PASS" : "FAIL"} |`, + ]; + }), + "", + "## Detailed measurements", + "", + "| Provider | Phase | Metric | Observed | Budget | Result |", + "| --- | --- | --- | ---: | ---: | --- |", + ]; + + for (const run of runs) { + const budget = TRANSFER_BUDGETS[run.provider]; + if (!budget) continue; + lines.push( + row( + run.provider, + "thread snapshot", + "HTTP wire", + run.threadSnapshot.wireBytes, + budget.threadSnapshotWireBytes, + ), + row( + run.provider, + "measured turn", + "WebSocket wire", + run.measuredTurnWebSocket.wireBytes, + budget.measuredTurnWebSocketWireBytes, + ), + row( + run.provider, + "measured turn", + "WebSocket decoded", + run.measuredTurnWebSocket.decodedBytes, + budget.measuredTurnWebSocketDecodedBytes, + ), + row( + run.provider, + "measured turn", + "WebSocket messages", + run.measuredTurnWebSocket.messages, + budget.measuredTurnWebSocketMessages, + String, + ), + ); + } + + lines.push("", "## Compression diagnostics", ""); + for (const run of runs) { + lines.push( + `- ${run.provider}: thread snapshot ${formatBytes(run.threadSnapshot.decodedBodyBytes)} decoded to ${formatBytes(run.threadSnapshot.encodedBodyBytes)} gzip.`, + ); + } + + return `${lines.join("\n")}\n`; +} diff --git a/apps/server/integration/TransferBudgetScenario.integration.ts b/apps/server/integration/TransferBudgetScenario.integration.ts new file mode 100644 index 000000000000..77dfbc1dd7fb --- /dev/null +++ b/apps/server/integration/TransferBudgetScenario.integration.ts @@ -0,0 +1,128 @@ +import { + CommandId, + defaultInstanceIdForDriver, + DEFAULT_MODEL, + DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_PROVIDER_INTERACTION_MODE, + MessageId, + ProjectId, + ProviderDriverKind, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import type { TurnProcessingQuiescedReceipt } from "../src/orchestration/Services/RuntimeReceiptBus.ts"; +import type { OrchestrationIntegrationHarness } from "./OrchestrationEngineHarness.integration.ts"; +import { + expectedRecordedAssistantText, + makeRecordedTransferTurn, + TRANSFER_HISTORY_TURN_COUNT, +} from "./fixtures/transferBudget.ts"; + +export const TRANSFER_PROJECT_ID = ProjectId.make("transfer-budget-project"); +export const TRANSFER_THREAD_ID = ThreadId.make("transfer-budget-thread"); +export const TRANSFER_MEASURED_TURN_INDEX = TRANSFER_HISTORY_TURN_COUNT; + +export function transferModelSelection(provider: ProviderDriverKind) { + return { + instanceId: defaultInstanceIdForDriver(provider), + model: DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL, + }; +} + +function turnTimestamp(turnIndex: number): string { + return `2026-06-01T00:${String(turnIndex).padStart(2, "0")}:00.000Z`; +} + +export const TRANSFER_MEASURED_TURN_CREATED_AT = turnTimestamp(TRANSFER_MEASURED_TURN_INDEX); + +const waitForTurnQuiesced = Effect.fn("TransferBudget.waitForTurnQuiesced")(function* ( + harness: OrchestrationIntegrationHarness, + checkpointTurnCount: number, +) { + const receipt = yield* harness.waitForReceipt( + (receipt): receipt is TurnProcessingQuiescedReceipt => + receipt.type === "turn.processing.quiesced" && + receipt.threadId === TRANSFER_THREAD_ID && + receipt.checkpointTurnCount === checkpointTurnCount, + ); + yield* harness.drainProviderRuntime; + yield* harness.drainCheckpointReactor; + return receipt; +}); + +export const seedTransferBudgetHistory = Effect.fn("TransferBudget.seedHistory")(function* ( + harness: OrchestrationIntegrationHarness, + provider: ProviderDriverKind, +) { + if (!harness.adapterHarness) { + return yield* Effect.die(new Error("Transfer budget history requires the replay adapter.")); + } + + const modelSelection = transferModelSelection(provider); + yield* harness.engine.dispatch({ + type: "project.create", + commandId: CommandId.make(`transfer:${provider}:project-create`), + projectId: TRANSFER_PROJECT_ID, + title: "Transfer Budget Project", + workspaceRoot: harness.workspaceDir, + defaultModelSelection: modelSelection, + createdAt: turnTimestamp(0), + }); + yield* harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(`transfer:${provider}:thread-create`), + threadId: TRANSFER_THREAD_ID, + projectId: TRANSFER_PROJECT_ID, + title: `${provider} transfer history`, + modelSelection, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: "main", + worktreePath: harness.workspaceDir, + createdAt: turnTimestamp(0), + }); + + for (let turnIndex = 0; turnIndex < TRANSFER_HISTORY_TURN_COUNT; turnIndex += 1) { + const response = makeRecordedTransferTurn(provider, turnIndex); + if (turnIndex === 0) { + yield* harness.adapterHarness.queueTurnResponseForNextSession(response); + } else { + yield* harness.adapterHarness.queueTurnResponse(TRANSFER_THREAD_ID, response); + } + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`transfer:${provider}:turn:${turnIndex + 1}`), + threadId: TRANSFER_THREAD_ID, + message: { + messageId: MessageId.make(`transfer-user-${turnIndex + 1}`), + role: "user", + text: `Inspect transfer behavior for historical turn ${turnIndex + 1}.`, + attachments: [], + }, + modelSelection, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: turnTimestamp(turnIndex), + }); + yield* waitForTurnQuiesced(harness, turnIndex + 1); + } +}); + +export const queueMeasuredTransferTurn = Effect.fn("TransferBudget.queueMeasuredTurn")(function* ( + harness: OrchestrationIntegrationHarness, + provider: ProviderDriverKind, +) { + if (!harness.adapterHarness) { + return yield* Effect.die(new Error("Transfer budget measurement requires the replay adapter.")); + } + const response = makeRecordedTransferTurn(provider, TRANSFER_MEASURED_TURN_INDEX); + yield* harness.adapterHarness.queueTurnResponse(TRANSFER_THREAD_ID, response); +}); + +export function expectedMeasuredAssistantText(provider: ProviderDriverKind): string { + return expectedRecordedAssistantText(provider, TRANSFER_MEASURED_TURN_INDEX); +} + +export { TRANSFER_HISTORY_TURN_COUNT, waitForTurnQuiesced }; diff --git a/apps/server/integration/fixtures/transferBudget.ts b/apps/server/integration/fixtures/transferBudget.ts new file mode 100644 index 000000000000..d3567d386b9e --- /dev/null +++ b/apps/server/integration/fixtures/transferBudget.ts @@ -0,0 +1,372 @@ +import { EventId, ProviderDriverKind } from "@t3tools/contracts"; + +import type { + FixtureProviderRuntimeEvent, + TestTurnResponse, +} from "../TestProviderAdapter.integration.ts"; + +const FIXTURE_THREAD_ID = "transfer-budget-thread"; +const FIXTURE_TURN_ID = "transfer-budget-turn"; + +export const TRANSFER_HISTORY_TURN_COUNT = 10; +export const TRANSFER_HISTORY_TOOLS_PER_TURN = 5; +export const TRANSFER_MEASURED_TOOLS = 20; +export const TRANSFER_HISTORY_MCP_RESULT_BYTES = 900_000; +export const TRANSFER_MEASURED_MCP_RESULT_BYTES = 1_100_000; + +const sourceModules = [ + "connection/session.ts", + "connection/supervisor.ts", + "rpc/client.ts", + "rpc/protocol.ts", + "state/threads.ts", + "state/threadReducer.ts", + "state/threadSnapshotHttp.ts", + "orchestration/http.ts", + "orchestration/Normalizer.ts", + "orchestration/ActivityPayloadProjection.ts", + "provider/ProviderService.ts", + "provider/ProviderRuntimeIngestion.ts", + "persistence/ProjectionSnapshotQuery.ts", + "persistence/OrchestrationEventStore.ts", + "checkpointing/CheckpointStore.ts", + "checkpointing/CheckpointDiffQuery.ts", + "server.ts", +] as const; + +function fixtureTimestamp(turnIndex: number, eventIndex: number): string { + const minute = String(turnIndex).padStart(2, "0"); + const second = String(Math.floor(eventIndex / 1_000)).padStart(2, "0"); + const millisecond = String(eventIndex % 1_000).padStart(3, "0"); + return `2026-06-01T00:${minute}:${second}.${millisecond}Z`; +} + +function mix(value: number): number { + let mixed = value | 0; + mixed ^= mixed >>> 16; + mixed = Math.imul(mixed, 0x7feb352d); + mixed ^= mixed >>> 15; + mixed = Math.imul(mixed, 0x846ca68b); + mixed ^= mixed >>> 16; + return mixed >>> 0; +} + +function digest(seed: number): string { + return [0, 1, 2, 3] + .map((offset) => + mix(seed + offset * 0x9e3779b9) + .toString(16) + .padStart(8, "0"), + ) + .join(""); +} + +/** Produces safe, deterministic output with enough entropy to exercise gzip. */ +function diagnosticOutput(input: { + readonly provider: ProviderDriverKind; + readonly turnIndex: number; + readonly toolIndex: number; + readonly targetBytes: number; +}): string { + const chunks: string[] = []; + const providerSeed = input.provider === "codex" ? 0x43_4f_44_45 : 0x43_4c_41_55; + let length = 0; + let lineIndex = 0; + + while (length < input.targetBytes) { + const modulePath = sourceModules[(input.toolIndex + lineIndex) % sourceModules.length]; + const seed = + providerSeed + input.turnIndex * 100_003 + input.toolIndex * 10_007 + lineIndex * 101; + const line = + `${String(lineIndex + 1).padStart(6, "0")} ${modulePath} ` + + `operation=project-transfer-${input.turnIndex + 1}-${input.toolIndex + 1} ` + + `cursor=${mix(seed)} digest=${digest(seed)} status=completed\n`; + chunks.push(line); + length += line.length; + lineIndex += 1; + } + + return chunks.join("").slice(0, input.targetBytes); +} + +function assistantChunks(provider: ProviderDriverKind, turnIndex: number): ReadonlyArray { + const providerName = provider === "codex" ? "Codex" : "Claude"; + const paragraphs: string[] = [ + `I traced the ${providerName} request through the environment connection and orchestration layers. `, + ]; + let paragraphIndex = 0; + while (paragraphs.join("").length < 4_096) { + const modulePath = sourceModules[paragraphIndex % sourceModules.length]; + paragraphs.push( + `Pass ${paragraphIndex + 1} reviewed ${modulePath} for turn ${turnIndex + 1}. ` + + "The shell cursor stayed monotonic, the thread snapshot remained resumable, and the client received only incremental events. ", + ); + paragraphIndex += 1; + } + const text = paragraphs.join("").slice(0, 4_096); + return Array.from({ length: Math.ceil(text.length / 256) }, (_, index) => + text.slice(index * 256, (index + 1) * 256), + ); +} + +export function expectedRecordedAssistantText( + provider: ProviderDriverKind, + turnIndex: number, +): string { + return assistantChunks(provider, turnIndex).join(""); +} + +function unifiedDiff(provider: ProviderDriverKind, turnIndex: number): string { + const lines = sourceModules + .slice(0, 8) + .flatMap((modulePath, index) => [ + `diff --git a/${modulePath} b/${modulePath}`, + `--- a/${modulePath}`, + `+++ b/${modulePath}`, + `@@ -${index + 1},2 +${index + 1},3 @@`, + ` const provider = "${provider}";`, + `+const transferTurn = ${turnIndex + 1};`, + `+const transferSample = ${1_500 + index * 97};`, + ]); + return lines.join("\n"); +} + +function baseEvent( + provider: ProviderDriverKind, + turnIndex: number, + eventIndex: number, +): Pick { + return { + eventId: EventId.make(`recorded:${provider}:${turnIndex}:${eventIndex}`), + provider, + createdAt: fixtureTimestamp(turnIndex, eventIndex), + threadId: FIXTURE_THREAD_ID, + }; +} + +/** + * Synthetic canonical events calibrated from heavy local Codex and Claude + * threads. Ten historical turns produce 9 MB of retained MCP results without + * committing user content. Command output is intentionally modest because the + * client projection strips it. + */ +export function makeRecordedTransferTurn( + provider: ProviderDriverKind, + turnIndex: number, +): TestTurnResponse { + const measuredTurn = turnIndex >= TRANSFER_HISTORY_TURN_COUNT; + const toolCount = measuredTurn ? TRANSFER_MEASURED_TOOLS : TRANSFER_HISTORY_TOOLS_PER_TURN; + const turnId = `${FIXTURE_TURN_ID}-${turnIndex + 1}`; + const events: FixtureProviderRuntimeEvent[] = []; + let eventIndex = 0; + + events.push({ + type: "turn.started", + ...baseEvent(provider, turnIndex, eventIndex++), + turnId, + payload: { + model: provider === "codex" ? "gpt-5.4" : "claude-opus-4-1", + effort: provider === "codex" ? "high" : "default", + }, + }); + + for (let toolIndex = 0; toolIndex < toolCount; toolIndex += 1) { + const itemId = `tool-${turnIndex + 1}-${toolIndex + 1}`; + const command = + provider === "codex" + ? `vp test transfer-budget-${toolIndex + 1}` + : `review transfer budget ${toolIndex + 1}`; + events.push( + { + type: "item.started", + ...baseEvent(provider, turnIndex, eventIndex++), + turnId, + itemId, + payload: { + itemType: "command_execution", + status: "inProgress", + title: `Inspect transfer path ${toolIndex + 1}`, + detail: "Inspecting the HTTP snapshot and WebSocket projection boundaries.", + data: { + threadId: FIXTURE_THREAD_ID, + turnId, + startedAtMs: turnIndex * 60_000 + eventIndex, + item: { + id: itemId, + type: "commandExecution", + command, + cwd: "/workspace/transfer-budget", + processId: String(toolIndex + 1), + status: "inProgress", + commandActions: [], + aggregatedOutput: "", + }, + }, + }, + }, + { + type: "item.completed", + ...baseEvent(provider, turnIndex, eventIndex++), + turnId, + itemId, + payload: { + itemType: "command_execution", + status: "completed", + title: `Inspected transfer path ${toolIndex + 1}`, + detail: "Collected a deterministic multi-module transfer diagnostic.", + data: { + threadId: FIXTURE_THREAD_ID, + turnId, + completedAtMs: turnIndex * 60_000 + eventIndex, + item: { + id: itemId, + type: "commandExecution", + command, + cwd: "/workspace/transfer-budget", + processId: String(toolIndex + 1), + status: "completed", + commandActions: [], + aggregatedOutput: diagnosticOutput({ + provider, + turnIndex, + toolIndex, + targetBytes: 1_000, + }), + exitCode: 0, + durationMs: 500 + toolIndex, + }, + }, + }, + }, + ); + } + + const mcpItemId = `mcp-${turnIndex + 1}`; + const mcpResultBytes = measuredTurn + ? TRANSFER_MEASURED_MCP_RESULT_BYTES + : TRANSFER_HISTORY_MCP_RESULT_BYTES; + events.push( + { + type: "item.started", + ...baseEvent(provider, turnIndex, eventIndex++), + turnId, + itemId: mcpItemId, + payload: { + itemType: "mcp_tool_call", + status: "inProgress", + title: "fixture-history · inspect_transfer_log", + detail: "Reading a retained diagnostic result from the provider history.", + data: { + startedAtMs: turnIndex * 60_000 + eventIndex, + threadId: FIXTURE_THREAD_ID, + turnId, + item: { + type: "mcpToolCall", + id: mcpItemId, + server: "fixture-history", + tool: "inspect_transfer_log", + arguments: { turn: turnIndex + 1 }, + status: "inProgress", + }, + }, + }, + }, + { + type: "item.completed", + ...baseEvent(provider, turnIndex, eventIndex++), + turnId, + itemId: mcpItemId, + payload: { + itemType: "mcp_tool_call", + status: "completed", + title: "fixture-history · inspect_transfer_log", + detail: "Retained a deterministic diagnostic result in the thread history.", + data: { + completedAtMs: turnIndex * 60_000 + eventIndex, + threadId: FIXTURE_THREAD_ID, + turnId, + item: { + type: "mcpToolCall", + id: mcpItemId, + server: "fixture-history", + tool: "inspect_transfer_log", + arguments: { turn: turnIndex + 1 }, + durationMs: 1_000 + turnIndex, + error: null, + result: { + content: [ + { + type: "text", + text: diagnosticOutput({ + provider, + turnIndex, + toolIndex: toolCount, + targetBytes: mcpResultBytes, + }), + }, + ], + }, + status: "completed", + }, + }, + }, + }, + ); + + const chunks = assistantChunks(provider, turnIndex); + for (const [contentIndex, delta] of chunks.entries()) { + events.push({ + type: "content.delta", + ...baseEvent(provider, turnIndex, eventIndex++), + turnId, + itemId: `assistant-${turnIndex + 1}`, + payload: { + streamKind: "assistant_text", + delta, + contentIndex, + }, + }); + } + + events.push( + { + type: "thread.token-usage.updated", + ...baseEvent(provider, turnIndex, eventIndex++), + turnId, + payload: { + usage: { + usedTokens: 18_000 + turnIndex * 1_900, + maxTokens: 200_000, + inputTokens: 15_000 + turnIndex * 1_700, + cachedInputTokens: 9_000 + turnIndex * 1_100, + outputTokens: 3_000 + turnIndex * 200, + toolUses: toolCount, + durationMs: 4_000 + turnIndex * 250, + }, + }, + }, + { + type: "turn.diff.updated", + ...baseEvent(provider, turnIndex, eventIndex++), + turnId, + payload: { + unifiedDiff: unifiedDiff(provider, turnIndex), + }, + }, + { + type: "turn.completed", + ...baseEvent(provider, turnIndex, eventIndex), + turnId, + payload: { + state: "completed", + stopReason: "end_turn", + usage: { + inputTokens: 15_000 + turnIndex * 1_700, + outputTokens: 3_000 + turnIndex * 200, + }, + }, + }, + ); + + return { events }; +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a403e228b060..4ddb01e09dd7 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2,7 +2,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeCrypto from "node:crypto"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { AuthAccessTokenType, @@ -16,6 +16,8 @@ import { KeybindingRule, MessageId, ExternalLauncherCommandNotFoundError, + OrchestrationThreadDetailSnapshot, + type OrchestrationThreadStreamItem, type OrchestrationThreadShell, TerminalNotRunningError, type OrchestrationCommand, @@ -41,6 +43,7 @@ import * as RelayClient from "@t3tools/shared/relayClient"; import { assert, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; import * as Clock from "effect/Clock"; +import * as Config from "effect/Config"; import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -52,6 +55,8 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -70,11 +75,34 @@ import * as Socket from "effect/unstable/socket/Socket"; import { vi } from "vite-plus/test"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationThreadDetailSnapshot), +); + +const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function* ( + queue: Queue.Queue, + predicate: (value: A) => boolean, + waitDescription: string, +) { + return yield* Effect.gen(function* () { + const values: A[] = []; + while (true) { + const value = yield* Queue.take(queue); + values.push(value); + if (predicate(value)) return values; + } + }).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => Effect.die(new Error(`Timed out waiting for ${waitDescription}`)), + }), + ); +}); import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; -import { resolveAvailableEditorsForConfig } from "./ws.ts"; +import { isThreadDetailEvent, resolveAvailableEditorsForConfig } from "./ws.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; @@ -123,6 +151,32 @@ import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as Data from "effect/Data"; +import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; +import { + countingWsRpcProtocolLayer, + makeCountingWsRpcClient, + makeWebSocketTransferRecorder, + measureHttpGet, + transferDelta, +} from "../integration/NetworkTransferMeasurement.integration.ts"; +import { + expectedMeasuredAssistantText, + queueMeasuredTransferTurn, + seedTransferBudgetHistory, + TRANSFER_HISTORY_TURN_COUNT, + TRANSFER_MEASURED_TURN_CREATED_AT, + TRANSFER_MEASURED_TURN_INDEX, + TRANSFER_THREAD_ID, + transferModelSelection, + waitForTurnQuiesced, +} from "../integration/TransferBudgetScenario.integration.ts"; +import { + formatTransferBudgetReport, + formatTransferBudgetResult, + type TransferBudgetRun, + transferBudgetViolations, +} from "../integration/TransferBudgetReport.integration.ts"; + const defaultProjectId = ProjectId.make("project-default"); const defaultThreadId = ThreadId.make("thread-default"); const defaultDesktopBootstrapToken = "test-desktop-bootstrap-token"; @@ -549,9 +603,12 @@ const buildAppUnderTest = (options?: { ), ), ); + const serviceLauncherClientLayer = ServiceLauncherClient.layer.pipe( + Layer.provide(Layer.succeed(HostProcessEnvironment, {})), + ); const servedRoutesLayer = HttpRouter.serve( - makeRoutesLayer.pipe(Layer.provide(ServiceLauncherClient.layer)), + makeRoutesLayer.pipe(Layer.provide(serviceLauncherClientLayer)), { disableListenLog: true, disableLogger: true, @@ -1319,6 +1376,28 @@ const getWsServerUrl = ( ); }); +// Mirrors NodeHttpServer.layerTest, which does not expose server options, +// with the production `websocket: { perMessageDeflate: true }` setting. +const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe( + Layer.provide( + Layer.fresh(FetchHttpClient.layer).pipe( + Layer.provide(Layer.succeed(FetchHttpClient.RequestInit)({ keepalive: false })), + ), + ), + Layer.provideMerge( + Layer.unwrap( + Effect.map( + Effect.promise(() => import("node:http")), + (NodeHttp) => + NodeHttpServer.layer(NodeHttp.createServer, { + port: 0, + websocket: { perMessageDeflate: true }, + }), + ), + ), + ), +); + it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("parks HTTP ingress until command readiness", () => Effect.gen(function* () { @@ -3219,28 +3298,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - // Mirrors NodeHttpServer.layerTest, which does not expose server options, - // with the production `websocket: { perMessageDeflate: true }` setting. - const NodeHttpServerTestWithWsDeflate = HttpServer.layerTestClient.pipe( - Layer.provide( - Layer.fresh(FetchHttpClient.layer).pipe( - Layer.provide(Layer.succeed(FetchHttpClient.RequestInit)({ keepalive: false })), - ), - ), - Layer.provideMerge( - Layer.unwrap( - Effect.map( - Effect.promise(() => import("node:http")), - (NodeHttp) => - NodeHttpServer.layer(NodeHttp.createServer, { - port: 0, - websocket: { perMessageDeflate: true }, - }), - ), - ), - ), - ); - it.effect("negotiates permessage-deflate with clients that offer it", () => Effect.gen(function* () { yield* buildAppUnderTest(); @@ -7839,3 +7896,167 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); }); + +it.live( + "reports thread HTTP and WebSocket transfer budgets", + () => + Effect.gen(function* () { + const providers = [ + ProviderDriverKind.make("codex"), + ProviderDriverKind.make("claudeAgent"), + ] as const; + + const runs = yield* Effect.forEach( + providers, + (provider) => + Effect.acquireUseRelease( + makeOrchestrationIntegrationHarness({ provider }), + (harness) => + Effect.gen(function* () { + yield* seedTransferBudgetHistory(harness, provider); + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: harness.engine, + projectionSnapshotQuery: harness.snapshotQuery, + }, + }); + + const baseUrl = yield* getHttpServerUrl(); + const cookie = yield* getAuthenticatedSessionCookieHeader(); + + const recorder = makeWebSocketTransferRecorder(); + const wsUrl = baseUrl.replace(/^http:/, "ws:") + "/ws"; + const protocolLayer = countingWsRpcProtocolLayer({ + url: wsUrl, + cookie, + recorder, + }); + + return yield* Effect.scoped( + Effect.gen(function* () { + const client = yield* makeCountingWsRpcClient; + + const threadSnapshot = yield* measureHttpGet({ + url: `${baseUrl}/api/orchestration/threads/${TRANSFER_THREAD_ID}`, + headers: { cookie }, + }); + assert.equal(threadSnapshot.status, 200); + assert.equal(threadSnapshot.contentEncoding, "gzip"); + const decodedThread = yield* decodeTransferThreadSnapshot( + Buffer.from(threadSnapshot.decodedBody).toString("utf8"), + ); + assert.equal( + decodedThread.thread.messages.length, + TRANSFER_HISTORY_TURN_COUNT * 2, + ); + + const threadItems = yield* Queue.unbounded(); + yield* client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: TRANSFER_THREAD_ID, + afterSequence: decodedThread.snapshotSequence, + requestCompletionMarker: true, + }).pipe( + Stream.runForEach((item) => + Queue.offer(threadItems, item).pipe(Effect.asVoid), + ), + Effect.forkScoped, + ); + const initialThreadItems = yield* collectQueueUntil( + threadItems, + (item) => item.kind === "synchronized", + `${provider} thread subscription to synchronize`, + ); + assert.isFalse(initialThreadItems.some((item) => item.kind === "snapshot")); + assert.include(recorder.negotiatedExtensions(), "permessage-deflate"); + + yield* queueMeasuredTransferTurn(harness, provider); + const turnStartTotals = recorder.totals(); + yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make(`transfer:${provider}:measured-turn`), + threadId: TRANSFER_THREAD_ID, + message: { + messageId: MessageId.make("transfer-user-measured"), + role: "user", + text: "Measure the client-bound transfer for this turn.", + attachments: [], + }, + modelSelection: transferModelSelection(provider), + runtimeMode: "approval-required", + interactionMode: "default", + createdAt: TRANSFER_MEASURED_TURN_CREATED_AT, + }); + yield* waitForTurnQuiesced(harness, TRANSFER_MEASURED_TURN_INDEX + 1); + const finalThreadSequence = yield* harness.engine + .readEvents(decodedThread.snapshotSequence, 10_000) + .pipe( + Stream.runFold( + () => decodedThread.snapshotSequence, + (sequence, event) => + event.aggregateId === TRANSFER_THREAD_ID && isThreadDetailEvent(event) + ? Math.max(sequence, event.sequence) + : sequence, + ), + ); + assert.isAbove(finalThreadSequence, decodedThread.snapshotSequence); + + yield* collectQueueUntil( + threadItems, + (item) => + item.kind === "event" && item.event.sequence === finalThreadSequence, + `${provider} thread stream to reach sequence ${finalThreadSequence}`, + ); + const measuredTurnWebSocket = transferDelta(turnStartTotals, recorder.totals()); + + const finalThreadSnapshot = yield* harness.snapshotQuery + .getThreadDetailSnapshot(TRANSFER_THREAD_ID) + .pipe(Effect.map(Option.getOrThrow)); + const expectedAssistantText = expectedMeasuredAssistantText(provider); + const measuredAssistant = finalThreadSnapshot.thread.messages.find( + (message) => + message.role === "assistant" && message.text === expectedAssistantText, + ); + assert.isDefined(measuredAssistant); + assert.isTrue( + finalThreadSnapshot.thread.messages.length >= TRANSFER_HISTORY_TURN_COUNT * 2, + ); + assert.equal(measuredAssistant?.streaming, false); + assert.equal(finalThreadSnapshot.thread.session?.status, "ready"); + assert.equal( + finalThreadSnapshot.thread.checkpoints.length, + TRANSFER_HISTORY_TURN_COUNT + 1, + ); + + return { + provider, + threadSnapshot, + measuredTurnWebSocket, + } satisfies TransferBudgetRun; + }).pipe(Effect.provide(protocolLayer)), + ); + }), + (harness) => harness.dispose, + ).pipe(Effect.provide(NodeHttpServerTestWithWsDeflate)), + { concurrency: 1 }, + ); + + const report = formatTransferBudgetReport(runs); + yield* Effect.logInfo(`\n${report}`); + const reportPath = yield* Config.string("T3CODE_TRANSFER_BUDGET_REPORT_PATH").pipe( + Config.option, + ); + if (Option.isSome(reportPath)) { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(reportPath.value, report); + } + const resultPath = yield* Config.string("T3CODE_TRANSFER_BUDGET_RESULT_PATH").pipe( + Config.option, + ); + if (Option.isSome(resultPath)) { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(resultPath.value, formatTransferBudgetResult(runs)); + } + assert.deepEqual(transferBudgetViolations(runs), []); + }).pipe(Effect.provide(NodeServices.layer)), + 120_000, +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6bafb9ec3ba9..a6b155c296f7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -268,7 +268,7 @@ function projectSetupScriptCompatibilityDetail( } } -function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< +export function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< OrchestrationEvent, { type: From 2288d416aa3bcb5f2eeb4004228872593296971e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 02:18:34 -0400 Subject: [PATCH 019/326] fix(web): stop the "requests are slow" warning from firing on every provider update (#5570) Co-authored-by: Claude Opus 5 (1M context) --- .../SlowRpcRequestToastCoordinator.tsx | 5 ++- apps/web/src/connection/platform.ts | 2 +- apps/web/src/rpc/requestLatencyState.test.ts | 27 ++++++++++++++ apps/web/src/rpc/requestLatencyState.ts | 37 +++++++++++++++---- 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx b/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx index 07711ca84b7b..f5391c13abaf 100644 --- a/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx +++ b/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx @@ -5,7 +5,10 @@ import { toastManager } from "./ui/toast"; function describeSlowRequests(requests: ReadonlyArray): string { const count = requests.length; - const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000); + // Thresholds vary per method, so report the smallest one the batch has passed. + const thresholdSeconds = Math.round( + Math.min(...requests.map((request) => request.thresholdMs)) / 1000, + ); return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; } diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 56d25fa142eb..c7652136f541 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -590,7 +590,7 @@ const rpcRequestObserverLayer = Layer.succeed( Effect.sync(() => { nextObservedRpcRequestId += 1; const requestId = `${environmentId}:${nextObservedRpcRequestId}`; - trackRpcRequestSent(requestId, `${method} · ${environmentId}`); + trackRpcRequestSent(requestId, method, `${method} · ${environmentId}`); return Effect.sync(() => { acknowledgeRpcRequest(requestId); }); diff --git a/apps/web/src/rpc/requestLatencyState.test.ts b/apps/web/src/rpc/requestLatencyState.test.ts index 504c93e1f78d..e5b3144d2520 100644 --- a/apps/web/src/rpc/requestLatencyState.test.ts +++ b/apps/web/src/rpc/requestLatencyState.test.ts @@ -6,6 +6,7 @@ import { getSlowRpcAckRequests, resetRequestLatencyStateForTests, trackRpcRequestSent, + LONG_RUNNING_RPC_ACK_THRESHOLD_MS, SLOW_RPC_ACK_THRESHOLD_MS, MAX_TRACKED_RPC_ACK_REQUESTS, } from "./requestLatencyState"; @@ -58,6 +59,32 @@ describe("requestLatencyState", () => { expect(getSlowRpcAckRequests()).toEqual([]); }); + it("keeps ignoring untracked methods when a display tag is supplied", () => { + trackRpcRequestSent( + "1", + WS_METHODS.previewAutomationConnect, + `${WS_METHODS.previewAutomationConnect} · env-1`, + ); + vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2); + + expect(getSlowRpcAckRequests()).toEqual([]); + }); + + it("gives provider updates a longer threshold before warning", () => { + trackRpcRequestSent("1", WS_METHODS.serverUpdateProvider, "server.updateProvider · env-1"); + vi.advanceTimersByTime(LONG_RUNNING_RPC_ACK_THRESHOLD_MS - 1); + expect(getSlowRpcAckRequests()).toEqual([]); + + vi.advanceTimersByTime(1); + expect(getSlowRpcAckRequests()).toMatchObject([ + { + requestId: "1", + tag: "server.updateProvider · env-1", + thresholdMs: LONG_RUNNING_RPC_ACK_THRESHOLD_MS, + }, + ]); + }); + it("evicts the oldest pending requests once the tracker reaches capacity", () => { for (let index = 0; index < MAX_TRACKED_RPC_ACK_REQUESTS + 1; index += 1) { trackRpcRequestSent(String(index), "server.getConfig"); diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index c30ffc882791..4736d3783c3b 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -5,6 +5,12 @@ import { Atom } from "effect/unstable/reactivity"; import { appAtomRegistry } from "./atomRegistry"; export const SLOW_RPC_ACK_THRESHOLD_MS = 15_000; +/** + * Some requests are slow by design — they shell out to a package manager on the + * server and only respond once the install finishes. Warning about those after + * 15s is noise, so they get a much longer leash. + */ +export const LONG_RUNNING_RPC_ACK_THRESHOLD_MS = 120_000; export const MAX_TRACKED_RPC_ACK_REQUESTS = 256; let slowRpcAckThresholdMs = SLOW_RPC_ACK_THRESHOLD_MS; @@ -22,7 +28,12 @@ interface PendingRpcAckRequest { } const pendingRpcAckRequests = new Map(); -const untrackedRpcAckTags = new Set([WS_METHODS.previewAutomationConnect]); +const untrackedRpcAckMethods = new Set([WS_METHODS.previewAutomationConnect]); +const longRunningRpcAckMethods = new Set([ + WS_METHODS.serverUpdateProvider, + WS_METHODS.serverRefreshProviders, + WS_METHODS.serverUpdateServer, +]); const slowRpcAckRequestsAtom = Atom.make>([]).pipe( Atom.keepAlive, @@ -37,16 +48,27 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray { return appAtomRegistry.get(slowRpcAckRequestsAtom); } -function shouldTrackRpcAck(tag: string): boolean { - return !tag.includes("subscribe") && !untrackedRpcAckTags.has(tag); +function shouldTrackRpcAck(method: string): boolean { + return !method.includes("subscribe") && !untrackedRpcAckMethods.has(method); +} + +function rpcAckThresholdMs(method: string): number { + return longRunningRpcAckMethods.has(method) + ? Math.max(slowRpcAckThresholdMs, LONG_RUNNING_RPC_ACK_THRESHOLD_MS) + : slowRpcAckThresholdMs; } export function getSlowRpcAckRequests(): ReadonlyArray { return getSlowRpcAckRequestsValue(); } -export function trackRpcRequestSent(requestId: string, tag: string): void { - if (!shouldTrackRpcAck(tag)) { +/** + * Starts the slow-request timer for one in-flight unary RPC. `method` is the + * bare WS method (used to decide whether and how long to wait); `tag` is the + * human-readable label shown in the toast, which defaults to the method. + */ +export function trackRpcRequestSent(requestId: string, method: string, tag = method): void { + if (!shouldTrackRpcAck(method)) { return; } @@ -54,17 +76,18 @@ export function trackRpcRequestSent(requestId: string, tag: string): void { evictOldestPendingRpcRequestIfNeeded(); const startedAtMs = Date.now(); + const thresholdMs = rpcAckThresholdMs(method); const request: SlowRpcAckRequest = { requestId, startedAt: new Date(startedAtMs).toISOString(), startedAtMs, tag, - thresholdMs: slowRpcAckThresholdMs, + thresholdMs, }; const timeoutId = setTimeout(() => { pendingRpcAckRequests.delete(requestId); appendSlowRpcAckRequest(request); - }, slowRpcAckThresholdMs); + }, thresholdMs); pendingRpcAckRequests.set(requestId, { request, From cf5c9948c895e120965165b7b2b643ca275c5315 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 02:19:20 -0400 Subject: [PATCH 020/326] fix(web): keep agent panel rows stable (#5569) --- .../ProviderRuntimeIngestion.activity.test.ts | 84 +++++++ .../Layers/ProviderRuntimeIngestion.ts | 105 +++++--- apps/web/src/components/AgentsPanel.tsx | 229 +++++++++--------- .../src/state/subagentRuntime.test.ts | 71 ++++++ .../src/state/subagentRuntime.ts | 24 +- 5 files changed, 369 insertions(+), 144 deletions(-) create mode 100644 apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts new file mode 100644 index 000000000000..936041038644 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts @@ -0,0 +1,84 @@ +import { + EventId, + ProviderDriverKind, + RuntimeTaskId, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { runtimeEventToActivities } from "./ProviderRuntimeIngestion.ts"; + +const base = { + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-08-06T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), +}; + +describe("runtimeEventToActivities task progress", () => { + it("persists usage independently from replaceable activity", () => { + const taskId = RuntimeTaskId.make("agent-1"); + const usageOnly = { + ...base, + type: "task.progress", + eventId: EventId.make("evt-usage"), + payload: { + taskId, + description: "Agent one", + typedUsage: { totalTokens: 73_700_000 }, + }, + } satisfies ProviderRuntimeEvent; + const command = { + ...base, + type: "task.progress", + eventId: EventId.make("evt-command"), + payload: { + taskId, + description: "Agent one", + summary: "Running tests", + lastToolName: "exec_command", + }, + } satisfies ProviderRuntimeEvent; + + const usageActivities = runtimeEventToActivities(usageOnly); + const commandActivities = runtimeEventToActivities(command); + + expect(usageActivities.map((activity) => activity.id)).toEqual(["task-usage:thread-1:agent-1"]); + expect(commandActivities.map((activity) => activity.id)).toEqual([ + "task-progress:thread-1:agent-1", + ]); + const usagePayload = usageActivities[0]?.payload as Record | undefined; + expect(usagePayload?.typedUsage).toEqual({ totalTokens: 73_700_000 }); + expect(usagePayload?.usageSnapshot).toBe(true); + }); + + it("splits combined progress and usage into their independent snapshots", () => { + const event = { + ...base, + type: "task.progress", + eventId: EventId.make("evt-combined"), + payload: { + taskId: RuntimeTaskId.make("agent-2"), + description: "Agent two", + summary: "Inspecting the panel", + typedUsage: { totalTokens: 4_200, toolUses: 7 }, + status: "running", + }, + } satisfies ProviderRuntimeEvent; + + const activities = runtimeEventToActivities(event); + const progressPayload = activities[0]?.payload as Record; + const usagePayload = activities[1]?.payload as Record; + + expect(activities.map((activity) => activity.id)).toEqual([ + "task-progress:thread-1:agent-2", + "task-usage:thread-1:agent-2", + ]); + expect(progressPayload.summary).toBe("Inspecting the panel"); + expect(progressPayload.status).toBe("running"); + expect(progressPayload).not.toHaveProperty("typedUsage"); + expect(usagePayload.typedUsage).toEqual({ totalTokens: 4_200, toolUses: 7 }); + expect(usagePayload.usageSnapshot).toBe(true); + expect(usagePayload).not.toHaveProperty("status"); + }); +}); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 0420420939e8..189dd6961062 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -563,39 +563,80 @@ export function runtimeEventToActivities( } case "task.progress": { + const linkage = taskLinkageActivityFields(event.payload as Record); + // Usage and activity are independent latest-state streams. Keeping them + // under separate stable ids prevents a command/reasoning update from + // replacing the last known token count (and prevents a usage-only tick + // from blanking the last meaningful activity). + const identityLinkage = { ...linkage }; + delete identityLinkage.typedUsage; + delete identityLinkage.status; + delete identityLinkage.error; + const title = + event.payload.description.trim().length > 0 + ? { title: truncateDetail(event.payload.description, 120) } + : {}; + const hasProgressState = + event.payload.typedUsage === undefined || + event.payload.summary !== undefined || + event.payload.lastToolName !== undefined || + event.payload.status !== undefined || + event.payload.error !== undefined; return [ - { - // Stable per-task id: progress is "latest state", not history, so - // each tick REPLACES the last via the activity upsert (PK + the - // replace-by-id apply in projector and client reducer). Keeps one - // progress row per task instead of thousands, so a large fleet's - // ticks can no longer evict its own start/terminal rows out of - // the 500-row retention window. Thread-scoped: activity_id is a - // GLOBAL primary key and Claude task ids are session-local, so a - // bare taskId could collide across threads and steal another - // thread's row (review finding). - id: EventId.make(`task-progress:${event.threadId}:${event.payload.taskId}`), - createdAt: event.createdAt, - tone: "info", - kind: "task.progress", - summary: - event.payload.description.trim().length > 0 - ? truncateDetail(event.payload.description, 120) - : "Reasoning update", - payload: { - taskId: event.payload.taskId, - ...(event.payload.description.trim().length > 0 - ? { title: truncateDetail(event.payload.description, 120) } - : {}), - detail: truncateDetail(event.payload.summary ?? event.payload.description), - ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}), - ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), - ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), - ...taskLinkageActivityFields(event.payload as Record), - }, - turnId: toTurnId(event.turnId) ?? null, - ...maybeSequence, - }, + ...(hasProgressState + ? [ + { + // Stable per-task id: activity is "latest state", not + // history, so each meaningful tick replaces the last. This + // bounds a large fleet to one activity row per task. + id: EventId.make(`task-progress:${event.threadId}:${event.payload.taskId}`), + createdAt: event.createdAt, + tone: "info" as const, + kind: "task.progress" as const, + summary: + event.payload.description.trim().length > 0 + ? truncateDetail(event.payload.description, 120) + : "Reasoning update", + payload: { + taskId: event.payload.taskId, + ...title, + detail: truncateDetail(event.payload.summary ?? event.payload.description), + ...(event.payload.summary + ? { summary: truncateDetail(event.payload.summary) } + : {}), + ...(event.payload.lastToolName + ? { lastToolName: event.payload.lastToolName } + : {}), + ...(event.payload.status ? { status: event.payload.status } : {}), + ...(event.payload.error ? { error: event.payload.error } : {}), + ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), + ...identityLinkage, + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ] + : []), + ...(event.payload.typedUsage !== undefined + ? [ + { + id: EventId.make(`task-usage:${event.threadId}:${event.payload.taskId}`), + createdAt: event.createdAt, + tone: "info" as const, + kind: "task.progress" as const, + summary: "Task usage updated", + payload: { + taskId: event.payload.taskId, + ...title, + ...identityLinkage, + usageSnapshot: true, + typedUsage: event.payload.typedUsage, + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ] + : []), ]; } diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 169c662e585e..4eeff67ce5f7 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -4,13 +4,11 @@ * spawn batch). * * Visualization rules (from live-test feedback): - * - Live work first: running workflows and direct spawns sort above settled. - * - Rows are flat status lines — no expansion, no per-agent tool feeds. The - * row answers "who / what phase / how much"; anything deeper is a future - * drill-in, not an unfold. - * - A settled workflow run collapses to a single summary line; click it to - * show its member list inline (the one allowed toggle — run granularity, - * not agent granularity). + * - Spawn order is stable. Activity and completion update rows in place. + * - Agent rows reserve three fixed lines for identity, activity, and metrics; + * changing data must never change their height. + * - Workflow expansion is presentation state. A live run stays expanded when + * it settles; older collapsed runs can still be opened at run granularity. * - Static status dots, DOM-write elapsed timers, plain token counters. */ import { useAtomValue } from "@effect/atom-react"; @@ -143,54 +141,50 @@ function AgentRow({ agent }: { agent: RuntimeSubagent }) { const visuals = STATUS_VISUALS[agent.status]; const activity = agentActivityText(agent); const modelLabel = formatSubagentModelLabel(agent.model, agent.effort); + const role = + agent.role?.trim().toLocaleLowerCase() === agent.title.trim().toLocaleLowerCase() + ? null + : agent.role; + const metadata = [ + modelLabel, + agent.usage ? `${formatSubagentTokenCount(agent.usage.totalTokens)} tok` : "— tok", + agent.usage?.toolUses !== undefined ? `${agent.usage.toolUses} tools` : null, + agent.activationCount > 1 ? `run ${agent.activationCount}` : null, + ].filter((value): value is string => value !== null); return ( -
-
- - - - - - {agent.title} - {agent.role ? ( - - {agent.role} - - ) : null} - - - {agent.status === "completed" ? ( - - ) : null} - +
+ + + + + {agent.title} + {role ? ( + + {role} - {activity ? ( - - {activity} - + ) : null} + + + + + {agent.status === "completed" ? ( + ) : null} - - {modelLabel ? {modelLabel} : null} - {agent.usage ? ( - - {modelLabel ? "· " : ""} - {formatSubagentTokenCount(agent.usage.totalTokens)} tok - - ) : null} - {agent.usage?.toolUses !== undefined ? ( - · {agent.usage.toolUses} tools - ) : null} - {agent.activationCount > 1 ? · run {agent.activationCount} : null} - {visuals.label} - -
+
+ + {activity ?? visuals.label} + + + {metadata.join(" · ")} + + {visuals.label}
); } @@ -314,18 +308,32 @@ function WorkflowScriptView({ } /** - * Collapsible phase section (Claude Code Background-tasks pattern): live - * phases open by default, done phases collapsed to header + member dot row. - * User toggles override the default and stick for the phase's lifetime. + * Collapsible phase section. A phase opens when it becomes active, then keeps + * that shape as it settles so completion never yanks rows out from under the + * user. Manual toggles stick until a later activation begins. */ -function PhaseSection({ phase }: { phase: AgentPanelWorkflowGroup["phases"][number] }) { - const [userOpen, setUserOpen] = useState(null); - const open = userOpen ?? phase.state === "running"; +function PhaseSection({ + phase, + defaultOpen = false, +}: { + phase: AgentPanelWorkflowGroup["phases"][number]; + defaultOpen?: boolean; +}) { + const [open, setOpen] = useState(defaultOpen || phase.state === "running"); + const previousState = useRef(phase.state); + + useEffect(() => { + if (previousState.current !== "running" && phase.state === "running") { + setOpen(true); + } + previousState.current = phase.state; + }, [phase.state]); + return (
{scriptOpen && canShowScript ? ( @@ -416,7 +436,7 @@ function LiveWorkflowSection({ /> ) : null} {group.phases.map((phase) => ( - + ))} {group.unphasedMembers.map((member) => ( @@ -429,11 +449,16 @@ function LiveWorkflowSection({ } /** - * Settled workflow: one summary line. Click toggles the member list — the - * only expansion in the panel, at run granularity. + * Collapsed workflow: one summary line. The parent owns expansion so a live + * workflow keeps its shape when it settles. */ -function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) { - const [open, setOpen] = useState(false); +function CollapsedWorkflowSection({ + group, + onExpand, +}: { + group: AgentPanelWorkflowGroup; + onExpand: () => void; +}) { const members = workflowMembers(group); const failed = members.filter((member) => member.status === "failed").length; // Coordinator usage may already aggregate members (panel-footer rule): @@ -450,9 +475,9 @@ function SettledWorkflowSection({ group }: { group: AgentPanelWorkflowGroup }) {
- {open ? ( -
- {members.map((member) => ( - - ))} -
- ) : null}
); } +/** A workflow's open state is presentation state, not a status derivative. */ +function WorkflowSection({ + group, + environmentId, + threadId, +}: { + group: AgentPanelWorkflowGroup; + environmentId: EnvironmentId | null; + threadId: ThreadId | null; +}) { + const [open, setOpen] = useState(() => workflowIsLive(group)); + return open ? ( + setOpen(false)} + /> + ) : ( + setOpen(true)} /> + ); +} + export function AgentsPanel({ model, environmentId = null, @@ -503,48 +540,24 @@ export function AgentsPanel({ ); } - const liveWorkflows = model.workflows.filter(workflowIsLive); - const settledWorkflows = model.workflows.filter((group) => !workflowIsLive(group)); - const liveDirect = model.directAgents.filter( - (agent) => - agent.status === "running" || agent.status === "pending" || agent.status === "waiting", - ); - const settledDirect = model.directAgents.filter( - (agent) => - agent.status !== "running" && agent.status !== "pending" && agent.status !== "waiting", - ); - return (
- {liveWorkflows.map((group) => ( - ( + ))} - {liveDirect.length > 0 ? ( + {model.directAgents.length > 0 ? (
Direct spawns
- {liveDirect.map((agent) => ( - - ))} -
- ) : null} - {settledWorkflows.length > 0 || settledDirect.length > 0 ? ( -
-
- Earlier -
- {settledWorkflows.map((group) => ( - - ))} - {settledDirect.map((agent) => ( + {model.directAgents.map((agent) => ( ))}
diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index c6c758511a3b..ceb40517550e 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -186,6 +186,34 @@ describe("foldSubagentActivities", () => { expect(agents[0]!.usage).toEqual({ totalTokens: 900, inputTokens: 700 }); }); + it("usage snapshots enrich an existing agent without changing its status", () => { + const [agent] = fold([ + activity("task.started", { taskId: "usage-waiting", taskType: "local_agent" }), + activity("task.progress", { taskId: "usage-waiting", status: "waiting" }), + activity("task.progress", { + taskId: "usage-waiting", + usageSnapshot: true, + typedUsage: { totalTokens: 1_200 }, + }), + ]); + + expect(agent?.status).toBe("waiting"); + expect(agent?.usage?.totalTokens).toBe(1_200); + }); + + it("a retained usage snapshot can still reconstruct a running agent", () => { + const [agent] = fold([ + activity("task.progress", { + taskId: "usage-only", + usageSnapshot: true, + typedUsage: { totalTokens: 800 }, + }), + ]); + + expect(agent?.status).toBe("running"); + expect(agent?.usage?.totalTokens).toBe(800); + }); + it("partial terminal usage preserves known breakdown fields", () => { const agents = fold([ activity("task.started", { taskId: "task-6", taskType: "local_agent" }), @@ -362,6 +390,49 @@ describe("deriveAgentPanelModel", () => { ); }); + it("keeps direct spawns in first-seen order as their activity changes", () => { + const directRoster = fold([ + activity("task.started", { taskId: "direct-a", title: "First" }, "2026-08-01T11:00:00.000Z"), + activity("task.started", { taskId: "direct-b", title: "Second" }, "2026-08-01T11:00:01.000Z"), + activity( + "task.progress", + { taskId: "direct-a", summary: "Newest activity" }, + "2026-08-01T11:00:02.000Z", + ), + ]); + + expect( + deriveAgentPanelModel({ agents: directRoster }).directAgents.map((agent) => agent.id), + ).toEqual(["direct-a", "direct-b"]); + }); + + it("keeps first-seen order after the roster retention ranking runs", () => { + const starts = Array.from({ length: 101 }, (_, index) => + activity( + "task.started", + { taskId: `capped-${index}`, title: `Agent ${index}` }, + `2026-08-01T12:${String(Math.floor(index / 60)).padStart(2, "0")}:${String( + index % 60, + ).padStart(2, "0")}.000Z`, + ), + ); + const cappedRoster = fold([ + ...starts, + activity( + "task.progress", + { taskId: "capped-0", summary: "Newest activity" }, + "2026-08-01T12:02:00.000Z", + ), + ]); + + const ids = deriveAgentPanelModel({ agents: cappedRoster }).directAgents.map( + (agent) => agent.id, + ); + expect(ids).toHaveLength(100); + expect(ids.slice(0, 3)).toEqual(["capped-0", "capped-2", "capped-3"]); + expect(ids.at(-1)).toBe("capped-100"); + }); + it("a phase with only pending members never reads as running", () => { const pendingRoster = fold([ activity("task.started", { taskId: "wf-9", taskType: "local_workflow" }), diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index c81dd6341409..e5f2b586b8c4 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -80,6 +80,8 @@ export interface RuntimeSubagent { readonly phases: ReadonlyArray; readonly runHandles: SubagentRunHandles | null; readonly recentActivity: ReadonlyArray; + /** First retained observation, used as the roster's stable display order. */ + readonly firstSeenAt: string; readonly startedAt: string | null; readonly completedAt: string | null; readonly updatedAt: string; @@ -247,6 +249,7 @@ interface MutableAgent { phases: ReadonlyArray; runHandles: SubagentRunHandles | null; recentActivity: ReadonlyArray; + firstSeenAt: string; startedAt: string | null; completedAt: string | null; updatedAt: string; @@ -300,6 +303,7 @@ function getOrCreate( phases: [], runHandles: null, recentActivity: [], + firstSeenAt: at, startedAt: null, completedAt: null, updatedAt: at, @@ -500,14 +504,19 @@ export function foldSubagentActivities( // Membership is sticky per taskId: rows after the first (terminal // rows often carry only taskId+status, no marker fields) inherit the // first row's classification instead of being re-judged. - if (!agents.has(taskId) && isBackgroundTaskActivity(payload)) break; + const existed = agents.has(taskId); + if (!existed && isBackgroundTaskActivity(payload)) break; const agent = getOrCreate(agents, taskId, payload, at); fillMetadata(agent, payload); if (agent.activationCount === 0) agent.activationCount = 1; const explicitStatus = asRuntimeStatus(payload.status); if (explicitStatus) { applyStatus(agent, explicitStatus, at); - } else if (!isTerminalSubagentStatus(agent.status) && agent.status !== "idle") { + } else if ( + (payload.usageSnapshot !== true || !existed) && + !isTerminalSubagentStatus(agent.status) && + agent.status !== "idle" + ) { applyStatus(agent, "running", at); } const summary = asString(payload.summary); @@ -726,7 +735,10 @@ export function deriveAgentPanelModel({ return EMPTY_PANEL_MODEL; } - const workflows = source.filter((agent) => agent.kind === "workflow"); + const workflows = source + .filter((agent) => agent.kind === "workflow") + .slice() + .sort((a, b) => a.firstSeenAt.localeCompare(b.firstSeenAt) || a.id.localeCompare(b.id)); const workflowIds = new Set(workflows.map((workflow) => workflow.id)); const members = new Map(); const direct: RuntimeSubagent[] = []; @@ -827,7 +839,11 @@ export function deriveAgentPanelModel({ return { workflows: workflowGroups, - directAgents: direct.slice().sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)), + // Updates and the >100-agent retention ranking must never reshuffle rows + // that remain visible. + directAgents: direct + .slice() + .sort((a, b) => a.firstSeenAt.localeCompare(b.firstSeenAt) || a.id.localeCompare(b.id)), runningCount, waitingCount, idleCount, From 4a07c1ca9d9033ef35161a1ffbea44d0418ca2bf Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 02:53:19 -0400 Subject: [PATCH 021/326] docs: ship production T3 Connect public config in .env.example (#5573) Co-authored-by: Claude Fable 5 --- .env.example | 21 ++++++++++++--------- docs/internals/t3-connect.md | 12 ++++++++++-- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 61cdd66d246a..fc67dcef9478 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,14 @@ # Optional: T3 Connect source builds -# Leave these unset to disable optional T3 Connect features in local source builds. -# Release builds inject their public values at build time. Do not add server-side -# secrets to this file. +# `cp .env.example .env` enables T3 Connect against the production deployment. +# These are the same public identifiers baked into official release builds, not +# secrets. Remove or comment them out to build with cloud features disabled. +# Do not add server-side secrets to this file. -# Get these from the Clerk Dashboard under API keys, JWT templates, and OAuth applications. -# T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_... -# T3CODE_CLERK_JWT_TEMPLATE=t3-relay -# T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=oauthapp_... +# Production Clerk instance. To use your own, get these from the Clerk Dashboard +# under API keys, JWT templates, and OAuth applications. +T3CODE_CLERK_PUBLISHABLE_KEY=pk_live_Y2xlcmsudDMuY29kZXMk +T3CODE_CLERK_JWT_TEMPLATE=t3-relay +T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=hzxSgY2cH10sDU2r # Optional: signed macOS passkey builds. The RP domain defaults to the Frontend API # hostname encoded in T3CODE_CLERK_PUBLISHABLE_KEY. Set the override only when Clerk @@ -15,8 +17,9 @@ # T3CODE_MACOS_PROVISIONING_PROFILE=/absolute/path/to/t3code.provisionprofile # T3CODE_CLERK_PASSKEY_RP_DOMAINS=example.clerk.accounts.dev,clerk.example.com -# Get this from your relay deployment. `infra/relay` deploys update it automatically. -# T3CODE_RELAY_URL=https://relay.example.com +# Production relay. For a self-hosted relay, `infra/relay` deploys update it +# automatically. +T3CODE_RELAY_URL=https://relay.t3.codes # Optional: hosted app origin used by the CLI's out-of-band OAuth flow. # Defaults to https://app.t3.codes; override to test against a staging deployment. diff --git a/docs/internals/t3-connect.md b/docs/internals/t3-connect.md index c8a0217919f7..c734f0f9dd74 100644 --- a/docs/internals/t3-connect.md +++ b/docs/internals/t3-connect.md @@ -14,8 +14,16 @@ For the wider system diagram, see ## Application Keys -T3 Connect is disabled in a fresh clone. To enable it for source builds, add a repository-root `.env` -or `.env.local` file: +T3 Connect is disabled in a fresh clone. To enable it for source builds against the production +deployment, copy the repository-root example file: + +```sh +cp .env.example .env +``` + +`.env.example` carries the production public identifiers (the same values baked into official +release builds). To target a different Clerk application or relay, set the values yourself in a +repository-root `.env` or `.env.local` file: ```dotenv T3CODE_CLERK_PUBLISHABLE_KEY= From a8cd2ad2ebb32ad789e8e0ecd2fc713c2edc38f4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 03:16:14 -0400 Subject: [PATCH 022/326] fix(web): plans stop hijacking the UI, fold into chat instead (#5558) Co-authored-by: Claude Fable 5 --- .../settings/DesktopClientSettings.test.ts | 1 - .../OrchestrationEngineHarness.integration.ts | 6 +- .../Layers/CheckpointReactor.test.ts | 3 + .../Layers/OrchestrationEngine.test.ts | 5 + .../Layers/ProjectionPipeline.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.test.ts | 4 + .../Layers/ProjectionSnapshotQuery.ts | 5 + .../Layers/ProviderCommandReactor.test.ts | 3 + .../Layers/ProviderRuntimeIngestion.test.ts | 2 + .../Layers/ProviderRuntimeIngestion.ts | 17 ++ .../orchestration/ThreadPlanProgress.test.ts | 44 +++ .../src/orchestration/ThreadPlanProgress.ts | 76 +++++ apps/server/src/orchestration/runtimeLayer.ts | 13 +- apps/web/src/components/ChatView.tsx | 207 +++---------- apps/web/src/components/PlanSidebar.tsx | 284 ------------------ apps/web/src/components/RightPanelTabs.tsx | 6 +- apps/web/src/components/SidebarV2.tsx | 13 +- apps/web/src/components/chat/ChatComposer.tsx | 58 ---- .../chat/CompactComposerControlsMenu.tsx | 18 +- .../components/chat/MessagesTimeline.logic.ts | 24 ++ .../src/components/chat/MessagesTimeline.tsx | 119 +++++++- .../components/settings/SettingsPanels.tsx | 31 -- .../src/components/settings/settingsSearch.ts | 5 - apps/web/src/planSidebarDismissal.ts | 21 -- apps/web/src/rightPanelStore.test.ts | 59 +++- apps/web/src/rightPanelStore.ts | 26 +- apps/web/src/session-logic.test.ts | 188 ++++++------ apps/web/src/session-logic.ts | 131 +++++--- packages/contracts/src/orchestration.ts | 14 + packages/contracts/src/settings.ts | 2 - 30 files changed, 619 insertions(+), 768 deletions(-) create mode 100644 apps/server/src/orchestration/ThreadPlanProgress.test.ts create mode 100644 apps/server/src/orchestration/ThreadPlanProgress.ts delete mode 100644 apps/web/src/components/PlanSidebar.tsx delete mode 100644 apps/web/src/planSidebarDismissal.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 53ef74f21911..26c3127de5f8 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,7 +13,6 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { - autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index d192cbeac8e2..71ef59a0910c 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -51,6 +51,7 @@ import { OrchestrationEngineLive } from "../src/orchestration/Layers/Orchestrati import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../src/orchestration/ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../src/orchestration/ThreadPlanProgress.ts"; import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; @@ -310,7 +311,10 @@ export const makeOrchestrationIntegrationHarness = ( checkpointStoreLayer, providerLayer, RuntimeReceiptBusTest, - ).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer)); + ).pipe( + Layer.provideMerge(ThreadBackgroundLiveness.layer), + Layer.provideMerge(ThreadPlanProgress.layer), + ); const serverSettingsLayer = ServerSettingsService.layerTest(); const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(runtimeServicesLayer), diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index ddb525cd5478..08ea1437bb29 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -40,6 +40,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { RuntimeReceiptBusLive } from "./RuntimeReceiptBus.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; @@ -296,6 +297,7 @@ describe("CheckpointReactor", () => { const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -304,6 +306,7 @@ describe("CheckpointReactor", () => { ); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 857f5b887fc0..19290d6ec40e 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -32,6 +32,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline, @@ -57,6 +58,7 @@ async function createOrchestrationSystem() { OrchestrationProjectionSnapshotQueryLive, ).pipe( Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), @@ -820,6 +822,7 @@ describe("OrchestrationEngine", () => { OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -926,6 +929,7 @@ describe("OrchestrationEngine", () => { OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -1070,6 +1074,7 @@ describe("OrchestrationEngine", () => { OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 09d7573f5d8e..8e65295b1bad 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -32,6 +32,7 @@ import { } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ServerConfig } from "../../config.ts"; @@ -2673,6 +2674,7 @@ const engineLayer = it.layer( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 92c87ebdc044..d5dda7aa86b2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -18,6 +18,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { encodeThreadDetailPageCursor } from "../threadDetailCursor.ts"; @@ -30,6 +31,7 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val const projectionSnapshotLayer = it.layer( OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(NodeServices.layer), @@ -447,6 +449,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { hasPendingUserInput: false, hasActionableProposedPlan: false, backgroundLiveness: null, + planProgress: null, }, ]); @@ -1830,6 +1833,7 @@ it.effect( const resolveCalls: string[] = []; const layer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provideMerge( Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { resolve: (cwd: string) => diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index f036198fe495..9633f162d2b4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -44,6 +44,7 @@ import { } from "../../persistence/Errors.ts"; import { ProjectionCheckpoint } from "../../persistence/Services/ProjectionCheckpoints.ts"; import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; +import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; import { ProjectionProject } from "../../persistence/Services/ProjectionProjects.ts"; import { ProjectionState } from "../../persistence/Services/ProjectionState.ts"; import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; @@ -344,6 +345,7 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st const makeProjectionSnapshotQuery = Effect.gen(function* () { const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; + const threadPlanProgress = yield* ThreadPlanProgressService; const sql = yield* SqlClient.SqlClient; const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const repositoryIdentityResolutionConcurrency = 4; @@ -1908,6 +1910,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( row.threadId, ), + planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), } satisfies OrchestrationThreadShell) : Result.failVoid, ), @@ -2051,6 +2054,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( row.threadId, ), + planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), }), ), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", @@ -2326,6 +2330,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { backgroundLiveness: threadBackgroundLiveness.getThreadBackgroundLiveness( threadRow.value.threadId, ), + planProgress: threadPlanProgress.getThreadPlanProgress(threadRow.value.threadId), } satisfies OrchestrationThreadShell); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index f355bfc45ae8..2b4d3771605a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -49,6 +49,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { providerErrorLabel, providerErrorLabelFromInstanceHint, @@ -347,6 +348,7 @@ describe("ProviderCommandReactor", () => { const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), @@ -355,6 +357,7 @@ describe("ProviderCommandReactor", () => { ); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 31e30c4d1ada..dfc473207680 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -45,6 +45,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProviderRuntimeIngestionLive } from "./ProviderRuntimeIngestion.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; @@ -241,6 +242,7 @@ describe("ProviderRuntimeIngestion", () => { // Single shared liveness instance across ingestion (writer), the // engine, and the snapshot query (reader). Layer.provideMerge(ThreadBackgroundLiveness.layer), + Layer.provideMerge(ThreadPlanProgress.layer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 189dd6961062..a86adea5232c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -35,6 +35,7 @@ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/Projectio import { isGitRepository } from "../../git/Utils.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; +import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; import { ProviderRuntimeIngestionService, @@ -867,6 +868,7 @@ export function runtimeEventToActivities( const make = Effect.gen(function* () { const threadBackgroundLiveness = yield* ThreadBackgroundLivenessService; + const threadPlanProgress = yield* ThreadPlanProgressService; const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; @@ -1935,6 +1937,21 @@ const make = Effect.gen(function* () { yield* rememberTaskDescription(thread.id, event.payload.taskId, description); } } + // Working-indicator plan progress: current step while the turn runs, + // cleared on settle so a finished plan never lingers as stale UI. + // Events carrying a turn id that conflicts with the active turn are + // stale (superseded turn) and must neither overwrite nor clear the + // active turn's progress; session.exited always clears. + if (event.type === "session.exited") { + threadPlanProgress.clearThreadPlanProgress(thread.id); + } else if (!conflictsWithActiveTurn) { + if (event.type === "turn.plan.updated") { + threadPlanProgress.recordPlanProgress(thread.id, event.payload.plan); + } else if (event.type === "turn.completed" || event.type === "turn.aborted") { + threadPlanProgress.clearThreadPlanProgress(thread.id); + } + } + // Sidebar background liveness: fed from the same lifecycle stream, // read by the shell query at mapping time (no persistence). switch (event.type) { diff --git a/apps/server/src/orchestration/ThreadPlanProgress.test.ts b/apps/server/src/orchestration/ThreadPlanProgress.test.ts new file mode 100644 index 000000000000..995453276729 --- /dev/null +++ b/apps/server/src/orchestration/ThreadPlanProgress.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as ThreadPlanProgress from "./ThreadPlanProgress.ts"; + +describe("ThreadPlanProgress", () => { + it("tracks the in-progress step and clears when the plan completes", () => { + const progress = ThreadPlanProgress.make(); + const threadId = "t-plan-1"; + progress.recordPlanProgress(threadId, [ + { step: "Audit failure paths", status: "completed" }, + { step: "Implement the fix", status: "inProgress" }, + { step: "Run targeted tests", status: "pending" }, + ]); + expect(progress.getThreadPlanProgress(threadId)).toEqual({ + step: "Implement the fix", + completedSteps: 1, + totalSteps: 3, + }); + + progress.recordPlanProgress(threadId, [ + { step: "Audit failure paths", status: "completed" }, + { step: "Implement the fix", status: "completed" }, + { step: "Run targeted tests", status: "completed" }, + ]); + expect(progress.getThreadPlanProgress(threadId)).toBeNull(); + }); + + it("falls back to the first non-completed step when nothing is in progress", () => { + const progress = ThreadPlanProgress.make(); + const threadId = "t-plan-2"; + progress.recordPlanProgress(threadId, [ + { step: "First", status: "pending" }, + { step: "Second", status: "pending" }, + ]); + expect(progress.getThreadPlanProgress(threadId)?.step).toBe("First"); + }); + + it("clearThreadPlanProgress removes the entry (turn settled / session died)", () => { + const progress = ThreadPlanProgress.make(); + const threadId = "t-plan-3"; + progress.recordPlanProgress(threadId, [{ step: "Only step", status: "inProgress" }]); + progress.clearThreadPlanProgress(threadId); + expect(progress.getThreadPlanProgress(threadId)).toBeNull(); + }); +}); diff --git a/apps/server/src/orchestration/ThreadPlanProgress.ts b/apps/server/src/orchestration/ThreadPlanProgress.ts new file mode 100644 index 000000000000..1c638bf89a61 --- /dev/null +++ b/apps/server/src/orchestration/ThreadPlanProgress.ts @@ -0,0 +1,76 @@ +/** + * ThreadPlanProgressService - in-memory per-thread plan progress for the + * Working indicators (sidebar rows, in-chat working line). + * + * Plans are a progress annotation, not a surface of their own: the useful + * kernel of a turn.plan.updated event is "which step is the agent on right + * now". Ingestion records the current step here and the shell query reads it + * at mapping time — no persistence, no migration (same pattern as + * ThreadBackgroundLivenessService). Cleared when the turn settles or the + * session dies, so a finished plan never lingers as stale UI. + * + * @module ThreadPlanProgressService + */ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +export interface ThreadPlanProgress { + readonly step: string; + readonly completedSteps: number; + readonly totalSteps: number; +} + +interface PlanStepInput { + readonly step: string; + readonly status: string; +} + +export class ThreadPlanProgressService extends Context.Service< + ThreadPlanProgressService, + { + /** + * Feed one turn.plan.updated payload. An all-completed plan clears the + * entry (the turn is wrapping up; nothing is "in progress" anymore). + */ + readonly recordPlanProgress: (threadId: string, plan: ReadonlyArray) => void; + + /** Turn settled or session died: the working indicator reverts to plain. */ + readonly clearThreadPlanProgress: (threadId: string) => void; + + readonly getThreadPlanProgress: (threadId: string) => ThreadPlanProgress | null; + } +>()("t3/orchestration/ThreadPlanProgress/ThreadPlanProgressService") {} + +export function make(): ThreadPlanProgressService["Service"] { + const progressByThreadId = new Map(); + + return { + recordPlanProgress: (threadId, plan) => { + const totalSteps = plan.length; + const completedSteps = plan.filter((step) => step.status === "completed").length; + // Current step: the in-progress one, else the first pending one (a + // plan that was just written has no in-progress step yet). + const current = + plan.find((step) => step.status === "inProgress") ?? + plan.find((step) => step.status !== "completed"); + if (totalSteps === 0 || completedSteps === totalSteps || current === undefined) { + progressByThreadId.delete(threadId); + return; + } + progressByThreadId.set(threadId, { + step: current.step, + completedSteps, + totalSteps, + }); + }, + + clearThreadPlanProgress: (threadId) => { + progressByThreadId.delete(threadId); + }, + + getThreadPlanProgress: (threadId) => progressByThreadId.get(threadId) ?? null, + }; +} + +export const layer = Layer.effect(ThreadPlanProgressService, Effect.sync(make)); diff --git a/apps/server/src/orchestration/runtimeLayer.ts b/apps/server/src/orchestration/runtimeLayer.ts index 0bc624ec3656..779042e2f685 100644 --- a/apps/server/src/orchestration/runtimeLayer.ts +++ b/apps/server/src/orchestration/runtimeLayer.ts @@ -6,6 +6,7 @@ import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "./ThreadPlanProgress.ts"; export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( OrchestrationEventStoreLive, @@ -20,10 +21,14 @@ export const OrchestrationInfrastructureLayerLive = Layer.mergeAll( OrchestrationProjectionSnapshotQueryLive, OrchestrationEventInfrastructureLayerLive, OrchestrationProjectionPipelineLayerLive, - // Shared background-liveness registry: written by runtime ingestion, - // read by the snapshot query. provideMerge feeds the same instance to - // the snapshot query here and re-exports it for runtime ingestion. -).pipe(Layer.provideMerge(ThreadBackgroundLiveness.layer)); + // Shared background-liveness and plan-progress registries: written by + // runtime ingestion, read by the snapshot query. provideMerge feeds the + // same instance to the snapshot query here and re-exports it for runtime + // ingestion. +).pipe( + Layer.provideMerge(ThreadBackgroundLiveness.layer), + Layer.provideMerge(ThreadPlanProgress.layer), +); export const OrchestrationLayerLive = Layer.mergeAll( OrchestrationInfrastructureLayerLive, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3c416b8f88aa..a4c9bcc7c540 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -85,7 +85,7 @@ import { deriveTimelineEntries, deriveActiveWorkStartedAt, deriveActivePlanState, - findSidebarProposedPlan, + deriveTurnPlans, findLatestProposedPlan, deriveWorkLogEntries, hasActionableProposedPlan, @@ -121,11 +121,6 @@ import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; -import { - clearPlanSidebarDismissal, - dismissPlanSidebarForTurn, - isPlanSidebarDismissedForTurn, -} from "../planSidebarDismissal"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { selectActiveRightPanel, @@ -157,7 +152,6 @@ import { import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; -import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, @@ -234,7 +228,6 @@ import { useProject, useProjects, useThread, - useThreadProposedPlans, useThreadRefs, useThreadShell, } from "../state/entities"; @@ -451,8 +444,6 @@ type EnvironmentUnavailableState = { readonly connection: EnvironmentConnectionPresentation; }; -type ThreadPlanCatalogEntry = Pick; - function eventPathContainsSelector(event: Event, selector: string): boolean { const path = event.composedPath(); if (path.length === 0 && event.target) { @@ -1271,7 +1262,6 @@ function ChatViewContent(props: ChatViewProps) { (store) => store.setStickyModelSelection, ); const timestampFormat = settings.timestampFormat; - const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. @@ -1341,10 +1331,7 @@ function ChatViewContent(props: ChatViewProps) { >({}); const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); - const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); - // When set, the thread-change reset effect will open the sidebar instead of closing it. - // Used by "Implement in a new thread" to carry the sidebar-open intent across navigation. - const planSidebarOpenOnNextThreadRef = useRef(false); + const shouldUseRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = useState(null); @@ -1577,10 +1564,10 @@ function ChatViewContent(props: ChatViewProps) { ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; - const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; + const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; - const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; + const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUseRightPanelSheet; useEffect(() => { if (!activeThreadRef) return; @@ -1607,36 +1594,11 @@ function ChatViewContent(props: ChatViewProps) { previewPanelOpen, ]); - const planSidebarOpen = activeRightPanelKind === "plan"; - const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; - const sourcePlanThreadRef = useMemo(() => { - const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; - if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) { - return null; - } - return scopeThreadRef(activeThread.environmentId, sourceThreadId); - }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]); - const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef); - const threadPlanCatalog = useMemo(() => { - if (!activeThread) { - return []; - } - const entries: ThreadPlanCatalogEntry[] = [ - { id: activeThread.id, proposedPlans: activeThread.proposedPlans }, - ]; - if (sourcePlanThreadRef) { - entries.push({ - id: sourcePlanThreadRef.threadId, - proposedPlans: sourceThreadProposedPlans, - }); - } - return entries; - }, [activeThread, sourcePlanThreadRef, sourceThreadProposedPlans]); useEffect(() => { setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ @@ -2086,6 +2048,7 @@ function ChatViewContent(props: ChatViewProps) { const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); + const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]); // Native subagent fold: memoized by activity-list identity, shared by the // Agents surface, live strip, and workflow cards. v2Projection is null // until orchestration-v2 lands (source precedence lives in the derive). @@ -2148,21 +2111,25 @@ function ChatViewContent(props: ChatViewProps) { activeLatestTurn?.turnId ?? null, ); }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); - const sidebarProposedPlan = useMemo( - () => - findSidebarProposedPlan({ - threads: threadPlanCatalog, - latestTurn: activeLatestTurn, - latestTurnSettled, - threadId: activeThread?.id ?? null, - }), - [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog], - ); const activePlan = useMemo( () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); - const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; + // Current step for the in-chat working row: only for the running turn's own + // plan (deriveActivePlanState falls back to older turns' plans, which must + // not label fresh work). Falls back to the first pending step so an + // all-pending freshly written plan labels the row, matching the chip and + // the server's planProgress. + const workingStepLabel = useMemo(() => { + if (!activePlan || activePlan.turnId !== (activeLatestTurn?.turnId ?? null)) { + return null; + } + return ( + activePlan.steps.find((step) => step.status === "inProgress")?.step ?? + activePlan.steps.find((step) => step.status === "pending")?.step ?? + null + ); + }, [activeLatestTurn?.turnId, activePlan]); const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && interactionMode === "plan" && @@ -2431,8 +2398,13 @@ function ChatViewContent(props: ChatViewProps) { }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]); const timelineEntries = useMemo( () => - deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), - [activeThread?.proposedPlans, timelineMessages, workLogEntries], + deriveTimelineEntries( + timelineMessages, + activeThread?.proposedPlans ?? [], + workLogEntries, + turnPlans, + ), + [activeThread?.proposedPlans, timelineMessages, turnPlans, workLogEntries], ); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = @@ -3142,47 +3114,15 @@ function ChatViewContent(props: ChatViewProps) { const toggleInteractionMode = useCallback(() => { handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan"); }, [handleInteractionModeChange, interactionMode]); - const dismissPlanSidebarForCurrentTurn = useCallback(() => { - if (!activeThreadKey) return; - dismissPlanSidebarForTurn( - activeThreadKey, - activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__", - ); - }, [activeThreadKey, activePlan?.turnId, sidebarProposedPlan?.turnId]); - const togglePlanSidebar = useCallback(() => { - if (!activeThreadRef) return; - if (planSidebarOpen) { - dismissPlanSidebarForCurrentTurn(); - } else if (activeThreadKey) { - clearPlanSidebarDismissal(activeThreadKey); - } - useRightPanelStore.getState().toggle(activeThreadRef, "plan"); - }, [activeThreadKey, activeThreadRef, dismissPlanSidebarForCurrentTurn, planSidebarOpen]); - const closePlanSidebar = useCallback(() => { - if (!activeThreadRef) return; - setMaximizedRightPanelThreadKey(null); - useRightPanelStore.getState().close(activeThreadRef); - dismissPlanSidebarForCurrentTurn(); - }, [activeThreadRef, dismissPlanSidebarForCurrentTurn]); const createBrowserSurface = useCallback(() => { if (!activeThreadRef) return; void addBrowserSurface({ threadRef: activeThreadRef, openPreview }); }, [activeThreadRef, openPreview]); const addDiffSurface = useCallback(() => { if (!activeThreadRef || !isServerThread || !isGitRepo) return; - if (planSidebarOpen) { - dismissPlanSidebarForCurrentTurn(); - } useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); - }, [ - activeThreadRef, - dismissPlanSidebarForCurrentTurn, - isGitRepo, - isServerThread, - onDiffPanelOpen, - planSidebarOpen, - ]); + }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); const addFilesSurface = useCallback(() => { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); @@ -3318,11 +3258,6 @@ function ChatViewContent(props: ChatViewProps) { const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; - if (surface.kind === "plan") { - clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); - } else if (planSidebarOpen) { - dismissPlanSidebarForCurrentTurn(); - } useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); if (surface.kind === "preview" && surface.resourceId) { setActivePreviewTab(activeThreadRef, surface.resourceId); @@ -3334,20 +3269,16 @@ function ChatViewContent(props: ChatViewProps) { onDiffPanelOpen?.(); } }, - [activeThreadRef, diffOpen, dismissPlanSidebarForCurrentTurn, onDiffPanelOpen, planSidebarOpen], + [activeThreadRef, diffOpen, onDiffPanelOpen], ); const toggleRightPanel = useCallback(() => { if (!activeThreadRef) return; if (rightPanelOpen) { - if (planSidebarOpen) { - closePlanSidebar(); - } else { - closePreviewPanel(); - } + closePreviewPanel(); return; } useRightPanelStore.getState().toggleVisibility(activeThreadRef); - }, [activeThreadRef, closePlanSidebar, closePreviewPanel, planSidebarOpen, rightPanelOpen]); + }, [activeThreadRef, closePreviewPanel, rightPanelOpen]); const toggleRightPanelMaximized = useCallback(() => { if (!canMaximizeRightPanel) return; setMaximizedRightPanelThreadKey((threadKey) => @@ -3357,10 +3288,6 @@ function ChatViewContent(props: ChatViewProps) { const cleanupRightPanelSurfaces = useCallback( (surfaces: readonly RightPanelSurface[]) => { if (!activeThreadRef) return; - if (surfaces.some((surface) => surface.kind === "plan")) { - dismissPlanSidebarForCurrentTurn(); - } - for (const surface of surfaces) { if (surface.kind === "preview" && surface.resourceId) { void closePreviewSession({ @@ -3386,7 +3313,6 @@ function ChatViewContent(props: ChatViewProps) { activePreviewState.sessions, closePreview, closeTerminalMutation, - dismissPlanSidebarForCurrentTurn, storeCloseTerminal, ], ); @@ -3976,37 +3902,9 @@ function ChatViewContent(props: ChatViewProps) { activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - if (planSidebarOpenOnNextThreadRef.current) { - planSidebarOpenOnNextThreadRef.current = false; - if (activeThreadRef) { - clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); - useRightPanelStore.getState().open(activeThreadRef, "plan"); - } - } // activeThreadRef resets transitively with the active thread. }, [activeThread?.id]); - // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. - // Don't auto-open for plans carried over from a previous turn (the user can open manually). - useEffect(() => { - if (!autoOpenPlanSidebar) return; - if (!activePlan) return; - if (planSidebarOpen) return; - const latestTurnId = activeLatestTurn?.turnId ?? null; - if (latestTurnId && activePlan.turnId !== latestTurnId) return; - const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__"; - if (!activeThreadRef) return; - if (isPlanSidebarDismissedForTurn(scopedThreadKey(activeThreadRef), turnKey)) return; - useRightPanelStore.getState().open(activeThreadRef, "plan"); - }, [ - activePlan, - activeLatestTurn?.turnId, - activeThreadRef, - autoOpenPlanSidebar, - planSidebarOpen, - sidebarProposedPlan?.turnId, - ]); - useEffect(() => { setIsRevertingCheckpoint(false); }, [activeThread?.id]); @@ -5556,15 +5454,6 @@ function ChatViewContent(props: ChatViewProps) { if (failure === null) { acknowledgeActiveThreadWoke(); - // Optimistically open the plan sidebar when implementing (not refining). - // "default" mode here means the agent is executing the plan, which produces - // step-tracking activities that the sidebar will display. - if (nextInteractionMode === "default" && autoOpenPlanSidebar) { - if (activeThreadRef) { - clearPlanSidebarDismissal(scopedThreadKey(activeThreadRef)); - useRightPanelStore.getState().open(activeThreadRef, "plan"); - } - } sendInFlightRef.current = false; return; } @@ -5597,7 +5486,6 @@ function ChatViewContent(props: ChatViewProps) { setComposerDraftInteractionMode, setThreadError, startThreadTurn, - autoOpenPlanSidebar, environmentId, composerRef, ], @@ -5700,8 +5588,6 @@ function ChatViewContent(props: ChatViewProps) { } if (failure === null) { - // Signal that the plan sidebar should open on the new thread when enabled. - planSidebarOpenOnNextThreadRef.current = autoOpenPlanSidebar; const navigateResult = await settlePromise(() => navigate({ to: "/$environmentId/$threadId", @@ -5758,7 +5644,6 @@ function ChatViewContent(props: ChatViewProps) { resetLocalDispatch, runtimeMode, startThreadTurn, - autoOpenPlanSidebar, environmentId, composerRef, ]); @@ -5950,12 +5835,12 @@ function ChatViewContent(props: ChatViewProps) {
- {rightPanelOpen && !shouldUsePlanSidebarSheet ? ( + {rightPanelOpen && !shouldUseRightPanelSheet ? ( - ) : activeRightPanelSurface?.kind === "plan" ? ( - ) : activeRightPanelSurface?.kind === "agents" ? ( - {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null} + {rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
- {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( + {!shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( ) : null} - {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( - + {shouldUseRightPanelSheet && rightPanelOpen && activeThreadRef ? ( + - - - ); - } - if (status === "inProgress") { - return ( - - - - ); - } - return ( - - - - ); -} - -interface PlanSidebarProps { - activePlan: ActivePlanState | null; - activeProposedPlan: LatestProposedPlanState | null; - label?: string; - environmentId: EnvironmentId; - threadRef?: ScopedThreadRef | undefined; - markdownCwd: string | undefined; - workspaceRoot: string | undefined; - timestampFormat: TimestampFormat; - mode?: "sheet" | "sidebar" | "embedded"; -} - -const PlanSidebar = memo(function PlanSidebar({ - activePlan, - activeProposedPlan, - label = "Plan", - environmentId, - threadRef, - markdownCwd, - workspaceRoot, - timestampFormat, - mode = "sidebar", -}: PlanSidebarProps) { - const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); - const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); - const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { - reportFailure: false, - }); - const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "plan" }); - - const planMarkdown = activeProposedPlan?.planMarkdown ?? null; - const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; - const planTitle = planMarkdown ? proposedPlanTitle(planMarkdown) : null; - - const handleCopyPlan = useCallback(() => { - if (!planMarkdown) return; - copyToClipboard(planMarkdown); - }, [planMarkdown, copyToClipboard]); - - const handleDownload = useCallback(() => { - if (!planMarkdown) return; - const filename = buildProposedPlanMarkdownFilename(planMarkdown); - downloadPlanAsTextFile(filename, normalizePlanMarkdownForExport(planMarkdown)); - }, [planMarkdown]); - - const handleSaveToWorkspace = useCallback(() => { - if (!workspaceRoot || !planMarkdown) return; - const filename = buildProposedPlanMarkdownFilename(planMarkdown); - setIsSavingToWorkspace(true); - void (async () => { - const result = await writeProjectFile({ - environmentId, - input: { - cwd: workspaceRoot, - relativePath: filename, - contents: normalizePlanMarkdownForExport(planMarkdown), - }, - }); - setIsSavingToWorkspace(false); - if (result._tag === "Success") { - toastManager.add({ - type: "success", - title: "Plan saved", - description: result.value.relativePath, - }); - return; - } - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not save plan", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }, [environmentId, planMarkdown, workspaceRoot, writeProjectFile]); - - return ( -
- {/* Header */} -
-
- - {label} - - {activePlan ? ( - - {formatTimestamp(activePlan.createdAt, timestampFormat)} - - ) : null} -
-
- {planMarkdown ? ( - - - } - > - - - - - {isCopied ? "Copied!" : "Copy to clipboard"} - - Download as markdown - - Save to workspace - - - - ) : null} -
-
- - {/* Content */} - -
- {/* Explanation */} - {activePlan?.explanation ? ( -

- {activePlan.explanation} -

- ) : null} - - {/* Plan Steps */} - {activePlan && activePlan.steps.length > 0 ? ( -
-

- Steps -

- {activePlan.steps.map((step) => ( -
- {stepStatusIcon(step.status)} -

- {step.step} -

-
- ))} -
- ) : null} - - {/* Proposed Plan Markdown */} - {planMarkdown ? ( -
- - {proposedPlanExpanded ? ( -
- -
- ) : null} -
- ) : null} - - {/* Empty state */} - {!activePlan && !planMarkdown ? ( -
-

No active plan yet.

-

- Plans will appear here when generated. -

-
- ) : null} -
-
-
- ); -}); - -export default PlanSidebar; -export type { PlanSidebarProps }; diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index b0f18f6e1266..b9345ab8c3c5 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,6 +1,6 @@ import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import { Bot, ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; +import { Bot, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; import { type MouseEvent as ReactMouseEvent, type ReactElement, @@ -213,8 +213,6 @@ function surfaceTitle( terminalLabelsById.get(surface.activeTerminalId) ?? getTerminalLabel(surface.activeTerminalId) ); - case "plan": - return "Plan"; case "agents": return "Agents"; case "preview": { @@ -276,8 +274,6 @@ function SurfaceIcon({ ); case "terminal": return ; - case "plan": - return ; case "agents": return ; } diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 003bec64d0f1..f918480e1a51 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1098,7 +1098,18 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null}
- {thread.branch ? ( + {/* While working, the current plan step outranks the branch: + it's the one line that says what the thread is doing. */} + {status === "working" && thread.planProgress ? ( + + {thread.planProgress.step} + {/* Completed count, matching the transcript chip's n/m. */} + + {" "} + {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps} + + + ) : thread.branch ? ( {thread.branch} ) : ( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f6d34315daca..6f3a6ec22cde 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -10,7 +10,6 @@ import type { ScopedThreadRef, ServerProvider, ThreadId, - TurnId, } from "@t3tools/contracts"; import { ProviderDriverKind, @@ -195,7 +194,6 @@ import { toastManager } from "../ui/toast"; import { BotIcon, CircleAlertIcon, - ListTodoIcon, PencilRulerIcon, type LucideIcon, LockIcon, @@ -300,12 +298,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop showInteractionModeToggle: boolean; interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; - showPlanToggle: boolean; - planSidebarLabel: string; - planSidebarOpen: boolean; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; - onTogglePlanSidebar: () => void; }) { const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; @@ -313,9 +307,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop props.interactionMode === "plan" ? "Plan mode — click to return to normal build mode" : "Default mode — click to enter plan mode"; - const planSidebarTooltip = props.planSidebarOpen - ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` - : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`; const interactionModeToggle = props.showInteractionModeToggle ? ( <> @@ -391,36 +382,6 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop {interactionModeToggle} - - {props.showPlanToggle ? ( - <> - - - - } - > - - {props.planSidebarLabel} - - {planSidebarTooltip} - - - ) : null} ); }); @@ -576,10 +537,6 @@ export interface ChatComposerProps { // Plan showPlanFollowUpPrompt: boolean; activeProposedPlan: Thread["proposedPlans"][number] | null; - activePlan: { turnId?: TurnId } | null; - sidebarProposedPlan: { turnId?: TurnId } | null; - planSidebarLabel: string; - planSidebarOpen: boolean; // Mode runtimeMode: RuntimeMode; @@ -632,7 +589,6 @@ export interface ChatComposerProps { toggleInteractionMode: () => void; handleRuntimeModeChange: (mode: RuntimeMode) => void; handleInteractionModeChange: (mode: ProviderInteractionMode) => void; - togglePlanSidebar: () => void; focusComposer: () => void; scheduleComposerFocus: () => void; @@ -675,10 +631,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) respondingRequestIds, showPlanFollowUpPrompt, activeProposedPlan, - activePlan, - sidebarProposedPlan, - planSidebarLabel, - planSidebarOpen, runtimeMode, interactionMode, lockedProvider, @@ -709,7 +661,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) toggleInteractionMode, handleRuntimeModeChange, handleInteractionModeChange, - togglePlanSidebar, focusComposer, scheduleComposerFocus, setThreadError, @@ -1180,7 +1131,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0; const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; - const showPlanSidebarToggle = Boolean(activePlan || sidebarProposedPlan || planSidebarOpen); const composerFooterActionLayoutKey = useMemo(() => { if (activePendingProgress) { return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; @@ -3187,15 +3137,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {isComposerFooterCompact ? ( ) : ( @@ -3210,12 +3156,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showInteractionModeToggle={composerProviderControls.showInteractionModeToggle} interactionMode={interactionMode} runtimeMode={runtimeMode} - showPlanToggle={showPlanSidebarToggle} - planSidebarLabel={planSidebarLabel} - planSidebarOpen={planSidebarOpen} onToggleInteractionMode={toggleInteractionMode} onRuntimeModeChange={handleRuntimeModeChange} - onTogglePlanSidebar={togglePlanSidebar} /> )} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index b808f5629201..20b57dea8c31 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -1,10 +1,9 @@ import { ProviderInteractionMode, RuntimeMode } from "@t3tools/contracts"; import { memo, type ReactNode } from "react"; -import { EllipsisIcon, ListTodoIcon } from "lucide-react"; +import { EllipsisIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Menu, - MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, @@ -13,15 +12,11 @@ import { } from "../ui/menu"; export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { - activePlan: boolean; interactionMode: ProviderInteractionMode; - planSidebarLabel: string; - planSidebarOpen: boolean; runtimeMode: RuntimeMode; showInteractionModeToggle: boolean; traitsMenuContent?: ReactNode; onToggleInteractionMode: () => void; - onTogglePlanSidebar: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { return ( @@ -74,17 +69,6 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls Auto Full access - {props.activePlan ? ( - <> - - - - {props.planSidebarOpen - ? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar` - : `Show ${props.planSidebarLabel.toLowerCase()} sidebar`} - - - ) : null} ); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c204499273ac..6bc0a2a6203c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -4,6 +4,7 @@ import { workEntryIndicatesToolNeutralStatus, workLogEntryIsToolLike, type TimelineEntry, + type TurnPlanEntry, type WorkLogEntry, } from "../../session-logic"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; @@ -201,6 +202,12 @@ export type MessagesTimelineRow = createdAt: string; proposedPlan: ProposedPlan; } + | { + kind: "turn-plan"; + id: string; + createdAt: string; + turnPlan: TurnPlanEntry; + } | { kind: "working"; id: string; createdAt: string | null }; export interface StableMessagesTimelineRowsState { @@ -576,6 +583,16 @@ export function deriveMessagesTimelineRows(input: { continue; } + if (timelineEntry.kind === "turn-plan") { + nextRows.push({ + kind: "turn-plan", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + turnPlan: timelineEntry.turnPlan, + }); + continue; + } + const assistantTurnStillInProgress = timelineEntry.message.role === "assistant" && unsettledTurnId !== null && @@ -659,6 +676,13 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "proposed-plan": return a.proposedPlan === (b as typeof a).proposedPlan; + case "turn-plan": { + const bp = b as typeof a; + // Plans rewrite in place: compare the snapshot's identity fields so an + // unchanged plan keeps its row reference (virtualization stability). + return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan; + } + case "work": return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a5fb03602046..9e10a8b39cfc 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -152,6 +152,8 @@ interface TimelineRowActivityState { isRevertingCheckpoint: boolean; activeTurnInProgress: boolean; latestTurnId: TurnId | null; + /** Current plan step label for the working row, when the turn has a plan. */ + workingStepLabel: string | null; } const TimelineRowCtx = createContext(null!); @@ -196,6 +198,7 @@ interface MessagesTimelineProps { agentPanelModel?: AgentPanelModel; onOpenAgents?: () => void; isWorking: boolean; + workingStepLabel?: string | null; activeTurnInProgress: boolean; activeTurnStartedAt: string | null; listRef: React.RefObject; @@ -240,6 +243,7 @@ interface MessagesTimelineProps { export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, + workingStepLabel = null, activeTurnInProgress, activeTurnStartedAt, agentPanelModel = EMPTY_AGENT_PANEL_MODEL, @@ -508,8 +512,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isRevertingCheckpoint, activeTurnInProgress, latestTurnId: latestTurn?.turnId ?? null, + workingStepLabel, }), - [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId], + [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel], ); // Stable renderItem — no closure deps. Row components read shared state @@ -911,7 +916,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time // they sit closer to the work that follows them. (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || row.kind === "work" || - row.kind === "work-toggle" + row.kind === "work-toggle" || + row.kind === "turn-plan" ? "pb-2" : "pb-4", row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, @@ -929,6 +935,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ) : null} {row.kind === "proposed-plan" ? : null} + {row.kind === "turn-plan" ? : null} {row.kind === "working" ? : null}
); @@ -1159,16 +1166,117 @@ function ProposedPlanTimelineRow({ ); } +/** + * Inline folded plan chip: one row per turn that produced plan/todo steps. + * Collapsed by default — a segment bar plus the in-progress step label — + * and expands in place to the full step list. Replaces the old plan sidebar. + */ +const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ + row, +}: { + row: Extract; +}) { + const [expanded, setExpanded] = useState(false); + const { steps } = row.turnPlan.plan; + const completedCount = steps.filter((step) => step.status === "completed").length; + const allDone = completedCount === steps.length; + // Label priority: the in-progress step, else the next pending step (plan + // just created), else the last step (plan finished, rendered muted). + const label = + steps.find((step) => step.status === "inProgress")?.step ?? + steps.find((step) => step.status === "pending")?.step ?? + steps.at(-1)?.step ?? + "Plan"; + const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon; + + return ( +
+ + {expanded ? ( +
+ {steps.map((step) => ( +
+ + {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"} + + + {step.step} + +
+ ))} +
+ ) : null} +
+ ); +}); + function WorkingTimelineRow({ row }: { row: Extract }) { + const { workingStepLabel } = use(TimelineRowActivityCtx); return (
-
+
- + {row.createdAt ? ( <> Working for @@ -1177,6 +1285,9 @@ function WorkingTimelineRow({ row }: { row: Extract + {workingStepLabel ? ( + · {workingStepLabel} + ) : null}
); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 9a5fe3195685..b1c50e8717ae 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -623,9 +623,6 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), - ...(settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar - ? ["Auto-open task panel"] - : []), ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), @@ -655,7 +652,6 @@ export function useSettingsRestore(onRestored?: () => void) { [ isTextGenerationModelDirty, isBackgroundActivityDirty, - settings.autoOpenPlanSidebar, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -701,7 +697,6 @@ export function useSettingsRestore(onRestored?: () => void) { glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, - autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, @@ -1897,32 +1892,6 @@ export function GeneralSettingsPanel() { } /> - - updateSettings({ - autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, - }) - } - /> - ) : null - } - control={ - - updateSettings({ autoOpenPlanSidebar: Boolean(checked) }) - } - aria-label="Open the task panel automatically" - /> - } - /> - (); - -export function dismissPlanSidebarForTurn(threadKey: string, turnKey: string): void { - dismissedTurnByThreadKey.set(threadKey, turnKey); -} - -export function clearPlanSidebarDismissal(threadKey: string): void { - dismissedTurnByThreadKey.delete(threadKey); -} - -export function isPlanSidebarDismissedForTurn(threadKey: string, turnKey: string): boolean { - return dismissedTurnByThreadKey.get(threadKey) === turnKey; -} diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index c7457cfd3040..69831242f2f4 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -102,6 +102,41 @@ describe("rightPanelStore", () => { }); }); + it("drops persisted plan surfaces and does not reopen an empty panel", () => { + expect( + migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "plan", + surfaces: [{ id: "plan", kind: "plan" }], + }, + "env-1:thread-B": { + isOpen: true, + activeSurfaceId: "plan", + surfaces: [ + { id: "plan", kind: "plan" }, + { id: "diff", kind: "diff" }, + ], + }, + }, + }), + ).toEqual({ + byThreadKey: { + "env-1:thread-A": { + isOpen: false, + activeSurfaceId: null, + surfaces: [], + }, + "env-1:thread-B": { + isOpen: true, + activeSurfaceId: "diff", + surfaces: [{ id: "diff", kind: "diff" }], + }, + }, + }); + }); + it("open sets the active panel for a thread", () => { useRightPanelStore.getState().open(refA, "preview"); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); @@ -109,7 +144,7 @@ describe("rightPanelStore", () => { }); it("opening a different kind keeps both surfaces and activates the new one", () => { - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().open(refA, "preview"); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); expect( @@ -119,7 +154,7 @@ describe("rightPanelStore", () => { it("reopening an inactive singleton activates its existing surface", () => { useRightPanelStore.getState().open(refA, "diff"); - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().open(refA, "diff"); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ @@ -127,7 +162,7 @@ describe("rightPanelStore", () => { activeSurfaceId: "diff", surfaces: [ { id: "diff", kind: "diff" }, - { id: "plan", kind: "plan" }, + { id: "agents", kind: "agents" }, ], }); }); @@ -207,15 +242,15 @@ describe("rightPanelStore", () => { it("removes persisted file surfaces when their workspace no longer exists", () => { useRightPanelStore.getState().openFile(refA, "src/index.ts"); - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().openFile(refA, "README.md"); useRightPanelStore.getState().reconcileFileSurfaces(refA, false); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ isOpen: true, - activeSurfaceId: "plan", - surfaces: [{ id: "plan", kind: "plan" }], + activeSurfaceId: "agents", + surfaces: [{ id: "agents", kind: "agents" }], }); useRightPanelStore.getState().openFile(refB, "conductor.json"); @@ -228,13 +263,13 @@ describe("rightPanelStore", () => { }); it("close hides the panel without clearing its selected surface", () => { - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().close(refA); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ isOpen: false, - activeSurfaceId: "plan", - surfaces: [{ id: "plan", kind: "plan" }], + activeSurfaceId: "agents", + surfaces: [{ id: "agents", kind: "agents" }], }); }); @@ -264,12 +299,12 @@ describe("rightPanelStore", () => { it("toggle to a different kind switches active", () => { useRightPanelStore.getState().toggle(refA, "preview"); - useRightPanelStore.getState().toggle(refA, "plan"); - expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("plan"); + useRightPanelStore.getState().toggle(refA, "agents"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("agents"); }); it("removeThread clears persisted state", () => { - useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "agents"); useRightPanelStore.getState().removeThread(refA); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); }); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index cccb7238ca8d..2e72c7b4e10a 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -5,7 +5,7 @@ * surface descriptors and the active surface, while each feature continues to * own its durable resource state. Browser surfaces point at preview tab ids, * terminal surfaces point at terminal session ids, file surfaces point at - * workspace paths, and diff/plan/files remain singleton surfaces. + * workspace paths, and diff/files remain singleton surfaces. */ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import type { ScopedThreadRef } from "@t3tools/contracts"; @@ -15,7 +15,6 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; export const RIGHT_PANEL_KINDS = [ - "plan", "diff", "files", "file", @@ -45,11 +44,11 @@ export type RightPanelSurface = revealLine: number | null; revealRequestId: number; } - | { id: "plan"; kind: "plan" } | { id: "agents"; kind: "agents" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; -const RIGHT_PANEL_STORAGE_VERSION = 8; +// v9 removed the "plan" surface kind (plans render inline in the transcript). +const RIGHT_PANEL_STORAGE_VERSION = 9; export interface ThreadRightPanelState { isOpen: boolean; @@ -99,8 +98,6 @@ const singletonSurface = ( return { id: "diff", kind }; case "files": return { id: "files", kind }; - case "plan": - return { id: "plan", kind }; case "agents": return { id: "agents", kind }; } @@ -181,6 +178,9 @@ export function migratePersistedRightPanelState(persistedState: unknown): { threadState && typeof threadState === "object" ? threadState : null; const surfaces = Array.isArray(validThreadState?.surfaces) ? validThreadState.surfaces.flatMap((surface) => { + // Dropped surface kind: plans now render inline in the + // transcript (v9). + if ((surface as { kind?: string }).kind === "plan") return []; if (surface.kind === "file") { const revealLine = typeof surface.revealLine === "number" && @@ -229,15 +229,23 @@ export function migratePersistedRightPanelState(persistedState: unknown): { ]; }) : []; - const activeSurfaceId = surfaces.some( + const persistedActiveSurfaceId = surfaces.some( (surface) => surface.id === validThreadState?.activeSurfaceId, ) ? (validThreadState?.activeSurfaceId ?? null) : null; + // A migration that dropped every surface (e.g. plan-only panels + // in v9) must not reopen an empty panel. const isOpen = - typeof validThreadState?.isOpen === "boolean" + surfaces.length > 0 && + (typeof validThreadState?.isOpen === "boolean" ? validThreadState.isOpen - : activeSurfaceId !== null; + : persistedActiveSurfaceId !== null); + // An open panel needs an active surface: if migration dropped + // the persisted one (e.g. plan was active), fall back to the + // first survivor instead of rendering an open empty panel. + const activeSurfaceId = + persistedActiveSurfaceId ?? (isOpen ? (surfaces[0]?.id ?? null) : null); return [threadKey, { isOpen, surfaces, activeSurfaceId }]; }, ), diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 3732afbd3387..f5effff6602c 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -11,12 +11,12 @@ import { describe, expect, it } from "vite-plus/test"; import { deriveActiveWorkStartedAt, deriveActivePlanState, + deriveTurnPlans, derivePendingApprovals, derivePendingUserInputs, deriveTimelineEntries, deriveWorkLogEntries, findLatestProposedPlan, - findSidebarProposedPlan, hasActionableProposedPlan, isLatestTurnSettled, workEntryIndicatesToolFailure, @@ -410,6 +410,95 @@ describe("deriveActivePlanState", () => { }); }); +describe("deriveTurnPlans", () => { + it("keeps one entry per turn, anchored at the first snapshot with the latest steps", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "plan-1a", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { + plan: [{ step: "Inspect code", status: "inProgress" }], + }, + }), + makeActivity({ + id: "plan-1b", + createdAt: "2026-02-23T00:00:05.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { + plan: [{ step: "Inspect code", status: "completed" }], + }, + }), + makeActivity({ + id: "plan-2a", + createdAt: "2026-02-23T00:01:00.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-2", + payload: { + plan: [{ step: "Ship it", status: "pending" }], + }, + }), + ]; + + const turnPlans = deriveTurnPlans(activities); + expect(turnPlans).toHaveLength(2); + expect(turnPlans[0]).toMatchObject({ + id: "turn-plan:turn-1", + createdAt: "2026-02-23T00:00:01.000Z", + turnId: "turn-1", + }); + expect(turnPlans[0]?.plan.steps).toEqual([{ step: "Inspect code", status: "completed" }]); + expect(turnPlans[1]?.plan.steps).toEqual([{ step: "Ship it", status: "pending" }]); + }); + + it("skips activities without parseable steps", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "plan-bad", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { plan: [] }, + }), + ]; + expect(deriveTurnPlans(activities)).toEqual([]); + }); + + it("drops a turn's chip when a later snapshot clears the plan", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "plan-set", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { plan: [{ step: "Inspect code", status: "inProgress" }] }, + }), + makeActivity({ + id: "plan-clear", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "turn.plan.updated", + summary: "Plan updated", + tone: "info", + turnId: "turn-1", + payload: { plan: [] }, + }), + ]; + expect(deriveTurnPlans(activities)).toEqual([]); + }); +}); + describe("findLatestProposedPlan", () => { it("prefers the latest proposed plan for the active turn", () => { expect( @@ -515,103 +604,6 @@ describe("hasActionableProposedPlan", () => { }); }); -describe("findSidebarProposedPlan", () => { - it("prefers the running turn source proposed plan when available on the same thread", () => { - expect( - findSidebarProposedPlan({ - threads: [ - { - id: ThreadId.make("thread-1"), - proposedPlans: [ - { - id: "plan-1", - turnId: TurnId.make("turn-plan"), - planMarkdown: "# Source plan", - implementedAt: "2026-02-23T00:00:03.000Z", - implementationThreadId: ThreadId.make("thread-2"), - createdAt: "2026-02-23T00:00:01.000Z", - updatedAt: "2026-02-23T00:00:02.000Z", - }, - ], - }, - { - id: ThreadId.make("thread-2"), - proposedPlans: [ - { - id: "plan-2", - turnId: TurnId.make("turn-other"), - planMarkdown: "# Latest elsewhere", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-23T00:00:04.000Z", - updatedAt: "2026-02-23T00:00:05.000Z", - }, - ], - }, - ], - latestTurn: { - turnId: TurnId.make("turn-implementation"), - sourceProposedPlan: { - threadId: ThreadId.make("thread-1"), - planId: "plan-1", - }, - }, - latestTurnSettled: false, - threadId: ThreadId.make("thread-1"), - }), - ).toEqual({ - id: "plan-1", - turnId: "turn-plan", - planMarkdown: "# Source plan", - implementedAt: "2026-02-23T00:00:03.000Z", - implementationThreadId: "thread-2", - createdAt: "2026-02-23T00:00:01.000Z", - updatedAt: "2026-02-23T00:00:02.000Z", - }); - }); - - it("falls back to the latest proposed plan once the turn is settled", () => { - expect( - findSidebarProposedPlan({ - threads: [ - { - id: ThreadId.make("thread-1"), - proposedPlans: [ - { - id: "plan-1", - turnId: TurnId.make("turn-plan"), - planMarkdown: "# Older", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-23T00:00:01.000Z", - updatedAt: "2026-02-23T00:00:02.000Z", - }, - { - id: "plan-2", - turnId: TurnId.make("turn-latest"), - planMarkdown: "# Latest", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-23T00:00:03.000Z", - updatedAt: "2026-02-23T00:00:04.000Z", - }, - ], - }, - ], - latestTurn: { - turnId: TurnId.make("turn-implementation"), - sourceProposedPlan: { - threadId: ThreadId.make("thread-1"), - planId: "plan-1", - }, - }, - latestTurnSettled: true, - threadId: ThreadId.make("thread-1"), - })?.planMarkdown, - ).toBe("# Latest"); - }); -}); - describe("workEntryIndicatesToolFailure", () => { const base = { id: "w1", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a1ff70bc0438..4d0a76cf133b 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -151,6 +151,12 @@ export type TimelineEntry = createdAt: string; proposedPlan: ProposedPlan; } + | { + id: string; + kind: "turn-plan"; + createdAt: string; + turnPlan: TurnPlanEntry; + } | { id: string; kind: "work"; @@ -532,26 +538,10 @@ export function derivePendingUserInputs( ); } -export function deriveActivePlanState( - activities: ReadonlyArray, - latestTurnId: TurnId | undefined, -): ActivePlanState | null { - const ordered = [...activities].toSorted(compareActivitiesByOrder); - const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); - // Prefer plan from the current turn; fall back to the most recent plan from any turn - // so that TodoWrite tasks persist across follow-up messages. - const latest = Option.firstSomeOf([ - ...(latestTurnId - ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId) - : Option.none()), - Arr.last(allPlanActivities), - ]).pipe(Option.getOrNull); - if (!latest) { - return null; - } +function planStateFromActivity(activity: OrchestrationThreadActivity): ActivePlanState | null { const payload = - latest.payload && typeof latest.payload === "object" - ? (latest.payload as Record) + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) : null; const rawPlan = payload?.plan; if (!Array.isArray(rawPlan)) { @@ -580,8 +570,8 @@ export function deriveActivePlanState( return null; } return { - createdAt: latest.createdAt, - turnId: latest.turnId, + createdAt: activity.createdAt, + turnId: activity.turnId, ...(payload && "explanation" in payload ? { explanation: payload.explanation as string | null } : {}), @@ -589,6 +579,72 @@ export function deriveActivePlanState( }; } +export function deriveActivePlanState( + activities: ReadonlyArray, + latestTurnId: TurnId | undefined, +): ActivePlanState | null { + const ordered = [...activities].toSorted(compareActivitiesByOrder); + const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); + // Prefer plan from the current turn; fall back to the most recent plan from any turn + // so that TodoWrite tasks persist across follow-up messages. + const latest = Option.firstSomeOf([ + ...(latestTurnId + ? Arr.findLast(allPlanActivities, (activity) => activity.turnId === latestTurnId) + : Option.none()), + Arr.last(allPlanActivities), + ]).pipe(Option.getOrNull); + if (!latest) { + return null; + } + return planStateFromActivity(latest); +} + +export interface TurnPlanEntry { + /** Stable per-turn row id (plans rewrite constantly; the row must not churn). */ + id: string; + /** Anchor timestamp: the turn's FIRST plan activity, so the chip renders where planning began. */ + createdAt: string; + turnId: TurnId | null; + plan: ActivePlanState; +} + +/** + * One inline plan chip per turn that produced plan/todo steps: the latest + * snapshot for the turn, anchored at the first snapshot's timestamp. Turn-less + * plan activities collapse into a single chip keyed by thread order. + */ +export function deriveTurnPlans( + activities: ReadonlyArray, +): TurnPlanEntry[] { + const ordered = [...activities].toSorted(compareActivitiesByOrder); + const byTurn = new Map(); + for (const activity of ordered) { + if (activity.kind !== "turn.plan.updated") { + continue; + } + const plan = planStateFromActivity(activity); + const key = activity.turnId ?? "no-turn"; + if (!plan) { + // A later snapshot with no steps clears the turn's plan; keeping the + // stale entry would freeze the chip on a withdrawn plan. + byTurn.delete(key); + continue; + } + const existing = byTurn.get(key); + if (existing) { + existing.plan = plan; + } else { + byTurn.set(key, { + id: `turn-plan:${key}`, + createdAt: activity.createdAt, + turnId: activity.turnId, + plan, + }); + } + } + return [...byTurn.values()]; +} + export function findLatestProposedPlan( proposedPlans: ReadonlyArray, latestTurnId: TurnId | string | null | undefined, @@ -619,30 +675,6 @@ export function findLatestProposedPlan( return toLatestProposedPlanState(latestPlan); } -export function findSidebarProposedPlan(input: { - threads: ReadonlyArray>; - latestTurn: Pick | null; - latestTurnSettled: boolean; - threadId: ThreadId | string | null | undefined; -}): LatestProposedPlanState | null { - const activeThreadPlans = - input.threads.find((thread) => thread.id === input.threadId)?.proposedPlans ?? []; - - if (!input.latestTurnSettled) { - const sourceProposedPlan = input.latestTurn?.sourceProposedPlan; - if (sourceProposedPlan) { - const sourcePlan = input.threads - .find((thread) => thread.id === sourceProposedPlan.threadId) - ?.proposedPlans.find((plan) => plan.id === sourceProposedPlan.planId); - if (sourcePlan) { - return toLatestProposedPlanState(sourcePlan); - } - } - } - - return findLatestProposedPlan(activeThreadPlans, input.latestTurn?.turnId ?? null); -} - export function hasActionableProposedPlan( proposedPlan: LatestProposedPlanState | Pick | null, ): boolean { @@ -1542,6 +1574,7 @@ export function deriveTimelineEntries( messages: ReadonlyArray, proposedPlans: ReadonlyArray, workEntries: ReadonlyArray, + turnPlans: ReadonlyArray = [], ): TimelineEntry[] { const messageRows: TimelineEntry[] = messages.map((message) => ({ id: message.id, @@ -1555,13 +1588,19 @@ export function deriveTimelineEntries( createdAt: proposedPlan.createdAt, proposedPlan, })); + const turnPlanRows: TimelineEntry[] = turnPlans.map((turnPlan) => ({ + id: turnPlan.id, + kind: "turn-plan", + createdAt: turnPlan.createdAt, + turnPlan, + })); const workRows: TimelineEntry[] = workEntries.map((entry) => ({ id: entry.id, kind: "work", createdAt: entry.createdAt, entry, })); - return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) => + return [...messageRows, ...proposedPlanRows, ...turnPlanRows, ...workRows].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt), ); } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 7ccb3dc7cac1..262049619230 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -446,6 +446,20 @@ export const OrchestrationThreadShell = Schema.Struct({ * live work. Optional so old servers/clients interop; absent = none. */ backgroundLiveness: Schema.optional(Schema.NullOr(Schema.Literals(["working", "monitoring"]))), + /** + * Current plan step while a turn runs, for the Working indicators + * (sidebar row, in-chat working line). Cleared when the turn settles — + * never persists as stale UI. Optional so old servers/clients interop. + */ + planProgress: Schema.optional( + Schema.NullOr( + Schema.Struct({ + step: TrimmedNonEmptyString, + completedSteps: NonNegativeInt, + totalSteps: NonNegativeInt, + }), + ), + ), }); export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cbb547b95fb8..7679ab6e4929 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -111,7 +111,6 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)) export type FontFamilyPreference = typeof FontFamilyPreference.Type; export const ClientSettingsSchema = Schema.Struct({ - autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( @@ -747,7 +746,6 @@ export const ServerSettingsPatch = Schema.Struct({ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ - autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), From 48aa875c0e6f8f2ee83d6972dd4357a4a083fe30 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 03:42:50 -0400 Subject: [PATCH 023/326] feat(web): remove Build/Plan toggle from the composer (#5551) Co-authored-by: Claude Fable 5 --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/ChatView.tsx | 12 ++++- apps/web/src/components/chat/ChatComposer.tsx | 53 ++++++++++++------- .../components/settings/BetaSettingsPanel.tsx | 12 +++++ .../src/components/settings/settingsSearch.ts | 5 ++ packages/contracts/src/settings.ts | 5 ++ 6 files changed, 66 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 26c3127de5f8..c1cb8588b5ea 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -29,6 +29,7 @@ const clientSettings: ClientSettings = { fontSizeTerminal: 12, fontSmoothing: true, glassOpacity: 80, + planModeEnabled: false, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a4c9bcc7c540..f0e25e644751 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1481,8 +1481,13 @@ function ChatViewContent(props: ChatViewProps) { ? (localServerError ?? activeServerThread?.session?.lastError ?? null) : localDraftError; const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; - const interactionMode = - composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; + // Plan mode is legacy (Settings → Beta). With the flag off the effective + // mode is forced to "default" — even for threads with a stored plan mode — + // so nobody is trapped in plan mode while its toggle is hidden. The next + // send persists "default" back to the thread. + const interactionMode = settings.planModeEnabled + ? (composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE) + : DEFAULT_INTERACTION_MODE; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const activeThreadId = activeThread?.id ?? null; @@ -4811,7 +4816,10 @@ function ChatViewContent(props: ChatViewProps) { }); return; } + // Legacy plan mode: /plan and /default only act when the beta flag is on; + // otherwise they send as plain text like any other message. const standaloneSlashCommand = + settings.planModeEnabled && composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 && composerElementContexts.length === 0 && diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6f3a6ec22cde..795cb910227a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -875,14 +875,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; + // Plan mode is a legacy feature behind Settings → Beta. With the flag off, + // ChatView forces the effective mode to "default", so hiding the toggle + // can't trap anyone in plan mode. + const planModeUiEnabled = settings.planModeEnabled; const composerProviderControls = useMemo( () => ({ - showInteractionModeToggle: getProviderInteractionModeToggle( - providerStatuses, - selectedProvider, - ), + showInteractionModeToggle: + planModeUiEnabled && getProviderInteractionModeToggle(providerStatuses, selectedProvider), }), - [providerStatuses, selectedProvider], + [planModeUiEnabled, providerStatuses, selectedProvider], ); const selectedModelSelection = useMemo( () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch), @@ -1043,20 +1045,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) label: "/model", description: "Switch response model for this thread", }, - { - id: "slash:plan", - type: "slash-command", - command: "plan", - label: "/plan", - description: "Switch this thread into plan mode", - }, - { - id: "slash:default", - type: "slash-command", - command: "default", - label: "/default", - description: "Switch this thread back to normal build mode", - }, + ...(planModeUiEnabled + ? ([ + { + id: "slash:plan", + type: "slash-command", + command: "plan", + label: "/plan", + description: "Switch this thread into plan mode", + }, + { + id: "slash:default", + type: "slash-command", + command: "default", + label: "/default", + description: "Switch this thread back to normal build mode", + }, + ] as const) + : []), ] satisfies ReadonlyArray>; const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( (command) => ({ @@ -1091,7 +1097,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); } return []; - }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); + }, [ + composerTrigger, + planModeUiEnabled, + selectedProvider, + selectedProviderStatus, + workspaceEntries.entries, + ]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -1857,6 +1869,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) event: KeyboardEvent, ) => { if (key === "Tab" && event.shiftKey) { + if (!planModeUiEnabled) return false; toggleInteractionMode(); return true; } diff --git a/apps/web/src/components/settings/BetaSettingsPanel.tsx b/apps/web/src/components/settings/BetaSettingsPanel.tsx index 740d3048f0e3..4b96fb15398d 100644 --- a/apps/web/src/components/settings/BetaSettingsPanel.tsx +++ b/apps/web/src/components/settings/BetaSettingsPanel.tsx @@ -60,6 +60,7 @@ export function BetaSettingsPanel() { const sidebarAutoSettleAfterDays = useClientSettings( (settings) => settings.sidebarAutoSettleAfterDays, ); + const planModeEnabled = useClientSettings((settings) => settings.planModeEnabled); const updateSettings = useUpdateClientSettings(); return ( @@ -114,6 +115,17 @@ export function BetaSettingsPanel() { ) : null} ) : null} + updateSettings({ planModeEnabled: Boolean(checked) })} + aria-label="Restore plan mode (legacy)" + /> + } + /> ); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 69d16147ac7e..3b3a8c220ac4 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -177,6 +177,11 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/beta", targetId: "sidebar-v2", }, + { + id: "restore-plan-mode", + title: "Restore plan mode (legacy)", + to: "/settings/beta", + }, { id: "archive", title: "Archived threads", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7679ab6e4929..4b477227f26e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -167,6 +167,10 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + // Legacy plan mode. The composer's Build/Plan toggle was removed from the + // default UI; this beta flag restores it (plus the /plan and /default slash + // commands) for users who still rely on the old workflow. + planModeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -781,6 +785,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + planModeEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( From 95305c36fa418301183e4750f13b6a525a20cafe Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 03:57:22 -0400 Subject: [PATCH 024/326] feat(web): per-device provider settings (#4479) Co-authored-by: Claude Opus 5 (1M context) --- .../src/components/ConnectionStatusDot.tsx | 22 + ...roviderInstanceDialog.environment.test.tsx | 54 ++ .../settings/AddProviderInstanceDialog.tsx | 24 +- ...ProviderSettingsPanel.environment.test.tsx | 256 +++++ .../ProviderSettingsPanel.logic.test.ts | 260 +++++ .../settings/ProviderSettingsPanel.logic.ts | 160 ++++ .../settings/ProviderSettingsPanel.tsx | 902 ++++++++++++++++++ .../settings/SettingsPanels.logic.ts | 57 ++ .../components/settings/SettingsPanels.tsx | 683 +------------ .../components/settings/settingsLayout.tsx | 24 +- apps/web/src/routes/settings.providers.tsx | 2 +- apps/web/src/state/server.ts | 2 +- apps/web/src/state/session.ts | 16 +- apps/web/src/test/reactElementTree.ts | 27 + apps/web/src/test/reactHookHarness.ts | 89 ++ packages/client-runtime/src/state/session.ts | 71 +- 16 files changed, 1961 insertions(+), 688 deletions(-) create mode 100644 apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx create mode 100644 apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx create mode 100644 apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts create mode 100644 apps/web/src/components/settings/ProviderSettingsPanel.logic.ts create mode 100644 apps/web/src/components/settings/ProviderSettingsPanel.tsx create mode 100644 apps/web/src/test/reactElementTree.ts create mode 100644 apps/web/src/test/reactHookHarness.ts diff --git a/apps/web/src/components/ConnectionStatusDot.tsx b/apps/web/src/components/ConnectionStatusDot.tsx index 0c22f1702e5e..2efddcfa736d 100644 --- a/apps/web/src/components/ConnectionStatusDot.tsx +++ b/apps/web/src/components/ConnectionStatusDot.tsx @@ -1,6 +1,28 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; + import { cn } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +/** Canonical connection-phase → dot color mapping shared by every status dot. */ +export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase): string { + switch (phase) { + case "connected": + return "bg-success"; + case "connecting": + case "reconnecting": + return "bg-warning"; + case "error": + return "bg-destructive"; + default: + return "bg-muted-foreground/40"; + } +} + +/** Ping halo for transitional phases; null renders no ping. */ +export function connectionPhasePingClassName(phase: EnvironmentConnectionPhase): string | null { + return phase === "connecting" || phase === "reconnecting" ? "bg-warning/60 duration-2000" : null; +} + type ConnectionStatusDotProps = { tooltipText?: string | null; dotClassName: string; diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx new file mode 100644 index 000000000000..3c502c624ddd --- /dev/null +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.environment.test.tsx @@ -0,0 +1,54 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; + +const settingsHooks = vi.hoisted(() => ({ + read: vi.fn(() => ({ providerInstances: {} })), + update: vi.fn(() => vi.fn()), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useMemo: reactHookHarness.useMemo, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("../../hooks/useSettings", () => ({ + useEnvironmentSettings: settingsHooks.read, + useUpdateEnvironmentSettings: settingsHooks.update, +})); + +import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; + +const remoteEnvironmentId = EnvironmentId.make("remote-device"); + +describe("AddProviderInstanceDialog environment routing", () => { + beforeEach(() => { + hooks.reset(); + settingsHooks.read.mockClear(); + settingsHooks.update.mockClear(); + }); + + it("reads and writes settings through the supplied environment", () => { + hooks.beginRender(); + AddProviderInstanceDialog({ + open: true, + environmentId: remoteEnvironmentId, + environmentLabel: "Remote device", + onOpenChange: vi.fn(), + }); + + expect(settingsHooks.read).toHaveBeenCalledWith(remoteEnvironmentId); + expect(settingsHooks.update).toHaveBeenCalledWith(remoteEnvironmentId); + }); +}); diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index a6da37c15519..158908b5e942 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -6,10 +6,11 @@ import { useMemo, useState } from "react"; import { ProviderInstanceId, ProviderDriverKind, + type EnvironmentId, type ProviderInstanceConfig, } from "@t3tools/contracts"; -import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Button } from "../ui/button"; @@ -115,13 +116,20 @@ function validateInstanceId(id: string, existing: ReadonlySet): string | } interface AddProviderInstanceDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; + readonly open: boolean; + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly onOpenChange: (open: boolean) => void; } -export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); +export function AddProviderInstanceDialog({ + open, + environmentId, + environmentLabel, + onOpenChange, +}: AddProviderInstanceDialogProps) { + const settings = useEnvironmentSettings(environmentId); + const updateSettings = useUpdateEnvironmentSettings(environmentId); const [wizardStep, setWizardStep] = useState(0); const [driver, setDriver] = useState(DEFAULT_DRIVER_KIND); @@ -227,8 +235,8 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns Add provider instance - Configure an additional provider instance — for example, a second Codex install - pointed at a different workspace. + Configure an additional provider instance on {environmentLabel} — for example, a + second Codex install pointed at a different workspace. ({ + providers: null as ReadonlyArray | null, + providersAtom: Symbol("providers"), + refreshProviders: Symbol("refreshProviders"), + updateProvider: Symbol("updateProvider"), +})); + +const commands = vi.hoisted(() => ({ + refresh: vi.fn(), + updateProvider: vi.fn(), +})); + +const settingsState = vi.hoisted(() => ({ + value: null as UnifiedSettings | null, + readEnvironmentIds: [] as EnvironmentId[], + updateEnvironmentIds: [] as EnvironmentId[], + updateSettings: vi.fn(), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useMemo: reactHookHarness.useMemo, + useRef: reactHookHarness.useRef, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => atoms.providers, +})); + +vi.mock("../../state/server", () => ({ + EMPTY_SERVER_PROVIDERS: [], + serverEnvironment: { + providersValueAtom: () => atoms.providersAtom, + refreshProviders: atoms.refreshProviders, + updateProvider: atoms.updateProvider, + }, +})); + +vi.mock("../../state/use-atom-command", () => ({ + useAtomCommand: (atom: symbol) => + atom === atoms.refreshProviders ? commands.refresh : commands.updateProvider, +})); + +vi.mock("../../hooks/useSettings", () => ({ + useEnvironmentSettings: (environmentId: EnvironmentId) => { + settingsState.readEnvironmentIds.push(environmentId); + return settingsState.value; + }, + useUpdateEnvironmentSettings: (environmentId: EnvironmentId) => { + settingsState.updateEnvironmentIds.push(environmentId); + return settingsState.updateSettings; + }, +})); + +vi.mock("../../environments/primary", () => ({ + usePrimarySessionState: () => ({ data: null, error: null, isPending: false, refresh: vi.fn() }), +})); + +vi.mock("../../state/session", () => ({ + useEnvironmentSessionState: () => ({ data: null, hasError: false, isPending: true }), +})); + +import { EnvironmentProviderSettings } from "./ProviderSettingsPanel"; + +const environmentId = EnvironmentId.make("remote-device"); +const codexId = ProviderInstanceId.make("codex"); +const customId = ProviderInstanceId.make("codex_work"); + +function provider(): ServerProvider { + return { + instanceId: codexId, + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-07-24T12:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + versionAdvisory: { + status: "behind_latest", + currentVersion: "1.0.0", + latestVersion: "1.1.0", + updateCommand: "pnpm add -g @openai/codex@latest", + canUpdate: true, + checkedAt: "2026-07-24T12:00:00.000Z", + message: "Update available.", + }, + }; +} + +function renderPanel(options?: { + readonly readOnly?: boolean; +}): ReactElement> { + hooks.beginRender(); + return EnvironmentProviderSettings({ + environmentId, + environmentLabel: "Remote device", + ...(options?.readOnly === undefined ? {} : { readOnly: options.readOnly }), + }) as ReactElement>; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("EnvironmentProviderSettings routing", () => { + beforeEach(() => { + hooks.reset(); + atoms.providers = null; + settingsState.value = DEFAULT_UNIFIED_SETTINGS; + settingsState.readEnvironmentIds = []; + settingsState.updateEnvironmentIds = []; + settingsState.updateSettings.mockReset(); + commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" }); + commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" }); + }); + + it("coalesces a nullable provider snapshot before rendering array-backed UI", () => { + expect(() => renderPanel()).not.toThrow(); + expect(settingsState.readEnvironmentIds).toEqual([environmentId]); + expect(settingsState.updateEnvironmentIds).toEqual([environmentId]); + }); + + it("routes refresh and provider update commands to the selected environment", async () => { + atoms.providers = [provider()]; + const panel = renderPanel(); + const refreshButton = visitElements( + panel, + (element) => element.props["aria-label"] === "Refresh provider status", + ); + expect(refreshButton).not.toBeNull(); + (refreshButton?.props.onClick as (() => void) | undefined)?.(); + await flushPromises(); + + expect(commands.refresh).toHaveBeenCalledWith({ environmentId, input: {} }); + + const providerCard = visitElements( + panel, + (element) => + element.props.instanceId === codexId && typeof element.props.onRunUpdate === "function", + ); + expect(providerCard).not.toBeNull(); + (providerCard?.props.onRunUpdate as (() => void) | undefined)?.(); + await flushPromises(); + + expect(commands.updateProvider).toHaveBeenCalledWith({ + environmentId, + input: { provider: ProviderDriverKind.make("codex"), instanceId: codexId }, + }); + }); + + it("renders the provider layout inert with a limited-permissions notice when read only", () => { + atoms.providers = [provider()]; + const panel = renderPanel({ readOnly: true }); + + const inertWrapper = visitElements(panel, (element) => element.props.inert === true); + expect(inertWrapper).not.toBeNull(); + const providerCard = visitElements(panel, (element) => element.props.instanceId === codexId); + expect(providerCard).not.toBeNull(); + + const notice = visitElements(panel, (element) => element.props.title === "Limited permissions"); + expect(notice).not.toBeNull(); + + expect( + visitElements(panel, (element) => element.props["aria-label"] === "Add provider instance"), + ).toBeNull(); + expect( + visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"), + ).toBeNull(); + }); + + it("keeps the editable layout interactive when not read only", () => { + atoms.providers = [provider()]; + const panel = renderPanel(); + expect(visitElements(panel, (element) => element.props.inert === true)).toBeNull(); + expect( + visitElements(panel, (element) => element.props.title === "Limited permissions"), + ).toBeNull(); + }); + + it("deletes and resets provider configuration without erasing shared preferences", () => { + settingsState.value = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + }, + [customId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + }, + }, + providerModelPreferences: { + [customId]: { hiddenModels: ["hidden"], modelOrder: ["model"] }, + }, + favorites: [{ provider: customId, model: "favorite" }], + }; + const panel = renderPanel(); + const customCard = visitElements(panel, (element) => element.props.instanceId === customId); + expect(customCard).not.toBeNull(); + (customCard?.props.onDelete as (() => void) | undefined)?.(); + + expect(settingsState.updateSettings).toHaveBeenLastCalledWith({ + providerInstances: { + [codexId]: settingsState.value.providerInstances?.[codexId], + }, + }); + + settingsState.updateSettings.mockClear(); + const defaultCard = visitElements(panel, (element) => element.props.instanceId === codexId); + const resetAction = defaultCard?.props.headerAction; + const resetButton = visitElements( + resetAction, + (element) => typeof element.props.onClick === "function", + ); + expect(resetButton).not.toBeNull(); + (resetButton?.props.onClick as (() => void) | undefined)?.(); + + const resetPatch = settingsState.updateSettings.mock.lastCall?.[0] as + | Record + | undefined; + expect(Object.keys(resetPatch ?? {}).sort()).toEqual(["providerInstances", "providers"]); + expect(resetPatch).not.toHaveProperty("favorites"); + expect(resetPatch).not.toHaveProperty("providerModelPreferences"); + }); +}); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts new file mode 100644 index 000000000000..bf558f5a4d66 --- /dev/null +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -0,0 +1,260 @@ +import { AuthOrchestrationOperateScope, EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildProviderEnvironmentOptions, + classifyProviderEnvironmentAccess, + resolvePrimaryOperateAccess, + resolveRemoteOperateAccess, + resolveSelectedProviderEnvironmentId, +} from "./ProviderSettingsPanel.logic"; + +const primaryId = EnvironmentId.make("primary"); +const relayId = EnvironmentId.make("relay"); +const sshId = EnvironmentId.make("ssh"); + +const environments = [ + { environmentId: sshId, label: "Zulu SSH" }, + { environmentId: relayId, label: "Alpha Relay" }, + { environmentId: primaryId, label: "This device" }, +] as const; + +describe("provider environment selection", () => { + it("sorts the primary environment first and the rest by label", () => { + expect( + buildProviderEnvironmentOptions(environments, primaryId).map( + (environment) => environment.environmentId, + ), + ).toEqual([primaryId, relayId, sshId]); + }); + + it("keeps a valid selection, then falls back to primary or the first environment", () => { + const options = buildProviderEnvironmentOptions(environments, primaryId); + + expect(resolveSelectedProviderEnvironmentId(options, sshId, primaryId)).toBe(sshId); + expect( + resolveSelectedProviderEnvironmentId( + options.filter((environment) => environment.environmentId !== sshId), + sshId, + primaryId, + ), + ).toBe(primaryId); + expect(resolveSelectedProviderEnvironmentId(options.slice(1), primaryId, primaryId)).toBe( + relayId, + ); + expect(resolveSelectedProviderEnvironmentId([], null, primaryId)).toBeNull(); + }); +}); + +describe("provider environment access", () => { + it("allows connected environments with config and operate access", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "connected", + hasServerConfig: true, + operateAccess: "granted", + }), + ).toEqual({ kind: "editable" }); + }); + + it("waits for config before exposing controls", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "connected", + hasServerConfig: false, + operateAccess: "granted", + }), + ).toEqual({ kind: "loading", reason: "config" }); + }); + + it("waits for unresolved operate access instead of assuming it is editable", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "connected", + hasServerConfig: true, + operateAccess: "pending", + }), + ).toEqual({ kind: "loading", reason: "permissions" }); + }); + + it("represents known missing operate access as read only", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "connected", + hasServerConfig: true, + operateAccess: "denied", + }), + ).toEqual({ kind: "read-only" }); + }); + + it.each(["available", "offline", "connecting", "reconnecting"] as const)( + "keeps %s environments unavailable", + (connectionPhase) => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase, + hasServerConfig: true, + operateAccess: "granted", + }), + ).toEqual({ kind: "unavailable" }); + }, + ); + + it("separates connection errors from other unavailable states", () => { + expect( + classifyProviderEnvironmentAccess({ + connectionPhase: "error", + hasServerConfig: true, + operateAccess: "granted", + }), + ).toEqual({ kind: "error" }); + }); +}); + +describe("primary operate access", () => { + const authenticated = { + authenticated: true as const, + scopes: [AuthOrchestrationOperateScope], + }; + + it("keeps cached session data authoritative while SWR revalidates", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: authenticated, + isPending: true, + hasError: false, + }), + ).toBe("granted"); + }); + + it("reports pending only before any session has resolved", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: null, + isPending: true, + hasError: false, + }), + ).toBe("pending"); + }); + + it("treats a failed session fetch as a transport problem, not a denial", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: null, + isPending: false, + hasError: true, + }), + ).toBe("granted"); + }); + + it("denies unauthenticated sessions and sessions without the operate scope", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: { authenticated: false }, + isPending: false, + hasError: false, + }), + ).toBe("denied"); + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: { authenticated: true, scopes: ["orchestration:read"] }, + isPending: false, + hasError: false, + }), + ).toBe("denied"); + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: null, + isPending: false, + hasError: false, + }), + ).toBe("denied"); + }); + + it("grants desktop bridge and remote environments without blocking on the primary session", () => { + expect( + resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: true, + session: null, + isPending: true, + hasError: false, + }), + ).toBe("granted"); + expect( + resolvePrimaryOperateAccess({ + isPrimary: false, + hasDesktopBridge: false, + session: null, + isPending: true, + hasError: false, + }), + ).toBe("granted"); + }); +}); + +describe("remote operate access", () => { + it("derives access from the environment session's granted scopes", () => { + expect( + resolveRemoteOperateAccess({ + session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] }, + isPending: false, + hasError: false, + }), + ).toBe("granted"); + expect( + resolveRemoteOperateAccess({ + session: { authenticated: true, scopes: ["orchestration:read"] }, + isPending: false, + hasError: false, + }), + ).toBe("denied"); + expect( + resolveRemoteOperateAccess({ + session: { authenticated: false }, + isPending: false, + hasError: false, + }), + ).toBe("denied"); + }); + + it("reports pending before the first session resolve, then keeps cached data", () => { + expect(resolveRemoteOperateAccess({ session: null, isPending: true, hasError: false })).toBe( + "pending", + ); + expect( + resolveRemoteOperateAccess({ + session: { authenticated: true, scopes: [AuthOrchestrationOperateScope] }, + isPending: true, + hasError: false, + }), + ).toBe("granted"); + }); + + it("stays optimistic when the session fetch fails or an older server omits scopes", () => { + // Transport failures and pre-scope-reporting servers are not permission + // decisions; the environment RPC layer still rejects unauthorized writes. + expect(resolveRemoteOperateAccess({ session: null, isPending: false, hasError: true })).toBe( + "granted", + ); + expect( + resolveRemoteOperateAccess({ + session: { authenticated: true }, + isPending: false, + hasError: false, + }), + ).toBe("granted"); + }); +}); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts new file mode 100644 index 000000000000..1c7dac391f6a --- /dev/null +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -0,0 +1,160 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { + AuthOrchestrationOperateScope, + type AuthSessionState, + type EnvironmentId, +} from "@t3tools/contracts"; + +export interface ProviderEnvironmentOptionLike { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +export function buildProviderEnvironmentOptions( + environments: ReadonlyArray, + primaryEnvironmentId: EnvironmentId | null, +): ReadonlyArray { + return environments.toSorted((left, right) => { + const leftIsPrimary = left.environmentId === primaryEnvironmentId; + const rightIsPrimary = right.environmentId === primaryEnvironmentId; + if (leftIsPrimary !== rightIsPrimary) { + return leftIsPrimary ? -1 : 1; + } + return ( + left.label.localeCompare(right.label) || + String(left.environmentId).localeCompare(String(right.environmentId)) + ); + }); +} + +export function resolveSelectedProviderEnvironmentId( + environments: ReadonlyArray, + selectedEnvironmentId: EnvironmentId | null, + primaryEnvironmentId: EnvironmentId | null, +): EnvironmentId | null { + if ( + selectedEnvironmentId !== null && + environments.some((environment) => environment.environmentId === selectedEnvironmentId) + ) { + return selectedEnvironmentId; + } + if ( + primaryEnvironmentId !== null && + environments.some((environment) => environment.environmentId === primaryEnvironmentId) + ) { + return primaryEnvironmentId; + } + return environments[0]?.environmentId ?? null; +} + +export type ProviderEnvironmentAccess = + | { readonly kind: "editable" } + /** `reason` distinguishes waiting on the device from waiting on permissions. */ + | { readonly kind: "loading"; readonly reason: "config" | "permissions" } + | { readonly kind: "read-only" } + | { readonly kind: "unavailable" } + | { readonly kind: "error" }; + +/** + * Whether the session may change provider configuration on an environment. + * `pending` means the answer is still unknown, which must not be presented as + * editable: rendering controls we already know might be rejected only turns a + * permission problem into a failed write. + */ +export type ProviderOperateAccess = "granted" | "denied" | "pending"; + +/** + * Resolve operate access from an environment's `/api/auth/session` answer. + * + * Cached session data wins over an in-flight revalidation. The session atoms + * are SWR-backed, so they report `isPending` on every background refresh; + * treating that as unknown would flip a working panel back to loading and + * discard in-progress edits. + * + * `missingScopesAccess` decides the case where the session resolved but did + * not report scopes: the primary serves the web app itself so its server + * always reports them (absence means denial), while a remote device may run an + * older server version that predates scope reporting, where denial would lock + * out a legitimate session. The environment RPC layer stays authoritative + * either way. + */ +function resolveSessionOperateAccess(input: { + readonly session: Pick | null; + readonly isPending: boolean; + readonly hasError: boolean; + readonly missingScopesAccess: "granted" | "denied"; +}): ProviderOperateAccess { + if (input.session === null) { + if (input.isPending) { + return "pending"; + } + // A failed session fetch is a transport problem, not a permission + // decision — locking the panel read-only would misreport it. Stay + // optimistic; the environment RPC layer still rejects unauthorized writes. + return input.hasError ? "granted" : "denied"; + } + if (!input.session.authenticated) { + return "denied"; + } + if (input.session.scopes === undefined) { + return input.missingScopesAccess; + } + return input.session.scopes.includes(AuthOrchestrationOperateScope) ? "granted" : "denied"; +} + +/** Operate access for the primary environment's own browser session. */ +export function resolvePrimaryOperateAccess(input: { + readonly isPrimary: boolean; + readonly hasDesktopBridge: boolean; + readonly session: Pick | null; + readonly isPending: boolean; + readonly hasError: boolean; +}): ProviderOperateAccess { + if (!input.isPrimary || input.hasDesktopBridge) { + return "granted"; + } + return resolveSessionOperateAccess({ + session: input.session, + isPending: input.isPending, + hasError: input.hasError, + missingScopesAccess: "denied", + }); +} + +/** + * Operate access for a non-primary environment, derived from the scopes its + * `/api/auth/session` endpoint reports for this client's credential. + */ +export function resolveRemoteOperateAccess(input: { + readonly session: Pick | null; + readonly isPending: boolean; + readonly hasError: boolean; +}): ProviderOperateAccess { + return resolveSessionOperateAccess({ + ...input, + missingScopesAccess: "granted", + }); +} + +export function classifyProviderEnvironmentAccess(input: { + readonly connectionPhase: EnvironmentConnectionPhase; + readonly hasServerConfig: boolean; + readonly operateAccess: ProviderOperateAccess; +}): ProviderEnvironmentAccess { + if (input.connectionPhase === "error") { + return { kind: "error" }; + } + if (input.connectionPhase !== "connected") { + return { kind: "unavailable" }; + } + if (!input.hasServerConfig) { + return { kind: "loading", reason: "config" }; + } + if (input.operateAccess === "pending") { + return { kind: "loading", reason: "permissions" }; + } + if (input.operateAccess === "denied") { + return { kind: "read-only" }; + } + return { kind: "editable" }; +} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx new file mode 100644 index 000000000000..c06826d450d4 --- /dev/null +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -0,0 +1,902 @@ +import { useAtomValue } from "@effect/atom-react"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + defaultInstanceIdForDriver, + type EnvironmentId, + PROVIDER_DISPLAY_NAMES, + ProviderDriverKind, + type ProviderInstanceConfig, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { + getBackgroundActivityPresetSettings, + resolveServerBackgroundActivitySettings, +} from "@t3tools/shared/backgroundActivitySettings"; +import * as Arr from "effect/Array"; +import * as Duration from "effect/Duration"; +import * as Equal from "effect/Equal"; +import * as Result from "effect/Result"; +import { + CloudIcon, + LaptopIcon, + LoaderIcon, + MonitorIcon, + PlusIcon, + RefreshCwIcon, + TerminalIcon, +} from "lucide-react"; +import { useCallback, useMemo, useRef, useState } from "react"; + +import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; +import { isElectron } from "../../env"; +import { usePrimarySessionState } from "../../environments/primary"; +import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings"; +import { cn } from "../../lib/utils"; +import { resolveAppModelSelectionState } from "../../modelSelection"; +import { + useEnvironments, + usePrimaryEnvironmentId, + type EnvironmentPresentation, +} from "../../state/environments"; +import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; +import { useEnvironmentSessionState } from "../../state/session"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { getRelativeTimeState } from "../../timestampFormat"; +import { + ConnectionStatusDot, + connectionPhaseDotClassName, + connectionPhasePingClassName, +} from "../ConnectionStatusDot"; +import { + canOneClickUpdateProviderCandidate, + collectProviderUpdateCandidates, + hasOneClickUpdateProviderCandidate, + isProviderUpdateActive, + type ProviderUpdateCandidate, +} from "../ProviderUpdateLaunchNotification.logic"; +import { Button } from "../ui/button"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "../ui/number-field"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; +import { ProviderInstanceCard } from "./ProviderInstanceCard"; +import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; +import { searchableSetting } from "./settingsSearch"; +import { + backgroundActivityOverrideSettings, + buildProviderInstanceUpdatePatch, + durationToSeconds, + normalizeIntervalSeconds, + PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, +} from "./SettingsPanels.logic"; +import { + PolicyTooltip, + SettingResetButton, + SettingsPageContainer, + SettingsRow, + SettingsSection, + useRelativeTimeTick, +} from "./settingsLayout"; +import { + buildProviderEnvironmentOptions, + classifyProviderEnvironmentAccess, + type ProviderEnvironmentAccess, + type ProviderOperateAccess, + resolvePrimaryOperateAccess, + resolveRemoteOperateAccess, + resolveSelectedProviderEnvironmentId, +} from "./ProviderSettingsPanel.logic"; + +function withoutProviderInstanceKey( + record: Readonly> | undefined, + key: ProviderInstanceId, +): Record { + const next = { ...record } as Record; + delete next[key]; + return next; +} + +function withoutProviderInstanceFavorites( + favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>, + instanceId: ProviderInstanceId, +) { + return favorites.filter((favorite) => favorite.provider !== instanceId); +} + +const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ + provider: definition.value, +})); + +function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { + useRelativeTimeTick(); + const lastCheckedRelative = getRelativeTimeState(lastCheckedAt); + + if (lastCheckedRelative.status === "missing") { + return null; + } + + if (lastCheckedRelative.status === "invalid") { + return Checked unavailable; + } + + return ( + + {lastCheckedRelative.suffix ? ( + <> + Checked {lastCheckedRelative.value}{" "} + {lastCheckedRelative.suffix} + + ) : ( + <>Checked {lastCheckedRelative.value} + )} + + ); +} + +function providerEnvironmentIcon(environment: EnvironmentPresentation) { + if (environment.entry.target._tag === "PrimaryConnectionTarget") return MonitorIcon; + if (environment.entry.target._tag === "RelayConnectionTarget") return CloudIcon; + if (environment.entry.target._tag === "SshConnectionTarget") return TerminalIcon; + if (isDesktopLocalConnectionTarget(environment.entry.target)) return LaptopIcon; + return CloudIcon; +} + +function providerEnvironmentDetail(environment: EnvironmentPresentation): string { + if (environment.entry.target._tag === "PrimaryConnectionTarget") return "Primary device"; + if (environment.relayManaged) return "T3 Connect"; + if (environment.entry.target._tag === "SshConnectionTarget") return "SSH"; + if (isDesktopLocalConnectionTarget(environment.entry.target)) return "Local device"; + return environment.displayUrl ?? "Remote device"; +} + +function EnvironmentUnavailableRow({ + environment, + access, +}: { + readonly environment: EnvironmentPresentation; + readonly access: Exclude; +}) { + const isLoading = access.kind === "loading"; + const title = isLoading + ? "Loading provider settings" + : access.kind === "error" + ? "Could not connect to this device" + : "Provider settings are unavailable"; + const description = isLoading + ? access.reason === "permissions" + ? "Checking what this session is allowed to change." + : `Waiting for ${environment.label}'s configuration.` + : connectionStatusText(environment.connection); + // No spinner: this state can persist indefinitely for a wedged device, and a + // continuously repainting animation would run the whole time. + return ( + + + + ); +} + +export function ProviderSettingsPanel() { + const { environments, isReady } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const options = useMemo( + () => buildProviderEnvironmentOptions(environments, primaryEnvironmentId), + [environments, primaryEnvironmentId], + ); + // Raw user intent; the effective selection is re-derived every render so a + // device that drops out of the catalog falls back without erasing the pick — + // if it reappears (e.g. after a reconnect) the selection is restored. + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState( + primaryEnvironmentId, + ); + const effectiveEnvironmentId = resolveSelectedProviderEnvironmentId( + options, + selectedEnvironmentId, + primaryEnvironmentId, + ); + const selectedEnvironment = + options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null; + const onlyPrimaryDevice = + options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget"; + + return ( + + {!onlyPrimaryDevice ? ( + + {options.length === 0 ? ( + // The catalog hydrates asynchronously, so an empty list before it is + // ready means "not loaded yet", not "nothing is connected". + + ) : ( +
+ {options.map((environment) => { + const Icon = providerEnvironmentIcon(environment); + const selected = environment.environmentId === effectiveEnvironmentId; + const statusText = connectionStatusText(environment.connection); + return ( + + ); + })} +
+ )} +
+ ) : null} + + {selectedEnvironment ? ( + + ) : null} +
+ ); +} + +function SelectedEnvironmentProviderSettings({ + environment, +}: { + readonly environment: EnvironmentPresentation; +}) { + const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; + if (isPrimary) { + // The desktop app owns its primary server outright; a browser session + // checks the scopes its cookie session was granted. + if (isElectron) { + return ; + } + return ; + } + return ; +} + +function PrimarySessionGatedProviderSettings({ + environment, +}: { + readonly environment: EnvironmentPresentation; +}) { + const primarySessionState = usePrimarySessionState(); + const operateAccess = resolvePrimaryOperateAccess({ + isPrimary: true, + hasDesktopBridge: false, + session: primarySessionState.data, + isPending: primarySessionState.isPending, + hasError: primarySessionState.error !== null, + }); + return ; +} + +function RemoteSessionGatedProviderSettings({ + environment, +}: { + readonly environment: EnvironmentPresentation; +}) { + const sessionState = useEnvironmentSessionState(environment.environmentId); + const operateAccess = resolveRemoteOperateAccess({ + session: sessionState.data, + isPending: sessionState.isPending, + hasError: sessionState.hasError, + }); + return ; +} + +function AccessGatedProviderSettings({ + environment, + operateAccess, +}: { + readonly environment: EnvironmentPresentation; + readonly operateAccess: ProviderOperateAccess; +}) { + const access = classifyProviderEnvironmentAccess({ + connectionPhase: environment.connection.phase, + hasServerConfig: environment.serverConfig !== null, + operateAccess, + }); + if (access.kind !== "editable" && access.kind !== "read-only") { + return ; + } + return ( + + ); +} + +export function EnvironmentProviderSettings({ + environmentId, + environmentLabel, + readOnly = false, +}: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + /** + * Render the full provider layout, greyed out and inert, when this session's + * credential lacks `orchestration:operate` on the environment. Showing the + * real configuration keeps the view honest; disabling interaction keeps + * every one of its writes from being offered and then rejected. + */ + readonly readOnly?: boolean; +}) { + const settings = useEnvironmentSettings(environmentId); + const updateSettings = useUpdateEnvironmentSettings(environmentId); + const serverProviders = + useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; + const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { + reportFailure: false, + }); + const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); + const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); + const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< + ReadonlySet + >(() => new Set()); + const [openInstanceDetails, setOpenInstanceDetails] = useState>({}); + const refreshingRef = useRef(false); + const updatingDriversRef = useRef>(new Set()); + + const providerUpdateCandidates = useMemo( + () => collectProviderUpdateCandidates(serverProviders), + [serverProviders], + ); + const providerUpdateCandidateByInstanceId = useMemo( + () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])), + [providerUpdateCandidates], + ); + const visibleProviderSettings = PROVIDER_SETTINGS.filter( + (providerSettings) => + providerSettings.provider !== "cursor" || + serverProviders.some( + (provider) => + provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), + ), + ); + const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); + const textGenInstanceId = textGenerationModelSelection.instanceId; + const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); + const providerHealthPreset = getBackgroundActivityPresetSettings( + resolvedBackgroundActivity.profile, + ).providerHealthRefreshInterval; + const providerHealthRefreshIntervalSeconds = durationToSeconds( + resolvedBackgroundActivity.providerHealthRefreshInterval, + ); + const defaultProviderHealthRefreshIntervalSeconds = durationToSeconds(providerHealthPreset); + const lastCheckedAt = + serverProviders.length > 0 + ? serverProviders.reduce( + (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest), + serverProviders[0]!.checkedAt, + ) + : null; + + const refreshProviders = useCallback(() => { + if (refreshingRef.current) return; + refreshingRef.current = true; + setIsRefreshingProviders(true); + void (async () => { + const result = await refreshServerProviders({ + environmentId, + input: {}, + }); + refreshingRef.current = false; + setIsRefreshingProviders(false); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + console.warn("Failed to refresh providers", { + operation: "refresh-providers", + environmentId, + ...safeErrorLogAttributes(squashAtomCommandFailure(result)), + }); + } + })(); + }, [environmentId, refreshServerProviders]); + + const runProviderUpdate = useCallback( + async (candidate: ProviderUpdateCandidate) => { + // Ref-based re-entry guard, mirroring refreshProviders: a state updater + // may run after this function returns, so it cannot gate the dispatch. + if (updatingDriversRef.current.has(candidate.driver)) { + return; + } + updatingDriversRef.current.add(candidate.driver); + setUpdatingProviderDrivers((previous) => new Set(previous).add(candidate.driver)); + + const result = await updateProvider({ + environmentId, + input: { + provider: candidate.driver, + instanceId: candidate.instanceId, + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, + description: + error instanceof Error + ? error.message + : "The provider update command could not be started.", + }), + ); + } + updatingDriversRef.current.delete(candidate.driver); + setUpdatingProviderDrivers((previous) => { + if (!previous.has(candidate.driver)) { + return previous; + } + const next = new Set(previous); + next.delete(candidate.driver); + return next; + }); + }, + [environmentId, updateProvider], + ); + + interface InstanceRow { + readonly instanceId: ProviderInstanceId; + readonly instance: ProviderInstanceConfig; + readonly driver: ProviderDriverKind; + readonly isDefault: boolean; + readonly isDirty?: boolean; + } + + const instancesByDriver = new Map< + ProviderDriverKind, + Array<[ProviderInstanceId, ProviderInstanceConfig]> + >(); + for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) { + const driver = instance.driver; + const list = instancesByDriver.get(driver) ?? []; + list.push([rawId as ProviderInstanceId, instance]); + instancesByDriver.set(driver, list); + } + + const defaultSlotIdsBySource = new Set( + visibleProviderSettings.map((providerSettings) => + String(defaultInstanceIdForDriver(providerSettings.provider)), + ), + ); + + const rows: InstanceRow[] = []; + const visibleDriverKinds = new Set( + visibleProviderSettings.map((providerSettings) => providerSettings.provider), + ); + + for (const providerSettings of visibleProviderSettings) { + type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; + const legacyProviders = settings.providers as Record; + const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< + string, + LegacyProviderSettings + >; + const driver = providerSettings.provider; + const defaultInstanceId = defaultInstanceIdForDriver(driver); + const explicitInstance = settings.providerInstances?.[defaultInstanceId]; + // A remote device may run a server version whose settings predate this + // driver, so the legacy mirror can be absent. Without either an explicit + // instance or a legacy blob there is nothing to render for the slot. + const legacyConfig = legacyProviders[providerSettings.provider]; + const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]; + const effectiveInstance: ProviderInstanceConfig | undefined = + explicitInstance ?? + (legacyConfig !== undefined + ? ({ + driver, + enabled: legacyConfig.enabled, + config: legacyConfig, + } satisfies ProviderInstanceConfig) + : undefined); + // Only the default slot depends on the legacy blob; custom instances for + // the driver must still render even when the slot has nothing to show. + if (effectiveInstance !== undefined) { + const isDirty = + explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); + rows.push({ + instanceId: defaultInstanceId, + instance: effectiveInstance, + driver, + isDefault: true, + isDirty, + }); + } + for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { + if (id === defaultInstanceId) continue; + rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false }); + } + } + for (const [driver, list] of instancesByDriver) { + if (visibleDriverKinds.has(driver)) continue; + for (const [id, instance] of list) { + rows.push({ + instanceId: id, + instance, + driver: instance.driver, + isDefault: defaultSlotIdsBySource.has(String(id)), + }); + } + } + + const updateProviderInstance = ( + row: InstanceRow, + next: ProviderInstanceConfig, + options?: { + readonly textGenerationModelSelection?: Parameters< + typeof buildProviderInstanceUpdatePatch + >[0]["textGenerationModelSelection"]; + }, + ) => { + updateSettings( + buildProviderInstanceUpdatePatch({ + settings, + instanceId: row.instanceId, + instance: next, + driver: row.driver, + isDefault: row.isDefault, + textGenerationModelSelection: options?.textGenerationModelSelection, + }), + ); + }; + + const deleteProviderInstance = (id: ProviderInstanceId) => { + updateSettings({ + providerInstances: withoutProviderInstanceKey(settings.providerInstances, id), + }); + }; + + const updateProviderModelPreferences = ( + instanceId: ProviderInstanceId, + next: { + readonly hiddenModels: ReadonlyArray; + readonly modelOrder: ReadonlyArray; + }, + ) => { + const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))]; + const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; + const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); + updateSettings({ + providerModelPreferences: + hiddenModels.length === 0 && modelOrder.length === 0 + ? rest + : { + ...rest, + [instanceId]: { + hiddenModels, + modelOrder, + }, + }, + }); + }; + + const updateProviderFavoriteModels = ( + instanceId: ProviderInstanceId, + nextFavoriteModels: ReadonlyArray, + ) => { + const favoriteModels = [ + ...new Set( + Arr.filterMap(nextFavoriteModels, (slug) => { + const trimmedSlug = slug.trim(); + return trimmedSlug.length > 0 ? Result.succeed(trimmedSlug) : Result.failVoid; + }), + ), + ]; + updateSettings({ + favorites: [ + ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), + ...favoriteModels.map((model) => ({ provider: instanceId, model })), + ], + }); + }; + + const resetDefaultInstance = (driverKind: ProviderDriverKind) => { + type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; + const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< + string, + LegacyProviderSettings | undefined + >; + const defaultInstanceId = defaultInstanceIdForDriver(driverKind); + const defaultLegacyProvider = defaultLegacyProviders[driverKind]; + if (defaultLegacyProvider === undefined) return; + updateSettings({ + providers: { + ...settings.providers, + [driverKind]: defaultLegacyProvider, + } as typeof settings.providers, + providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId), + }); + }; + + return ( + <> + + + {!readOnly ? ( + <> + + setIsAddInstanceDialogOpen(true)} + aria-label="Add provider instance" + > + + + } + /> + Add provider instance + + + void refreshProviders()} + aria-label="Refresh provider status" + > + {isRefreshingProviders ? ( + + ) : ( + + )} + + } + /> + Refresh provider status + + + ) : null} +
+ } + > + {readOnly ? ( + + ) : null} +
+ + Health check interval + + This interval is configured here, then the shared Background activity policy + decides whether provider probes may run when the timer fires. Custom intervals + appear as Advanced in General settings. + + + } + description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes." + resetAction={ + providerHealthRefreshIntervalSeconds !== + defaultProviderHealthRefreshIntervalSeconds ? ( + + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + providerHealthRefreshInterval: undefined, + }, + ), + ) + } + /> + ) : null + } + control={ +
+ + updateSettings( + backgroundActivityOverrideSettings( + settings.backgroundActivity, + resolvedBackgroundActivity, + { + providerHealthRefreshInterval: Duration.seconds( + normalizeIntervalSeconds(value), + ), + }, + ), + ) + } + > + + + + + + + seconds +
+ } + /> + + {rows.map((row) => { + const driverOption = getDriverOption(row.driver); + const liveProvider = serverProviders.find( + (candidate) => candidate.instanceId === row.instanceId, + ); + const updateCandidate = liveProvider + ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) + : undefined; + const isDriverUpdateRunning = + updateCandidate !== undefined && + (updatingProviderDrivers.has(updateCandidate.driver) || + serverProviders.some( + (provider) => + provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), + )); + const showInlineUpdateButton = + updateCandidate !== undefined && + hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); + const canRunInlineUpdate = + updateCandidate !== undefined && + canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && + !updatingProviderDrivers.has(updateCandidate.driver); + const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { + hiddenModels: [], + modelOrder: [], + }; + const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => + favorite.provider === row.instanceId + ? Result.succeed(favorite.model) + : Result.failVoid, + ); + const resetLabel = driverOption?.label ?? String(row.driver); + const headerAction = + row.isDefault && row.isDirty ? ( + resetDefaultInstance(row.driver)} + /> + ) : null; + return ( + + setOpenInstanceDetails((existing) => ({ + ...existing, + [row.instanceId]: open, + })) + } + onUpdate={(next) => { + const wasEnabled = row.instance.enabled ?? true; + const isDisabling = next.enabled === false && wasEnabled; + const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; + if (shouldClearTextGen) { + updateProviderInstance(row, next, { + textGenerationModelSelection: + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + }); + } else { + updateProviderInstance(row, next); + } + }} + onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} + headerAction={headerAction} + hiddenModels={modelPreferences.hiddenModels} + favoriteModels={favoriteModels} + modelOrder={modelPreferences.modelOrder} + onHiddenModelsChange={(hiddenModels) => + updateProviderModelPreferences(row.instanceId, { + ...modelPreferences, + hiddenModels, + }) + } + onFavoriteModelsChange={(favoriteModels) => + updateProviderFavoriteModels(row.instanceId, favoriteModels) + } + onModelOrderChange={(modelOrder) => + updateProviderModelPreferences(row.instanceId, { + ...modelPreferences, + modelOrder, + }) + } + onRunUpdate={ + showInlineUpdateButton && updateCandidate + ? () => { + if (!canRunInlineUpdate) { + return; + } + void runProviderUpdate(updateCandidate); + } + : undefined + } + isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} + /> + ); + })} +
+ + + {isAddInstanceDialogOpen ? ( + + ) : null} + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 1d4baefa53a5..efb5e12ff33d 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -10,10 +10,12 @@ import type { } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { + getBackgroundActivityBaseProfile, normalizeBackgroundActivitySettings, normalizeServerBackgroundActivitySettings, resolveServerBackgroundActivitySettings, } from "@t3tools/shared/backgroundActivitySettings"; +import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean { @@ -190,3 +192,58 @@ export function buildProviderInstanceUpdatePatch(input: { : {}), }; } + +// ── Background-activity interval helpers ───────────────────────────── +// Shared by the General panel's interval rows and the Providers panel's +// health-check row. + +export const PROVIDER_HEALTH_INTERVAL_STEP_SECONDS = 30; + +type BackgroundActivityOverridePatch = Partial<{ + [K in keyof BackgroundActivitySettings["overrides"]]: + | BackgroundActivitySettings["overrides"][K] + | undefined; +}>; + +export function durationToSeconds(duration: Duration.Duration): number { + return Math.round(Duration.toMillis(duration) / 1_000); +} + +export function normalizeIntervalSeconds(value: number | null, minimum = 0): number { + if (value === null || !Number.isFinite(value)) { + return minimum; + } + return Math.max(minimum, Math.round(value)); +} + +export function backgroundActivityOverrideSettings( + current: BackgroundActivitySettings, + resolved: ReturnType, + overrides: BackgroundActivityOverridePatch, +) { + const nextOverrides: BackgroundActivityOverridePatch = { + automaticGitFetchInterval: resolved.automaticGitFetchInterval, + providerHealthRefreshInterval: resolved.providerHealthRefreshInterval, + hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval, + hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval, + idleClientTtl: resolved.idleClientTtl, + pauseWhenHostLocked: resolved.pauseWhenHostLocked, + pauseWhenHostLowPower: resolved.pauseWhenHostLowPower, + pauseWhenClientLowPower: resolved.pauseWhenClientLowPower, + pauseWhenOnBattery: resolved.pauseWhenOnBattery, + ...overrides, + }; + for (const [key, value] of Object.entries(nextOverrides)) { + if (value === undefined) { + delete nextOverrides[key as keyof typeof nextOverrides]; + } + } + return { + backgroundActivity: { + schemaVersion: 1 as const, + profile: "custom" as const, + baseProfile: getBackgroundActivityBaseProfile(current), + overrides: nextOverrides as BackgroundActivitySettings["overrides"], + }, + }; +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b1c50e8717ae..00d9573a1acd 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,30 +1,16 @@ -import { - ArchiveIcon, - ArchiveX, - InfoIcon, - LoaderIcon, - PlusIcon, - RefreshCwIcon, - SettingsIcon, -} from "lucide-react"; +import { ArchiveIcon, ArchiveX, LoaderIcon, SettingsIcon } from "lucide-react"; import { Link } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { - defaultInstanceIdForDriver, type BackgroundActivityProfile, - type BackgroundActivitySettings, type DesktopUpdateChannel, - PROVIDER_DISPLAY_NAMES, ProviderDriverKind, - type ProviderInstanceConfig, - type ProviderInstanceId, type ScopedThreadRef, type SidebarProjectGroupingMode, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted, settlePromise, @@ -45,16 +31,10 @@ import { MIN_PROMPT_FONT_SIZE, MIN_TERMINAL_FONT_SIZE, } from "@t3tools/contracts/settings"; -import { - getBackgroundActivityBaseProfile, - getBackgroundActivityPresetSettings, - resolveServerBackgroundActivitySettings, -} from "@t3tools/shared/backgroundActivitySettings"; +import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { createModelSelection } from "@t3tools/shared/model"; -import * as Arr from "effect/Array"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; -import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; import { @@ -88,15 +68,10 @@ import { } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; -import { - primaryServerObservabilityAtom, - primaryServerProvidersAtom, - serverEnvironment, -} from "../../state/server"; -import { usePrimaryEnvironment } from "../../state/environments"; +import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server"; import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; -import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; import { Dialog, @@ -131,20 +106,13 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; -import { - canOneClickUpdateProviderCandidate, - collectProviderUpdateCandidates, - hasOneClickUpdateProviderCandidate, - isProviderUpdateActive, - type ProviderUpdateCandidate, -} from "../ProviderUpdateLaunchNotification.logic"; -import { ProviderInstanceCard } from "./ProviderInstanceCard"; -import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { + backgroundActivityOverrideSettings, backgroundActivitySharedPolicySettings, - buildProviderInstanceUpdatePatch, + durationToSeconds, formatDiagnosticsDescription, + normalizeIntervalSeconds, + PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, hasChangedBackgroundActivitySettings, isProjectGroupingEnabled, projectGroupingModeFromToggle, @@ -153,16 +121,15 @@ import { resolveBackgroundActivityProfileOption, } from "./SettingsPanels.logic"; import { + PolicyTooltip, SettingResetButton, SettingsPageContainer, SettingsRow, SettingsSection, - useRelativeTimeTick, useSettingsSearchTargetId, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; -import { useAtomCommand } from "../../state/use-atom-command"; const THEME_OPTIONS = [ { @@ -198,11 +165,6 @@ const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record; const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record = { ...BACKGROUND_ACTIVITY_PROFILE_LABELS, @@ -219,7 +181,6 @@ const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record, - overrides: BackgroundActivityOverridePatch, -) { - const nextOverrides: BackgroundActivityOverridePatch = { - automaticGitFetchInterval: resolved.automaticGitFetchInterval, - providerHealthRefreshInterval: resolved.providerHealthRefreshInterval, - hostPowerMonitorActiveInterval: resolved.hostPowerMonitorActiveInterval, - hostPowerMonitorIdleInterval: resolved.hostPowerMonitorIdleInterval, - idleClientTtl: resolved.idleClientTtl, - pauseWhenHostLocked: resolved.pauseWhenHostLocked, - pauseWhenHostLowPower: resolved.pauseWhenHostLowPower, - pauseWhenClientLowPower: resolved.pauseWhenClientLowPower, - pauseWhenOnBattery: resolved.pauseWhenOnBattery, - ...overrides, - }; - for (const [key, value] of Object.entries(nextOverrides)) { - if (value === undefined) { - delete nextOverrides[key as keyof typeof nextOverrides]; - } - } - return { - backgroundActivity: { - schemaVersion: 1 as const, - profile: "custom" as const, - baseProfile: getBackgroundActivityBaseProfile(current), - overrides: nextOverrides as BackgroundActivitySettings["overrides"], - }, - }; -} - -function PolicyTooltip({ children }: { readonly children: string }) { - return ( - - - - - } - /> - - {children} - - - ); -} - -function withoutProviderInstanceKey( - record: Readonly> | undefined, - key: ProviderInstanceId, -): Record { - const next = { ...record } as Record; - delete next[key]; - return next; -} - -function withoutProviderInstanceFavorites( - favorites: ReadonlyArray<{ readonly provider: ProviderInstanceId; readonly model: string }>, - instanceId: ProviderInstanceId, -) { - return favorites.filter((favorite) => favorite.provider !== instanceId); -} - -const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ - provider: definition.value, -})); - -function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { - useRelativeTimeTick(); - const lastCheckedRelative = getRelativeTimeState(lastCheckedAt); - - if (lastCheckedRelative.status === "missing") { - return null; - } - - if (lastCheckedRelative.status === "invalid") { - return Checked unavailable; - } - - return ( - - {lastCheckedRelative.suffix ? ( - <> - Checked {lastCheckedRelative.value}{" "} - {lastCheckedRelative.suffix} - - ) : ( - <>Checked {lastCheckedRelative.value} - )} - - ); -} - function AboutVersionTitle() { return ( @@ -2146,522 +1997,6 @@ export function GeneralSettingsPanel() { ); } -export function ProviderSettingsPanel() { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); - const serverProviders = useAtomValue(primaryServerProvidersAtom); - const primaryEnvironment = usePrimaryEnvironment(); - const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { - reportFailure: false, - }); - const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { - reportFailure: false, - }); - const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); - const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); - const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< - ReadonlySet - >(() => new Set()); - const [openInstanceDetails, setOpenInstanceDetails] = useState>({}); - const refreshingRef = useRef(false); - - const providerUpdateCandidates = useMemo( - () => collectProviderUpdateCandidates(serverProviders), - [serverProviders], - ); - const providerUpdateCandidateByInstanceId = useMemo( - () => new Map(providerUpdateCandidates.map((candidate) => [candidate.instanceId, candidate])), - [providerUpdateCandidates], - ); - const visibleProviderSettings = PROVIDER_SETTINGS.filter( - (providerSettings) => - providerSettings.provider !== "cursor" || - serverProviders.some( - (provider) => - provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")), - ), - ); - const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); - const textGenInstanceId = textGenerationModelSelection.instanceId; - const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); - const providerHealthPreset = getBackgroundActivityPresetSettings( - resolvedBackgroundActivity.profile, - ).providerHealthRefreshInterval; - const providerHealthRefreshIntervalSeconds = durationToSeconds( - resolvedBackgroundActivity.providerHealthRefreshInterval, - ); - const defaultProviderHealthRefreshIntervalSeconds = durationToSeconds(providerHealthPreset); - const lastCheckedAt = - serverProviders.length > 0 - ? serverProviders.reduce( - (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest), - serverProviders[0]!.checkedAt, - ) - : null; - - const refreshProviders = useCallback(() => { - if (refreshingRef.current) return; - refreshingRef.current = true; - setIsRefreshingProviders(true); - if (!primaryEnvironment) { - refreshingRef.current = false; - setIsRefreshingProviders(false); - return; - } - void (async () => { - const result = await refreshServerProviders({ - environmentId: primaryEnvironment.environmentId, - input: {}, - }); - refreshingRef.current = false; - setIsRefreshingProviders(false); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - console.warn("Failed to refresh providers", { - operation: "refresh-providers", - environmentId: primaryEnvironment.environmentId, - ...safeErrorLogAttributes(squashAtomCommandFailure(result)), - }); - } - })(); - }, [primaryEnvironment, refreshServerProviders]); - - const runProviderUpdate = useCallback( - async (candidate: ProviderUpdateCandidate) => { - if (!primaryEnvironment) return; - let started = false; - setUpdatingProviderDrivers((previous) => { - if (previous.has(candidate.driver)) { - return previous; - } - started = true; - const next = new Set(previous); - next.add(candidate.driver); - return next; - }); - if (!started) { - return; - } - - const result = await updateProvider({ - environmentId: primaryEnvironment.environmentId, - input: { - provider: candidate.driver, - instanceId: candidate.instanceId, - }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, - description: - error instanceof Error - ? error.message - : "The provider update command could not be started.", - }), - ); - } - setUpdatingProviderDrivers((previous) => { - if (!previous.has(candidate.driver)) { - return previous; - } - const next = new Set(previous); - next.delete(candidate.driver); - return next; - }); - }, - [primaryEnvironment, updateProvider], - ); - - interface InstanceRow { - readonly instanceId: ProviderInstanceId; - readonly instance: ProviderInstanceConfig; - readonly driver: ProviderDriverKind; - readonly isDefault: boolean; - readonly isDirty?: boolean; - } - - const instancesByDriver = new Map< - ProviderDriverKind, - Array<[ProviderInstanceId, ProviderInstanceConfig]> - >(); - for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) { - const driver = instance.driver; - const list = instancesByDriver.get(driver) ?? []; - list.push([rawId as ProviderInstanceId, instance]); - instancesByDriver.set(driver, list); - } - - const defaultSlotIdsBySource = new Set( - visibleProviderSettings.map((providerSettings) => - String(defaultInstanceIdForDriver(providerSettings.provider)), - ), - ); - - const rows: InstanceRow[] = []; - const visibleDriverKinds = new Set( - visibleProviderSettings.map((providerSettings) => providerSettings.provider), - ); - - for (const providerSettings of visibleProviderSettings) { - type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; - const legacyProviders = settings.providers as Record; - const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< - string, - LegacyProviderSettings - >; - const driver = providerSettings.provider; - const defaultInstanceId = defaultInstanceIdForDriver(driver); - const explicitInstance = settings.providerInstances?.[defaultInstanceId]; - const legacyConfig = legacyProviders[providerSettings.provider]!; - const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]!; - const effectiveInstance: ProviderInstanceConfig = - explicitInstance ?? - ({ - driver, - enabled: legacyConfig.enabled, - config: legacyConfig, - } satisfies ProviderInstanceConfig); - const isDirty = - explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); - rows.push({ - instanceId: defaultInstanceId, - instance: effectiveInstance, - driver, - isDefault: true, - isDirty, - }); - for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { - if (id === defaultInstanceId) continue; - rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false }); - } - } - for (const [driver, list] of instancesByDriver) { - if (visibleDriverKinds.has(driver)) continue; - for (const [id, instance] of list) { - rows.push({ - instanceId: id, - instance, - driver: instance.driver, - isDefault: defaultSlotIdsBySource.has(String(id)), - }); - } - } - - const updateProviderInstance = ( - row: InstanceRow, - next: ProviderInstanceConfig, - options?: { - readonly textGenerationModelSelection?: Parameters< - typeof buildProviderInstanceUpdatePatch - >[0]["textGenerationModelSelection"]; - }, - ) => { - updateSettings( - buildProviderInstanceUpdatePatch({ - settings, - instanceId: row.instanceId, - instance: next, - driver: row.driver, - isDefault: row.isDefault, - textGenerationModelSelection: options?.textGenerationModelSelection, - }), - ); - }; - - const deleteProviderInstance = (id: ProviderInstanceId) => { - updateSettings({ - providerInstances: withoutProviderInstanceKey(settings.providerInstances, id), - providerModelPreferences: withoutProviderInstanceKey(settings.providerModelPreferences, id), - favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], id), - }); - }; - - const updateProviderModelPreferences = ( - instanceId: ProviderInstanceId, - next: { - readonly hiddenModels: ReadonlyArray; - readonly modelOrder: ReadonlyArray; - }, - ) => { - const hiddenModels = [...new Set(next.hiddenModels.filter((slug) => slug.trim().length > 0))]; - const modelOrder = [...new Set(next.modelOrder.filter((slug) => slug.trim().length > 0))]; - const rest = withoutProviderInstanceKey(settings.providerModelPreferences, instanceId); - updateSettings({ - providerModelPreferences: - hiddenModels.length === 0 && modelOrder.length === 0 - ? rest - : { - ...rest, - [instanceId]: { - hiddenModels, - modelOrder, - }, - }, - }); - }; - - const updateProviderFavoriteModels = ( - instanceId: ProviderInstanceId, - nextFavoriteModels: ReadonlyArray, - ) => { - const favoriteModels = [ - ...new Set( - Arr.filterMap(nextFavoriteModels, (slug) => { - const trimmedSlug = slug.trim(); - return trimmedSlug.length > 0 ? Result.succeed(trimmedSlug) : Result.failVoid; - }), - ), - ]; - updateSettings({ - favorites: [ - ...withoutProviderInstanceFavorites(settings.favorites ?? [], instanceId), - ...favoriteModels.map((model) => ({ provider: instanceId, model })), - ], - }); - }; - - const resetDefaultInstance = (driverKind: ProviderDriverKind) => { - type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; - const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< - string, - LegacyProviderSettings | undefined - >; - const defaultInstanceId = defaultInstanceIdForDriver(driverKind); - const defaultLegacyProvider = defaultLegacyProviders[driverKind]; - if (defaultLegacyProvider === undefined) return; - updateSettings({ - providers: { - ...settings.providers, - [driverKind]: defaultLegacyProvider, - } as typeof settings.providers, - providerInstances: withoutProviderInstanceKey(settings.providerInstances, defaultInstanceId), - providerModelPreferences: withoutProviderInstanceKey( - settings.providerModelPreferences, - defaultInstanceId, - ), - favorites: withoutProviderInstanceFavorites(settings.favorites ?? [], defaultInstanceId), - }); - }; - - return ( - - - - - setIsAddInstanceDialogOpen(true)} - aria-label="Add provider instance" - > - - - } - /> - Add provider instance - - - void refreshProviders()} - aria-label="Refresh provider status" - > - {isRefreshingProviders ? ( - - ) : ( - - )} - - } - /> - Refresh provider status - -
- } - > - - Health check interval - - This interval is configured here, then the shared Background activity policy decides - whether provider probes may run when the timer fires. Custom intervals appear as - Advanced in General settings. - - - } - description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes." - resetAction={ - providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? ( - - updateSettings( - backgroundActivityOverrideSettings( - settings.backgroundActivity, - resolvedBackgroundActivity, - { - providerHealthRefreshInterval: undefined, - }, - ), - ) - } - /> - ) : null - } - control={ -
- - updateSettings( - backgroundActivityOverrideSettings( - settings.backgroundActivity, - resolvedBackgroundActivity, - { - providerHealthRefreshInterval: Duration.seconds( - normalizeIntervalSeconds(value), - ), - }, - ), - ) - } - > - - - - - - - seconds -
- } - /> - - {rows.map((row) => { - const driverOption = getDriverOption(row.driver); - const liveProvider = serverProviders.find( - (candidate) => candidate.instanceId === row.instanceId, - ); - const updateCandidate = liveProvider - ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) - : undefined; - const isDriverUpdateRunning = - updateCandidate !== undefined && - (updatingProviderDrivers.has(updateCandidate.driver) || - serverProviders.some( - (provider) => - provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), - )); - const showInlineUpdateButton = - updateCandidate !== undefined && - hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); - const canRunInlineUpdate = - updateCandidate !== undefined && - canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && - !updatingProviderDrivers.has(updateCandidate.driver); - const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { - hiddenModels: [], - modelOrder: [], - }; - const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => - favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid, - ); - const resetLabel = driverOption?.label ?? String(row.driver); - const headerAction = - row.isDefault && row.isDirty ? ( - resetDefaultInstance(row.driver)} - /> - ) : null; - return ( - - setOpenInstanceDetails((existing) => ({ - ...existing, - [row.instanceId]: open, - })) - } - onUpdate={(next) => { - const wasEnabled = row.instance.enabled ?? true; - const isDisabling = next.enabled === false && wasEnabled; - const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; - if (shouldClearTextGen) { - updateProviderInstance(row, next, { - textGenerationModelSelection: - DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, - }); - } else { - updateProviderInstance(row, next); - } - }} - onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} - headerAction={headerAction} - hiddenModels={modelPreferences.hiddenModels} - favoriteModels={favoriteModels} - modelOrder={modelPreferences.modelOrder} - onHiddenModelsChange={(hiddenModels) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - hiddenModels, - }) - } - onFavoriteModelsChange={(favoriteModels) => - updateProviderFavoriteModels(row.instanceId, favoriteModels) - } - onModelOrderChange={(modelOrder) => - updateProviderModelPreferences(row.instanceId, { - ...modelPreferences, - modelOrder, - }) - } - onRunUpdate={ - showInlineUpdateButton && updateCandidate - ? () => { - if (!canRunInlineUpdate) { - return; - } - void runProviderUpdate(updateCandidate); - } - : undefined - } - isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} - /> - ); - })} - - - {isAddInstanceDialogOpen ? ( - - ) : null} - - ); -} - export function ArchivedThreadsPanel() { const projects = useProjects(); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 84fb95a47417..0bb1a1aa6784 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -1,4 +1,4 @@ -import { Undo2Icon } from "lucide-react"; +import { InfoIcon, Undo2Icon } from "lucide-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { createContext, @@ -83,6 +83,28 @@ function useSettingsSearchTarget(id: string | undefined) return targetRef; } +/** Info affordance explaining how a setting interacts with the shared background policy. */ +export function PolicyTooltip({ children }: { readonly children: string }) { + return ( + + + + + } + /> + + {children} + + + ); +} + /** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */ export function useRelativeTimeTick(intervalMs = 1_000) { const [nowMs, setNowMs] = useState(() => Date.now()); diff --git a/apps/web/src/routes/settings.providers.tsx b/apps/web/src/routes/settings.providers.tsx index a7a86c2b50b0..deab014722dc 100644 --- a/apps/web/src/routes/settings.providers.tsx +++ b/apps/web/src/routes/settings.providers.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ProviderSettingsPanel } from "../components/settings/SettingsPanels"; +import { ProviderSettingsPanel } from "../components/settings/ProviderSettingsPanel"; function SettingsProvidersRoute() { return ; diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 3271eefd1e18..1071d8209dfe 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -33,7 +33,7 @@ interface PrimaryServerState { } const EMPTY_AVAILABLE_EDITORS: ReadonlyArray = []; -const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; +export const EMPTY_SERVER_PROVIDERS: ReadonlyArray = []; const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = { config: null, latestEvent: null, diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts index 37fed3b188f2..a7d5a53d10d2 100644 --- a/apps/web/src/state/session.ts +++ b/apps/web/src/state/session.ts @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { Atom } from "effect/unstable/reactivity"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -26,3 +26,17 @@ export function readPreparedConnection(environmentId: EnvironmentId) { appAtomRegistry.get(environmentSession.preparedConnectionValueAtom(environmentId)), ); } + +/** + * This client's authenticated session on one environment, as reported by that + * environment's `/api/auth/session` endpoint. `data` stays populated across + * SWR revalidations; `isPending` is only meaningful before the first resolve. + */ +export function useEnvironmentSessionState(environmentId: EnvironmentId) { + const result = useAtomValue(environmentSession.sessionStateAtom(environmentId)); + return { + data: Option.getOrNull(AsyncResult.value(result)), + hasError: result._tag === "Failure", + isPending: result.waiting, + }; +} diff --git a/apps/web/src/test/reactElementTree.ts b/apps/web/src/test/reactElementTree.ts new file mode 100644 index 000000000000..33351c35eb17 --- /dev/null +++ b/apps/web/src/test/reactElementTree.ts @@ -0,0 +1,27 @@ +import { isValidElement, type ReactElement } from "react"; + +/** + * Depth-first search over a React element tree produced by calling a component + * as a plain function (see `reactHookHarness`). Descends through props so + * render-prop and slot-style children are reachable. Returns the first element + * the visitor accepts, or null. + */ +export function visitElements( + node: unknown, + visitor: (element: ReactElement>) => boolean, +): ReactElement> | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = visitElements(child, visitor); + if (found) return found; + } + return null; + } + if (!isValidElement>(node)) return null; + if (visitor(node)) return node; + for (const value of Object.values(node.props)) { + const found = visitElements(value, visitor); + if (found) return found; + } + return null; +} diff --git a/apps/web/src/test/reactHookHarness.ts b/apps/web/src/test/reactHookHarness.ts new file mode 100644 index 000000000000..1b4b26fb6988 --- /dev/null +++ b/apps/web/src/test/reactHookHarness.ts @@ -0,0 +1,89 @@ +import type { Dispatch, SetStateAction } from "react"; + +/** + * Minimal React hook shim for tests that call components as plain functions + * instead of mounting a renderer. Slots are keyed by call order, mirroring + * React's own rules-of-hooks contract, and `useMemoCache` emulates the React + * Compiler runtime so compiled components can execute unmodified. + * + * This module must stay free of runtime `react` imports: it is loaded from + * inside `vi.mock("react", ...)` factories, and a value import would recurse + * into the in-progress mock. Wire it up in each test file (mock calls cannot + * live here because vitest hoists them per test module): + * + * ```ts + * import { reactHookHarness } from "~/test/reactHookHarness"; + * + * vi.mock("react", async (importOriginal) => { + * const actual = await importOriginal(); + * const { reactHookHarness } = await import("~/test/reactHookHarness"); + * return { + * ...actual, + * useCallback: reactHookHarness.useCallback, + * useMemo: reactHookHarness.useMemo, + * useRef: reactHookHarness.useRef, + * useState: reactHookHarness.useState, + * }; + * }); + * vi.mock("react/compiler-runtime", async () => { + * const { reactHookHarness } = await import("~/test/reactHookHarness"); + * return { c: reactHookHarness.useMemoCache }; + * }); + * ``` + * + * Call `beginRender()` before each component invocation and `reset()` in + * `beforeEach` to drop persisted state between tests. + */ +export function createReactHookHarness() { + let cursor = 0; + let slots: unknown[] = []; + const nextIndex = () => cursor++; + + return { + beginRender() { + cursor = 0; + }, + reset() { + cursor = 0; + slots = []; + }, + useCallback(callback: T): T { + nextIndex(); + return callback; + }, + useMemo(factory: () => T): T { + nextIndex(); + return factory(); + }, + useMemoCache(size: number): unknown[] { + const index = nextIndex(); + if (!slots[index]) { + slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); + } + return slots[index] as unknown[]; + }, + useRef(initialValue: T): { current: T } { + const index = nextIndex(); + if (!slots[index]) { + slots[index] = { current: initialValue }; + } + return slots[index] as { current: T }; + }, + useState(initialValue: T | (() => T)): [T, Dispatch>] { + const index = nextIndex(); + if (index >= slots.length) { + slots[index] = + typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; + } + const setValue: Dispatch> = (nextValue) => { + const previous = slots[index] as T; + slots[index] = + typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue; + }; + return [slots[index] as T, setValue]; + }, + }; +} + +/** Shared instance so `vi.mock` factories and test bodies see the same slots. */ +export const reactHookHarness = createReactHookHarness(); diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 3cb62009a208..31fd297da3f0 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -1,14 +1,19 @@ -import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import type { HttpClient } from "effect/unstable/http"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { EnvironmentRegistry } from "../connection/registry.ts"; import type { PreparedConnection } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; +import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; +import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; import { followStreamInEnvironment } from "./runtime.ts"; export function initialConfigOption( @@ -25,8 +30,39 @@ export function initialConfigOption( ); } +// Bounded like the snapshot fetches: a wedged environment must not pin the +// permissions check (and with it the settings UI) in a loading state for long. +const DEFAULT_SESSION_STATE_TIMEOUT_MS = 6_000; + +/** + * Read the granted scopes of this client's session on one environment via its + * `/api/auth/session` endpoint, authenticated with whatever credential the + * connection was prepared with (cookie, bearer, or DPoP). + */ +export const fetchEnvironmentSessionState = Effect.fn( + "clientRuntime.state.fetchEnvironmentSessionState", +)(function* (input: { + readonly prepared: PreparedConnection; + readonly signer: Option.Option; + readonly timeoutMs?: number; +}) { + const requestUrl = environmentEndpointUrl(input.prepared.httpBaseUrl, "/api/auth/session"); + const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); + const headers = yield* buildEnvironmentAuthHeaders( + input.prepared.httpAuthorization, + "GET", + requestUrl, + input.signer, + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS, + withEnvironmentCredentials(input.prepared.httpAuthorization, client.auth.session({ headers })), + ); +}); + export function createEnvironmentSessionAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, ) { const initialConfigAtom = Atom.family((environmentId: EnvironmentId) => runtime.atom( @@ -86,10 +122,41 @@ export function createEnvironmentSessionAtoms( ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), ); + // Keyed on the prepared connection's identity: a reconnect (new credential, + // new base URL) swaps the prepared value, which re-runs the fetch, so scope + // changes from re-pairing are picked up without an explicit refresh. + const sessionStateAtom = Atom.family((environmentId: EnvironmentId) => + runtime + .atom((get) => { + const prepared = Option.getOrNull(get(preparedConnectionValueAtom(environmentId))); + if (prepared === null) { + return Effect.never; + } + return Effect.gen(function* () { + const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); + return yield* fetchEnvironmentSessionState({ prepared, signer }); + }); + }) + .pipe( + Atom.swr({ staleTime: 30_000, revalidateOnMount: true }), + Atom.setIdleTTL(5 * 60_000), + Atom.withLabel(`environment-session-state:${environmentId}`), + ), + ); + + const sessionStateValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get): AuthSessionState | null => + Option.getOrNull(AsyncResult.value(get(sessionStateAtom(environmentId)))) ?? null, + ).pipe(Atom.withLabel(`environment-session-state-value:${environmentId}`)), + ); + return { initialConfigAtom, initialConfigValueAtom, preparedConnectionAtom, preparedConnectionValueAtom, + sessionStateAtom, + sessionStateValueAtom, }; } From b98a0f0d2292d180db0ac7c6ae8ccdbc9f6478f7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 03:58:26 -0400 Subject: [PATCH 025/326] fix(mobile): invisible T3 Connect devices can now be seen and removed (#5563) Co-authored-by: Claude Fable 5 --- .../connection/CloudEnvironmentRows.tsx | 71 ++++++++++++------- .../SettingsEnvironmentsRouteScreen.tsx | 26 +++---- 2 files changed, 60 insertions(+), 37 deletions(-) diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 173d093d8495..b7cb28376815 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -21,6 +21,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useThemeColor } from "../../lib/useThemeColor"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; +import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController"; @@ -42,6 +43,11 @@ interface CloudEnvironmentRowsProps { * with connect switches, availability status, refresh, and loading/error * states. Shared between the Settings environments screen and the T3 Connect * onboarding sheet. + * + * Already-connected relay environments render even without cloud config or a + * signed-in account — they are registered on this device and must stay + * reachable and removable. Only discovery (the available list, refresh, and + * its errors) requires a signed-in session. */ export function CloudEnvironmentRows(props: CloudEnvironmentRowsProps) { // Showcase captures run without a Clerk publishable key, so `ClerkProvider` @@ -50,20 +56,33 @@ export function CloudEnvironmentRows(props: CloudEnvironmentRowsProps) { if (props.showcaseSignedIn !== undefined) { return props.showcaseSignedIn ? : null; } + // No cloud config means no `ClerkProvider` either, so `useAuth` would throw. + if (!hasCloudPublicConfig()) { + return ; + } return ; } function SignedInCloudEnvironmentRows(props: CloudEnvironmentRowsProps) { const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); - if (!isSignedIn) return null; + if (!isSignedIn) return ; return ; } -function CloudEnvironmentRowsContent(props: CloudEnvironmentRowsProps) { +function ConnectedOnlyCloudEnvironmentRows(props: CloudEnvironmentRowsProps) { + if (props.connectedCloudEnvironments.length === 0) return null; + return ; +} + +function CloudEnvironmentRowsContent( + props: CloudEnvironmentRowsProps & { readonly discoveryAvailable?: boolean }, +) { const controller = useConnectionController(); const iconColor = useThemeColor("--color-icon"); - const availableCloudEnvironments = - props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments; + const discoveryAvailable = props.discoveryAvailable ?? true; + const availableCloudEnvironments = discoveryAvailable + ? (props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments) + : []; const [expandedErrorId, setExpandedErrorId] = useState(null); const hasCloudRows = props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; @@ -89,25 +108,27 @@ function CloudEnvironmentRowsContent(props: CloudEnvironmentRowsProps) { {showHeader ? ( T3 Connect - { - void controller.refreshRelayEnvironments(); - }} - className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" - > - {controller.relayDiscovery.isRefreshing ? ( - - ) : ( - - )} - + {discoveryAvailable ? ( + { + void controller.refreshRelayEnvironments(); + }} + className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" + > + {controller.relayDiscovery.isRefreshing ? ( + + ) : ( + + )} + + ) : null} ) : null} @@ -152,7 +173,9 @@ function CloudEnvironmentRowsContent(props: CloudEnvironmentRowsProps) { {/* Rendered alongside any connected rows — a failed discovery must not hide behind an otherwise-healthy list. */} - {controller.relayDiscovery.error && !controller.relayDiscovery.isRefreshing ? ( + {discoveryAvailable && + controller.relayDiscovery.error && + !controller.relayDiscovery.isRefreshing ? ( Could not load T3 Connect environments diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 93b806f6487b..53bbe4806462 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -8,7 +8,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; -import { hasCloudPublicConfig } from "../cloud/publicConfig"; import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; import { splitEnvironmentSections } from "../connection/environmentSections"; @@ -161,18 +160,19 @@ export function SettingsEnvironmentsRouteScreen() { )} - {hasCloudPublicConfig() || SHOWCASE_ENABLED ? ( - - ) : null} + {/* Always mounted: already-connected relay environments must stay + visible (and removable) even when cloud config is missing or the + user is signed out — the component gates discovery itself. */} + ); From 220efad62b7ce7b9ee4befff75edb0753467df0a Mon Sep 17 00:00:00 2001 From: Mateleo Date: Fri, 7 Aug 2026 10:21:18 +0200 Subject: [PATCH 026/326] fix: add missing space before 'GitHub releases page' link on download page (#4511) --- apps/marketing/src/pages/download.astro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro index 111482208cfa..5557f5fb6b19 100644 --- a/apps/marketing/src/pages/download.astro +++ b/apps/marketing/src/pages/download.astro @@ -79,7 +79,7 @@ import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "../lib/site";
{rightPanelOpen && !shouldUseRightPanelSheet ? ( diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index eee6ba5886e6..c9ded5e7eee1 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -12,7 +12,7 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; export const RECENT_THREAD_LIMIT = 12; -export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; +export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; /** diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 60493063664c..605127f97378 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -35,6 +35,7 @@ import { FolderPlusIcon, LinkIcon, MessageSquareIcon, + PaletteIcon, SettingsIcon, SquarePenIcon, TextSearchIcon, @@ -57,6 +58,7 @@ import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useClientSettings } from "../hooks/useSettings"; +import { useTheme } from "../hooks/useTheme"; import { readLocalApi } from "../localApi"; import { desktopLocalBackendId } from "../connection/desktopLocal"; import { filesystemEnvironment } from "../state/filesystem"; @@ -121,6 +123,7 @@ import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons" import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; +import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; import { resolveDefaultProviderModelSelection } from "../providerInstances"; @@ -386,6 +389,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { theme, themeHalves, resolvedTheme } = useTheme(); const composerHandleRef = useRef(null); const routeTarget = useParams({ strict: false, @@ -428,6 +432,16 @@ export function CommandPalette({ children }: { children: ReactNode }) { previewOpen, }, }); + if (command === "themeEditor.toggle") { + event.preventDefault(); + event.stopPropagation(); + toggleThemeEditorForTheme({ + theme, + themeHalves, + initialAppearance: resolvedTheme, + }); + return; + } const mode = overlayModeForCommand(command); if (mode === null) { return; @@ -438,7 +452,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, previewOpen, terminalOpen, toggleMode]); + }, [keybindings, previewOpen, resolvedTheme, terminalOpen, theme, themeHalves, toggleMode]); useEffect( () => @@ -567,6 +581,7 @@ function OpenCommandPaletteDialog(props: { const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { theme, themeHalves, resolvedTheme } = useTheme(); const providers = useAtomValue(primaryServerProvidersAtom); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; @@ -1463,6 +1478,22 @@ function OpenCommandPaletteDialog(props: { }); } + actionItems.push({ + kind: "action", + value: "action:theme-editor", + searchTerms: ["theme", "appearance", "colors", "palette", "customize"], + title: "Toggle theme editor", + icon: , + shortcutCommand: "themeEditor.toggle", + run: async () => { + toggleThemeEditorForTheme({ + theme, + themeHalves, + initialAppearance: resolvedTheme, + }); + }, + }); + actionItems.push({ kind: "action", value: "action:settings", diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 67b82388bbcb..0489e8c79cdf 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1765,7 +1765,7 @@ function ComposerPromptEditorInner({ } placeholder={ terminalContexts.length > 0 ? null : ( -
+
{placeholder}
) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index a10cdafd7835..76191e6d4d76 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -36,6 +36,7 @@ import { getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, + DIFF_SURFACE_THEME_UNSAFE_CSS, } from "../lib/diffRendering"; import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; @@ -86,54 +87,7 @@ interface CollapsedDiffFilesState { const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet = new Set(); -const DIFF_PANEL_UNSAFE_CSS = ` -[data-diffs-header], -[data-diff], -[data-file], -[data-error-wrapper], -[data-virtualizer-buffer] { - --diffs-header-font-family: var(--font-sans) !important; - --diffs-font-family: var(--font-mono) !important; - --diffs-bg: var(--background) !important; - --diffs-light-bg: var(--background) !important; - --diffs-dark-bg: var(--background) !important; - --diffs-token-light-bg: transparent; - --diffs-token-dark-bg: transparent; - - --diffs-bg-context-override: color-mix(in srgb, var(--background) 97%, var(--foreground)); - --diffs-bg-hover-override: color-mix(in srgb, var(--background) 94%, var(--foreground)); - --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground)); - --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground)); - - --diffs-bg-addition-override: light-dark( - color-mix(in srgb, var(--background) 50%, var(--success)), - color-mix(in srgb, var(--background) 70%, var(--success)) - ); - --diffs-bg-addition-number-override: light-dark( - color-mix(in srgb, var(--background) 35%, var(--success)), - color-mix(in srgb, var(--background) 60%, var(--success)) - ); - --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success)); - --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success)); - - --diffs-bg-deletion-override: light-dark( - color-mix(in srgb, var(--background) 50%, var(--destructive)), - color-mix(in srgb, var(--background) 70%, var(--destructive)) - ); - --diffs-bg-deletion-number-override: light-dark( - color-mix(in srgb, var(--background) 35%, var(--destructive)), - color-mix(in srgb, var(--background) 60%, var(--destructive)) - ); - --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive)); - --diffs-bg-deletion-emphasis-override: color-mix( - in srgb, - var(--background) 80%, - var(--destructive) - ); - - background-color: var(--diffs-bg) !important; -} - +const DIFF_PANEL_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} :is( [data-line], [data-line-annotation], @@ -144,13 +98,13 @@ const DIFF_PANEL_UNSAFE_CSS = ` --diffs-line-bg: light-dark( color-mix( in lab, - var(--background) 88%, - color-mix(in srgb, var(--background) 50%, var(--diffs-modified-base)) + var(--code-background) 88%, + color-mix(in srgb, var(--code-background) 50%, var(--diffs-modified-base)) ), color-mix( in lab, - var(--background) 80%, - color-mix(in srgb, var(--background) 70%, var(--diffs-modified-base)) + var(--code-background) 80%, + color-mix(in srgb, var(--code-background) 70%, var(--diffs-modified-base)) ) ) !important; } @@ -159,13 +113,13 @@ const DIFF_PANEL_UNSAFE_CSS = ` --diffs-line-bg: light-dark( color-mix( in lab, - var(--background) 91%, - color-mix(in srgb, var(--background) 35%, var(--diffs-modified-base)) + var(--code-background) 91%, + color-mix(in srgb, var(--code-background) 35%, var(--diffs-modified-base)) ), color-mix( in lab, - var(--background) 85%, - color-mix(in srgb, var(--background) 60%, var(--diffs-modified-base)) + var(--code-background) 85%, + color-mix(in srgb, var(--code-background) 60%, var(--diffs-modified-base)) ) ) !important; } @@ -192,16 +146,16 @@ const DIFF_PANEL_UNSAFE_CSS = ` } [data-file-info] { - background-color: var(--background) !important; + background-color: var(--code-background) !important; border-block-color: transparent !important; - color: var(--foreground) !important; + color: var(--code-foreground) !important; } [data-diffs-header] { position: sticky !important; top: 0; z-index: 4; - background-color: var(--background) !important; + background-color: var(--code-background) !important; border-bottom-color: transparent !important; align-items: center !important; font-family: var(--font-sans) !important; @@ -213,13 +167,13 @@ const DIFF_PANEL_UNSAFE_CSS = ` } [data-diffs-header]:hover { - background-color: color-mix(in srgb, var(--background) 97%, var(--foreground)) !important; + background-color: color-mix(in srgb, var(--code-background) 97%, var(--code-foreground)) !important; } :is([data-separator="line-info"], [data-separator="line-info-basic"]) { height: 24px !important; margin-block: 0 !important; - background-color: var(--background) !important; + background-color: var(--code-background) !important; } :is([data-separator="line-info"], [data-separator="line-info-basic"]) @@ -233,7 +187,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` gap: 8px; padding-inline: 0 !important; background-color: transparent !important; - color: color-mix(in srgb, var(--foreground) 52%, var(--background)) !important; + color: color-mix(in srgb, var(--code-foreground) 52%, var(--code-background)) !important; font-family: var(--font-sans) !important; font-size: 11px !important; text-decoration: none !important; @@ -257,7 +211,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` height: 1px; flex: 1 1 auto; content: ""; - background-color: color-mix(in srgb, var(--background) 92%, var(--foreground)); + background-color: color-mix(in srgb, var(--code-background) 92%, var(--code-foreground)); } :is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index] @@ -286,7 +240,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` [data-expand-button] ):hover [data-separator-content] { - color: color-mix(in srgb, var(--foreground) 76%, var(--background)) !important; + color: color-mix(in srgb, var(--code-foreground) 76%, var(--code-background)) !important; } :is([data-separator="line-info"], [data-separator="line-info-basic"]):has( @@ -297,7 +251,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` [data-expand-button] ):hover [data-unmodified-lines]::after { - background-color: color-mix(in srgb, var(--background) 84%, var(--foreground)); + background-color: color-mix(in srgb, var(--code-background) 84%, var(--code-foreground)); } [data-diffs-header] [data-header-content] { @@ -337,7 +291,7 @@ const DIFF_PANEL_UNSAFE_CSS = ` } [data-title]:hover { - color: color-mix(in srgb, var(--foreground) 84%, var(--primary)) !important; + color: color-mix(in srgb, var(--code-foreground) 84%, var(--primary)) !important; text-decoration-color: currentColor; } `; @@ -796,11 +750,11 @@ export default function DiffPanel({
{selectedScopeLabel} - + -

{selectedPatchError}

+

{selectedPatchError}

)} {!renderablePatch ? ( diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 1df19a640756..66216e10cb58 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -47,7 +47,7 @@ function ProjectFaviconFallback({ readonly className?: string | undefined; readonly icon: ComponentType<{ className?: string }>; }) { - return ; + return ; } function ProjectFaviconImage({ diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 3a1d71150988..7f21177e7b14 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -343,6 +343,9 @@ export default function ProjectScriptsControl({ variant="outline" className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]" aria-label={`Run ${primaryScript.name}`} + // The tooltip wrapper replaces data-slot="button", so themed + // toolbar styling needs its own hook. + data-toolbar-control="" onClick={() => onRunScript(primaryScript)} /> } @@ -447,6 +450,9 @@ export default function ProjectScriptsControl({ variant="outline" className="w-7 px-0 sm:w-6 @3xl/header-actions:w-auto! @3xl/header-actions:px-[calc(--spacing(2)-1px)]" aria-label="Add action" + // The tooltip wrapper replaces data-slot="button", so themed + // toolbar styling needs its own hook. + data-toolbar-control="" onClick={openAddDialog} /> } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cffab8bd5778..232ea0998ef7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -225,7 +225,7 @@ const PROJECT_GROUPING_MODE_LABELS: Record = separate: "Keep separate", }; const SIDEBAR_ICON_ACTION_BUTTON_CLASS = - "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; + "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { useEnvironmentThread(threadRef.environmentId, threadRef.threadId); @@ -857,9 +857,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr ) : ( {formatRelativeTimeLabel( @@ -2245,7 +2243,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }`} /> - + {projectStatus.label} @@ -2262,7 +2260,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec {project.displayName} {project.groupedProjectCount > 1 ? ( - + {project.groupedProjectCount} projects ) : null} @@ -2281,7 +2279,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ? "Local sandbox project" : "Remote project" } - className="pointer-events-none absolute top-1 right-1.5 inline-flex size-5 items-center justify-center rounded-md text-muted-foreground/60 transition-opacity duration-150 max-sm:right-7 group-hover/project-header:opacity-0 group-focus-within/project-header:opacity-0 max-sm:group-hover/project-header:opacity-100 max-sm:group-focus-within/project-header:opacity-100" + className="pointer-events-none absolute top-1 right-1.5 inline-flex size-5 items-center justify-center rounded-md text-icon-muted transition-opacity duration-150 max-sm:right-7 group-hover/project-header:opacity-0 group-focus-within/project-header:opacity-0 max-sm:group-hover/project-header:opacity-100 max-sm:group-focus-within/project-header:opacity-100" /> } > @@ -2600,7 +2598,7 @@ function ProjectSortMenu({ + } > @@ -2824,7 +2822,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( + // Lifted above the stage backdrop, whose fade bleeds below the + // header and would otherwise paint across the search row's outline. + } @@ -2977,9 +2977,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( )} {projectsLength === 0 && ( -
- No projects yet -
+
No projects yet
)}
diff --git a/apps/web/src/components/SidebarStageBackdrop.test.tsx b/apps/web/src/components/SidebarStageBackdrop.test.tsx index 114fd5f9241e..c34eec58316d 100644 --- a/apps/web/src/components/SidebarStageBackdrop.test.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.test.tsx @@ -5,7 +5,6 @@ import { resolveEnvironmentIdentificationPillLabel, resolveSidebarStageBackdropVariant, StageBackdropArt, - StageBackdropButtonArt, } from "./SidebarStageBackdrop"; describe("SidebarStageBackdrop", () => { @@ -29,7 +28,7 @@ describe("SidebarStageBackdrop", () => { const markup = renderToStaticMarkup( <> - + , ); const ids = Array.from(markup.matchAll(/\sid="([^"]+)"/g), (match) => match[1]); diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index 9fb448e940de..ee669e94bd47 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -62,10 +62,6 @@ export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVar return variant === "nightly" ? : ; } -export function StageBackdropButtonArt({ variant }: { variant: SidebarStageBackdropVariant }) { - return variant === "nightly" ? : ; -} - const NIGHTLY_STARS: ReadonlyArray<{ cx: number; cy: number; @@ -97,7 +93,7 @@ const NIGHTLY_SPARKLES: ReadonlyArray<{ x: number; y: number }> = [ { x: 246, y: 26 }, ]; -function NightlySkyArt({ compact = false }: { compact?: boolean }) { +function NightlySkyArt() { const idPrefix = useId().replaceAll(":", ""); const skyId = `${idPrefix}-stage-night-sky`; const glowId = `${idPrefix}-stage-night-glow`; @@ -111,7 +107,7 @@ function NightlySkyArt({ compact = false }: { compact?: boolean }) { className="h-full w-full" fill="none" preserveAspectRatio="xMinYMin slice" - viewBox={compact ? "96 0 8192 96" : STAGE_BACKDROP_VIEW_BOX} + viewBox={STAGE_BACKDROP_VIEW_BOX} xmlns="http://www.w3.org/2000/svg" > @@ -195,7 +191,7 @@ function NightlySkyArt({ compact = false }: { compact?: boolean }) { ); } -function DevBlueprintArt({ compact = false }: { compact?: boolean }) { +function DevBlueprintArt() { const idPrefix = useId().replaceAll(":", ""); const paperId = `${idPrefix}-stage-bp-paper`; const glowId = `${idPrefix}-stage-bp-glow`; @@ -212,7 +208,7 @@ function DevBlueprintArt({ compact = false }: { compact?: boolean }) { className="stage-blueprint h-full w-full" fill="none" preserveAspectRatio="xMinYMin slice" - viewBox={compact ? "64 0 8192 96" : STAGE_BACKDROP_VIEW_BOX} + viewBox={STAGE_BACKDROP_VIEW_BOX} xmlns="http://www.w3.org/2000/svg" > diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index f84c54a33389..0b1df2f57c3f 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -768,7 +768,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { isUnread || isWoke ? "text-foreground" : shouldRecede - ? "text-muted-foreground/80" + ? "text-secondary-label" : status === "failed" ? "text-foreground/95" : "text-foreground/90", @@ -779,7 +779,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ? "text-foreground" : isUnread ? "text-muted-foreground" - : "text-muted-foreground/70", + : "text-secondary-label/70", ), isRegeneratingTitle && "opacity-[0.55]", )} @@ -799,8 +799,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { "shrink-0 text-xs tabular-nums hover:underline", variant === "slim" && variantAction === "unsettle" ? props.isActive - ? "text-muted-foreground/70" - : cn("text-muted-foreground/35 transition-colors", settledPrHoverClass) + ? "text-secondary-label" + : cn("text-secondary-label transition-colors", settledPrHoverClass) : prStatus.colorClass, )} aria-label={prStatus.tooltip} @@ -871,7 +871,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { @@ -983,7 +983,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { {props.projectTitle ? ( @@ -1012,7 +1012,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { isWokeStatus ? "pointer-events-auto" : "pointer-events-none group-has-[:focus-visible]/v2-status-slot:absolute group-has-[:focus-visible]/v2-status-slot:right-0 group-has-[:focus-visible]/v2-status-slot:opacity-0 group-hover/v2-row:absolute group-hover/v2-row:right-0 group-hover/v2-row:opacity-0", - "self-center justify-self-end tabular-nums text-muted-foreground/65 transition-opacity", + "self-center justify-self-end tabular-nums text-secondary-label transition-opacity", snoozeMenuOpen && "pointer-events-none absolute right-0 opacity-0", )} > @@ -1101,14 +1101,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null}
-
+
{/* While working, the current plan step outranks the branch: it's the one line that says what the thread is doing. */} {status === "working" && thread.planProgress ? ( {thread.planProgress.step} {/* Completed count, matching the transcript chip's n/m. */} - + {" "} {thread.planProgress.completedSteps}/{thread.planProgress.totalSteps} @@ -2834,7 +2834,9 @@ export default function SidebarV2() { + // Lifted above the stage backdrop, whose fade bleeds below the + // header and would otherwise paint across the search row's outline. +
@@ -2969,7 +2971,7 @@ export default function SidebarV2() { type="button" aria-label={`Project actions for ${project.displayName}`} title={`Project actions for ${project.displayName}`} - className="ml-auto inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground/55 outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring" + className="ml-auto inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-icon-muted outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring" onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { void handleProjectActions(event, project); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 072241426e25..25b4abb3fbe0 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -135,6 +135,10 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri return value ?? fallback; } +function readThemeColor(styles: CSSStyleDeclaration, variable: string, fallback: string): string { + return normalizeComputedColor(styles.getPropertyValue(variable), fallback); +} + /** The surface treats an omitted family or size as "use the built-in default". */ function terminalFontOptions(family: string, size: number): { family?: string; size: number } { const trimmed = family.trim(); @@ -151,6 +155,7 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty document.body; const drawerStyles = getComputedStyle(drawerSurface); const bodyStyles = getComputedStyle(document.body); + const themeStyles = getComputedStyle(document.documentElement); const background = normalizeComputedColor( drawerStyles.backgroundColor, normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), @@ -159,20 +164,32 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty drawerStyles.color, normalizeComputedColor(bodyStyles.color, fallbackForeground), ); - + const terminalBackground = readThemeColor(themeStyles, "--terminal-background", background); + const terminalForeground = readThemeColor(themeStyles, "--terminal-foreground", foreground); + const terminalCursor = readThemeColor( + themeStyles, + "--terminal-cursor", + isDark ? "rgb(180, 203, 255)" : "rgb(38, 56, 78)", + ); + const terminalSelection = readThemeColor( + themeStyles, + "--terminal-selection-background", + isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)", + ); return { background: parseTerminalColor( - background, + terminalBackground, isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 }, ), foreground: parseTerminalColor( - foreground, + terminalForeground, isDark ? { r: 237, g: 241, b: 247 } : { r: 28, g: 33, b: 41 }, ), - cursor: isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 }, - // Matches the xterm selection overlays this renderer replaced; the text - // color underneath is left unchanged for contrast in both themes. - selectionBackground: isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)", + cursor: parseTerminalColor( + terminalCursor, + isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 }, + ), + selectionBackground: terminalSelection, }; } diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 3a705eef36d4..0955bd3abcb4 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -62,6 +62,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { } >
) : null} {props.isPreparingWorktree ? ( - Preparing worktree... + Preparing worktree... ) : null} event.preventDefault()} @@ -2814,7 +2812,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none", (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim()) ? "text-foreground" - : "text-muted-foreground/35", + : "text-placeholder", )} onPointerDown={(event) => event.preventDefault()} onClick={expandMobileComposer} @@ -2828,7 +2826,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : ( -
+
{image.name}
)} @@ -3116,7 +3114,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) variant="ghost" disabled data-chat-provider-unavailable="true" - className="shrink-0 gap-2 px-2 text-muted-foreground/70 sm:px-3" + className="shrink-0 gap-2 px-2 text-secondary-label sm:px-3" > No provider available diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 0adeed6ffa61..b11e2136770d 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -114,7 +114,7 @@ export const ChatHeader = memo(function ChatHeader({ New thread in {activeProjectName} - + / diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 73fc63489056..3ed2a9432e48 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -150,7 +150,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { {groupIndex > 0 ? : null} {group.label ? ( - + {group.label} ) : null} @@ -172,10 +172,10 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
{props.triggerKind === "skill" ? ( - + Skills -

+

{props.isLoading ? "Searching workspace skills..." : (props.emptyStateText ?? @@ -183,7 +183,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {

) : ( -

+

{props.isLoading ? "Searching workspace files..." : (props.emptyStateText ?? @@ -235,26 +235,26 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { /> ) : null} {props.item.type === "slash-command" ? ( - + ) : null} {props.item.type === "provider-slash-command" ? ( - + ) : null} {props.item.type === "skill" ? ( - + ) : null} {props.item.label} - + {props.item.description} {skillSourceLabel ? ( - {skillSourceLabel} + {skillSourceLabel} ) : null} ); diff --git a/apps/web/src/components/chat/ComposerControl.tsx b/apps/web/src/components/chat/ComposerControl.tsx index 8eab75171c82..a7ba40581457 100644 --- a/apps/web/src/components/chat/ComposerControl.tsx +++ b/apps/web/src/components/chat/ComposerControl.tsx @@ -6,7 +6,7 @@ import { Button } from "../ui/button"; import { SelectTrigger } from "../ui/select"; const composerControlClassName = - "h-7 min-h-7 gap-1.5 px-2.5 text-muted-foreground/70 transition-none hover:text-foreground/80 [&_svg[data-composer-control-icon]]:mx-0 [&_svg[data-composer-control-chevron]]:-mx-0.5"; + "h-7 min-h-7 gap-1.5 px-2.5 text-secondary-label transition-none hover:text-foreground [&_svg[data-composer-control-icon]]:mx-0 [&_svg[data-composer-control-chevron]]:-mx-0.5"; export function ComposerControl({ className, @@ -46,7 +46,7 @@ export function ComposerControlChevron() { return (

- + {activeQuestion.header} {prompt.questions.length > 1 ? ( - + {questionIndex + 1}/{prompt.questions.length} ) : null}

{activeQuestion.question}

{activeQuestion.multiSelect ? ( -

Select one or more options.

+

Select one or more options.

) : null}
{activeQuestion.options.map((option, index) => { @@ -190,7 +190,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
{option.label} {option.description && option.description !== option.label ? ( - {option.description} + {option.description} ) : null}
{isSelected ? ( @@ -199,7 +199,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( {shortcutKey} diff --git a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx index 602ad114464a..5e9e43dcf21e 100644 --- a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx +++ b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx @@ -63,13 +63,13 @@ export function ComposerPreviewAnnotationCards({ /> ) : ( - + )}
{annotation.comment.trim() ? ( -

+

{annotation.comment.trim()}

) : null} @@ -84,13 +84,13 @@ export function ComposerPreviewAnnotationCards({ {elementLabels.slice(0, 2).map(({ id, label }) => ( {label} ))} {elementLabels.length > 2 ? ( - + +{elementLabels.length - 2} ) : null} @@ -131,7 +131,7 @@ export function ComposerPreviewAnnotationCards({ ) : ( -
+
{image.name}
)} @@ -1270,7 +1268,7 @@ function WorkingTimelineRow({ row }: { row: Extract -
+
@@ -1349,9 +1347,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ return (
{!onlyToolEntries && ( -

- {groupLabel} -

+

{groupLabel}

)}
{nonEmptyEntries.map((workEntry) => ( @@ -1391,7 +1387,7 @@ function WorkGroupToggleTimelineRow({ ctx.onToggleWorkGroup(row.groupId, anchorElement); }} > - + {row.expanded ? ( - + Show fewer {row.onlyToolEntries ? "tool calls" : "log entries"} ) : ( - + +{row.hiddenCount} previous {labelNoun} )} @@ -1509,7 +1505,7 @@ const UserMessageElementContextChip = memo(function UserMessageElementContextChi + {props.context.header} @@ -1549,13 +1545,13 @@ function UserMessagePreviewAnnotationCard(props: { ) : null}
{props.annotation.comment ? ( -
+
{props.annotation.comment}
) : null}
@@ -1644,7 +1640,7 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop aria-expanded={expanded} data-scroll-anchor-ignore onClick={() => setExpanded((value) => !value)} - className="-ml-1 h-6 rounded-md px-1.5 text-xs text-muted-foreground/72 hover:bg-muted/55 hover:text-foreground/85" + className="-ml-1 h-6 rounded-md px-1.5 text-secondary-label text-xs hover:bg-muted/55 hover:text-message-foreground" > {expanded ? "Show less" : "Show full message"} @@ -1683,7 +1679,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { cwd={props.markdownCwd} threadRef={ctx.threadRef ?? undefined} skills={props.skills} - className="text-foreground" + className="text-message-foreground" lineBreaks /> ) : null} @@ -1695,7 +1691,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { const reviewCommentSegments = parseReviewCommentMessageSegments(props.text); if (reviewCommentSegments.some((segment) => segment.kind === "review-comment")) { return ( -
+
{reviewCommentSegments.map((segment) => segment.kind === "text" ? ( segment.text.trim().length > 0 ? ( @@ -1705,7 +1701,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { cwd={props.markdownCwd} threadRef={ctx.threadRef ?? undefined} skills={props.skills} - className="text-foreground" + className="text-message-foreground" lineBreaks />
@@ -1764,7 +1760,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); @@ -1793,7 +1789,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { cwd={props.markdownCwd} threadRef={ctx.threadRef ?? undefined} skills={props.skills} - className="text-foreground" + className="text-message-foreground" lineBreaks />, ); @@ -1802,7 +1798,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); @@ -1818,7 +1814,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { cwd={props.markdownCwd} threadRef={ctx.threadRef ?? undefined} skills={props.skills} - className="text-foreground" + className="text-message-foreground" lineBreaks /> ); @@ -1835,10 +1831,10 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte return (
-
+
{formatWorkspaceRelativePath(comment.filePath, ctx.workspaceRoot)}
-
+
{comment.sectionTitle} · {comment.rangeLabel}
@@ -1853,7 +1849,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte cwd={ctx.markdownCwd} threadRef={ctx.threadRef ?? undefined} skills={ctx.skills} - className="text-foreground" + className="text-message-foreground" /> )} {renderablePatch?.kind === "files" && @@ -1978,24 +1974,24 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { if (tone === "error") { return { iconName: "circle-alert", - className: "text-foreground/92", + className: "text-foreground", }; } if (tone === "thinking") { return { iconName: "bot", - className: "text-foreground/92", + className: "text-foreground", }; } if (tone === "info") { return { iconName: "check", - className: "text-muted-foreground", + className: "text-icon-muted", }; } return { iconName: "zap", - className: "text-foreground/92", + className: "text-foreground", }; } @@ -2244,14 +2240,14 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { : showDestructiveRowStyle ? "text-destructive" : workEntry.tone === "tool" || showFailedIndicator - ? "text-muted-foreground/65" + ? "text-icon-muted" : iconConfig.className, ); const headingClass = showWarningIndicator ? "font-medium text-warning" : showDestructiveRowStyle ? "font-medium text-destructive" - : "font-medium text-foreground/82"; + : "font-medium text-foreground"; const turnSettled = !activity.activeTurnInProgress; const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); const showSuccessIndicator = @@ -2293,11 +2289,11 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {

{heading} {preview && ( - {preview} + {preview} )}

-
+
-
+          
             {expandedBody}
           
diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index a74a4ebf8c26..70475ffd0380 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -69,7 +69,7 @@ export const ModelListRow = memo(function ModelListRow(props: {
{props.showNewBadge ? ( New diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 24ec66cd6142..82ee33615b06 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -29,7 +29,7 @@ const SELECTED_INDICATOR_CLASS = "pointer-events-none absolute -right-1 top-1/2 z-10 h-5 w-0.75 -translate-y-1/2 rounded-l-full bg-primary"; const BADGE_BASE_CLASS = "pointer-events-none absolute -right-0.5 top-0.5 z-10 flex size-3.5 items-center justify-center rounded-full bg-transparent shadow-sm "; -const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-amber-600 dark:text-amber-300 `; +const NEW_BADGE_CLASS = `${BADGE_BASE_CLASS} text-update `; /** Opens toward the rail so the list stays readable (not over the model names). */ const PICKER_TOOLTIP_SIDE = "left" as const; diff --git a/apps/web/src/components/chat/PierreEntryIcon.tsx b/apps/web/src/components/chat/PierreEntryIcon.tsx index 17dfa8362af6..df41adb7dd53 100644 --- a/apps/web/src/components/chat/PierreEntryIcon.tsx +++ b/apps/web/src/components/chat/PierreEntryIcon.tsx @@ -73,9 +73,9 @@ export const PierreEntryIcon = memo(function PierreEntryIcon(props: { if (!icon) { return props.kind === "directory" ? ( - + ) : ( - + ); } diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index a736cf96cd3e..090acdb9c02c 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -23,7 +23,7 @@ import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; -import { resolveDiffThemeName } from "~/lib/diffRendering"; +import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; import { resolvePathLinkTarget } from "~/terminal-links"; @@ -84,6 +84,16 @@ const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown"; const FILE_SAVE_DEBOUNCE_MS = 500; const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal"; const FILE_LINK_REVEAL_UNSAFE_CSS = ` + ${DIFF_SURFACE_THEME_UNSAFE_CSS} + + diffs-container { + --diffs-bg: var(--code-background, var(--background)) !important; + --diffs-light-bg: var(--code-background, var(--background)) !important; + --diffs-dark-bg: var(--code-background, var(--background)) !important; + background-color: var(--code-background, var(--background)) !important; + color: var(--code-foreground, var(--foreground)) !important; + } + [${FILE_LINK_REVEAL_ATTRIBUTE}][data-line] { background-color: light-dark( color-mix( @@ -959,7 +969,7 @@ export default function FilePreviewPanel({
) : null} {relativePath && file.data?.truncated ? ( -
+
Preview limited to the first 1 MB of a {file.data.byteLength.toLocaleString()} byte file.
) : null} diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 75235a053076..22a91b7c1504 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -122,6 +122,7 @@ describe("KeybindingsSettings.logic", () => { it("formats static and project script command labels", () => { expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle"); + expect(commandLabel("themeEditor.toggle")).toBe("Theme Editor: Toggle"); expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db"); }); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 2a691943df4e..17b1ebdf33d0 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -619,7 +619,7 @@ export function ProviderInstanceCard({ "size-5 rounded-sm p-0", versionAdvisory.emphasis === "strong" ? "text-warning hover:text-warning" - : "text-primary hover:text-primary", + : "text-update hover:text-update", )} aria-label="Update available — view details" > diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 9541a0f07e00..05ea2c9f04e3 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -85,6 +85,26 @@ function loadDiffPreviewHtml(theme: DiffThemeName): Promise { return promise; } +// Pierre's prerendered stylesheet bakes its own light/dark surface colors +// into the shadow root's @layer rules. These unlayered rules win the cascade +// without !important and re-point the surfaces at the app's code tokens +// (custom properties inherit across the shadow boundary), so the preview +// follows the active theme exactly like the real diff panel does. +const DIFF_PREVIEW_THEME_BRIDGE = ` + :host { + color: var(--code-foreground); + background-color: var(--code-background); + --diffs-fg: var(--code-foreground); + --diffs-bg: var(--code-background); + --diffs-light-bg: var(--code-background); + --diffs-dark-bg: var(--code-background); + } + [data-diffs-header] { + background-color: var(--code-background); + color: var(--code-foreground); + } +`; + function StaticDiffHtml({ html }: { html: string }) { const hostRef = useRef(null); useEffect(() => { @@ -92,6 +112,9 @@ function StaticDiffHtml({ html }: { html: string }) { if (host === null) return; const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" }); shadow.innerHTML = html; + const bridge = document.createElement("style"); + bridge.textContent = DIFF_PREVIEW_THEME_BRIDGE; + shadow.append(bridge); }, [html]); return
; } @@ -158,7 +181,7 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu const mountRef = useRef(null); const surfaceRef = useRef(null); const fontRef = useRef({ family, size }); - const { resolvedTheme } = useTheme(); + const { theme, resolvedTheme } = useTheme(); useEffect(() => { const current = fontRef.current; @@ -167,12 +190,14 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu void surfaceRef.current?.setFont(previewTerminalFont(family, size)); }, [family, size]); + // Re-read the terminal tokens on any theme change — switching between two + // palettes can leave resolvedTheme (light/dark) untouched. useEffect(() => { const mount = mountRef.current; const surface = surfaceRef.current; if (!mount || !surface) return; surface.setTheme(terminalThemeFromApp(mount)); - }, [resolvedTheme]); + }, [theme, resolvedTheme]); useEffect(() => { const mount = mountRef.current; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 00d9573a1acd..5b9347f03075 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -52,7 +52,13 @@ import { } from "../SidebarStageBackdrop"; import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; -import { useTheme } from "../../hooks/useTheme"; +import { useCustomThemes } from "../../hooks/useCustomThemes"; +import { + readAppearanceModePreference, + readThemeHalves, + readThemePreference, + useTheme, +} from "../../hooks/useTheme"; import { useLocalStorage } from "../../hooks/useLocalStorage"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; @@ -106,6 +112,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { ThemeLibrary } from "./ThemeSettings"; import { backgroundActivityOverrideSettings, backgroundActivitySharedPolicySettings, @@ -131,21 +138,6 @@ import { import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; -const THEME_OPTIONS = [ - { - value: "system", - label: "System", - }, - { - value: "light", - label: "Light", - }, - { - value: "dark", - label: "Dark", - }, -] as const; - const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", pill: "Version pill", @@ -432,7 +424,15 @@ function AboutVersionSection() { } export function useSettingsRestore(onRestored?: () => void) { - const { theme, setTheme } = useTheme(); + const { + theme, + setTheme, + followSystem, + setFollowSystem, + setThemeHalf, + clearThemeHalves, + themeHalves, + } = useTheme(); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); @@ -445,6 +445,8 @@ export function useSettingsRestore(onRestored?: () => void) { const changedSettingLabels = useMemo( () => [ ...(theme !== "system" ? ["Theme"] : []), + ...(!followSystem ? ["Follow system"] : []), + ...(themeHalves !== null ? ["Theme mix"] : []), ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []), ...(settings.environmentIdentificationMode !== DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode @@ -525,6 +527,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarThreadPreviewCount, settings.timestampFormat, settings.wordWrap, + followSystem, theme, ], ); @@ -539,7 +542,57 @@ export function useSettingsRestore(onRestored?: () => void) { ); if (!confirmed) return; - setTheme("system"); + // Only touch the theme keys that are actually dirty, so a theme-storage + // failure cannot block restoring unrelated settings. Preferences are + // re-read after the confirmation dialog: they may have changed (another + // tab, an OS flip) while it was open, and rollback must restore the live + // values rather than the ones captured at render time. + let previousTheme = theme; + try { + previousTheme = readThemePreference(); + } catch { + // Storage is unreadable; the render-time value is the best rollback. + } + // The mix may have changed while the confirmation dialog was open; both + // the dirty check and the rollback must see the live value. + const liveHalves = readThemeHalves(); + const needsThemeReset = previousTheme !== "system"; + const needsMixReset = liveHalves !== null; + // Same for the appearance mode: trusting the render-time value would skip + // the reset and report success while a non-system mode stayed in storage. + const needsFollowSystemReset = readAppearanceModePreference(previousTheme) !== "system"; + const notifyThemeRestoreFailure = () => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Couldn’t restore theme settings", + description: "Try again.", + }), + ); + }; + // Rollback restores the base preference first (which clears any mix) and + // then re-applies the captured mix on top, so no failure path can leave + // the pair of keys half-restored. + const previousHalves = liveHalves; + const rollbackThemeState = () => { + if (needsThemeReset) setTheme(previousTheme); + if (previousHalves?.light) setThemeHalf("light", previousHalves.light); + if (previousHalves?.dark) setThemeHalf("dark", previousHalves.dark); + }; + if (needsThemeReset && !setTheme("system")) { + notifyThemeRestoreFailure(); + return; + } + if (needsMixReset && !clearThemeHalves()) { + rollbackThemeState(); + notifyThemeRestoreFailure(); + return; + } + if (needsFollowSystemReset && !setFollowSystem(true)) { + rollbackThemeState(); + notifyThemeRestoreFailure(); + return; + } updateSettings({ timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, @@ -566,7 +619,17 @@ export function useSettingsRestore(onRestored?: () => void) { fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal, }); onRestored?.(); - }, [changedSettingLabels, onRestored, setTheme, updateSettings]); + }, [ + changedSettingLabels, + clearThemeHalves, + onRestored, + setFollowSystem, + setTheme, + setThemeHalf, + theme, + themeHalves, + updateSettings, + ]); return { changedSettingLabels, @@ -841,7 +904,18 @@ function BackgroundActivityAdvancedDialog({ } export function AppearanceSettingsPanel() { - const { theme, setTheme } = useTheme(); + const { + appearanceMode, + refreshTheme, + resolvedTheme, + setAppearanceMode, + setTheme, + setThemeHalf, + theme, + themeHalves, + } = useTheme(); + const customThemes = useCustomThemes(); + const [isImportThemeOpen, setIsImportThemeOpen] = useState(false); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const environmentStageLabel = useEnvironmentStageLabel(); @@ -857,38 +931,21 @@ export function AppearanceSettingsPanel() { return ( - setTheme("system")} /> - ) : null - } - control={ - - } - /> +
+ +
> = { + canvas: "Background", + toolbar: "Toolbar background", + toolbarForeground: "Toolbar text", + toolbarBorder: "Toolbar border", + toolbarControl: "Toolbar control", + toolbarControlForeground: "Toolbar control text", + toolbarControlHover: "Toolbar control hover", + accent: "Accent color", + errorForeground: "Error text", + errorSurface: "Error background", + warningForeground: "Warning text", + warningSurface: "Warning background", + updateForeground: "Update text", + updateSurface: "Update background", + }; + const label = labels[role]; + if (label) return label; + return role.replace(/([A-Z])/g, " $1").replace(/^./, (character) => character.toUpperCase()); +} + +type ThemeColorHsv = { + h: number; + s: number; + v: number; +}; + +function clampThemeColor(value: number, min = 0, max = 1) { + return Math.min(max, Math.max(min, value)); +} + +/** + * The picker's plane and sliders operate on opaque six-digit hex, but theme + * colors may carry alpha. The suffix is preserved separately and re-attached + * on commit so adjusting hue or brightness cannot change transparency. + */ +function themePickerAlphaSuffix(value: string): string { + const trimmed = value.trim().toLowerCase(); + const alpha = /^#[0-9a-f]{4}$/.test(trimmed) + ? trimmed.slice(4).repeat(2) + : /^#[0-9a-f]{8}$/.test(trimmed) + ? trimmed.slice(7) + : ""; + return alpha === "ff" ? "" : alpha; +} + +function normalizeThemePickerColor(value: string): string { + const trimmed = value.trim(); + if (/^#[0-9a-f]{3}$/i.test(trimmed)) { + return `#${trimmed + .slice(1) + .split("") + .map((character) => `${character}${character}`) + .join("")}`; + } + if (/^#[0-9a-f]{4}$/i.test(trimmed)) { + return `#${trimmed + .slice(1, 4) + .split("") + .map((character) => `${character}${character}`) + .join("")}`; + } + if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed; + if (/^#[0-9a-f]{8}$/i.test(trimmed)) return trimmed.slice(0, 7); + return "#000000"; +} + +function themeHexToHsv(hex: string): ThemeColorHsv { + const normalized = normalizeThemePickerColor(hex); + const numeric = Number.parseInt(normalized.slice(1), 16); + const red = ((numeric >> 16) & 255) / 255; + const green = ((numeric >> 8) & 255) / 255; + const blue = (numeric & 255) / 255; + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const delta = max - min; + + let hue = 0; + if (delta !== 0) { + if (max === red) { + hue = ((green - blue) / delta) % 6; + } else if (max === green) { + hue = (blue - red) / delta + 2; + } else { + hue = (red - green) / delta + 4; + } + hue *= 60; + if (hue < 0) hue += 360; + } + + return { + h: hue, + s: max === 0 ? 0 : delta / max, + v: max, + }; +} + +function themeHsvToHex(hue: number, saturation: number, value: number) { + const normalizedHue = ((hue % 360) + 360) % 360; + const chroma = value * saturation; + const x = chroma * (1 - Math.abs(((normalizedHue / 60) % 2) - 1)); + const match = value - chroma; + const [red, green, blue] = + normalizedHue < 60 + ? [chroma, x, 0] + : normalizedHue < 120 + ? [x, chroma, 0] + : normalizedHue < 180 + ? [0, chroma, x] + : normalizedHue < 240 + ? [0, x, chroma] + : normalizedHue < 300 + ? [x, 0, chroma] + : [chroma, 0, x]; + + return `#${[red, green, blue] + .map((channel) => + Math.round((channel + match) * 255) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`; +} + +function themeHexToRgb(hex: string) { + const numeric = Number.parseInt(normalizeThemePickerColor(hex).slice(1), 16); + return [numeric >> 16, (numeric >> 8) & 255, numeric & 255] as const; +} + +function themeRgbToHex(value: string): string | null { + const normalized = value + .trim() + .replace(/^rgb\(\s*/i, "") + .replace(/\s*\)$/, ""); + const channels = normalized + .split(/[,\s]+/) + .filter(Boolean) + .map(Number); + if ( + channels.length !== 3 || + channels.some((channel) => !Number.isInteger(channel) || channel < 0 || channel > 255) + ) { + return null; + } + + return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; +} + +function themeRgbValue(hex: string) { + return themeHexToRgb(hex).join(", "); +} + +function ThemeColorPickerPanel({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (value: string) => void; +}) { + const normalizedValue = normalizeThemePickerColor(value); + const alphaSuffix = themePickerAlphaSuffix(value); + const [hsv, setHsv] = useState(() => themeHexToHsv(normalizedValue)); + const [hexDraft, setHexDraft] = useState(normalizedValue); + const [rgbDraft, setRgbDraft] = useState(() => themeRgbValue(normalizedValue)); + const [isDragging, setIsDragging] = useState(false); + const isEditingTextRef = useRef(false); + const currentColor = themeHsvToHex(hsv.h, hsv.s, hsv.v); + const currentRgb = themeRgbValue(currentColor); + + useEffect(() => { + // While a text field is focused, the incoming value may be the guided + // editor's readability-adjusted echo of what is being typed; rewriting the + // draft would fight the keystrokes. The swatch still tracks via hsv. + if (!isEditingTextRef.current) { + setHexDraft(normalizedValue); + setRgbDraft(themeRgbValue(normalizedValue)); + } + // Keep the current hue/saturation when the incoming value is just our own + // change echoed back; hex → HSV is lossy for greys, white, and black. + setHsv((current) => + themeHsvToHex(current.h, current.s, current.v) === normalizedValue + ? current + : themeHexToHsv(normalizedValue), + ); + }, [normalizedValue]); + + // Local state updates immediately for a smooth thumb; the parent commit + // (which can regenerate a whole guided palette) is batched to one call per + // animation frame. + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + const pendingCommitRef = useRef(null); + const commitFrameRef = useRef(null); + // The final drag frame must not be lost when the popover closes or the + // pointer lifts before the animation frame fires. + const flushPendingCommit = useCallback(() => { + if (commitFrameRef.current !== null) { + cancelAnimationFrame(commitFrameRef.current); + commitFrameRef.current = null; + } + const pending = pendingCommitRef.current; + pendingCommitRef.current = null; + if (pending !== null) onChangeRef.current(pending); + }, []); + useEffect(() => () => flushPendingCommit(), [flushPendingCommit]); + const scheduleCommit = useCallback((color: string) => { + pendingCommitRef.current = color; + commitFrameRef.current ??= requestAnimationFrame(() => { + commitFrameRef.current = null; + const pending = pendingCommitRef.current; + pendingCommitRef.current = null; + if (pending !== null) onChangeRef.current(pending); + }); + }, []); + + const commitHsv = useCallback( + (nextHsv: ThemeColorHsv) => { + setHsv(nextHsv); + const nextColor = themeHsvToHex(nextHsv.h, nextHsv.s, nextHsv.v); + setHexDraft(nextColor); + setRgbDraft(themeRgbValue(nextColor)); + scheduleCommit(nextColor + alphaSuffix); + }, + [alphaSuffix, scheduleCommit], + ); + + const updateFromPlane = useCallback( + (event: PointerEvent) => { + const bounds = event.currentTarget.getBoundingClientRect(); + const saturation = clampThemeColor((event.clientX - bounds.left) / bounds.width); + const value = 1 - clampThemeColor((event.clientY - bounds.top) / bounds.height); + commitHsv({ ...hsv, s: saturation, v: value }); + }, + [commitHsv, hsv], + ); + + const updateFromHue = useCallback( + (event: PointerEvent) => { + const bounds = event.currentTarget.getBoundingClientRect(); + const hue = clampThemeColor((event.clientX - bounds.left) / bounds.width) * 360; + commitHsv({ ...hsv, h: hue }); + }, + [commitHsv, hsv], + ); + + const handleHueKeyDown = (event: KeyboardEvent) => { + const step = event.shiftKey ? 10 : 1; + const direction = event.key === "ArrowRight" || event.key === "ArrowUp" ? 1 : -1; + if (!["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(event.key)) return; + event.preventDefault(); + commitHsv({ ...hsv, h: (hsv.h + direction * step + 360) % 360 }); + }; + + const handlePlaneKeyDown = (event: KeyboardEvent) => { + if (!["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp"].includes(event.key)) return; + event.preventDefault(); + const step = event.shiftKey ? 0.1 : 0.02; + const nextHsv = { ...hsv }; + if (event.key === "ArrowLeft") nextHsv.s = clampThemeColor(hsv.s - step); + if (event.key === "ArrowRight") nextHsv.s = clampThemeColor(hsv.s + step); + if (event.key === "ArrowUp") nextHsv.v = clampThemeColor(hsv.v + step); + if (event.key === "ArrowDown") nextHsv.v = clampThemeColor(hsv.v - step); + commitHsv(nextHsv); + }; + + const handlePointerDown = (handler: (event: PointerEvent) => void) => { + return (event: PointerEvent) => { + event.currentTarget.setPointerCapture(event.pointerId); + setIsDragging(true); + handler(event); + }; + }; + + const stopDragging = () => { + setIsDragging(false); + flushPendingCommit(); + }; + + // Thumbs travel inside the control by half their own size so they never + // clip at the extremes; movement only animates for keyboard steps and + // click-to-jump, never while dragging. + const thumbTransition = isDragging + ? undefined + : "left 80ms linear, top 80ms linear, background-color 80ms linear"; + + const handleHexChange = (nextValue: string) => { + setHexDraft(nextValue); + if (!/^#[0-9a-f]{6}$/i.test(nextValue)) return; + const nextHsv = themeHexToHsv(nextValue); + setHsv(nextHsv); + setRgbDraft(themeRgbValue(nextValue)); + onChange(nextValue.toLowerCase()); + }; + + const handleRgbChange = (nextValue: string) => { + setRgbDraft(nextValue); + const nextColor = themeRgbToHex(nextValue); + if (!nextColor) return; + setHsv(themeHexToHsv(nextColor)); + setHexDraft(nextColor); + // RGB cannot express alpha, so a commit keeps the incoming suffix just + // like the plane and hue controls do. + onChange(nextColor + alphaSuffix); + }; + + return ( +
+
+
+

{label}

+

Choose a color

+
+ +
+
+
{ + if (event.currentTarget.hasPointerCapture(event.pointerId)) updateFromPlane(event); + }} + onPointerUp={stopDragging} + > + +
+
{ + if (event.currentTarget.hasPointerCapture(event.pointerId)) updateFromHue(event); + }} + onPointerUp={stopDragging} + > + + +
+
+ + +
+
+
+ ); +} + +function ThemeColorPicker({ + label, + value, + onChange, + onInteract, +}: { + label: string; + value: string; + onChange: (value: string) => void; + onInteract?: () => void; +}) { + return ( + + + + + } + /> + + + + + ); +} + +export const ThemeColorField = memo(function ThemeColorField({ + role, + value, + onChange, + onSelect, + onToggleSelected, + selected = false, + label: customLabel, +}: { + role: ThemeColorRole; + value: string; + onChange: (role: ThemeColorRole, value: string) => void; + onSelect?: (role: ThemeColorRole) => void; + onToggleSelected?: (role: ThemeColorRole) => void; + selected?: boolean; + label?: string; +}) { + const label = customLabel ?? getThemeRoleLabel(role); + const isColorValue = isThemeColor(value); + const swatchValue = isColorValue ? value : "#000000"; + + return ( +
+ +
+ onChange(role, nextValue)} + onInteract={() => onSelect?.(role)} + value={swatchValue} + /> + onChange(role, event.currentTarget.value)} + onFocus={() => onSelect?.(role)} + onPointerDown={() => onSelect?.(role)} + size="sm" + unstyled + value={value} + /> +
+
+ ); +}); diff --git a/apps/web/src/components/settings/ThemeEditorHost.tsx b/apps/web/src/components/settings/ThemeEditorHost.tsx new file mode 100644 index 000000000000..faf2d770e90d --- /dev/null +++ b/apps/web/src/components/settings/ThemeEditorHost.tsx @@ -0,0 +1,114 @@ +import { useCallback } from "react"; + +import { useTheme } from "../../hooks/useTheme"; +import { getThemeDefinition, type ThemeAppearance, type ThemeDefinition } from "../../themePalette"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { ThemeEditorPanel } from "./ThemeEditorPanel"; +import { useThemeEditorStore } from "./themeEditorStore"; + +/** + * Renders the theme editor above the router. The editor paints its draft on + * the live app, so it has to outlive the settings route: the point is to walk + * through threads, panels, and pages while the colors are being tuned. + */ +export function ThemeEditorHost() { + const session = useThemeEditorStore((store) => store.session); + const closeThemeEditor = useThemeEditorStore((store) => store.closeThemeEditor); + const { theme, setTheme, themeHalves, refreshTheme } = useTheme(); + + // The panel reports which path it actually took: a theme removed while its + // editor is open resolves to null there, so the save becomes a create even + // though the session still names it. + const handleSaved = useCallback( + ( + savedTheme: ThemeDefinition, + { created, mergedAppearance }: { created: boolean; mergedAppearance?: ThemeAppearance }, + ) => { + // A merge completed an existing theme's light/dark pair; activating the + // whole theme shows the new palette right away. + if (mergedAppearance) { + if (!setTheme(savedTheme.id)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not save your theme", + description: "Browser storage is unavailable, so the change was not kept.", + }), + ); + return false; + } + toastManager.add( + stackedThreadToast({ + type: "success", + title: `${savedTheme.label} updated`, + description: `Its ${mergedAppearance} palette was added.`, + }), + ); + return true; + } + if (!created) { + // The edited theme may be showing through the base preference or either + // half of the mix; the preference itself is untouched (a setTheme here + // would clear the mix), the palette just needs re-applying. + const wasActive = + getThemeDefinition(theme)?.id === savedTheme.id || + themeHalves?.light === savedTheme.id || + themeHalves?.dark === savedTheme.id; + if (wasActive) refreshTheme(); + toastManager.add( + stackedThreadToast({ + type: "success", + title: `${savedTheme.label} saved`, + description: wasActive ? "Your changes are now active." : "Your changes are saved.", + }), + ); + return true; + } + + if (!setTheme(savedTheme.id)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not save your theme", + description: "Browser storage is unavailable, so the change was not kept.", + }), + ); + return false; + } + toastManager.add( + stackedThreadToast({ + type: "success", + title: `${savedTheme.label} created`, + description: "It’s now active.", + }), + ); + return true; + }, + [refreshTheme, setTheme, theme, themeHalves], + ); + + if (!session) return null; + + // Resolve on every render: an edit or import can change the stored + // definitions while a session is open. + const editingTheme = session.editingThemeId + ? (getThemeDefinition(session.editingThemeId) ?? null) + : null; + const seedTheme = session.seedThemeId ? (getThemeDefinition(session.seedThemeId) ?? null) : null; + + return ( + { + if (!open) closeThemeEditor(); + }} + onSaved={handleSaved} + open + restoreTheme={refreshTheme} + seedName={session.seedName ?? undefined} + seedTheme={seedTheme} + /> + ); +} diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx new file mode 100644 index 000000000000..0074ac89304e --- /dev/null +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -0,0 +1,1143 @@ +import { ChevronDownIcon, ChevronUpIcon, MousePointer2Icon, PlusIcon, XIcon } from "lucide-react"; +import { + useCallback, + useEffect, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { + applyThemeColorPreview, + THEME_COLOR_ROLES, + THEME_FILE_VERSION, + createVividThemeColors, + getCustomThemes, + getStandardThemeColors, + getThemeColorsForMode, + getThemeModes, + installCustomTheme, + isThemeColor, + parseThemeFile, + removeCustomTheme, + themeIdFromName, + updateCustomTheme, + type ThemeAppearance, + type ThemeColorRole, + type ThemeDefinition, +} from "../../themePalette"; +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Switch } from "../ui/switch"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { getThemeRoleLabel, ThemeColorField } from "./ThemeColorPicker"; +import { + clearThemeInspectorHover, + clearThemeInspectorHighlights, + highlightThemeRoleUsage, + inspectThemeRoleAtElement, + inspectThemeRoleFromUtilitiesAtElement, + refreshThemeInspectorSpotlight, + showThemeInspectorHover, + type ThemeElementInspection, +} from "./themeInspector"; + +const THEME_EDITOR_PRIMARY_ROLES: ReadonlyArray = [ + "canvas", + "chrome", + "sidebar", + "surface", + "text", + "textMuted", + "placeholder", + "secondaryLabel", + "iconMuted", + "accent", + "messageSurface", + "messageAction", +]; + +const THEME_EDITOR_SIMPLE_ROLES: ReadonlyArray = ["canvas", "accent"]; + +const THEME_EDITOR_STATUS_ROLES: ReadonlyArray = [ + "error", + "errorForeground", + "errorSurface", + "warning", + "warningForeground", + "warningSurface", + "update", + "updateForeground", + "updateSurface", +]; + +const THEME_EDITOR_ADVANCED_ROLES = THEME_COLOR_ROLES.filter( + (role) => !THEME_EDITOR_PRIMARY_ROLES.includes(role) && !THEME_EDITOR_STATUS_ROLES.includes(role), +); + +const THEME_EDITOR_ROLE_GROUPS: ReadonlyArray<{ + id: string; + title: string; + roles: ReadonlyArray; +}> = [ + { + id: "main", + title: "Main colors", + roles: THEME_EDITOR_PRIMARY_ROLES, + }, + { + id: "status", + title: "Status colors", + roles: THEME_EDITOR_STATUS_ROLES, + }, + { + id: "additional", + title: "Other colors", + roles: THEME_EDITOR_ADVANCED_ROLES, + }, +]; + +type ThemeEditorColors = Record; +type ThemeEditorColorsByAppearance = Record; + +// A draft with no source theme starts as the standard T3 Code look — the +// palette on screen when no theme is installed — so creating from the default +// theme changes nothing until the user edits a color. +function getThemeEditorDefaults(appearance: ThemeAppearance): ThemeEditorColors { + return { ...getStandardThemeColors(appearance) }; +} + +function getThemeEditorColorsByAppearance(): ThemeEditorColorsByAppearance { + return { + light: getThemeEditorDefaults("light"), + dark: getThemeEditorDefaults("dark"), + }; +} + +function isThemeEditorColor(value: string): boolean { + return isThemeColor(value.trim()); +} + +function getManagedEditorColors( + appearance: ThemeAppearance, + colors: ThemeEditorColors, +): ThemeEditorColors { + const defaults = getStandardThemeColors(appearance); + // The editor keeps the user's exact picks and derives the rest through the + // perceptual vivid engine, so a two-color theme carries its own identity. + return createVividThemeColors( + appearance, + isThemeEditorColor(colors.canvas) ? colors.canvas : defaults.canvas, + isThemeEditorColor(colors.accent) ? colors.accent : defaults.accent, + ); +} + +export function ThemeEditorPanel({ + open, + onOpenChange, + onSaved, + editingTheme, + initialAppearance, + seedTheme, + seedName, + restoreTheme, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSaved: ( + theme: ThemeDefinition, + context: { + created: boolean; + /** Set when a create merged its palette into an existing theme. */ + mergedAppearance?: ThemeAppearance; + }, + ) => boolean; + editingTheme: ThemeDefinition | null; + initialAppearance: ThemeAppearance; + /** The theme a new theme starts from, so tuning what you already use is a + * matter of editing rather than rebuilding. Null starts from the defaults. */ + seedTheme?: ThemeDefinition | null; + /** Prefilled name for an explicit duplicate; a plain create stays unnamed. */ + seedName?: string | undefined; + /** Reapplies the stored theme once the draft stops being previewed. */ + restoreTheme: () => void; +}) { + const isEditing = editingTheme !== null; + const [name, setName] = useState(""); + const [activeAppearance, setActiveAppearance] = useState(initialAppearance); + const [isAdvanced, setIsAdvanced] = useState(false); + const [colorsByAppearance, setColorsByAppearance] = useState(() => + getThemeEditorColorsByAppearance(), + ); + const [simpleColorsDirtyByAppearance, setSimpleColorsDirtyByAppearance] = useState< + Record + >({ light: false, dark: false }); + const [error, setError] = useState(null); + const [isMinimized, setIsMinimized] = useState(false); + const [roleQuery, setRoleQuery] = useState(""); + const [isInspecting, setIsInspecting] = useState(false); + const [selectedRole, setSelectedRole] = useState(null); + const [usageCount, setUsageCount] = useState(null); + // Null parks the panel at its default corner; a value is a dragged spot, + // kept clamped so the header can always be grabbed again. + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + // Null keeps the responsive default size; a value is a corner-grip resize. + const [size, setSize] = useState<{ width: number; height: number } | null>(null); + const panelRef = useRef(null); + const dragOffsetRef = useRef<{ dx: number; dy: number } | null>(null); + const resizeStartRef = useRef<{ + pointerX: number; + pointerY: number; + // Where the panel's top-left sits: the grip only moves the opposite + // corner, so the room to grow is measured from here. + left: number; + top: number; + width: number; + height: number; + } | null>(null); + useEffect(() => { + if (!open) return; + // A panel sized wider than the window can no longer be clamped back into + // view by position alone -- its right edge (close, minimize, the grip) + // stays off screen. So the size shrinks to fit first, then the position + // is re-clamped against the new size. + const clamp = () => { + const margin = 8; + let clampedWidth: number | undefined; + let clampedHeight: number | undefined; + setSize((current) => { + if (!current) return current; + clampedWidth = Math.max(280, Math.min(current.width, window.innerWidth - margin * 2)); + clampedHeight = Math.max(220, Math.min(current.height, window.innerHeight - margin * 2)); + return { width: clampedWidth, height: clampedHeight }; + }); + setPosition((current) => { + if (!current) return current; + const clamped = clampPosition(current.x, current.y, clampedWidth); + // Dragging may park the panel with only its header showing, but a + // window resize should pull the whole thing back into view when it + // fits -- otherwise the grip ends up below the fold. Minimized, the + // stored height is not applied (the panel hugs its header), so the + // rendered height is what has to fit. + const height = isMinimized + ? (panelRef.current?.offsetHeight ?? 0) + : (clampedHeight ?? panelRef.current?.offsetHeight ?? 0); + const maxY = Math.max(margin, window.innerHeight - height - margin); + return { x: clamped.x, y: Math.min(clamped.y, maxY) }; + }); + }; + window.addEventListener("resize", clamp); + return () => window.removeEventListener("resize", clamp); + // oxlint-disable-next-line exhaustive-deps -- clampPosition reads live layout only. + }, [isMinimized, open]); + + // The draft only reaches the live app once this open has been seeded; + // previewing in the seeding commit would paint the previous session's + // colors for a frame. + const [isDraftSeeded, setIsDraftSeeded] = useState(false); + const previousOpenRef = useRef(false); + + useEffect(() => { + if (open && !previousOpenRef.current) { + // Editing works on the theme itself; creating starts from the theme + // that is currently in use, so tuning what you already run is an edit + // away instead of a rebuild from the defaults. + const sourceTheme = editingTheme ?? seedTheme ?? null; + const nextColors = getThemeEditorColorsByAppearance(); + const nextAppearance = sourceTheme + ? getThemeColorsForMode(sourceTheme, initialAppearance) + ? initialAppearance + : sourceTheme.appearance + : initialAppearance; + if (sourceTheme) { + nextColors[sourceTheme.appearance] = { ...sourceTheme.colors }; + for (const appearance of ["light", "dark"] as const) { + const variantColors = sourceTheme.variants?.[appearance]; + if (variantColors) nextColors[appearance] = { ...variantColors }; + } + } + + setName(editingTheme?.label ?? seedName ?? ""); + setActiveAppearance(nextAppearance); + // Themes saved by the guided editor carry the managed flag; anything + // else (imports, hand-edited files, older saves) opens in advanced mode + // so guided regeneration cannot silently discard hand-tuned colors. A + // seeded new theme follows the same rule: its palette is only safe to + // regenerate when the guided editor produced it. + setIsAdvanced(sourceTheme !== null && sourceTheme.managed !== true); + setSimpleColorsDirtyByAppearance({ light: false, dark: false }); + setColorsByAppearance(nextColors); + setSelectedRole(null); + setUsageCount(null); + setIsInspecting(false); + setError(null); + setIsDraftSeeded(true); + } + if (!open && isDraftSeeded) setIsDraftSeeded(false); + previousOpenRef.current = open; + }, [editingTheme, initialAppearance, isDraftSeeded, open, seedName, seedTheme]); + + // A name an installed theme already uses combines instead of failing: + // creating adds the new palette to that theme, and renaming an existing + // theme onto it folds the edited palette in and retires the old entry — + // light "My Theme" plus a dark "My Theme" become one theme with both modes. + // Labels are matched as well as derived ids: a rename keeps a theme's + // original id, so its label is the only name a user can see and retype. + const nameTargetId = themeIdFromName(name); + const normalizedName = name.trim().toLowerCase(); + const mergeTarget = + normalizedName === "" + ? null + : (getCustomThemes().find( + (theme) => + theme.id !== editingTheme?.id && + (theme.id === nameTargetId || theme.label.trim().toLowerCase() === normalizedName), + ) ?? null); + const takenAppearances = mergeTarget ? getThemeModes(mergeTarget) : []; + const editableAppearances = editingTheme ? getThemeModes(editingTheme) : null; + + // The appearance a mode button would produce can be blocked two ways: the + // merge target already has that palette, or the theme being edited never + // had it (adding one is a create-with-same-name away). + const appearanceLockReason = (appearance: ThemeAppearance): string | null => { + if (editableAppearances && !editableAppearances.includes(appearance)) { + return `“${editingTheme?.label}” has no ${appearance} palette. Create a theme with the same name to add one.`; + } + if (!isEditing && takenAppearances.includes(appearance)) { + return `“${mergeTarget?.label}” already has a ${appearance} palette.`; + } + return null; + }; + + // Typing a name whose theme already owns the selected appearance flips the + // draft to the free side, so the merge affordance works without a manual + // toggle. Both sides taken leaves the selection alone; save is blocked with + // an explanation instead. + const mergeTargetId = mergeTarget?.id ?? null; + const takenAppearancesKey = takenAppearances.join(","); + useEffect(() => { + if (isEditing || mergeTargetId === null) return; + const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[]; + if (taken.length !== 1) return; + setActiveAppearance((current) => { + if (!taken.includes(current)) return current; + return taken[0] === "light" ? "dark" : "light"; + }); + }, [isEditing, mergeTargetId, takenAppearancesKey]); + + // The whole app wears the draft while the editor is open, so a role change + // is judged on the real interface rather than a miniature. The stored theme + // comes back when the editor closes, including on cancel. + useEffect(() => { + if (!open || !isDraftSeeded) return; + applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance); + }, [activeAppearance, colorsByAppearance, isDraftSeeded, open]); + + useEffect(() => { + if (!open) return; + return () => { + restoreTheme(); + }; + }, [open, restoreTheme]); + + const updateColor = useCallback( + (role: ThemeColorRole, value: string) => { + setColorsByAppearance((current) => { + const nextColors = { ...current[activeAppearance], [role]: value }; + const shouldManageColors = + !isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value); + + return { + ...current, + [activeAppearance]: shouldManageColors + ? getManagedEditorColors(activeAppearance, nextColors) + : nextColors, + }; + }); + if (!isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value)) { + setSimpleColorsDirtyByAppearance((current) => ({ + ...current, + [activeAppearance]: true, + })); + } + }, + [activeAppearance, isAdvanced], + ); + + const selectThemeRole = useCallback((role: ThemeColorRole, reveal = false) => { + setSelectedRole(role); + if (!THEME_EDITOR_SIMPLE_ROLES.includes(role)) { + setIsAdvanced(true); + setRoleQuery(""); + } + if (!reveal) return; + + requestAnimationFrame(() => { + panelRef.current + ?.querySelector(`[data-theme-color-role="${role}"]`) + ?.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }); + }, []); + + const toggleThemeRole = useCallback((role: ThemeColorRole) => { + setSelectedRole((current) => (current === role ? null : role)); + }, []); + + const clearInspectorSelection = useCallback(() => { + setSelectedRole(null); + setUsageCount(null); + setIsInspecting(false); + }, []); + + const selectedHighlightRoles = selectedRole + ? !isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole) + ? THEME_COLOR_ROLES.filter( + (role) => + colorsByAppearance[activeAppearance][role].trim().toLowerCase() === + colorsByAppearance[activeAppearance][selectedRole].trim().toLowerCase(), + ) + : [selectedRole] + : []; + const selectedHighlightRolesKey = selectedHighlightRoles.join(","); + + useEffect(() => { + clearThemeInspectorHighlights(); + if (!open || selectedRole === null) { + setUsageCount(null); + return; + } + // Picking a new element needs the unobscured app, so suspend the existing + // spotlight while the picker is armed. + if (isInspecting) return; + + const highlightedRoles = selectedHighlightRolesKey.split(",") as Array; + const refreshHighlights = () => setUsageCount(highlightThemeRoleUsage(highlightedRoles)); + refreshHighlights(); + // A refresh snapshots computed styles for the whole tree twice, so it is + // throttled rather than run per frame: a streaming reply or a virtualized + // list mutates the DOM continuously and would otherwise stall the main + // thread for as long as the inspector is open. + const MIN_REFRESH_INTERVAL_MS = 500; + let refreshFrame: number | null = null; + let refreshTimer: ReturnType | null = null; + let lastRefreshAt = performance.now(); + const scheduleRefresh = () => { + if (refreshFrame !== null || refreshTimer !== null) return; + const wait = Math.max(0, MIN_REFRESH_INTERVAL_MS - (performance.now() - lastRefreshAt)); + const run = () => { + refreshFrame = null; + refreshTimer = null; + lastRefreshAt = performance.now(); + refreshHighlights(); + }; + if (wait === 0) refreshFrame = requestAnimationFrame(run); + else refreshTimer = setTimeout(run, wait); + }; + const observer = new MutationObserver((mutations) => { + if ( + mutations.every( + (mutation) => + mutation.target instanceof Element && + (mutation.target.closest("#theme-inspector-spotlight") || + mutation.target.closest("[data-theme-editor-panel]")), + ) + ) { + return; + } + scheduleRefresh(); + }); + observer.observe(document.body, { childList: true, subtree: true }); + let spotlightFrame: number | null = null; + const scheduleSpotlightRefresh = () => { + spotlightFrame ??= requestAnimationFrame(() => { + spotlightFrame = null; + refreshThemeInspectorSpotlight(); + }); + }; + window.addEventListener("resize", scheduleSpotlightRefresh); + window.addEventListener("scroll", scheduleSpotlightRefresh, true); + return () => { + observer.disconnect(); + if (refreshFrame !== null) cancelAnimationFrame(refreshFrame); + if (refreshTimer !== null) clearTimeout(refreshTimer); + if (spotlightFrame !== null) cancelAnimationFrame(spotlightFrame); + window.removeEventListener("resize", scheduleSpotlightRefresh); + window.removeEventListener("scroll", scheduleSpotlightRefresh, true); + clearThemeInspectorHighlights(); + }; + }, [isInspecting, open, selectedHighlightRolesKey, selectedRole]); + + useEffect(() => { + if (!open || !isInspecting) { + clearThemeInspectorHover(); + return; + } + + let shouldDisarmAfterClick = false; + let hoverTarget: Element | null = null; + let hoverInspection: ThemeElementInspection | null = null; + let hoverTimer: number | null = null; + let hoverFrame: number | null = null; + const clearHoverTimer = () => { + if (hoverTimer === null) return; + window.clearTimeout(hoverTimer); + hoverTimer = null; + }; + const clearHover = () => { + clearHoverTimer(); + hoverTarget = null; + hoverInspection = null; + clearThemeInspectorHover(); + }; + const showInspection = (inspection: ThemeElementInspection) => { + hoverInspection = inspection; + showThemeInspectorHover(inspection, getThemeRoleLabel(inspection.role)); + }; + const handlePointerOver = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) { + clearHover(); + return; + } + + clearHoverTimer(); + hoverTarget = target; + hoverInspection = null; + const utilityInspection = inspectThemeRoleFromUtilitiesAtElement(target); + if (utilityInspection) { + showInspection(utilityInspection); + return; + } + + clearThemeInspectorHover(); + hoverTimer = window.setTimeout(() => { + hoverTimer = null; + if (hoverTarget !== target || !target.isConnected) return; + const inspection = inspectThemeRoleAtElement(target); + if (inspection) showInspection(inspection); + }, 140); + }; + const handlePointerOut = (event: PointerEvent) => { + if (event.relatedTarget === null) clearHover(); + }; + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) return; + event.preventDefault(); + event.stopPropagation(); + clearHoverTimer(); + const inspection = + hoverTarget === target && hoverInspection + ? hoverInspection + : inspectThemeRoleAtElement(target); + if (!inspection) return; + clearHover(); + selectThemeRole(inspection.role, true); + shouldDisarmAfterClick = true; + }; + const blockInspectedClick = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element) || target.closest("[data-theme-editor-panel]")) return; + event.preventDefault(); + event.stopPropagation(); + if (shouldDisarmAfterClick) setIsInspecting(false); + shouldDisarmAfterClick = false; + }; + const cancelInspection = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + clearHover(); + clearInspectorSelection(); + }; + const refreshHover = () => { + if (!hoverInspection) return; + hoverFrame ??= requestAnimationFrame(() => { + hoverFrame = null; + if (hoverInspection) { + showThemeInspectorHover(hoverInspection, getThemeRoleLabel(hoverInspection.role)); + } + }); + }; + const clearHoverOnScroll = () => clearHover(); + + document.addEventListener("pointerover", handlePointerOver, true); + document.addEventListener("pointerout", handlePointerOut, true); + document.addEventListener("pointerdown", handlePointerDown, true); + document.addEventListener("click", blockInspectedClick, true); + document.addEventListener("keydown", cancelInspection, true); + window.addEventListener("resize", refreshHover); + window.addEventListener("scroll", clearHoverOnScroll, true); + return () => { + document.removeEventListener("pointerover", handlePointerOver, true); + document.removeEventListener("pointerout", handlePointerOut, true); + document.removeEventListener("pointerdown", handlePointerDown, true); + document.removeEventListener("click", blockInspectedClick, true); + document.removeEventListener("keydown", cancelInspection, true); + window.removeEventListener("resize", refreshHover); + window.removeEventListener("scroll", clearHoverOnScroll, true); + clearHoverTimer(); + if (hoverFrame !== null) cancelAnimationFrame(hoverFrame); + clearThemeInspectorHover(); + }; + }, [clearInspectorSelection, isInspecting, open, selectThemeRole]); + + const handleAdvancedChange = useCallback( + (checked: boolean) => { + setIsAdvanced(checked); + if (checked) return; + if (selectedRole && !THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole)) { + setSelectedRole(null); + } + + // Regenerate every appearance the theme will save, not just the visible + // one, so the palettes shown after toggling match what gets saved. + const managedAppearances: ReadonlyArray = + editingTheme && getThemeModes(editingTheme).length > 1 + ? ["light", "dark"] + : [activeAppearance]; + setSimpleColorsDirtyByAppearance((current) => { + const next = { ...current }; + for (const appearance of managedAppearances) next[appearance] = true; + return next; + }); + setColorsByAppearance((current) => { + const next = { ...current }; + for (const appearance of managedAppearances) { + next[appearance] = getManagedEditorColors(appearance, current[appearance]); + } + return next; + }); + }, + [activeAppearance, editingTheme, selectedRole], + ); + + const handleSubmit = useCallback(() => { + if (!name.trim()) { + setError("Name your theme first."); + return; + } + + try { + // Only regenerate palettes the user actually touched in guided mode, so + // untouched appearances save exactly what the editor displayed. + const colorsForSave = !isAdvanced + ? { + light: simpleColorsDirtyByAppearance.light + ? getManagedEditorColors("light", colorsByAppearance.light) + : colorsByAppearance.light, + dark: simpleColorsDirtyByAppearance.dark + ? getManagedEditorColors("dark", colorsByAppearance.dark) + : colorsByAppearance.dark, + } + : colorsByAppearance; + + let savedTheme: ThemeDefinition; + let mergedAppearance: ThemeAppearance | null = null; + let retiredTheme: ThemeDefinition | null = null; + if (editingTheme && mergeTarget) { + // Renamed onto another installed theme: this theme's palettes fold + // into it and the edited entry retires, so both cards become one. + // Colliding palettes cannot merge — neither side should be silently + // overwritten. + const editedModes = getThemeModes(editingTheme); + const collision = editedModes.find((mode) => takenAppearances.includes(mode)); + if (collision) { + setError(`“${mergeTarget.label}” already has a ${collision} palette. Pick another name.`); + return; + } + mergedAppearance = editedModes[0] ?? null; + savedTheme = updateCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + id: mergeTarget.id, + name: mergeTarget.label, + appearance: mergeTarget.appearance, + colors: mergeTarget.colors, + variants: { + ...mergeTarget.variants, + ...Object.fromEntries(editedModes.map((mode) => [mode, colorsForSave[mode]])), + }, + ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), + }), + ); + retiredTheme = editingTheme; + try { + removeCustomTheme(editingTheme.id); + } catch (cause) { + // The merge already persisted. Leaving it while the edited theme + // survives would collide on every retry, so the target goes back to + // its pre-merge palettes before the failure surfaces. + try { + updateCustomTheme(mergeTarget); + } catch { + // Storage is failing wholesale; the rethrow below reports it. + } + throw cause; + } + } else if (editingTheme) { + const baseAppearance = editingTheme.appearance; + const variantAppearance = baseAppearance === "light" ? "dark" : "light"; + savedTheme = updateCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + id: editingTheme.id, + name, + appearance: baseAppearance, + colors: colorsForSave[baseAppearance], + ...(getThemeModes(editingTheme).length > 1 + ? { variants: { [variantAppearance]: colorsForSave[variantAppearance] } } + : {}), + ...(isAdvanced ? {} : { managed: true }), + }), + ); + } else if (mergeTarget) { + if (takenAppearances.includes(activeAppearance)) { + setError( + `“${mergeTarget.label}” already has light and dark palettes. Pick another name.`, + ); + return; + } + // The new palette joins the existing theme as its other mode; its + // stored palettes are untouched. The guided (managed) flag only + // survives when every palette in the theme came from the guided + // editor. + mergedAppearance = activeAppearance; + savedTheme = updateCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + id: mergeTarget.id, + name: mergeTarget.label, + appearance: mergeTarget.appearance, + colors: mergeTarget.colors, + variants: { + ...mergeTarget.variants, + [activeAppearance]: colorsForSave[activeAppearance], + }, + ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), + }), + ); + } else { + savedTheme = installCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + name, + appearance: activeAppearance, + colors: colorsForSave[activeAppearance], + ...(isAdvanced ? {} : { managed: true }), + }), + ); + } + if ( + !onSaved(savedTheme, { + created: editingTheme === null && mergedAppearance === null, + ...(mergedAppearance ? { mergedAppearance } : {}), + }) + ) { + if (!editingTheme && mergedAppearance === null) { + // Roll the install back so a retry can run it again instead of + // failing on the already-taken theme id. + try { + removeCustomTheme(savedTheme.id); + } catch { + // Storage is failing wholesale; the error below covers it. + } + } else if (mergeTarget && mergedAppearance !== null) { + // Put the pre-merge definitions back for the same reason. + try { + updateCustomTheme(mergeTarget); + if (retiredTheme) installCustomTheme(retiredTheme); + } catch { + // Storage is failing wholesale; the error below covers it. + } + } + setError("Theme saved, but it could not be made active. Try again."); + return; + } + onOpenChange(false); + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : isEditing + ? "Could not save the theme." + : "Could not create the theme.", + ); + } + }, [ + activeAppearance, + colorsByAppearance, + editingTheme, + isAdvanced, + isEditing, + mergeTarget, + name, + onOpenChange, + onSaved, + simpleColorsDirtyByAppearance, + takenAppearances, + ]); + + const renderNameField = () => ( + + ); + + const renderAppearanceButton = (appearance: ThemeAppearance) => { + const isActive = activeAppearance === appearance; + const lockReason = appearanceLockReason(appearance); + // A locked mode stays hoverable so the tooltip can say why it is off; + // a real disabled attribute would swallow the pointer events. + const button = ( + + ); + if (lockReason === null) return button; + return ( + + + {lockReason} + + ); + }; + + const renderAppearanceButtons = () => ( +
+ Appearance +
+ {renderAppearanceButton("light")} + {renderAppearanceButton("dark")} +
+
+ ); + + const renderColorsHeader = () => ( +
+
+

Colors

+ {isAdvanced ? null : ( +

Two colors, rest derived

+ )} +
+
+ {isAdvanced ? ( + setRoleQuery(event.currentTarget.value)} + placeholder="Filter colors" + size="sm" + value={roleQuery} + /> + ) : null} + +
+
+ ); + + const renderRoleFields = ( + roles: ReadonlyArray, + gridClassName = "grid gap-2 sm:grid-cols-2", + ) => ( +
+ {roles.map((role) => ( + + ))} +
+ ); + + const renderColorFields = () => { + const query = roleQuery.trim().toLowerCase(); + const groups = THEME_EDITOR_ROLE_GROUPS.map((group) => ({ + ...group, + roles: group.roles.filter( + (role) => !query || getThemeRoleLabel(role).toLowerCase().includes(query), + ), + })).filter((group) => group.roles.length > 0); + return isAdvanced ? ( +
+ {groups.map((group) => ( +
+

{group.title}

+ {renderRoleFields(group.roles, "grid gap-1")} +
+ ))} + {groups.length === 0 ?

No matches.

: null} +
+ ) : ( +
+ {THEME_EDITOR_SIMPLE_ROLES.map((role) => ( + + ))} +
+ ); + }; + + const clampPosition = (x: number, y: number, widthOverride?: number) => { + const panel = panelRef.current; + const margin = 8; + // The caller passes a width when it has just shrunk the panel: the DOM + // still reports the old one until React commits. + const width = widthOverride ?? panel?.offsetWidth ?? 0; + return { + x: Math.min(Math.max(x, margin), Math.max(margin, window.innerWidth - width - margin)), + // Keep at least the header on screen even when dragged far down. + y: Math.min(Math.max(y, margin), Math.max(margin, window.innerHeight - 48)), + }; + }; + + const handleDragPointerDown = (event: ReactPointerEvent) => { + // Buttons in the header keep their own behavior. + if ((event.target as HTMLElement).closest("button, input, a")) return; + const rect = panelRef.current?.getBoundingClientRect(); + if (!rect) return; + dragOffsetRef.current = { dx: event.clientX - rect.x, dy: event.clientY - rect.y }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const handleDragPointerMove = (event: ReactPointerEvent) => { + const offset = dragOffsetRef.current; + if (!offset) return; + setPosition(clampPosition(event.clientX - offset.dx, event.clientY - offset.dy)); + }; + + const endDrag = () => { + dragOffsetRef.current = null; + }; + + const handleResizePointerDown = (event: ReactPointerEvent) => { + const rect = panelRef.current?.getBoundingClientRect(); + if (!rect) return; + event.preventDefault(); + // The grip drags the bottom-right corner, so the top-left must hold + // still; the default parking spot is anchored bottom-right and would + // slide, so it converts to an explicit position first. + if (position === null) setPosition(clampPosition(rect.x, rect.y)); + resizeStartRef.current = { + pointerX: event.clientX, + pointerY: event.clientY, + left: rect.x, + top: rect.y, + width: rect.width, + height: rect.height, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const handleResizePointerMove = (event: ReactPointerEvent) => { + const start = resizeStartRef.current; + if (!start) return; + const margin = 8; + const MIN_WIDTH = 280; + const MIN_HEIGHT = 220; + // Grow only into the space right of and below the panel's own corner, + // otherwise a panel parked away from the top-left pushes its far edges + // (and this grip) off screen. + const maxWidth = Math.max(MIN_WIDTH, window.innerWidth - margin - start.left); + const maxHeight = Math.max(MIN_HEIGHT, window.innerHeight - margin - start.top); + setSize({ + width: Math.min(Math.max(start.width + event.clientX - start.pointerX, MIN_WIDTH), maxWidth), + height: Math.min( + Math.max(start.height + event.clientY - start.pointerY, MIN_HEIGHT), + maxHeight, + ), + }); + }; + + const endResize = () => { + resizeStartRef.current = null; + }; + + return ( +
+
+
+

+ {isEditing ? "Edit theme" : "Create theme"} +

+ {isMinimized ? null : ( +

+ {isInspecting + ? "Select an element · Esc to cancel" + : selectedRole + ? `${getThemeRoleLabel(selectedRole)} · ${usageCount ?? 0} ${usageCount === 1 ? "use" : "uses"}` + : "Select a color below"} +

+ )} +
+ + { + if (isInspecting) { + clearInspectorSelection(); + return; + } + setIsInspecting(true); + }} + > + + {isInspecting ? "Cancel" : "Inspect"} + + } + /> + + {isInspecting ? "Cancel and clear the selection" : "Pick a color from the app"} + + + + +
+ + {isMinimized ? null : ( + <> +
+ {renderNameField()} + {/* Inline and above the color list: the panel scrolls, and an + error parked below every role would go unseen. */} + {error ? ( +

+ {error} +

+ ) : null} + {renderAppearanceButtons()} +
+ {renderColorsHeader()} + {renderColorFields()} +
+
+
+ + +
+
+ + + +
+ + )} +
+ ); +} diff --git a/apps/web/src/components/settings/ThemeImportDialog.test.ts b/apps/web/src/components/settings/ThemeImportDialog.test.ts new file mode 100644 index 000000000000..6cd51e9b77ae --- /dev/null +++ b/apps/web/src/components/settings/ThemeImportDialog.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { describeOversizedThemeFile, MAX_THEME_FILE_BYTES } from "./ThemeImportDialog"; + +describe("theme import size guard", () => { + it("accepts anything a theme file could plausibly be", () => { + for (const bytes of [0, 4_096, MAX_THEME_FILE_BYTES]) { + expect(describeOversizedThemeFile(bytes)).toBeNull(); + } + }); + + it("rejects a file too large to be a theme and names its size", () => { + const message = describeOversizedThemeFile(100 * 1024 * 1024); + expect(message).toContain("100.0 MB"); + expect(message).toContain("256 KB"); + }); + + it("reports sizes just past the limit in KB", () => { + expect(describeOversizedThemeFile(MAX_THEME_FILE_BYTES + 1)).toContain("256 KB"); + }); +}); diff --git a/apps/web/src/components/settings/ThemeImportDialog.tsx b/apps/web/src/components/settings/ThemeImportDialog.tsx new file mode 100644 index 000000000000..a74842acac3e --- /dev/null +++ b/apps/web/src/components/settings/ThemeImportDialog.tsx @@ -0,0 +1,537 @@ +import { PlusIcon, UploadIcon } from "lucide-react"; +import type { ChangeEvent, DragEvent, UIEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { cn } from "../../lib/utils"; +import { + getCustomThemes, + installCustomTheme, + parseThemeFile, + removeCustomTheme, + THEME_FILE_VERSION, + updateCustomTheme, + type ThemeDefinition, +} from "../../themePalette"; +import { + humanizeThemeName, + isVsCodeThemeFile, + pairVsCodeThemes, + parseVsCodeThemeFile, + resolveThemeLabelCollisions, +} from "../../vscodeThemeImport"; +import { Alert } from "../ui/alert"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; + +/** + * A full theme export is a few KB, so anything past this is not a theme file. + * The guard runs on the size before the bytes are ever read: a large file + * would otherwise be pulled into memory, highlighted, and rendered, which + * locks the UI for as long as that takes. + */ +export const MAX_THEME_FILE_BYTES = 256 * 1024; + +/** Highlighting rebuilds the whole markup on every keystroke, so oversized + * pastes fall back to plain text instead of freezing the editor. */ +const MAX_HIGHLIGHTED_JSON_LENGTH = 20_000; + +function formatByteSize(bytes: number): string { + if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`; + return `${bytes} bytes`; +} + +/** Returns the error to show for a file too large to be a theme, else null. */ +export function describeOversizedThemeFile(bytes: number): string | null { + if (bytes <= MAX_THEME_FILE_BYTES) return null; + return `That file is ${formatByteSize(bytes)}. Theme files are only a few KB, so this one was not read (limit ${formatByteSize(MAX_THEME_FILE_BYTES)}).`; +} + +function escapeJsonHtml(value: string): string { + return value.replace( + /[&<>"']/g, + (character) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[character] ?? character, + ); +} + +function highlightJson(value: string): string { + const tokenPattern = + /"(?:\\.|[^"\\])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null/g; + let highlighted = ""; + let cursor = 0; + + for (const match of value.matchAll(tokenPattern)) { + const token = match[0]; + const index = match.index ?? 0; + highlighted += escapeJsonHtml(value.slice(cursor, index)); + + let tokenClass = "theme-json-number"; + if (token.startsWith('"')) { + tokenClass = /^\s*:/.test(value.slice(index + token.length)) + ? "theme-json-key" + : "theme-json-string"; + } else if (token === "true" || token === "false" || token === "null") { + tokenClass = "theme-json-constant"; + } + highlighted += `${escapeJsonHtml(token)}`; + cursor = index + token.length; + } + + return highlighted + escapeJsonHtml(value.slice(cursor)); +} + +function ThemeJsonEditor({ + id, + value, + onChange, +}: { + id: string; + value: string; + onChange: (value: string) => void; +}) { + const highlightRef = useRef(null); + const isPlainText = value.length > MAX_HIGHLIGHTED_JSON_LENGTH; + const highlightedJson = useMemo( + () => (value.length > MAX_HIGHLIGHTED_JSON_LENGTH ? "" : highlightJson(value)), + [value], + ); + + const syncScroll = useCallback((event: UIEvent) => { + const highlightElement = highlightRef.current; + if (!highlightElement) return; + highlightElement.scrollTop = event.currentTarget.scrollTop; + highlightElement.scrollLeft = event.currentTarget.scrollLeft; + }, []); + + return ( +
+ {isPlainText ? null : ( +
+          
+        
+ )} +