From 920770bd25b3d51223be65b60b195421c76a15c5 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Fri, 2 Oct 2026 20:20:07 +1000 Subject: [PATCH 01/22] refactor(web): register the Diff side panel --- apps/web/src/components/ChatView.tsx | 6 +- .../diffs/DiffFileLoadingBoundary.tsx | 2 +- .../DiffLoadingState.tsx} | 48 +----- .../pullRequest/PullRequestCodeTab.tsx | 2 +- .../pullRequest/PullRequestDetailPanel.tsx | 2 +- apps/web/src/panels/bundledPanels.tsx | 26 ++++ .../diff/DiffSidePanel.tsx} | 142 ++++++++++-------- apps/web/src/panels/panelRegistry.test.tsx | 67 +++++++++ apps/web/src/panels/panelRegistry.ts | 18 +++ 9 files changed, 200 insertions(+), 113 deletions(-) rename apps/web/src/components/{DiffPanelShell.tsx => diffs/DiffLoadingState.tsx} (55%) create mode 100644 apps/web/src/panels/bundledPanels.tsx rename apps/web/src/{components/DiffPanel.tsx => panels/diff/DiffSidePanel.tsx} (93%) create mode 100644 apps/web/src/panels/panelRegistry.test.tsx create mode 100644 apps/web/src/panels/panelRegistry.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b760810a3876..1dd2da414399 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -167,6 +167,7 @@ import { flushSync } from "react-dom"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; import { isMacPlatform } from "../lib/utils"; +import { RegisteredSidePanel } from "~/panels/bundledPanels"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; import { @@ -704,7 +705,6 @@ function useDraftHeroLayoutTransition( const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), ); -const DiffPanel = lazy(() => import("./DiffPanel")); const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) => settings.browserAutoShowFloatingPreview; const DevicePanel = lazy(() => @@ -11113,9 +11113,9 @@ export default function ChatView(props: ChatViewProps) { /> ) : renderedRightPanelSurface?.kind === "diff" ? ( - diff --git a/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx b/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx index 6bf4302fbcc1..b903e57087a0 100644 --- a/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx +++ b/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from "react"; -import { DiffFileHeaderSkeleton } from "../DiffPanelShell"; +import { DiffFileHeaderSkeleton } from "./DiffLoadingState"; /** Load the next batch before the reader reaches the end of the current files. */ export function DiffFileLoadingBoundary({ load, count }: { load: () => void; count: number }) { diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/diffs/DiffLoadingState.tsx similarity index 55% rename from apps/web/src/components/DiffPanelShell.tsx rename to apps/web/src/components/diffs/DiffLoadingState.tsx index 456f70516b23..34b1e4960587 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/diffs/DiffLoadingState.tsx @@ -1,50 +1,4 @@ -import type { ReactNode } from "react"; - -import { isElectron } from "~/env"; -import { cn } from "~/lib/utils"; - -import { Skeleton } from "./ui/skeleton"; - -export type DiffPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; - -function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { - const shouldUseDragRegion = isElectron && mode !== "sheet" && mode !== "embedded"; - return cn( - "flex items-center justify-between gap-2", - mode === "embedded" ? "px-2" : "px-4", - shouldUseDragRegion - ? "drag-region h-[var(--workspace-topbar-height)] border-b border-border wco:pr-(--workspace-native-controls-inset)" - : "flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-transparent", - ); -} - -export function DiffPanelShell(props: { - mode: DiffPanelMode; - header: ReactNode; - children: ReactNode; -}) { - const shouldUseDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; - - return ( -
- {shouldUseDragRegion ? ( -
{props.header}
- ) : ( -
- {props.header} -
- )} - {props.children} -
- ); -} +import { Skeleton } from "../ui/skeleton"; export function DiffFileHeaderSkeleton({ titleWidth, diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 25a8c10ad6b2..d0c473b39768 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -58,7 +58,7 @@ import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; -import { DiffPanelLoadingState } from "../DiffPanelShell"; +import { DiffPanelLoadingState } from "../diffs/DiffLoadingState"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { DiffFileTree } from "../diffs/DiffFileTree"; import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal"; diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index d68983937f19..09231cda02ad 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -125,7 +125,7 @@ import { import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; -import { DiffPanelLoadingState } from "../DiffPanelShell"; +import { DiffPanelLoadingState } from "../diffs/DiffLoadingState"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab"; import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; diff --git a/apps/web/src/panels/bundledPanels.tsx b/apps/web/src/panels/bundledPanels.tsx new file mode 100644 index 000000000000..13a6910824d1 --- /dev/null +++ b/apps/web/src/panels/bundledPanels.tsx @@ -0,0 +1,26 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { DraftId } from "~/composerDraftStore"; + +import { createPanelRegistry } from "./panelRegistry"; + +export interface SidePanelProps { + composerDraftTarget: ScopedThreadRef | DraftId; + workspaceMutationId: string | null; +} + +const bundledPanels = createPanelRegistry([ + { + id: "diff", + load: () => import("./diff/DiffSidePanel"), + }, +]); + +export function RegisteredSidePanel({ + id, + ...props +}: SidePanelProps & { id: Parameters[0] }) { + const panel = bundledPanels.get(id); + if (!panel) throw new Error(`Unknown panel id: ${id}`); + const Component = panel.Component; + return ; +} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/panels/diff/DiffSidePanel.tsx similarity index 93% rename from apps/web/src/components/DiffPanel.tsx rename to apps/web/src/panels/diff/DiffSidePanel.tsx index 0c7a4a28988d..4b57934f4873 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/panels/diff/DiffSidePanel.tsx @@ -1,4 +1,3 @@ -import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import type { FileDiffContentsLoader, FileDiffMetadata } from "@pierre/diffs"; import { useParams } from "@tanstack/react-router"; @@ -7,7 +6,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; -import type { ScopedThreadRef, RunId } from "@t3tools/contracts"; +import type { RunId } from "@t3tools/contracts"; import { ArrowRightIcon, CheckIcon, @@ -21,45 +20,31 @@ import { import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide"; import * as Schema from "effect/Schema"; import * as DateTime from "effect/DateTime"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { useCodeViewFileReveal } from "~/components/diffs/useCodeViewFileReveal"; import { useFilesystemReadAccess } from "~/state/filesystem"; -import { useOpenInPreferredEditor } from "../editorPreferences"; -import { useFileContextMenuHandler } from "../fileContextMenu"; -import { type DraftId } from "../composerDraftStore"; -import { openDiffFilePrimaryAction } from "../diffFileActions"; -import { useCheckpointDiff } from "~/lib/checkpointDiffState"; -import { cn } from "~/lib/utils"; -import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; -import { useLocalStorage } from "../hooks/useLocalStorage"; -import { useTheme } from "../hooks/useTheme"; +import { DiffFilePathCopyButton } from "~/components/DiffFilePathCopyButton"; +import { DiffStatLabel } from "~/components/chat/DiffStatLabel"; import { - buildFileDiffContentVersion, - buildFileDiffIdentityKey, - getDiffCollapseIconClassName, - getDiffLineStat, - getRenderablePatch, - resolveDiffThemeName, - resolveFileDiffPath, -} from "../lib/diffRendering"; -import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; -import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; -import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; -import { useProject, useThreadProjection, useThreadShell } from "../state/entities"; -import { resolveThreadRouteRef } from "../threadRoutes"; -import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; -import { formatShortTimestamp } from "../timestampFormat"; -import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; -import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { DiffStatLabel } from "./chat/DiffStatLabel"; -import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; -import { DiffFileTree } from "./diffs/DiffFileTree"; -import { diffFileTreeEntries } from "./diffs/diffFileTree.logic"; -import { Button } from "./ui/button"; + AnnotatableCodeView, + type AnnotatableCodeViewHandle, +} from "~/components/diffs/AnnotatableCodeView"; +import { DiffFileTree } from "~/components/diffs/DiffFileTree"; +import { diffFileTreeEntries } from "~/components/diffs/diffFileTree.logic"; +import { Button } from "~/components/ui/button"; import { MorphIcon } from "~/components/MorphIcon"; -import { ToggleGroup, Toggle } from "./ui/toggle-group"; -import { Switch } from "./ui/switch"; +import { ToggleGroup, Toggle } from "~/components/ui/toggle-group"; +import { Switch } from "~/components/ui/switch"; import { Combobox, ComboboxEmpty, @@ -68,7 +53,7 @@ import { ComboboxList, ComboboxPopup, ComboboxTrigger, -} from "./ui/combobox"; +} from "~/components/ui/combobox"; import { DropdownMenu, DropdownMenuContent, @@ -78,19 +63,48 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, -} from "./ui/menu"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { useEnvironmentQuery } from "../state/query"; -import { useAtomCommand } from "../state/use-atom-command"; -import { serverEnvironment } from "../state/server"; -import { reviewEnvironment } from "../state/review"; -import { vcsEnvironment } from "../state/vcs"; -import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; -import { createGitDiffFileContentsLoader } from "../lib/diffFileContents"; +} from "~/components/ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { useReviewFilePatches } from "~/components/diffs/useReviewFilePatches"; +import { DiffFileLoadingBoundary } from "~/components/diffs/DiffFileLoadingBoundary"; +import { DiffFileStatus } from "~/components/diffs/DiffFileStatus"; +import { DiffPanelLoadingState } from "~/components/diffs/DiffLoadingState"; -import { useReviewFilePatches } from "./diffs/useReviewFilePatches"; -import { DiffFileLoadingBoundary } from "./diffs/DiffFileLoadingBoundary"; -import { DiffFileStatus } from "./diffs/DiffFileStatus"; +import type { SidePanelProps } from "../bundledPanels"; + +import { useOpenInPreferredEditor } from "~/editorPreferences"; +import { useFileContextMenuHandler } from "~/fileContextMenu"; +import { openDiffFilePrimaryAction } from "~/diffFileActions"; +import { useCheckpointDiff } from "~/lib/checkpointDiffState"; +import { cn } from "~/lib/utils"; +import { selectThreadDiffPanelSelection, useDiffPanelStore } from "~/diffPanelStore"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { useTheme } from "~/hooks/useTheme"; +import { + buildFileDiffContentVersion, + buildFileDiffIdentityKey, + getDiffCollapseIconClassName, + getDiffLineStat, + getRenderablePatch, + resolveDiffThemeName, + resolveFileDiffPath, +} from "~/lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; +import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "~/lib/diffCollapse"; +import { useTurnDiffSummaries } from "~/hooks/useTurnDiffSummaries"; +import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; +import { useProject, useThreadProjection, useThreadShell } from "~/state/entities"; +import { resolveThreadRouteRef } from "~/threadRoutes"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; +import { formatShortTimestamp } from "~/timestampFormat"; + +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { serverEnvironment } from "~/state/server"; +import { reviewEnvironment } from "~/state/review"; +import { vcsEnvironment } from "~/state/vcs"; +import { buildBaseRefChoices, filterBaseRefChoices } from "~/lib/baseRefChoices"; +import { createGitDiffFileContentsLoader } from "~/lib/diffFileContents"; type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; @@ -187,17 +201,25 @@ function DiffFileHeaderSuffix({ ); } -interface DiffPanelProps { - mode?: DiffPanelMode; - composerDraftTarget: ScopedThreadRef | DraftId; - workspaceMutationId: string | null; +// RightPanelTabs owns placement and desktop chrome; Diff keeps its embedded toolbar. +function DiffSidePanelFrame({ header, children }: { header: ReactNode; children: ReactNode }) { + return ( +
+
+ {header} +
+ {children} +
+ ); } -export default function DiffPanel({ - mode = "inline", +export default function DiffSidePanel({ composerDraftTarget, workspaceMutationId, -}: DiffPanelProps) { +}: SidePanelProps) { const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const diffLayout = settings.diffLayout; @@ -1041,7 +1063,7 @@ export default function DiffPanel({ ); return ( - + {!activeThread ? (
Select a thread to inspect turn diffs. @@ -1249,6 +1271,6 @@ export default function DiffPanel({
)} -
+ ); } diff --git a/apps/web/src/panels/panelRegistry.test.tsx b/apps/web/src/panels/panelRegistry.test.tsx new file mode 100644 index 000000000000..9a4f6a5c811e --- /dev/null +++ b/apps/web/src/panels/panelRegistry.test.tsx @@ -0,0 +1,67 @@ +import { act, Suspense, useEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createPanelRegistry } from "./panelRegistry"; + +// These exercise lazy evaluation and actual mount/disposal, without inspecting markup. +describe("panel registry", () => { + it("rejects duplicate ids without loading either panel", () => { + const load = vi.fn(); + const definition = { id: "diff", load } as const; + expect(() => createPanelRegistry([definition, definition])).toThrow("Duplicate panel id: diff"); + expect(load).not.toHaveBeenCalled(); + }); + + it("does no work until opened and keeps one mount across lookups", async () => { + let finish!: (value: { default: typeof Panel }) => void; + const loaded = new Promise<{ default: typeof Panel }>((resolve) => { + finish = resolve; + }); + const mounted = vi.fn(); + const disposed = vi.fn(); + function Panel() { + useEffect(() => { + mounted(); + return disposed; + }, []); + return null; + } + const load = vi.fn(() => loaded); + const registry = createPanelRegistry([{ id: "diff", load }]); + expect(load).not.toHaveBeenCalled(); + const Component = registry.get("diff")!.Component; + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create( + + + , + ); + }); + expect(load).toHaveBeenCalledTimes(1); + expect(mounted).not.toHaveBeenCalled(); + await act(async () => { + finish({ default: Panel }); + await loaded; + }); + expect(mounted).toHaveBeenCalledTimes(1); + // A parent re-render looks the panel up again; it must keep the same mount and state. + const Again = registry.get("diff")!.Component; + expect(Again).toBe(Component); + await act(async () => { + renderer.update( + + + , + ); + }); + expect(load).toHaveBeenCalledTimes(1); + expect(mounted).toHaveBeenCalledTimes(1); + expect(disposed).not.toHaveBeenCalled(); + await act(async () => { + renderer.unmount(); + }); + expect(disposed).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/panels/panelRegistry.ts b/apps/web/src/panels/panelRegistry.ts new file mode 100644 index 000000000000..48c62e04a076 --- /dev/null +++ b/apps/web/src/panels/panelRegistry.ts @@ -0,0 +1,18 @@ +import { lazy, type ComponentType } from "react"; + +export interface PanelDefinition { + id: Id; + load: () => Promise<{ default: ComponentType }>; +} + +// Registration only creates lazy component identities. Reads and workers belong to mounts. +export function createPanelRegistry( + definitions: readonly PanelDefinition[], +) { + const panels = new Map & { Component: ComponentType }>(); + for (const definition of definitions) { + if (panels.has(definition.id)) throw new Error(`Duplicate panel id: ${definition.id}`); + panels.set(definition.id, { ...definition, Component: lazy(definition.load) }); + } + return { get: (id: Id) => panels.get(id) }; +} From e1ae80851cf3fece8ed9b0551b525c98ff1205c6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:51:26 +1100 Subject: [PATCH 02/22] refactor(web): register the Preview side panel Preview becomes the second panel on the side-panel registry that Diff started. Each definition now also carries the panel's title, icon, launcher letter, client support and unavailable copy, so the tabs, the empty launcher and the add menu read one ordered list instead of three hand-kept ones. Labels, letters, order and copy are unchanged. Panel props are inferred from each lazily loaded body, and the caller is a closed union, so another panel's props, unknown ids and widened ids do not compile. ChatView lends the rendered panel a small host (thread, right panel visibility, composer draft target, workspace mutation id and the annotation send) instead of drilling the same props into each body; the annotation send keeps the per-render closure it had before, and PreviewView still drops a pick that settles after a thread switch. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatView.tsx | 62 ++-- .../RightPanelTabs.browserProfile.test.tsx | 142 ++++++++ .../RightPanelTabs.keyboard.test.tsx | 8 +- .../src/components/RightPanelTabs.test.tsx | 51 ++- apps/web/src/components/RightPanelTabs.tsx | 334 ++++++++---------- apps/web/src/panels/bundledPanels.test.tsx | 72 ++++ apps/web/src/panels/bundledPanels.tsx | 68 +++- apps/web/src/panels/diff/DiffSidePanel.tsx | 8 +- apps/web/src/panels/panelHost.ts | 33 ++ apps/web/src/panels/panelRegistry.test.tsx | 85 ++++- apps/web/src/panels/panelRegistry.ts | 56 ++- .../panels/preview/PreviewSidePanel.test.tsx | 275 ++++++++++++++ .../preview/PreviewSidePanel.tsx} | 39 +- apps/web/src/routes/_chat.pull-requests.tsx | 9 +- 14 files changed, 942 insertions(+), 300 deletions(-) create mode 100644 apps/web/src/components/RightPanelTabs.browserProfile.test.tsx create mode 100644 apps/web/src/panels/bundledPanels.test.tsx create mode 100644 apps/web/src/panels/panelHost.ts create mode 100644 apps/web/src/panels/preview/PreviewSidePanel.test.tsx rename apps/web/src/{components/preview/PreviewPanel.tsx => panels/preview/PreviewSidePanel.tsx} (58%) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1dd2da414399..cfba3ddf53c4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -168,6 +168,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; import { isMacPlatform } from "../lib/utils"; import { RegisteredSidePanel } from "~/panels/bundledPanels"; +import { PanelHostContext, type PanelHost } from "~/panels/panelHost"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; import { @@ -702,9 +703,6 @@ function useDraftHeroLayoutTransition( } as const; } -const PreviewPanel = lazy(() => - import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), -); const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) => settings.browserAutoShowFloatingPreview; const DevicePanel = lazy(() => @@ -11078,20 +11076,13 @@ export default function ChatView(props: ChatViewProps) { return ; } - const rightPanelContent = activeThreadRef ? ( + const rightPanelSurfaceContent = activeThreadRef ? ( renderedRightPanelSurface?.kind === "preview" ? ( - - { - void onSend(undefined, "auto", "foreground", { annotation, image }); - }} - /> - + ) : renderedRightPanelSurface?.kind === "terminal" ? ( ) : renderedRightPanelSurface?.kind === "diff" ? ( - - - + ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( ) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( @@ -11236,6 +11220,26 @@ export default function ChatView(props: ChatViewProps) {
) : null ) : null; + const panelHost: PanelHost | null = activeThreadRef + ? { + threadRef: activeThreadRef, + visible: rightPanelOpen, + composerDraftTarget, + workspaceMutationId, + // PreviewView drops a pick that settles after a thread switch, so this + // render's closure only ever sends for its own thread. + sendAnnotation: (annotation, image) => { + void onSend(undefined, "auto", "foreground", { annotation, image }); + }, + } + : null; + const rightPanelContent = ( + {rightPanelSurfaceContent} + ); + const sidePanelLaunchers = { + preview: { available: canOperatePreview && browserAvailable, onOpen: createBrowserSurface }, + diff: { available: isServerThread && isGitRepo, onOpen: addDiffSurface }, + }; const threadDetailsPanelProps: ThreadDetailsPanelProps = { anchor: threadPanelPopoverAnchorRef, handle: threadPanelPopoverHandle, @@ -12056,17 +12060,14 @@ export default function ChatView(props: ChatViewProps) { onCloseAllSurfaces={closeAllRightPanelSurfaces} onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={() => createBrowserSurface()} + panels={sidePanelLaunchers} onAddBrowserInProfile={createBrowserSurface} onAddTerminal={addTerminalSurface} - onAddDiff={addDiffSurface} onAddFiles={addFilesSurface} onAddPullRequest={addPullRequestSurface} onAddPullRequests={addPullRequestsSurface} onAddDevice={addDeviceSurface} - browserAvailable={canOperatePreview && browserAvailable} terminalAvailable={activeProject !== null && canOperateTerminal} - diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} pullRequestsAvailable={pullRequestsSurfaceAvailable} @@ -12115,17 +12116,14 @@ export default function ChatView(props: ChatViewProps) { onCloseAllSurfaces={closeAllRightPanelSurfaces} onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={() => createBrowserSurface()} + panels={sidePanelLaunchers} onAddBrowserInProfile={createBrowserSurface} onAddTerminal={addTerminalSurface} - onAddDiff={addDiffSurface} onAddFiles={addFilesSurface} onAddPullRequest={addPullRequestSurface} onAddPullRequests={addPullRequestsSurface} onAddDevice={addDeviceSurface} - browserAvailable={canOperatePreview && browserAvailable} terminalAvailable={activeProject !== null && canOperateTerminal} - diffAvailable={isServerThread && isGitRepo} filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} pullRequestsAvailable={pullRequestsSurfaceAvailable} diff --git a/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx b/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx new file mode 100644 index 000000000000..805a58ab263e --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx @@ -0,0 +1,142 @@ +// @vitest-environment jsdom + +import { + BUILT_IN_BROWSER_PROFILES, + DEFAULT_BROWSER_PROFILE_ID, + INCOGNITO_BROWSER_PROFILE_ID, +} from "@t3tools/contracts"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +// The profile list normally comes from client settings; the built-ins are enough to choose from. +vi.mock("~/browser/browserDefaults", async (importOriginal) => ({ + ...(await importOriginal()), + useBrowserDefaults: () => ({ profiles: BUILT_IN_BROWSER_PROFILES }), +})); + +import { RightPanelTabs } from "./RightPanelTabs"; + +let root: Root; +let container: HTMLDivElement; +let opened: string[]; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + // Browser is desktop-only; the launcher offers it once the preview bridge exists. + vi.stubGlobal("desktopBridge", { preview: {} }); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + // jsdom lacks the Web Animations API that the tab bar's scroll area waits on. + Element.prototype.getAnimations ??= () => []; + opened = []; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +// Records Browser opens the way ChatView's createBrowserSurface does: one +// handler for the default open and the profile chooser, where an omitted +// profile means the default one. +function Harness() { + const openBrowser = (profileId?: string) => { + opened.push(profileId ?? DEFAULT_BROWSER_PROFILE_ID); + }; + return ( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + surfaces={[]} + environmentId={null} + activeSurfaceId={null} + pendingSurfaceIds={new Set()} + previewSessions={{}} + desktopByTabId={{}} + terminalLabelsById={new Map()} + onActivate={() => undefined} + onCloseSurface={() => undefined} + onCloseOtherSurfaces={() => undefined} + onCloseSurfacesToRight={() => undefined} + onCloseAllSurfaces={() => undefined} + onCopyFilePath={() => undefined} + panels={{ + preview: { available: true, onOpen: openBrowser }, + diff: { available: false, onOpen: () => undefined }, + }} + onAddBrowserInProfile={openBrowser} + onAddTerminal={() => undefined} + onAddPullRequest={() => undefined} + onAddPullRequests={() => undefined} + onAddFiles={() => undefined} + onAddDevice={() => undefined} + terminalAvailable={false} + filesAvailable={false} + pullRequestAvailable={false} + pullRequestsAvailable={false} + deviceAvailable={false} + > + {null} + + ); +} + +function launcherRow(label: string): HTMLButtonElement { + const launcher = container.querySelector('[aria-label="Open a surface"]'); + const row = [...(launcher?.querySelectorAll("button") ?? [])].find((button) => + button.textContent?.startsWith(label), + ); + if (!row) throw new Error(`No launcher row ${label}`); + return row; +} + +function menuItem(label: string): HTMLElement { + const item = [...document.querySelectorAll('[role="menuitem"]')].find( + (element) => element.textContent === label, + ); + if (!item) throw new Error(`No menu item ${label}`); + return item; +} + +async function click(element: Element) { + await act(async () => { + element.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +describe("opening Browser from the launcher", () => { + it("opens the default profile from the row and another profile from the chooser", async () => { + await act(async () => root.render()); + + await click(launcherRow("Browser")); + expect(opened).toEqual([DEFAULT_BROWSER_PROFILE_ID]); + + await click(container.querySelector('[aria-label="Open browser in a profile"]')!); + await click(menuItem("Incognito")); + expect(opened).toEqual([DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID]); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx index e7a952ba43c5..444e9c610859 100644 --- a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx +++ b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx @@ -76,17 +76,17 @@ async function renderPanel(overrides: Partial { }); }); +describe("right panel surface actions", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const inputs = () => ({ + panels: { + preview: { available: true, onOpen: () => undefined }, + diff: { available: true, onOpen: () => undefined }, + }, + onAddTerminal: () => undefined, + onAddFiles: () => undefined, + onAddPullRequest: () => undefined, + onAddPullRequests: () => undefined, + onAddDevice: () => undefined, + terminalAvailable: true, + filesAvailable: true, + pullRequestAvailable: true, + pullRequestsAvailable: true, + deviceAvailable: true, + }); + + it("keeps launcher order, letters and copy for registered and local surfaces", () => { + const actions = rightPanelSurfaceActions(inputs()); + expect(actions.map((action) => [action.shortcut, action.label])).toEqual([ + ["B", "Browser"], + ["T", "Terminal"], + ["F", "Files"], + ["D", "Diff"], + ["P", "Pull request"], + ["L", "Linked pull requests"], + ["M", "Device"], + ]); + expect(actions.find((action) => action.id === "diff")).toMatchObject({ + unavailableHint: "Available for Git repositories.", + unavailableReason: "Diff is only available for server threads in Git repositories.", + }); + }); +}); + function shortcutEvent( key: string, overrides: Partial[1]> = {}, @@ -125,17 +166,17 @@ function renderTabs( onCloseSurfacesToRight={() => undefined} onCloseAllSurfaces={() => undefined} onCopyFilePath={() => undefined} - onAddBrowser={() => undefined} + panels={{ + preview: { available: true, onOpen: () => undefined }, + diff: { available: false, onOpen: () => undefined }, + }} onAddBrowserInProfile={() => undefined} onAddTerminal={() => undefined} onAddPullRequest={() => undefined} onAddPullRequests={() => undefined} - onAddDiff={() => undefined} onAddFiles={() => undefined} onAddDevice={() => undefined} - browserAvailable terminalAvailable={false} - diffAvailable={false} filesAvailable={false} pullRequestAvailable={false} pullRequestsAvailable={false} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 5c8323a7ae34..45fde10e1e59 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -30,15 +30,14 @@ import { ChevronDown, ChevronLeft, ChevronRight, - FileDiff, Files, - Globe2, Plus, TerminalSquare, } from "lucide-react"; import { Volume2, VolumeOff } from "lucide"; import { type ComponentProps, + type ComponentType, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, @@ -52,6 +51,7 @@ import { } from "react"; import { isElectron } from "~/env"; +import { getSidePanelMetadata, type SidePanelId } from "~/panels/bundledPanels"; import type { DesktopPreviewOverlay } from "~/previewStateStore"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; @@ -130,22 +130,15 @@ interface RightPanelTabsProps { /** Tabs are draggable only when the owner can persist the new order. */ onMoveSurface?: (surfaceId: string, toIndex: number) => void; onCopyFilePath: (relativePath: string) => void; - onAddBrowser: () => void; - /** - * Separate from `onAddBrowser` on purpose: that one is passed directly as a - * DOM click handler, and a `(profileId?: string)` signature would silently - * accept the MouseEvent as a profile id. - */ + /** Whether each registered panel can open here, and how; titles and icons come from its definition. */ + panels: Readonly>; onAddBrowserInProfile: (profileId: string) => void; onAddTerminal: () => void; - onAddDiff: () => void; onAddFiles: () => void; onAddPullRequest: () => void; onAddPullRequests: () => void; onAddDevice: () => void; - browserAvailable: boolean; terminalAvailable: boolean; - diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; pullRequestsAvailable: boolean; @@ -170,16 +163,6 @@ export function shouldOpenDefaultBrowserProfileFromMenuClick( return pointerType !== "touch"; } -const SURFACE_DISABLED_REASONS = { - browser: "Browser previews are only available in the T3 Code desktop app.", - terminal: "Terminal surfaces are only available from a project thread.", - files: "Files are only available when a project is open.", - diff: "Diff is only available for server threads in Git repositories.", - pullRequest: "This thread's branch has no pull request yet.", - pullRequests: "No linked pull requests are available for this thread.", - device: "Devices are only available from a thread.", -} as const; - /** Overlays that must win over the launcher's letter shortcuts. */ const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ '[data-slot="dialog-popup"]', @@ -192,16 +175,117 @@ const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ '[data-slot="autocomplete-popup"]', ].join(","); -/** One-line unavailability hints for the empty-state rows. */ -const SURFACE_UNAVAILABLE_HINTS = { - browser: "Only available in the desktop app.", - terminal: "Available when a project is open.", - files: "Available when a project is open.", - diff: "Available for Git repositories.", - pullRequest: "No pull request on this branch yet.", - pullRequests: "No linked pull requests available.", - device: "Available from a thread.", -} as const; +interface SidePanelLauncher { + available: boolean; + onOpen: () => void; +} + +interface SurfaceAction { + id: string; + label: string; + description?: string; + icon: ComponentType<{ className?: string }>; + shortcut: string; + available: boolean; + /** One-line reason for the empty launcher rows. */ + unavailableHint: string; + /** Full reason for the add menu tooltip. */ + unavailableReason: string; + onClick: () => void; +} + +type SurfaceActionInputs = Pick< + RightPanelTabsProps, + | "panels" + | "onAddTerminal" + | "onAddFiles" + | "onAddPullRequest" + | "onAddPullRequests" + | "onAddDevice" + | "terminalAvailable" + | "filesAvailable" + | "pullRequestAvailable" + | "pullRequestsAvailable" + | "deviceAvailable" +>; + +/** + * The surfaces the empty launcher and the add menu offer, in launcher order. + * Registered panels describe themselves; the rest are listed here until they + * move onto the panel registry. + */ +export function rightPanelSurfaceActions(props: SurfaceActionInputs): SurfaceAction[] { + const registered = (id: SidePanelId): SurfaceAction => { + const panel = getSidePanelMetadata(id); + const launcher = props.panels[id]; + return { + id, + label: panel.title, + icon: panel.icon, + shortcut: panel.launcherKey, + available: (panel.isSupported?.() ?? true) && launcher.available, + unavailableHint: panel.unavailableHint, + unavailableReason: panel.unavailableReason, + // Never forward the click event as an argument. + onClick: () => launcher.onOpen(), + }; + }; + return [ + registered("preview"), + { + id: "terminal", + label: "Terminal", + icon: TerminalSquare, + shortcut: "T", + available: props.terminalAvailable, + unavailableHint: "Available when a project is open.", + unavailableReason: "Terminal surfaces are only available from a project thread.", + onClick: props.onAddTerminal, + }, + { + id: "files", + label: "Files", + icon: Files, + shortcut: "F", + available: props.filesAvailable, + unavailableHint: "Available when a project is open.", + unavailableReason: "Files are only available when a project is open.", + onClick: props.onAddFiles, + }, + registered("diff"), + { + id: "pull-request", + label: "Pull request", + icon: PullRequestGlyph.pullRequest, + shortcut: "P", + available: props.pullRequestAvailable, + unavailableHint: "No pull request on this branch yet.", + unavailableReason: "This thread's branch has no pull request yet.", + onClick: props.onAddPullRequest, + }, + { + id: "pull-requests", + label: "Linked pull requests", + icon: PullRequestGlyph.link, + shortcut: "L", + available: props.pullRequestsAvailable, + unavailableHint: "No linked pull requests available.", + unavailableReason: "No linked pull requests are available for this thread.", + onClick: props.onAddPullRequests, + }, + { + id: "device", + label: "Device", + description: "Watch an iOS Simulator or Android Emulator.", + icon: Smartphone, + shortcut: "M", + available: props.deviceAvailable, + unavailableHint: "Available from a thread.", + unavailableReason: "Devices are only available from a thread.", + onClick: props.onAddDevice, + }, + ]; +} type TabContextMenuAction = | "rename" @@ -332,87 +416,13 @@ function SurfaceMenuItem(props: { * surfaces stay visible with a one-line reason. */ function RightPanelEmptyState(props: { - onAddBrowser: () => void; + actions: readonly SurfaceAction[]; onAddBrowserInProfile: (profileId: string) => void; browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>; - onAddTerminal: () => void; - onAddDiff: () => void; - onAddFiles: () => void; - onAddPullRequest: () => void; - onAddPullRequests: () => void; - onAddDevice: () => void; - browserAvailable: boolean; - terminalAvailable: boolean; - diffAvailable: boolean; - filesAvailable: boolean; - pullRequestAvailable: boolean; - pullRequestsAvailable: boolean; - deviceAvailable: boolean; }) { // -1 means no highlight: it only appears on hover or arrow use. const [highlight, setHighlight] = useState(-1); - - const actions = [ - { - label: "Browser", - icon: Globe2, - shortcut: "B", - available: props.browserAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.browser, - onClick: props.onAddBrowser, - }, - { - label: "Terminal", - icon: TerminalSquare, - shortcut: "T", - available: props.terminalAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal, - onClick: props.onAddTerminal, - }, - { - label: "Files", - icon: Files, - shortcut: "F", - available: props.filesAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.files, - onClick: props.onAddFiles, - }, - { - label: "Diff", - icon: FileDiff, - shortcut: "D", - available: props.diffAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.diff, - onClick: props.onAddDiff, - }, - { - label: "Pull request", - icon: PullRequestGlyph.pullRequest, - shortcut: "P", - available: props.pullRequestAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest, - onClick: props.onAddPullRequest, - }, - { - label: "Linked pull requests", - icon: PullRequestGlyph.link, - shortcut: "L", - available: props.pullRequestsAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, - onClick: props.onAddPullRequests, - }, - { - label: "Device", - description: "Watch an iOS Simulator or Android Emulator.", - icon: Smartphone, - shortcut: "M", - available: props.deviceAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.device, - onClick: props.onAddDevice, - }, - ] as const; - - type SurfaceAction = (typeof actions)[number]; + const actions = props.actions; const availableActions = actions.filter((action) => action.available); const highlightIndex = @@ -511,7 +521,7 @@ function RightPanelEmptyState(props: { // wrapper: the chooser overlays the row, and a pointer moving // onto it must not read as leaving the row.
setHighlight(availableActions.indexOf(action))} onMouseLeave={() => @@ -532,7 +542,7 @@ function RightPanelEmptyState(props: { 1 && "pr-7", + action.id === "preview" && props.browserProfiles.length > 1 && "pr-7", )} > {action.label} @@ -544,7 +554,7 @@ function RightPanelEmptyState(props: { default profile, the chevron picks another. Only worth showing once there is something to choose between. */} - {action.label === "Browser" && props.browserProfiles.length > 1 ? ( + {action.id === "preview" && props.browserProfiles.length > 1 ? ( ) : ( 0) return snapshot.navStatus.title; try { - return new URL(snapshot.navStatus.url).host || "Browser"; + return new URL(snapshot.navStatus.url).host || fallback; } catch { - return "Browser"; + return fallback; } } } @@ -635,10 +646,11 @@ function surfaceTitle( function PreviewFavicon({ capturedUrl, url }: { capturedUrl: string | null; url: string | null }) { const publicProviderUrl = faviconUrlForOrigin(url, 32); + const Icon = getSidePanelMetadata("preview").icon; return ( } + fallback={} className="size-3 shrink-0 rounded-sm object-contain" /> ); @@ -676,8 +688,10 @@ function SurfaceIcon({ favicon && url && sameOrigin(favicon.pageUrl, url) ? favicon.dataUrl : null; return ; } - case "diff": - return ; + case "diff": { + const Icon = getSidePanelMetadata("diff").icon; + return ; + } case "files": return ; case "file": @@ -934,64 +948,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }); }, []); - const addSurfaceActions = [ - { - label: "Browser", - icon: Globe2, - shortcut: "B", - available: props.browserAvailable, - disabledReason: SURFACE_DISABLED_REASONS.browser, - onClick: props.onAddBrowser, - }, - { - label: "Terminal", - icon: TerminalSquare, - shortcut: "T", - available: props.terminalAvailable, - disabledReason: SURFACE_DISABLED_REASONS.terminal, - onClick: props.onAddTerminal, - }, - { - label: "Files", - icon: Files, - shortcut: "F", - available: props.filesAvailable, - disabledReason: SURFACE_DISABLED_REASONS.files, - onClick: props.onAddFiles, - }, - { - label: "Diff", - icon: FileDiff, - shortcut: "D", - available: props.diffAvailable, - disabledReason: SURFACE_DISABLED_REASONS.diff, - onClick: props.onAddDiff, - }, - { - label: "Pull request", - icon: PullRequestGlyph.pullRequest, - shortcut: "P", - available: props.pullRequestAvailable, - disabledReason: SURFACE_DISABLED_REASONS.pullRequest, - onClick: props.onAddPullRequest, - }, - { - label: "Linked pull requests", - icon: PullRequestGlyph.link, - shortcut: "L", - available: props.pullRequestsAvailable, - disabledReason: SURFACE_DISABLED_REASONS.pullRequests, - onClick: props.onAddPullRequests, - }, - { - label: "Device", - icon: Smartphone, - shortcut: "M", - available: props.deviceAvailable, - disabledReason: SURFACE_DISABLED_REASONS.device, - onClick: props.onAddDevice, - }, - ] as const; + const addSurfaceActions = rightPanelSurfaceActions(props); const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { const action = surfaceShortcutActionForKey(addSurfaceActions, event.nativeEvent); @@ -1353,9 +1310,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) { // while hover or arrow reveals the profiles. The choice // lives at open time because a tab's profile is fixed then — // Electron only honours a partition before attach. - if (action.label === "Browser" && action.available) { + if (action.id === "preview" && action.available) { return ( - + @@ -1474,22 +1431,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
{props.activeSurfaceId === null ? ( ) : ( props.children diff --git a/apps/web/src/panels/bundledPanels.test.tsx b/apps/web/src/panels/bundledPanels.test.tsx new file mode 100644 index 000000000000..c1a14f8140e6 --- /dev/null +++ b/apps/web/src/panels/bundledPanels.test.tsx @@ -0,0 +1,72 @@ +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { act, Suspense } from "react"; +import { create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const loaded = vi.hoisted(() => ({ diff: 0, preview: 0, previewRenders: [] as unknown[] })); +vi.mock("./diff/DiffSidePanel", () => { + loaded.diff += 1; + return { default: () => null }; +}); +vi.mock("./preview/PreviewSidePanel", () => { + loaded.preview += 1; + return { + default: function PreviewSidePanel(props: unknown) { + loaded.previewRenders.push({ props, host: usePanelHost() }); + return null; + }, + }; +}); + +import { RegisteredSidePanel } from "./bundledPanels"; +import { PanelHostContext, usePanelHost, type PanelHost } from "./panelHost"; + +const threadRef: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), +}; +const host: PanelHost = { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, +}; + +describe("bundled side panels", () => { + it("loads only the selected panel body and lends it the host", async () => { + expect(loaded).toMatchObject({ diff: 0, preview: 0 }); + await act(async () => { + create( + + + + + , + ); + }); + expect(loaded).toMatchObject({ diff: 0, preview: 1 }); + expect(loaded.previewRenders).toEqual([{ props: { tabId: "tab-1" }, host }]); + }); +}); + +// Never called. The project typecheck compiles these pairings, and each +// expect-error directive fails it if a wrong pairing starts to compile. +export function typeFixtures(widenedId: "diff" | "preview") { + return ( + <> + + + {/* @ts-expect-error Preview props on Diff. */} + + {/* @ts-expect-error The host owns the thread; panels do not take it as a prop. */} + + {/* @ts-expect-error Wrong input shape. */} + + {/* @ts-expect-error Unknown id. */} + + {/* @ts-expect-error A widened id cannot borrow one panel's props. */} + + + ); +} diff --git a/apps/web/src/panels/bundledPanels.tsx b/apps/web/src/panels/bundledPanels.tsx index 13a6910824d1..1a6659f5cb2a 100644 --- a/apps/web/src/panels/bundledPanels.tsx +++ b/apps/web/src/panels/bundledPanels.tsx @@ -1,26 +1,62 @@ -import type { ScopedThreadRef } from "@t3tools/contracts"; -import type { DraftId } from "~/composerDraftStore"; +import { FileDiff, Globe2 } from "lucide-react"; +import { Suspense, type ComponentType } from "react"; -import { createPanelRegistry } from "./panelRegistry"; +import { createPanelRegistry, type PanelMetadata, type PanelProps } from "./panelRegistry"; -export interface SidePanelProps { - composerDraftTarget: ScopedThreadRef | DraftId; - workspaceMutationId: string | null; -} - -const bundledPanels = createPanelRegistry([ +const bundledPanels = createPanelRegistry([ { id: "diff", + title: "Diff", + icon: FileDiff, + launcherKey: "D", + unavailableHint: "Available for Git repositories.", + unavailableReason: "Diff is only available for server threads in Git repositories.", load: () => import("./diff/DiffSidePanel"), }, + { + id: "preview", + title: "Browser", + icon: Globe2, + launcherKey: "B", + unavailableHint: "Only available in the desktop app.", + unavailableReason: "Browser previews are only available in the T3 Code desktop app.", + load: () => import("./preview/PreviewSidePanel"), + }, ]); -export function RegisteredSidePanel({ - id, - ...props -}: SidePanelProps & { id: Parameters[0] }) { +export type SidePanelId = (typeof bundledPanels.definitions)[number]["id"]; + +/** Metadata for launchers and tabs; reading it never loads a panel body. */ +export function getSidePanelMetadata(id: SidePanelId): PanelMetadata { + return bundledPanels.get(id); +} + +type SidePanel = ReturnType; +type SidePanelPropKey = SidePanel extends infer Panel + ? Panel extends SidePanel + ? keyof PanelProps + : never + : never; + +/** + * One member per registered id. Other panels' prop keys are forbidden on each + * member, so a widened id cannot carry props the selected panel does not take. + */ +type RegisteredSidePanelProps = SidePanel extends infer Panel + ? Panel extends SidePanel + ? { id: Panel["id"] } & PanelProps & { + [Key in Exclude>]?: never; + } + : never + : never; + +export function RegisteredSidePanel({ id, ...props }: RegisteredSidePanelProps) { const panel = bundledPanels.get(id); - if (!panel) throw new Error(`Unknown panel id: ${id}`); - const Component = panel.Component; - return ; + // The union caller already paired id with its props; destructuring loses that correlation. + const Component = panel.Component as ComponentType; + return ( + + + + ); } diff --git a/apps/web/src/panels/diff/DiffSidePanel.tsx b/apps/web/src/panels/diff/DiffSidePanel.tsx index 4b57934f4873..b66b1953db38 100644 --- a/apps/web/src/panels/diff/DiffSidePanel.tsx +++ b/apps/web/src/panels/diff/DiffSidePanel.tsx @@ -70,7 +70,7 @@ import { DiffFileLoadingBoundary } from "~/components/diffs/DiffFileLoadingBound import { DiffFileStatus } from "~/components/diffs/DiffFileStatus"; import { DiffPanelLoadingState } from "~/components/diffs/DiffLoadingState"; -import type { SidePanelProps } from "../bundledPanels"; +import { usePanelHost } from "../panelHost"; import { useOpenInPreferredEditor } from "~/editorPreferences"; import { useFileContextMenuHandler } from "~/fileContextMenu"; @@ -216,10 +216,8 @@ function DiffSidePanelFrame({ header, children }: { header: ReactNode; children: ); } -export default function DiffSidePanel({ - composerDraftTarget, - workspaceMutationId, -}: SidePanelProps) { +export default function DiffSidePanel() { + const { composerDraftTarget, workspaceMutationId } = usePanelHost(); const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const diffLayout = settings.diffLayout; diff --git a/apps/web/src/panels/panelHost.ts b/apps/web/src/panels/panelHost.ts new file mode 100644 index 000000000000..20761ca54c28 --- /dev/null +++ b/apps/web/src/panels/panelHost.ts @@ -0,0 +1,33 @@ +import type { PreviewAnnotationPayload, ScopedThreadRef } from "@t3tools/contracts"; +import { createContext, use } from "react"; + +import type { ComposerImageAttachment, DraftId } from "~/composerDraftStore"; + +/** + * What the chat view lends the panel it is rendering. A panel reads this + * instead of receiving the same values as props, so only panel-specific + * inputs stay on its props. + */ +export interface PanelHost { + /** Thread the panel belongs to; its environmentId scopes every server call. */ + readonly threadRef: ScopedThreadRef; + /** False while the right panel is collapsed but the panel stays mounted. */ + readonly visible: boolean; + /** Draft that comments and attached context land in. */ + readonly composerDraftTarget: ScopedThreadRef | DraftId; + /** Changes when a turn finishes mutating the workspace, so file views can refresh. */ + readonly workspaceMutationId: string | null; + /** Sends an annotation as its own message through the composer of the render that lent it. */ + readonly sendAnnotation: ( + annotation: PreviewAnnotationPayload, + image: ComposerImageAttachment | null, + ) => void; +} + +export const PanelHostContext = createContext(null); + +export function usePanelHost(): PanelHost { + const host = use(PanelHostContext); + if (!host) throw new Error("usePanelHost must be used inside a panel host"); + return host; +} diff --git a/apps/web/src/panels/panelRegistry.test.tsx b/apps/web/src/panels/panelRegistry.test.tsx index 9a4f6a5c811e..b2c81ab122a3 100644 --- a/apps/web/src/panels/panelRegistry.test.tsx +++ b/apps/web/src/panels/panelRegistry.test.tsx @@ -4,11 +4,18 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { createPanelRegistry } from "./panelRegistry"; +const metadata = { + icon: () => null, + launcherKey: "X", + unavailableHint: "Unavailable.", + unavailableReason: "Unavailable here.", +} as const; + // These exercise lazy evaluation and actual mount/disposal, without inspecting markup. describe("panel registry", () => { it("rejects duplicate ids without loading either panel", () => { const load = vi.fn(); - const definition = { id: "diff", load } as const; + const definition = { ...metadata, id: "diff", title: "Diff", load } as const; expect(() => createPanelRegistry([definition, definition])).toThrow("Duplicate panel id: diff"); expect(load).not.toHaveBeenCalled(); }); @@ -28,9 +35,9 @@ describe("panel registry", () => { return null; } const load = vi.fn(() => loaded); - const registry = createPanelRegistry([{ id: "diff", load }]); + const registry = createPanelRegistry([{ ...metadata, id: "diff", title: "Diff", load }]); expect(load).not.toHaveBeenCalled(); - const Component = registry.get("diff")!.Component; + const Component = registry.get("diff").Component; let renderer!: ReactTestRenderer; await act(async () => { renderer = create( @@ -47,7 +54,7 @@ describe("panel registry", () => { }); expect(mounted).toHaveBeenCalledTimes(1); // A parent re-render looks the panel up again; it must keep the same mount and state. - const Again = registry.get("diff")!.Component; + const Again = registry.get("diff").Component; expect(Again).toBe(Component); await act(async () => { renderer.update( @@ -64,4 +71,74 @@ describe("panel registry", () => { }); expect(disposed).toHaveBeenCalledTimes(1); }); + + it("keeps heterogeneous panels independent and their lazy identities stable", async () => { + const events: string[] = []; + function Notes({ text }: { text: string }) { + useEffect(() => { + events.push(`mount notes:${text}`); + return () => { + events.push("dispose notes"); + }; + }, [text]); + return null; + } + function Counter({ count }: { count: number }) { + useEffect(() => { + events.push(`mount counter:${count}`); + }, [count]); + return null; + } + const loadNotes = vi.fn(async () => ({ default: Notes })); + const loadCounter = vi.fn(async () => ({ default: Counter })); + const registry = createPanelRegistry([ + { ...metadata, id: "notes", title: "Notes", load: loadNotes }, + { ...metadata, id: "counter", title: "Counter", load: loadCounter }, + ]); + const NotesPanel = registry.get("notes").Component; + // Compile-only: the project typecheck rejects these pairings. + const compileOnly = () => [ + // @ts-expect-error Missing required text. + , + // @ts-expect-error Counter's props on Notes. + , + // @ts-expect-error Unknown id. + registry.get("missing"), + ]; + expect(compileOnly).toBeTypeOf("function"); + expect(registry.get("notes").Component).toBe(NotesPanel); + expect(loadNotes).not.toHaveBeenCalled(); + expect(loadCounter).not.toHaveBeenCalled(); + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create( + + + , + ); + }); + await act(async () => { + renderer.update( + + + , + ); + }); + expect(loadNotes).toHaveBeenCalledTimes(1); + expect(loadCounter).not.toHaveBeenCalled(); + expect(events).toEqual(["mount notes:a"]); + + const CounterPanel = registry.get("counter").Component; + await act(async () => { + renderer.update( + + + , + ); + }); + expect(loadCounter).toHaveBeenCalledTimes(1); + expect(events).toEqual(["mount notes:a", "dispose notes", "mount counter:2"]); + expect(registry.get("counter").Component).toBe(CounterPanel); + }); }); diff --git a/apps/web/src/panels/panelRegistry.ts b/apps/web/src/panels/panelRegistry.ts index 48c62e04a076..c2ef77aaa883 100644 --- a/apps/web/src/panels/panelRegistry.ts +++ b/apps/web/src/panels/panelRegistry.ts @@ -1,18 +1,58 @@ -import { lazy, type ComponentType } from "react"; +import { lazy, type ComponentType, type ReactNode } from "react"; -export interface PanelDefinition { +/** Panel bodies are function components; their props are inferred per id. */ +type PanelBody = (props: never) => ReactNode; + +export interface PanelDefinition { id: Id; - load: () => Promise<{ default: ComponentType }>; + /** Launcher label and tab title fallback. */ + title: string; + icon: ComponentType<{ className?: string }>; + /** Letter that opens the panel from the empty launcher and the add menu. */ + launcherKey: string; + /** Whether this client can show the panel at all, such as desktop-only panels on web. */ + isSupported?: () => boolean; + /** One-line reason shown in the empty launcher while unavailable. */ + unavailableHint: string; + /** Full reason shown in the add menu tooltip while unavailable. */ + unavailableReason: string; + load: () => Promise<{ default: Body }>; } +type AnyPanelDefinition = PanelDefinition; + +/** Everything a launcher or tab may read about a panel without loading it. */ +export type PanelMetadata = Omit; + +/** Props of the body a definition lazily loads, inferred from its `load` import. */ +export type PanelProps = Awaited< + ReturnType +>["default"] extends (props: infer Props) => ReactNode + ? Props + : never; + +// Distributes, so a widened id yields a union of bodies rather than a body accepting either props. +type RegisteredPanel = Definition extends AnyPanelDefinition + ? Definition & { Component: ComponentType> } + : never; + // Registration only creates lazy component identities. Reads and workers belong to mounts. -export function createPanelRegistry( - definitions: readonly PanelDefinition[], +export function createPanelRegistry( + definitions: readonly Definition[], ) { - const panels = new Map & { Component: ComponentType }>(); + const panels = new Map }>(); for (const definition of definitions) { if (panels.has(definition.id)) throw new Error(`Duplicate panel id: ${definition.id}`); - panels.set(definition.id, { ...definition, Component: lazy(definition.load) }); + const load = definition.load as () => Promise<{ default: ComponentType }>; + panels.set(definition.id, { ...definition, Component: lazy(load) }); } - return { get: (id: Id) => panels.get(id) }; + return { + definitions, + // lazy() erases the per-id props; get() restores them from the definition's id. + get: (id: Id) => { + const panel = panels.get(id); + if (!panel) throw new Error(`Unknown panel id: ${id}`); + return panel as RegisteredPanel>; + }, + }; } diff --git a/apps/web/src/panels/preview/PreviewSidePanel.test.tsx b/apps/web/src/panels/preview/PreviewSidePanel.test.tsx new file mode 100644 index 000000000000..f71ed8e97828 --- /dev/null +++ b/apps/web/src/panels/preview/PreviewSidePanel.test.tsx @@ -0,0 +1,275 @@ +import { + BUILT_IN_BROWSER_PROFILES, + DEFAULT_BROWSER_PROFILE_ID, + DEFAULT_PREVIEW_APPEARANCE, + DEFAULT_PREVIEW_ZOOM_FACTOR, + EnvironmentId, + FILL_PREVIEW_VIEWPORT, + ThreadId, + type PreviewAnnotationPayload, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { act, useEffect, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +// Drives the registered Preview through the real RegisteredSidePanel -> +// PreviewSidePanel -> PreviewView path. Only the desktop bridge, stores and +// leaf chrome are stubbed; the chrome row stub exposes the pick button. +const mocks = vi.hoisted(() => ({ + pickElement: vi.fn(), + cancelPickElement: vi.fn(async (_runtimeTabId: string) => undefined), + addPreviewAnnotation: vi.fn(), + togglePick: null as (() => void) | null, + pickActive: false, + mounts: 0, + disposals: 0, +})); + +const BROWSER_DEFAULTS = { + viewport: FILL_PREVIEW_VIEWPORT, + zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, + appearance: DEFAULT_PREVIEW_APPEARANCE, + autoShowFloatingPreview: true, + profiles: BUILT_IN_BROWSER_PROFILES, + profileId: DEFAULT_BROWSER_PROFILE_ID, +}; + +vi.mock("~/components/preview/previewBridge", () => ({ + previewBridge: { pickElement: mocks.pickElement, cancelPickElement: mocks.cancelPickElement }, +})); +vi.mock("~/components/preview/usePreviewSession", () => ({ + // Lives exactly as long as the PreviewView instance, so it counts remounts. + usePreviewSession: () => { + useEffect(() => { + mocks.mounts += 1; + return () => { + mocks.disposals += 1; + }; + }, []); + }, +})); +vi.mock("~/components/preview/PreviewChromeRow", () => ({ + PreviewChromeRow: (props: { onPickElement?: () => void; pickActive?: boolean }) => { + mocks.togglePick = props.onPickElement ?? null; + mocks.pickActive = props.pickActive ?? false; + return null; + }, +})); +vi.mock("~/components/preview/PreviewPanelShell", () => ({ + PreviewPanelShell: (props: { children: ReactNode }) => props.children, +})); +vi.mock("~/components/preview/PreviewEmptyState", () => ({ PreviewEmptyState: () => null })); +vi.mock("~/components/preview/PreviewMoreMenu", () => ({ PreviewMoreMenu: () => null })); +vi.mock("~/components/preview/PreviewUnreachable", () => ({ PreviewUnreachable: () => null })); +vi.mock("~/components/preview/ZoomIndicator", () => ({ ZoomIndicator: () => null })); +vi.mock("~/components/preview/AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); +vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null })); +vi.mock("~/browser/browserSurfaceStore", () => ({ + useBrowserSurfaceStore: (select: (state: { byTabId: object }) => unknown) => + select({ byTabId: {} }), +})); +vi.mock("~/browser/browserDefaults", () => ({ + useBrowserDefaults: () => BROWSER_DEFAULTS, + getBrowserDefaults: () => BROWSER_DEFAULTS, + browserResponsiveViewportForToggle: () => FILL_PREVIEW_VIEWPORT, +})); +vi.mock("~/browser/browserRecording", () => ({ + findActiveBrowserRecordingRuntimeTabId: () => null, + isBrowserRecordingStartCancelledError: () => false, + startBrowserRecording: vi.fn(), + stopBrowserRecording: vi.fn(), + useActiveBrowserRecordingTabIds: () => new Set(), +})); +vi.mock("~/browserHistoryStore", () => ({ + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT: 50, + recordVisitForThread: vi.fn(), + removeUrlForThread: vi.fn(), + setTitleForThreadUrl: vi.fn(), + useThreadRecentHistory: () => [], +})); +vi.mock("~/composerDraftStore", () => ({ + useComposerDraftStore: (select: (store: object) => unknown) => + select({ addPreviewAnnotation: mocks.addPreviewAnnotation, addImage: vi.fn() }), +})); +vi.mock("~/localApi", () => ({ ensureLocalApi: vi.fn() })); +vi.mock("~/previewStateStore", () => ({ + isPreviewSupportedInRuntime: () => true, + rememberPreviewUrl: vi.fn(), + updatePreviewServerSnapshot: vi.fn(), + useThreadPreviewState: (threadRef: ScopedThreadRef) => ({ + activeTabId: "tab-1", + serverEpoch: null, + desktopByTabId: {}, + recentlySeenUrls: [], + sessions: { + "tab-1": { + threadId: threadRef.threadId, + tabId: "tab-1", + navStatus: { _tag: "Success", url: "http://localhost:3000/", title: "App" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-10-04T00:00:00.000Z", + }, + }, + }), +})); +vi.mock("~/previewMiniPlayerStore", () => ({ + browserMiniPlayerSource: (tabId: string) => ({ kind: "browser", tabId }), + selectThreadPreviewMiniPlayerTabId: () => null, + usePreviewMiniPlayerStore: Object.assign( + (select: (state: { byThreadKey: object }) => unknown) => select({ byThreadKey: {} }), + { getState: () => ({ open: vi.fn(), close: vi.fn() }) }, + ), +})); +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { getState: () => ({ close: vi.fn() }) }, +})); +vi.mock("~/state/environments", () => ({ + useEnvironment: () => ({ label: "Local" }), + useEnvironmentHttpBaseUrl: () => "http://localhost:3773", + usePrimaryEnvironmentId: () => null, +})); +vi.mock("~/state/preview", () => ({ previewEnvironment: { open: {}, resize: {} } })); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +// This client holds every scope, so preview and annotation sends stay enabled. +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +vi.mock("~/components/ui/toast", () => ({ + stackedThreadToast: vi.fn(), + toastManager: { add: vi.fn() }, +})); + +import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +function thread(id: string): ScopedThreadRef { + return { environmentId: EnvironmentId.make("environment-1"), threadId: ThreadId.make(id) }; +} + +function hostFor(threadRef: ScopedThreadRef): PanelHost { + // A fresh closure per render, as ChatView lends it. + return { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: vi.fn(), + }; +} + +function panel(host: PanelHost) { + return ( + + + + ); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + +const annotation: PreviewAnnotationPayload = { + id: "annotation-1", + pageUrl: "http://localhost:3000/", + pageTitle: "App", + comment: "Tighten this spacing", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-10-04T00:00:00.000Z", +}; + +describe("registered Preview side panel", () => { + let renderer: ReactTestRenderer | null = null; + + // The registry loads the body lazily; warm the module so a render settles in one act. + beforeAll(() => import("./PreviewSidePanel")); + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + mocks.pickElement.mockReset(); + mocks.cancelPickElement.mockClear(); + mocks.addPreviewAnnotation.mockClear(); + mocks.togglePick = null; + mocks.pickActive = false; + mocks.mounts = 0; + mocks.disposals = 0; + }); + + afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = null; + vi.unstubAllGlobals(); + }); + + async function render(host: PanelHost) { + await act(async () => { + if (renderer) renderer.update(panel(host)); + else renderer = create(panel(host)); + }); + } + + async function startPick() { + const pick = deferred<{ annotation: PreviewAnnotationPayload; submission: "send" } | null>(); + mocks.pickElement.mockReturnValueOnce(pick.promise); + await act(async () => mocks.togglePick?.()); + return pick; + } + + it("keeps an in-flight pick across a same-thread host re-render", async () => { + const threadA = thread("thread-a"); + const first = hostFor(threadA); + await render(first); + const pick = await startPick(); + expect(mocks.pickActive).toBe(true); + + const second = hostFor({ ...threadA }); + await render(second); + expect(mocks).toMatchObject({ mounts: 1, disposals: 0, pickActive: true }); + expect(mocks.cancelPickElement).not.toHaveBeenCalled(); + + await act(async () => pick.resolve({ annotation, submission: "send" })); + expect(mocks.pickActive).toBe(false); + // The pick reports to the render that started it, as before the registry. + expect(first.sendAnnotation).toHaveBeenCalledWith(annotation, null); + expect(second.sendAnnotation).not.toHaveBeenCalled(); + expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(threadA, annotation); + }); + + it("stays mounted across a thread switch and drops a pick that settles after it", async () => { + const threadA = thread("thread-a"); + const threadB = thread("thread-b"); + const hostA = hostFor(threadA); + await render(hostA); + const pick = await startPick(); + expect(mocks.pickElement).toHaveBeenCalledWith(previewRuntimeTabId(threadA, null, "tab-1")); + + const hostB = hostFor(threadB); + await render(hostB); + expect(mocks).toMatchObject({ mounts: 1, disposals: 0, pickActive: false }); + // The old thread's picker is told to stop; the panel itself is not torn down. + expect(mocks.cancelPickElement).toHaveBeenCalledWith( + previewRuntimeTabId(threadA, null, "tab-1"), + ); + + // The switch cancelled the pick, so its late result reaches neither thread. + await act(async () => pick.resolve({ annotation, submission: "send" })); + expect(hostA.sendAnnotation).not.toHaveBeenCalled(); + expect(hostB.sendAnnotation).not.toHaveBeenCalled(); + expect(mocks.addPreviewAnnotation).not.toHaveBeenCalled(); + expect(mocks.disposals).toBe(0); + }); +}); diff --git a/apps/web/src/components/preview/PreviewPanel.tsx b/apps/web/src/panels/preview/PreviewSidePanel.tsx similarity index 58% rename from apps/web/src/components/preview/PreviewPanel.tsx rename to apps/web/src/panels/preview/PreviewSidePanel.tsx index 81501ae7d727..8a2cdc571b86 100644 --- a/apps/web/src/components/preview/PreviewPanel.tsx +++ b/apps/web/src/panels/preview/PreviewSidePanel.tsx @@ -1,43 +1,28 @@ "use client"; -import { - AuthPreviewOperateScope, - type PreviewAnnotationPayload, - type ScopedThreadRef, -} from "@t3tools/contracts"; +import { AuthPreviewOperateScope } from "@t3tools/contracts"; -import type { ComposerImageAttachment } from "~/composerDraftStore"; +import { PreviewPanelShell } from "~/components/preview/PreviewPanelShell"; +import { PreviewView } from "~/components/preview/PreviewView"; import { usePreviewAvailable } from "~/browser/previewRuntime"; import { useEnvironmentScope } from "~/state/session"; -import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell"; -import { PreviewView } from "./PreviewView"; +import { usePanelHost } from "../panelHost"; -interface Props { - mode: PreviewPanelMode; - threadRef: ScopedThreadRef; +interface PreviewSidePanelProps { tabId?: string | null; configuredUrls?: ReadonlyArray | undefined; - visible: boolean; - onSendAnnotation?: ( - annotation: PreviewAnnotationPayload, - image: ComposerImageAttachment | null, - ) => void; } -export function PreviewPanel({ - mode, - threadRef, - tabId, - configuredUrls, - visible, - onSendAnnotation, -}: Props) { +// RightPanelTabs owns placement, so the side panel is always embedded. +export default function PreviewSidePanel({ tabId, configuredUrls }: PreviewSidePanelProps) { + const { threadRef, visible, sendAnnotation } = usePanelHost(); + // The desktop app hosts browsers itself; other clients need an environment that runs them. const available = usePreviewAvailable(threadRef.environmentId); const canOperatePreview = useEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope); if (!canOperatePreview || !available) { return ( - +

{canOperatePreview @@ -50,13 +35,13 @@ export function PreviewPanel({ } return ( - + ); diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index b3d1b140aece..b4218af1036b 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -276,6 +276,10 @@ const NO_LIST_TARGETS: ReadonlyArray(); +const UNAVAILABLE_SIDE_PANELS = { + preview: { available: false, onOpen: () => undefined }, + diff: { available: false, onOpen: () => undefined }, +}; const EMPTY_PENDING_SURFACES = new Set(); const MAX_SEARCH_LABEL_CANDIDATES = 100; @@ -2284,17 +2288,14 @@ function PullRequestsRouteView() { useRightPanelStore.getState().moveSurface(rightPanelRef, surfaceId, toIndex); }} onCopyFilePath={() => undefined} - onAddBrowser={() => undefined} + panels={UNAVAILABLE_SIDE_PANELS} onAddBrowserInProfile={() => undefined} onAddTerminal={() => undefined} - onAddDiff={() => undefined} onAddFiles={() => undefined} onAddPullRequest={() => undefined} onAddPullRequests={() => undefined} onAddDevice={() => undefined} - browserAvailable={false} terminalAvailable={false} - diffAvailable={false} filesAvailable={false} pullRequestAvailable={false} pullRequestsAvailable={false} From 4851ae8c5034aa89f1201917c03b311d764a4581 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:37:25 +1100 Subject: [PATCH 03/22] perf(web): keep the panel host stable across chat view renders ChatView built a new PanelHost on every render, so every usePanelHost consumer re-rendered even when no host field changed. Memoize it on its fields and send annotations through onSendRef so the sender stays stable. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatView.tsx | 32 +++++++++++++++++----------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cfba3ddf53c4..323a303703da 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -11071,6 +11071,25 @@ export default function ChatView(props: ChatViewProps) { pendingSidebarFileDrops, ]); + // Memoized so mounted panels re-render only when a host field changes. + const panelHost = useMemo( + () => + activeThreadRef + ? { + threadRef: activeThreadRef, + visible: rightPanelOpen, + composerDraftTarget, + workspaceMutationId, + // PreviewView drops a pick that settles after a thread switch, so the + // latest send only ever receives picks for the thread it belongs to. + sendAnnotation: (annotation, image) => { + void onSendRef.current(undefined, "auto", "foreground", { annotation, image }); + }, + } + : null, + [activeThreadRef, composerDraftTarget, rightPanelOpen, workspaceMutationId], + ); + // Empty state: no active thread if (!activeThread) { return ; @@ -11220,19 +11239,6 @@ export default function ChatView(props: ChatViewProps) { ) : null ) : null; - const panelHost: PanelHost | null = activeThreadRef - ? { - threadRef: activeThreadRef, - visible: rightPanelOpen, - composerDraftTarget, - workspaceMutationId, - // PreviewView drops a pick that settles after a thread switch, so this - // render's closure only ever sends for its own thread. - sendAnnotation: (annotation, image) => { - void onSend(undefined, "auto", "foreground", { annotation, image }); - }, - } - : null; const rightPanelContent = ( {rightPanelSurfaceContent} ); From 30972c60d8a3eccbc49aaf7ca569e1c3d48fb08a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:08:00 +1100 Subject: [PATCH 04/22] fix(web): send a late annotation only to the thread it was picked in Plain server threads reuse one ChatView, so the memoized panel host's sender could resolve to the next thread's composer when a pick settled after a switch. The latest sender now carries its thread key, and each host forwards only to a sender for its own thread. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatView.tsx | 35 +++++++++--- apps/web/src/panels/panelHost.test.ts | 77 +++++++++++++++++++++++++++ apps/web/src/panels/panelHost.ts | 22 ++++++++ 3 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/panels/panelHost.test.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 323a303703da..bdae564c20d2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -168,7 +168,12 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; import { isMacPlatform } from "../lib/utils"; import { RegisteredSidePanel } from "~/panels/bundledPanels"; -import { PanelHostContext, type PanelHost } from "~/panels/panelHost"; +import { + PanelHostContext, + threadBoundAnnotationSender, + type PanelHost, + type ThreadAnnotationSender, +} from "~/panels/panelHost"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; import { @@ -11071,6 +11076,17 @@ export default function ChatView(props: ChatViewProps) { pendingSidebarFileDrops, ]); + // Plain server threads share one ChatView, so the sender carries its thread + // and a host only forwards to a sender for the thread it was built for. + const annotationSenderRef = useRef(null); + annotationSenderRef.current = activeThreadKey + ? { + threadKey: activeThreadKey, + send: (annotation, image) => { + void onSend(undefined, "auto", "foreground", { annotation, image }); + }, + } + : null; // Memoized so mounted panels re-render only when a host field changes. const panelHost = useMemo( () => @@ -11080,14 +11096,19 @@ export default function ChatView(props: ChatViewProps) { visible: rightPanelOpen, composerDraftTarget, workspaceMutationId, - // PreviewView drops a pick that settles after a thread switch, so the - // latest send only ever receives picks for the thread it belongs to. - sendAnnotation: (annotation, image) => { - void onSendRef.current(undefined, "auto", "foreground", { annotation, image }); - }, + sendAnnotation: threadBoundAnnotationSender( + () => annotationSenderRef.current, + scopedThreadKey(activeThreadRef), + ), } : null, - [activeThreadRef, composerDraftTarget, rightPanelOpen, workspaceMutationId], + [ + activeThreadRef, + annotationSenderRef, + composerDraftTarget, + rightPanelOpen, + workspaceMutationId, + ], ); // Empty state: no active thread diff --git a/apps/web/src/panels/panelHost.test.ts b/apps/web/src/panels/panelHost.test.ts new file mode 100644 index 000000000000..5a2423868c5b --- /dev/null +++ b/apps/web/src/panels/panelHost.test.ts @@ -0,0 +1,77 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId, type PreviewAnnotationPayload } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { threadBoundAnnotationSender, type ThreadAnnotationSender } from "./panelHost"; + +const THREAD_A = scopedThreadKey( + scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-a")), +); +const THREAD_B = scopedThreadKey( + scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-b")), +); +const THREAD_A_ELSEWHERE = scopedThreadKey( + scopeThreadRef(EnvironmentId.make("environment-2"), ThreadId.make("thread-a")), +); + +const annotation: PreviewAnnotationPayload = { + id: "annotation-1", + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + comment: "Tighten this spacing", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-07-27T00:00:00.000Z", +}; + +describe("threadBoundAnnotationSender", () => { + it("drops a pick that settles after the chat view moved to another thread", async () => { + const sendA = vi.fn(); + const sendB = vi.fn(); + const senderRef: { current: ThreadAnnotationSender | null } = { + current: { threadKey: THREAD_A, send: sendA }, + }; + const hostSendForA = threadBoundAnnotationSender(() => senderRef.current, THREAD_A); + + let resolvePick!: (value: PreviewAnnotationPayload) => void; + const pick = new Promise((resolve) => { + resolvePick = resolve; + }); + const settled = pick.then((picked) => hostSendForA(picked, null)); + + // The shared ChatView renders thread B before A's pick is cancelled. + senderRef.current = { threadKey: THREAD_B, send: sendB }; + resolvePick(annotation); + await settled; + + expect(sendB).not.toHaveBeenCalled(); + expect(sendA).not.toHaveBeenCalled(); + }); + + it("treats the same thread id in another environment as a different thread", () => { + const sendElsewhere = vi.fn(); + const senderRef = { current: { threadKey: THREAD_A_ELSEWHERE, send: sendElsewhere } }; + + threadBoundAnnotationSender(() => senderRef.current, THREAD_A)(annotation, null); + + expect(sendElsewhere).not.toHaveBeenCalled(); + }); + + it("uses the newest sender after a same-thread rerender", () => { + const firstRender = vi.fn(); + const latestRender = vi.fn(); + const senderRef: { current: ThreadAnnotationSender | null } = { + current: { threadKey: THREAD_A, send: firstRender }, + }; + const hostSend = threadBoundAnnotationSender(() => senderRef.current, THREAD_A); + + senderRef.current = { threadKey: THREAD_A, send: latestRender }; + hostSend(annotation, null); + + expect(firstRender).not.toHaveBeenCalled(); + expect(latestRender).toHaveBeenCalledWith(annotation, null); + }); +}); diff --git a/apps/web/src/panels/panelHost.ts b/apps/web/src/panels/panelHost.ts index 20761ca54c28..09805d1f3227 100644 --- a/apps/web/src/panels/panelHost.ts +++ b/apps/web/src/panels/panelHost.ts @@ -24,6 +24,28 @@ export interface PanelHost { ) => void; } +/** The newest annotation sender, tagged with the thread whose render created it. */ +export interface ThreadAnnotationSender { + readonly threadKey: string; + readonly send: PanelHost["sendAnnotation"]; +} + +/** + * A stable `sendAnnotation` for one thread. It forwards to the newest sender + * only while that sender belongs to the same thread, so a pick that settles + * after the chat view moved to another thread is never sent there. + */ +export function threadBoundAnnotationSender( + latestSender: () => ThreadAnnotationSender | null, + threadKey: string, +): PanelHost["sendAnnotation"] { + return (annotation, image) => { + const latest = latestSender(); + if (latest?.threadKey !== threadKey) return; + latest.send(annotation, image); + }; +} + export const PanelHostContext = createContext(null); export function usePanelHost(): PanelHost { From 52a78e70a1985a34b77636d107185e6e09e5256f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:59:29 +1100 Subject: [PATCH 05/22] fix(web): lend the annotation sender only after the chat view commits ChatView replaced the panel host's annotation sender while rendering. If React threw that render away, an in-flight preview pick could still call its onSend, for example one that edits a queued message instead of sending a turn. Update the sender in a layout effect so only committed renders lend it. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatView.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bdae564c20d2..b9ef9b9ec4e9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -11078,15 +11078,18 @@ export default function ChatView(props: ChatViewProps) { // Plain server threads share one ChatView, so the sender carries its thread // and a host only forwards to a sender for the thread it was built for. + // Updated after commit so a discarded render cannot lend its `onSend`. const annotationSenderRef = useRef(null); - annotationSenderRef.current = activeThreadKey - ? { - threadKey: activeThreadKey, - send: (annotation, image) => { - void onSend(undefined, "auto", "foreground", { annotation, image }); - }, - } - : null; + useLayoutEffect(() => { + annotationSenderRef.current = activeThreadKey + ? { + threadKey: activeThreadKey, + send: (annotation, image) => { + void onSend(undefined, "auto", "foreground", { annotation, image }); + }, + } + : null; + }); // Memoized so mounted panels re-render only when a host field changes. const panelHost = useMemo( () => From 7be7fb543d7680110f9b6663ecf33fee11075c2a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:58:28 +1100 Subject: [PATCH 06/22] refactor(web): move the thread terminal panel and drawer out of ChatView PersistentThreadTerminalPanel, PersistentThreadTerminalDrawer, their two reconciliation helpers and the terminal launch-context types now live in apps/web/src/panels/terminal. The moved code is unchanged apart from the added export keywords; ChatView imports them and its call sites, props, memo boundaries and callbacks are untouched. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatView.tsx | 629 +----------------- .../PersistentThreadTerminalDrawer.tsx | 467 +++++++++++++ .../PersistentThreadTerminalPanel.tsx | 192 ++++++ 3 files changed, 664 insertions(+), 624 deletions(-) create mode 100644 apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx create mode 100644 apps/web/src/panels/terminal/PersistentThreadTerminalPanel.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b9ef9b9ec4e9..72cc3b25fedb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -70,7 +70,6 @@ import { type PreviewAnnotationPayload, ProviderInstanceId, type ServerProvider, - type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, type ThreadLinkedPullRequest, @@ -152,7 +151,6 @@ import { Atom } from "effect/reactivity"; import { Fragment, lazy, - memo, type SetStateAction, Suspense, useCallback, @@ -174,6 +172,11 @@ import { type PanelHost, type ThreadAnnotationSender, } from "~/panels/panelHost"; +import { PersistentThreadTerminalDrawer } from "~/panels/terminal/PersistentThreadTerminalDrawer"; +import { + PersistentThreadTerminalPanel, + type TerminalLaunchContext, +} from "~/panels/terminal/PersistentThreadTerminalPanel"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; import { @@ -305,7 +308,6 @@ import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { isEditableFocused } from "../lib/editableFocus"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { resolveChatShortcutCommand, shortcutLabelForCommand } from "../keybindings"; -import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, CheckCircle2Icon, @@ -850,14 +852,6 @@ type ChatViewProps = draftId: DraftId; }; -interface TerminalLaunchContext { - threadId: ThreadId; - cwd: string; - worktreePath: string | null; -} - -type PersistentTerminalLaunchContext = Pick; - function useLocalDispatchState(input: { activeThread: Thread | undefined; activeLatestRun: Thread["latestRun"] | null; @@ -928,619 +922,6 @@ function useLocalDispatchState(input: { }; } -/** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */ -function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean { - if (left.length !== right.length) { - return false; - } - if (left.length === 0) { - return true; - } - const sortedLeft = left.toSorted((a, b) => a.localeCompare(b)); - const sortedRight = right.toSorted((a, b) => a.localeCompare(b)); - for (let index = 0; index < sortedLeft.length; index += 1) { - if (sortedLeft[index] !== sortedRight[index]) { - return false; - } - } - return true; -} - -/** - * Server knows about fewer sessions than the client, but every server id still exists locally. - * Typical right after `terminal.open`: known-session list lags; reconciling would drop the new id - * and later re-add it as a separate group (no split layout). - */ -function serverTerminalIdsStrictSubsetOfClient( - serverIds: readonly string[], - clientIds: readonly string[], -): boolean { - if (serverIds.length >= clientIds.length || clientIds.length === 0) { - return false; - } - const clientSet = new Set(clientIds); - for (const id of serverIds) { - if (!clientSet.has(id)) { - return false; - } - } - return true; -} - -interface PersistentThreadTerminalDrawerProps { - threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; - threadId: ThreadId; - active: boolean; - launchContext: PersistentTerminalLaunchContext | null; - focusRequestId: number; - splitShortcutLabel: string | undefined; - splitVerticalShortcutLabel: string | undefined; - newShortcutLabel: string | undefined; - closeShortcutLabel: string | undefined; - keybindings: ResolvedKeybindingsConfig; - onAddTerminalContext: (selection: TerminalContextSelection) => void; -} - -const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ - threadRef, - threadId, - active, - launchContext, - focusRequestId, - splitShortcutLabel, - splitVerticalShortcutLabel, - newShortcutLabel, - closeShortcutLabel, - keybindings, - onAddTerminalContext, -}: PersistentThreadTerminalDrawerProps) { - const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); - const hasTerminalWriteAccess = useCallback( - () => readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope), - [threadRef.environmentId], - ); - const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); - const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); - const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); - const serverThread = useThreadShell(threadRef); - const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const projectRef = serverThread - ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) - : draftThread - ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) - : null; - const project = useProject(projectRef); - const terminalUiState = useTerminalUiStateStore((state) => - selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), - ); - const visible = active && terminalUiState.terminalOpen; - const knownTerminalSessions = useKnownTerminalSessions({ - environmentId: threadRef.environmentId, - threadId, - }); - const panelSurfaces = useRightPanelStore( - (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, - ); - const panelTerminalIds = useMemo( - () => - new Set( - panelSurfaces.flatMap((surface) => - surface.kind === "terminal" ? surface.terminalIds : [], - ), - ), - [panelSurfaces], - ); - const drawerTerminalSessions = useMemo( - () => - knownTerminalSessions?.filter( - (session) => !panelTerminalIds.has(session.target.terminalId), - ) ?? [], - [knownTerminalSessions, panelTerminalIds], - ); - const terminalLabelsById = useMemo(() => { - const next = new Map(); - for (const session of drawerTerminalSessions) { - next.set( - session.target.terminalId, - resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), - ); - } - return next; - }, [drawerTerminalSessions]); - const terminalLaunchLocationsById = useMemo(() => { - const next = new Map< - string, - { - readonly cwd: string; - readonly worktreePath: string | null; - readonly runtimeEnv: Record; - } - >(); - if (!project) { - return next; - } - - for (const session of drawerTerminalSessions) { - const summary = session.state.summary; - if (!summary) { - continue; - } - const worktreePathForLaunch = - launchContext !== null ? launchContext.worktreePath : summary.worktreePath; - next.set(session.target.terminalId, { - cwd: launchContext?.cwd ?? summary.cwd, - worktreePath: worktreePathForLaunch, - runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: worktreePathForLaunch, - }), - }); - } - - return next; - }, [drawerTerminalSessions, launchContext, project]); - const serverOrderedTerminalIds = useMemo( - () => drawerTerminalSessions.map((session) => session.target.terminalId), - [drawerTerminalSessions], - ); - // Every client-side id source participates in allocation: the server list - // lags fresh opens, and panel terminals are filtered out of the drawer's - // sessions — an id collision attaches two viewports to one PTY session. - const allocatableTerminalIds = useMemo( - () => [ - ...new Set([ - ...serverOrderedTerminalIds, - ...terminalUiState.terminalIds, - ...panelTerminalIds, - ]), - ], - [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds], - ); - const allocateTerminalId = useCallback( - () => - nextTerminalId( - allocatableTerminalIds, - knownTerminalSessions === null || - !readEnvironmentScope(threadRef.environmentId, AuthTerminalReadScope) - ? randomUUID() - : undefined, - ), - [allocatableTerminalIds, knownTerminalSessions, threadRef.environmentId], - ); - const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); - const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); - const storeSplitTerminalVertical = useTerminalUiStateStore( - (state) => state.splitTerminalVertical, - ); - const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); - const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); - const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); - const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); - - useEffect(() => { - if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) { - return; - } - if ( - serverTerminalIdsStrictSubsetOfClient(serverOrderedTerminalIds, terminalUiState.terminalIds) - ) { - return; - } - reconcileTerminalIds(threadRef, serverOrderedTerminalIds); - }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]); - const [localFocusRequestId, setLocalFocusRequestId] = useState(0); - const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const effectiveWorktreePath = useMemo(() => { - if (launchContext !== null) { - return launchContext.worktreePath; - } - return worktreePath; - }, [launchContext, worktreePath]); - const cwd = useMemo( - () => - launchContext?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.workspaceRoot }, - worktreePath: effectiveWorktreePath, - }) - : null), - [effectiveWorktreePath, launchContext?.cwd, project], - ); - const runtimeEnv = useMemo( - () => - project - ? projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: effectiveWorktreePath, - }) - : {}, - [effectiveWorktreePath, project], - ); - - const bumpFocusRequestId = useCallback(() => { - if (!visible) { - return; - } - setLocalFocusRequestId((value) => value + 1); - }, [visible]); - - const setTerminalHeight = useCallback( - (height: number) => { - storeSetTerminalHeight(threadRef, height); - }, - [storeSetTerminalHeight, threadRef], - ); - - const splitTerminal = useCallback(() => { - if (!hasTerminalWriteAccess() || !cwd) { - return; - } - const terminalId = allocateTerminalId(); - storeSplitTerminal(threadRef, terminalId); - bumpFocusRequestId(); - void openTerminal({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }, - }); - }, [ - allocateTerminalId, - bumpFocusRequestId, - cwd, - effectiveWorktreePath, - runtimeEnv, - storeSplitTerminal, - threadId, - threadRef, - openTerminal, - hasTerminalWriteAccess, - ]); - const splitTerminalVertical = useCallback(() => { - if (!hasTerminalWriteAccess() || !cwd) { - return; - } - const terminalId = allocateTerminalId(); - storeSplitTerminalVertical(threadRef, terminalId); - bumpFocusRequestId(); - void openTerminal({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }, - }); - }, [ - allocateTerminalId, - bumpFocusRequestId, - cwd, - effectiveWorktreePath, - openTerminal, - hasTerminalWriteAccess, - runtimeEnv, - storeSplitTerminalVertical, - threadId, - threadRef, - ]); - - const createNewTerminal = useCallback(() => { - if (!hasTerminalWriteAccess() || !cwd) { - return; - } - const terminalId = allocateTerminalId(); - storeNewTerminal(threadRef, terminalId); - bumpFocusRequestId(); - void openTerminal({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }, - }); - }, [ - bumpFocusRequestId, - cwd, - effectiveWorktreePath, - allocateTerminalId, - runtimeEnv, - storeNewTerminal, - threadId, - threadRef, - openTerminal, - hasTerminalWriteAccess, - ]); - - const activateTerminal = useCallback( - (terminalId: string) => { - storeSetActiveTerminal(threadRef, terminalId); - bumpFocusRequestId(); - }, - [bumpFocusRequestId, storeSetActiveTerminal, threadRef], - ); - - const closeTerminal = useCallback( - (terminalId: string) => { - if (!hasTerminalWriteAccess()) return; - const fallbackExitWrite = () => - writeTerminal({ - environmentId: threadRef.environmentId, - input: { threadId, terminalId, data: "exit\n" }, - }); - - void (async () => { - const closeResult = await closeTerminalMutation({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - deleteHistory: true, - }, - }); - if ( - closeResult._tag === "Failure" && - !isAtomCommandInterrupted(closeResult) && - hasTerminalWriteAccess() - ) { - await fallbackExitWrite(); - } - })(); - - storeCloseTerminal(threadRef, terminalId); - bumpFocusRequestId(); - }, - [ - bumpFocusRequestId, - storeCloseTerminal, - threadId, - threadRef, - closeTerminalMutation, - hasTerminalWriteAccess, - writeTerminal, - ], - ); - - const handleAddTerminalContext = useCallback( - (selection: TerminalContextSelection) => { - if (!visible) { - return; - } - onAddTerminalContext(selection); - }, - [onAddTerminalContext, visible], - ); - - if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) { - return null; - } - - return ( -

-
- -
-
- ); -}); - -interface PersistentThreadTerminalPanelProps { - visible: boolean; - threadRef: ScopedThreadRef; - surface: Extract; - launchContext: PersistentTerminalLaunchContext | null; - focusRequestId: number; - keybindings: ResolvedKeybindingsConfig; - onAddTerminalContext: (selection: TerminalContextSelection) => void; - onSplitTerminal: () => void; - onSplitTerminalVertical: () => void; - onNewTerminal: () => void; - onActiveTerminalChange: (terminalId: string) => void; - onCloseTerminal: (terminalId: string) => void; - splitShortcutLabel?: string | undefined; - splitVerticalShortcutLabel?: string | undefined; - newShortcutLabel?: string | undefined; - closeShortcutLabel?: string | undefined; -} - -const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ - visible, - threadRef, - surface, - launchContext, - focusRequestId, - keybindings, - onAddTerminalContext, - onSplitTerminal, - onSplitTerminalVertical, - onNewTerminal, - onActiveTerminalChange, - onCloseTerminal, - splitShortcutLabel, - splitVerticalShortcutLabel, - newShortcutLabel, - closeShortcutLabel, -}: PersistentThreadTerminalPanelProps) { - const serverThread = useThreadShell(threadRef); - const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const projectRef = serverThread - ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) - : draftThread - ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) - : null; - const project = useProject(projectRef); - const knownTerminalSessions = useKnownTerminalSessions({ - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - }); - const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const activeSummary = - knownTerminalSessions?.find((session) => session.target.terminalId === surface.activeTerminalId) - ?.state.summary ?? null; - const worktreePath = - launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath; - const cwd = useMemo( - () => - launchContext?.cwd ?? - activeSummary?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.workspaceRoot }, - worktreePath, - }) - : null), - [activeSummary?.cwd, launchContext?.cwd, project, worktreePath], - ); - const runtimeEnv = useMemo( - () => - project - ? projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath, - }) - : {}, - [project, worktreePath], - ); - const terminalLabelsById = useMemo(() => { - const labels = new Map(); - for (const terminalId of surface.terminalIds) { - const summary = - knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state - .summary ?? null; - labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); - } - return labels; - }, [knownTerminalSessions, surface.terminalIds]); - const terminalLaunchLocationsById = useMemo(() => { - const locations = new Map< - string, - { - readonly cwd: string; - readonly worktreePath: string | null; - readonly runtimeEnv: Record; - } - >(); - for (const terminalId of surface.terminalIds) { - const summary = - knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state - .summary ?? null; - const terminalWorktreePath = - launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath; - const terminalCwd = - launchContext?.cwd ?? - summary?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.workspaceRoot }, - worktreePath: terminalWorktreePath, - }) - : null); - if (!terminalCwd || !project) continue; - locations.set(terminalId, { - cwd: terminalCwd, - worktreePath: terminalWorktreePath, - runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: terminalWorktreePath, - }), - }); - } - return locations; - }, [ - knownTerminalSessions, - launchContext?.cwd, - launchContext?.worktreePath, - project, - surface.terminalIds, - threadWorktreePath, - ]); - - if (!project || !cwd) return null; - - return ( - undefined} - onAddTerminalContext={onAddTerminalContext} - terminalLabelsById={terminalLabelsById} - terminalLaunchLocationsById={terminalLaunchLocationsById} - keybindings={keybindings} - /> - ); -}); - // Errors surface through two maps (draft-keyed and thread-keyed) whose entries // can race around promotion, so each write carries its time to let the latest // one win when they collide. diff --git a/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx b/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx new file mode 100644 index 000000000000..e0211e84dca9 --- /dev/null +++ b/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx @@ -0,0 +1,467 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +import { + AuthTerminalOperateScope, + AuthTerminalReadScope, + type EnvironmentId, + type ResolvedKeybindingsConfig, + type ThreadId, +} from "@t3tools/contracts"; +import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; + +import ThreadTerminalDrawer from "~/components/ThreadTerminalDrawer"; +import { useComposerDraftStore } from "~/composerDraftStore"; +import type { TerminalContextSelection } from "~/lib/terminalContext"; +import { cn, randomUUID } from "~/lib/utils"; +import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; +import { useProject, useThreadShell } from "~/state/entities"; +import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; +import { terminalEnvironment } from "~/state/terminal"; +import { useKnownTerminalSessions } from "~/state/terminalSessions"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "~/terminalUiStateStore"; + +import type { PersistentTerminalLaunchContext } from "./PersistentThreadTerminalPanel"; + +/** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */ +function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) { + return false; + } + if (left.length === 0) { + return true; + } + const sortedLeft = left.toSorted((a, b) => a.localeCompare(b)); + const sortedRight = right.toSorted((a, b) => a.localeCompare(b)); + for (let index = 0; index < sortedLeft.length; index += 1) { + if (sortedLeft[index] !== sortedRight[index]) { + return false; + } + } + return true; +} + +/** + * Server knows about fewer sessions than the client, but every server id still exists locally. + * Typical right after `terminal.open`: known-session list lags; reconciling would drop the new id + * and later re-add it as a separate group (no split layout). + */ +function serverTerminalIdsStrictSubsetOfClient( + serverIds: readonly string[], + clientIds: readonly string[], +): boolean { + if (serverIds.length >= clientIds.length || clientIds.length === 0) { + return false; + } + const clientSet = new Set(clientIds); + for (const id of serverIds) { + if (!clientSet.has(id)) { + return false; + } + } + return true; +} + +interface PersistentThreadTerminalDrawerProps { + threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; + threadId: ThreadId; + active: boolean; + launchContext: PersistentTerminalLaunchContext | null; + focusRequestId: number; + splitShortcutLabel: string | undefined; + splitVerticalShortcutLabel: string | undefined; + newShortcutLabel: string | undefined; + closeShortcutLabel: string | undefined; + keybindings: ResolvedKeybindingsConfig; + onAddTerminalContext: (selection: TerminalContextSelection) => void; +} + +export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ + threadRef, + threadId, + active, + launchContext, + focusRequestId, + splitShortcutLabel, + splitVerticalShortcutLabel, + newShortcutLabel, + closeShortcutLabel, + keybindings, + onAddTerminalContext, +}: PersistentThreadTerminalDrawerProps) { + const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); + const hasTerminalWriteAccess = useCallback( + () => readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope), + [threadRef.environmentId], + ); + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); + const serverThread = useThreadShell(threadRef); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const projectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const project = useProject(projectRef); + const terminalUiState = useTerminalUiStateStore((state) => + selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), + ); + const visible = active && terminalUiState.terminalOpen; + const knownTerminalSessions = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId, + }); + const panelSurfaces = useRightPanelStore( + (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, + ); + const panelTerminalIds = useMemo( + () => + new Set( + panelSurfaces.flatMap((surface) => + surface.kind === "terminal" ? surface.terminalIds : [], + ), + ), + [panelSurfaces], + ); + const drawerTerminalSessions = useMemo( + () => + knownTerminalSessions?.filter( + (session) => !panelTerminalIds.has(session.target.terminalId), + ) ?? [], + [knownTerminalSessions, panelTerminalIds], + ); + const terminalLabelsById = useMemo(() => { + const next = new Map(); + for (const session of drawerTerminalSessions) { + next.set( + session.target.terminalId, + resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + ); + } + return next; + }, [drawerTerminalSessions]); + const terminalLaunchLocationsById = useMemo(() => { + const next = new Map< + string, + { + readonly cwd: string; + readonly worktreePath: string | null; + readonly runtimeEnv: Record; + } + >(); + if (!project) { + return next; + } + + for (const session of drawerTerminalSessions) { + const summary = session.state.summary; + if (!summary) { + continue; + } + const worktreePathForLaunch = + launchContext !== null ? launchContext.worktreePath : summary.worktreePath; + next.set(session.target.terminalId, { + cwd: launchContext?.cwd ?? summary.cwd, + worktreePath: worktreePathForLaunch, + runtimeEnv: projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: worktreePathForLaunch, + }), + }); + } + + return next; + }, [drawerTerminalSessions, launchContext, project]); + const serverOrderedTerminalIds = useMemo( + () => drawerTerminalSessions.map((session) => session.target.terminalId), + [drawerTerminalSessions], + ); + // Every client-side id source participates in allocation: the server list + // lags fresh opens, and panel terminals are filtered out of the drawer's + // sessions — an id collision attaches two viewports to one PTY session. + const allocatableTerminalIds = useMemo( + () => [ + ...new Set([ + ...serverOrderedTerminalIds, + ...terminalUiState.terminalIds, + ...panelTerminalIds, + ]), + ], + [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds], + ); + const allocateTerminalId = useCallback( + () => + nextTerminalId( + allocatableTerminalIds, + knownTerminalSessions === null || + !readEnvironmentScope(threadRef.environmentId, AuthTerminalReadScope) + ? randomUUID() + : undefined, + ), + [allocatableTerminalIds, knownTerminalSessions, threadRef.environmentId], + ); + const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); + const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); + const storeSplitTerminalVertical = useTerminalUiStateStore( + (state) => state.splitTerminalVertical, + ); + const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); + const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); + const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); + const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); + + useEffect(() => { + if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) { + return; + } + if ( + serverTerminalIdsStrictSubsetOfClient(serverOrderedTerminalIds, terminalUiState.terminalIds) + ) { + return; + } + reconcileTerminalIds(threadRef, serverOrderedTerminalIds); + }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]); + const [localFocusRequestId, setLocalFocusRequestId] = useState(0); + const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const effectiveWorktreePath = useMemo(() => { + if (launchContext !== null) { + return launchContext.worktreePath; + } + return worktreePath; + }, [launchContext, worktreePath]); + const cwd = useMemo( + () => + launchContext?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.workspaceRoot }, + worktreePath: effectiveWorktreePath, + }) + : null), + [effectiveWorktreePath, launchContext?.cwd, project], + ); + const runtimeEnv = useMemo( + () => + project + ? projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: effectiveWorktreePath, + }) + : {}, + [effectiveWorktreePath, project], + ); + + const bumpFocusRequestId = useCallback(() => { + if (!visible) { + return; + } + setLocalFocusRequestId((value) => value + 1); + }, [visible]); + + const setTerminalHeight = useCallback( + (height: number) => { + storeSetTerminalHeight(threadRef, height); + }, + [storeSetTerminalHeight, threadRef], + ); + + const splitTerminal = useCallback(() => { + if (!hasTerminalWriteAccess() || !cwd) { + return; + } + const terminalId = allocateTerminalId(); + storeSplitTerminal(threadRef, terminalId); + bumpFocusRequestId(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); + }, [ + allocateTerminalId, + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + runtimeEnv, + storeSplitTerminal, + threadId, + threadRef, + openTerminal, + hasTerminalWriteAccess, + ]); + const splitTerminalVertical = useCallback(() => { + if (!hasTerminalWriteAccess() || !cwd) { + return; + } + const terminalId = allocateTerminalId(); + storeSplitTerminalVertical(threadRef, terminalId); + bumpFocusRequestId(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); + }, [ + allocateTerminalId, + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + openTerminal, + hasTerminalWriteAccess, + runtimeEnv, + storeSplitTerminalVertical, + threadId, + threadRef, + ]); + + const createNewTerminal = useCallback(() => { + if (!hasTerminalWriteAccess() || !cwd) { + return; + } + const terminalId = allocateTerminalId(); + storeNewTerminal(threadRef, terminalId); + bumpFocusRequestId(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); + }, [ + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + allocateTerminalId, + runtimeEnv, + storeNewTerminal, + threadId, + threadRef, + openTerminal, + hasTerminalWriteAccess, + ]); + + const activateTerminal = useCallback( + (terminalId: string) => { + storeSetActiveTerminal(threadRef, terminalId); + bumpFocusRequestId(); + }, + [bumpFocusRequestId, storeSetActiveTerminal, threadRef], + ); + + const closeTerminal = useCallback( + (terminalId: string) => { + if (!hasTerminalWriteAccess()) return; + const fallbackExitWrite = () => + writeTerminal({ + environmentId: threadRef.environmentId, + input: { threadId, terminalId, data: "exit\n" }, + }); + + void (async () => { + const closeResult = await closeTerminalMutation({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + deleteHistory: true, + }, + }); + if ( + closeResult._tag === "Failure" && + !isAtomCommandInterrupted(closeResult) && + hasTerminalWriteAccess() + ) { + await fallbackExitWrite(); + } + })(); + + storeCloseTerminal(threadRef, terminalId); + bumpFocusRequestId(); + }, + [ + bumpFocusRequestId, + storeCloseTerminal, + threadId, + threadRef, + closeTerminalMutation, + hasTerminalWriteAccess, + writeTerminal, + ], + ); + + const handleAddTerminalContext = useCallback( + (selection: TerminalContextSelection) => { + if (!visible) { + return; + } + onAddTerminalContext(selection); + }, + [onAddTerminalContext, visible], + ); + + if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) { + return null; + } + + return ( +
+
+ +
+
+ ); +}); diff --git a/apps/web/src/panels/terminal/PersistentThreadTerminalPanel.tsx b/apps/web/src/panels/terminal/PersistentThreadTerminalPanel.tsx new file mode 100644 index 000000000000..ee03633a6491 --- /dev/null +++ b/apps/web/src/panels/terminal/PersistentThreadTerminalPanel.tsx @@ -0,0 +1,192 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { ResolvedKeybindingsConfig, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; +import { memo, useMemo } from "react"; + +import ThreadTerminalDrawer from "~/components/ThreadTerminalDrawer"; +import { useComposerDraftStore } from "~/composerDraftStore"; +import type { TerminalContextSelection } from "~/lib/terminalContext"; +import type { RightPanelSurface } from "~/rightPanelStore"; +import { useProject, useThreadShell } from "~/state/entities"; +import { useKnownTerminalSessions } from "~/state/terminalSessions"; + +export interface TerminalLaunchContext { + threadId: ThreadId; + cwd: string; + worktreePath: string | null; +} + +export type PersistentTerminalLaunchContext = Pick; + +interface PersistentThreadTerminalPanelProps { + visible: boolean; + threadRef: ScopedThreadRef; + surface: Extract; + launchContext: PersistentTerminalLaunchContext | null; + focusRequestId: number; + keybindings: ResolvedKeybindingsConfig; + onAddTerminalContext: (selection: TerminalContextSelection) => void; + onSplitTerminal: () => void; + onSplitTerminalVertical: () => void; + onNewTerminal: () => void; + onActiveTerminalChange: (terminalId: string) => void; + onCloseTerminal: (terminalId: string) => void; + splitShortcutLabel?: string | undefined; + splitVerticalShortcutLabel?: string | undefined; + newShortcutLabel?: string | undefined; + closeShortcutLabel?: string | undefined; +} + +export const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ + visible, + threadRef, + surface, + launchContext, + focusRequestId, + keybindings, + onAddTerminalContext, + onSplitTerminal, + onSplitTerminalVertical, + onNewTerminal, + onActiveTerminalChange, + onCloseTerminal, + splitShortcutLabel, + splitVerticalShortcutLabel, + newShortcutLabel, + closeShortcutLabel, +}: PersistentThreadTerminalPanelProps) { + const serverThread = useThreadShell(threadRef); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const projectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const project = useProject(projectRef); + const knownTerminalSessions = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }); + const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const activeSummary = + knownTerminalSessions?.find((session) => session.target.terminalId === surface.activeTerminalId) + ?.state.summary ?? null; + const worktreePath = + launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath; + const cwd = useMemo( + () => + launchContext?.cwd ?? + activeSummary?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.workspaceRoot }, + worktreePath, + }) + : null), + [activeSummary?.cwd, launchContext?.cwd, project, worktreePath], + ); + const runtimeEnv = useMemo( + () => + project + ? projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath, + }) + : {}, + [project, worktreePath], + ); + const terminalLabelsById = useMemo(() => { + const labels = new Map(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; + labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); + } + return labels; + }, [knownTerminalSessions, surface.terminalIds]); + const terminalLaunchLocationsById = useMemo(() => { + const locations = new Map< + string, + { + readonly cwd: string; + readonly worktreePath: string | null; + readonly runtimeEnv: Record; + } + >(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; + const terminalWorktreePath = + launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath; + const terminalCwd = + launchContext?.cwd ?? + summary?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.workspaceRoot }, + worktreePath: terminalWorktreePath, + }) + : null); + if (!terminalCwd || !project) continue; + locations.set(terminalId, { + cwd: terminalCwd, + worktreePath: terminalWorktreePath, + runtimeEnv: projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: terminalWorktreePath, + }), + }); + } + return locations; + }, [ + knownTerminalSessions, + launchContext?.cwd, + launchContext?.worktreePath, + project, + surface.terminalIds, + threadWorktreePath, + ]); + + if (!project || !cwd) return null; + + return ( + undefined} + onAddTerminalContext={onAddTerminalContext} + terminalLabelsById={terminalLabelsById} + terminalLaunchLocationsById={terminalLaunchLocationsById} + keybindings={keybindings} + /> + ); +}); From a0ea16423be90da80a3bc1d820b7a55563811245 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:02:30 +1100 Subject: [PATCH 07/22] refactor(web): open the right-panel terminal through the panel host The right-panel terminal is now a registered side panel. Its body reads the thread and visibility from the panel host and keybindings from the server keybindings atom, then hands them to the unchanged memoized terminal, so ChatView renders that leave its inputs alone still skip it. ChatView passes only the terminal surface, launch context, focus request, callbacks and shortcut labels. Launcher copy, letter, order and availability are unchanged; the bottom drawer stays mounted by ChatView. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatView.tsx | 19 +- .../RightPanelTabs.browserProfile.test.tsx | 3 +- .../RightPanelTabs.keyboard.test.tsx | 3 +- .../RightPanelTabs.terminal.test.tsx | 141 +++++++++++++ .../src/components/RightPanelTabs.test.tsx | 6 +- apps/web/src/components/RightPanelTabs.tsx | 31 +-- apps/web/src/panels/bundledPanels.test.tsx | 73 ++++++- apps/web/src/panels/bundledPanels.tsx | 11 +- .../PersistentThreadTerminalDrawer.tsx | 2 +- .../TerminalSidePanel.attach.test.tsx | 192 ++++++++++++++++++ .../terminal/TerminalSidePanel.test.tsx | 82 ++++++++ ...erminalPanel.tsx => TerminalSidePanel.tsx} | 26 ++- apps/web/src/routes/_chat.pull-requests.tsx | 3 +- 13 files changed, 537 insertions(+), 55 deletions(-) create mode 100644 apps/web/src/components/RightPanelTabs.terminal.test.tsx create mode 100644 apps/web/src/panels/terminal/TerminalSidePanel.attach.test.tsx create mode 100644 apps/web/src/panels/terminal/TerminalSidePanel.test.tsx rename apps/web/src/panels/terminal/{PersistentThreadTerminalPanel.tsx => TerminalSidePanel.tsx} (88%) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 72cc3b25fedb..ee016473c2f7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -173,10 +173,7 @@ import { type ThreadAnnotationSender, } from "~/panels/panelHost"; import { PersistentThreadTerminalDrawer } from "~/panels/terminal/PersistentThreadTerminalDrawer"; -import { - PersistentThreadTerminalPanel, - type TerminalLaunchContext, -} from "~/panels/terminal/PersistentThreadTerminalPanel"; +import type { TerminalLaunchContext } from "~/panels/terminal/TerminalSidePanel"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; import { @@ -10508,13 +10505,11 @@ export default function ChatView(props: ChatViewProps) { configuredUrls={configuredPreviewUrls} /> ) : renderedRightPanelSurface?.kind === "terminal" ? ( - undefined }, + terminal: { available: false, onOpen: () => undefined }, }} onAddBrowserInProfile={openBrowser} - onAddTerminal={() => undefined} onAddPullRequest={() => undefined} onAddPullRequests={() => undefined} onAddFiles={() => undefined} onAddDevice={() => undefined} - terminalAvailable={false} filesAvailable={false} pullRequestAvailable={false} pullRequestsAvailable={false} diff --git a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx index 444e9c610859..ac4c836d226f 100644 --- a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx +++ b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx @@ -79,14 +79,13 @@ async function renderPanel(overrides: Partial { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + // jsdom lacks the Web Animations API that the tab bar's scroll area waits on. + Element.prototype.getAnimations ??= () => []; + useRightPanelStore.setState({ byThreadKey: {} }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +// Shows the thread's surfaces from the real right-panel store and opens the +// terminal into it the way ChatView's addTerminalSurface does. +function Harness({ terminalAvailable }: { terminalAvailable: boolean }) { + const surfaces = useRightPanelStore( + (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, + ); + return ( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + surfaces={surfaces} + environmentId={threadRef.environmentId} + activeSurfaceId={surfaces[0]?.id ?? null} + pendingSurfaceIds={new Set()} + previewSessions={{}} + desktopByTabId={{}} + terminalLabelsById={new Map()} + onActivate={() => undefined} + onCloseSurface={() => undefined} + onCloseOtherSurfaces={() => undefined} + onCloseSurfacesToRight={() => undefined} + onCloseAllSurfaces={() => undefined} + onCopyFilePath={() => undefined} + panels={{ + preview: { available: false, onOpen: () => undefined }, + diff: { available: false, onOpen: () => undefined }, + terminal: { + available: terminalAvailable, + onOpen: () => useRightPanelStore.getState().openTerminal(threadRef, "term-1"), + }, + }} + onAddBrowserInProfile={() => undefined} + onAddPullRequest={() => undefined} + onAddPullRequests={() => undefined} + onAddFiles={() => undefined} + onAddDevice={() => undefined} + filesAvailable={false} + pullRequestAvailable={false} + pullRequestsAvailable={false} + deviceAvailable={false} + > + {null} + + ); +} + +function launcherRow(label: string): HTMLElement { + const launcher = container.querySelector('[aria-label="Open a surface"]'); + // Unavailable rows render as aria-disabled elements rather than buttons. + const row = [ + ...(launcher?.querySelectorAll('button, [aria-disabled="true"]') ?? []), + ].find((element) => element.textContent?.startsWith(label)); + if (!row) throw new Error(`No launcher row ${label}`); + return row; +} + +async function click(element: Element) { + await act(async () => { + element.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +const threadSurfaces = () => + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef).surfaces; + +describe("opening Terminal from the launcher", () => { + it("opens a terminal surface from the row when a project allows it", async () => { + await act(async () => root.render()); + + await click(launcherRow("Terminal")); + expect(threadSurfaces()).toMatchObject([{ kind: "terminal", activeTerminalId: "term-1" }]); + expect(container.querySelector('[aria-label="Open a surface"]')).toBeNull(); + expect(container.textContent).toContain("Terminal 1"); + }); + + it("keeps the row disabled and opens nothing without a project", async () => { + await act(async () => root.render()); + + const row = launcherRow("Terminal"); + expect(row.getAttribute("aria-disabled")).toBe("true"); + await click(row); + expect(threadSurfaces()).toEqual([]); + expect(container.querySelector('[aria-label="Open a surface"]')).not.toBeNull(); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index f8f143eb57d2..dd4bee2eefcb 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -31,13 +31,12 @@ describe("right panel surface actions", () => { panels: { preview: { available: true, onOpen: () => undefined }, diff: { available: true, onOpen: () => undefined }, + terminal: { available: true, onOpen: () => undefined }, }, - onAddTerminal: () => undefined, onAddFiles: () => undefined, onAddPullRequest: () => undefined, onAddPullRequests: () => undefined, onAddDevice: () => undefined, - terminalAvailable: true, filesAvailable: true, pullRequestAvailable: true, pullRequestsAvailable: true, @@ -169,14 +168,13 @@ function renderTabs( panels={{ preview: { available: true, onOpen: () => undefined }, diff: { available: false, onOpen: () => undefined }, + terminal: { available: false, onOpen: () => undefined }, }} onAddBrowserInProfile={() => undefined} - onAddTerminal={() => undefined} onAddPullRequest={() => undefined} onAddPullRequests={() => undefined} onAddFiles={() => undefined} onAddDevice={() => undefined} - terminalAvailable={false} filesAvailable={false} pullRequestAvailable={false} pullRequestsAvailable={false} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 45fde10e1e59..b4eb3660f831 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -25,15 +25,7 @@ import { import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers"; import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { - Smartphone, - ChevronDown, - ChevronLeft, - ChevronRight, - Files, - Plus, - TerminalSquare, -} from "lucide-react"; +import { Smartphone, ChevronDown, ChevronLeft, ChevronRight, Files, Plus } from "lucide-react"; import { Volume2, VolumeOff } from "lucide"; import { type ComponentProps, @@ -133,12 +125,10 @@ interface RightPanelTabsProps { /** Whether each registered panel can open here, and how; titles and icons come from its definition. */ panels: Readonly>; onAddBrowserInProfile: (profileId: string) => void; - onAddTerminal: () => void; onAddFiles: () => void; onAddPullRequest: () => void; onAddPullRequests: () => void; onAddDevice: () => void; - terminalAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; pullRequestsAvailable: boolean; @@ -197,12 +187,10 @@ interface SurfaceAction { type SurfaceActionInputs = Pick< RightPanelTabsProps, | "panels" - | "onAddTerminal" | "onAddFiles" | "onAddPullRequest" | "onAddPullRequests" | "onAddDevice" - | "terminalAvailable" | "filesAvailable" | "pullRequestAvailable" | "pullRequestsAvailable" @@ -232,16 +220,7 @@ export function rightPanelSurfaceActions(props: SurfaceActionInputs): SurfaceAct }; return [ registered("preview"), - { - id: "terminal", - label: "Terminal", - icon: TerminalSquare, - shortcut: "T", - available: props.terminalAvailable, - unavailableHint: "Available when a project is open.", - unavailableReason: "Terminal surfaces are only available from a project thread.", - onClick: props.onAddTerminal, - }, + registered("terminal"), { id: "files", label: "Files", @@ -703,8 +682,10 @@ function SurfaceIcon({ className="size-3" /> ); - case "terminal": - return ; + case "terminal": { + const Icon = getSidePanelMetadata("terminal").icon; + return ; + } case "pull-request": return ( ({ diff: 0, preview: 0, previewRenders: [] as unknown[] })); +const loaded = vi.hoisted(() => ({ + diff: 0, + preview: 0, + terminal: 0, + previewRenders: [] as unknown[], +})); vi.mock("./diff/DiffSidePanel", () => { loaded.diff += 1; return { default: () => null }; }); +vi.mock("./terminal/TerminalSidePanel", () => { + loaded.terminal += 1; + return { default: () => null }; +}); vi.mock("./preview/PreviewSidePanel", () => { loaded.preview += 1; return { @@ -18,6 +27,8 @@ vi.mock("./preview/PreviewSidePanel", () => { }; }); +import type { RightPanelSurface } from "~/rightPanelStore"; + import { RegisteredSidePanel } from "./bundledPanels"; import { PanelHostContext, usePanelHost, type PanelHost } from "./panelHost"; @@ -35,7 +46,7 @@ const host: PanelHost = { describe("bundled side panels", () => { it("loads only the selected panel body and lends it the host", async () => { - expect(loaded).toMatchObject({ diff: 0, preview: 0 }); + expect(loaded).toMatchObject({ diff: 0, preview: 0, terminal: 0 }); await act(async () => { create( @@ -45,18 +56,70 @@ describe("bundled side panels", () => { , ); }); - expect(loaded).toMatchObject({ diff: 0, preview: 1 }); + expect(loaded).toMatchObject({ diff: 0, preview: 1, terminal: 0 }); expect(loaded.previewRenders).toEqual([{ props: { tabId: "tab-1" }, host }]); }); + + it("loads only the terminal body when the terminal is selected", async () => { + const surface: Extract = { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", + }; + await act(async () => { + create( + + + undefined} + onSplitTerminal={() => undefined} + onSplitTerminalVertical={() => undefined} + onNewTerminal={() => undefined} + onActiveTerminalChange={() => undefined} + onCloseTerminal={() => undefined} + /> + + , + ); + }); + expect(loaded).toMatchObject({ diff: 0, terminal: 1 }); + }); }); // Never called. The project typecheck compiles these pairings, and each // expect-error directive fails it if a wrong pairing starts to compile. -export function typeFixtures(widenedId: "diff" | "preview") { +export function typeFixtures( + widenedId: "diff" | "preview", + terminalSurface: Extract, +) { + const terminalProps = { + surface: terminalSurface, + launchContext: null, + focusRequestId: 0, + onAddTerminalContext: () => undefined, + onSplitTerminal: () => undefined, + onSplitTerminalVertical: () => undefined, + onNewTerminal: () => undefined, + onActiveTerminalChange: () => undefined, + onCloseTerminal: () => undefined, + }; return ( <> + + {/* @ts-expect-error Terminal requires its surface and callbacks. */} + + {/* @ts-expect-error The host owns visibility; the terminal does not take it as a prop. */} + + {/* @ts-expect-error Terminal props on Preview. */} + {/* @ts-expect-error Preview props on Diff. */} {/* @ts-expect-error The host owns the thread; panels do not take it as a prop. */} @@ -64,7 +127,7 @@ export function typeFixtures(widenedId: "diff" | "preview") { {/* @ts-expect-error Wrong input shape. */} {/* @ts-expect-error Unknown id. */} - + {/* @ts-expect-error A widened id cannot borrow one panel's props. */} diff --git a/apps/web/src/panels/bundledPanels.tsx b/apps/web/src/panels/bundledPanels.tsx index 1a6659f5cb2a..e4ef4db5839f 100644 --- a/apps/web/src/panels/bundledPanels.tsx +++ b/apps/web/src/panels/bundledPanels.tsx @@ -1,4 +1,4 @@ -import { FileDiff, Globe2 } from "lucide-react"; +import { FileDiff, Globe2, TerminalSquare } from "lucide-react"; import { Suspense, type ComponentType } from "react"; import { createPanelRegistry, type PanelMetadata, type PanelProps } from "./panelRegistry"; @@ -22,6 +22,15 @@ const bundledPanels = createPanelRegistry([ unavailableReason: "Browser previews are only available in the T3 Code desktop app.", load: () => import("./preview/PreviewSidePanel"), }, + { + id: "terminal", + title: "Terminal", + icon: TerminalSquare, + launcherKey: "T", + unavailableHint: "Available when a project is open.", + unavailableReason: "Terminal surfaces are only available from a project thread.", + load: () => import("./terminal/TerminalSidePanel"), + }, ]); export type SidePanelId = (typeof bundledPanels.definitions)[number]["id"]; diff --git a/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx b/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx index e0211e84dca9..45256e185872 100644 --- a/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx +++ b/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx @@ -23,7 +23,7 @@ import { useKnownTerminalSessions } from "~/state/terminalSessions"; import { useAtomCommand } from "~/state/use-atom-command"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "~/terminalUiStateStore"; -import type { PersistentTerminalLaunchContext } from "./PersistentThreadTerminalPanel"; +import type { PersistentTerminalLaunchContext } from "./TerminalSidePanel"; /** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */ function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean { diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.attach.test.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.attach.test.tsx new file mode 100644 index 000000000000..e0d995a5f048 --- /dev/null +++ b/apps/web/src/panels/terminal/TerminalSidePanel.attach.test.tsx @@ -0,0 +1,192 @@ +// @vitest-environment jsdom + +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { act, Suspense } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +// Drives the registered terminal through the real RegisteredSidePanel -> +// TerminalSidePanel -> ThreadTerminalDrawer -> TerminalViewport path. Only the +// terminal transport (attach, write, resize), the WASM surface and the thread +// read model are stubbed. +const transport = vi.hoisted(() => ({ + attached: [] as unknown[], + commands: [] as Array<{ command: string; value: unknown }>, + surfaces: [] as Array<{ onData: (data: string) => void }>, +})); + +vi.mock("~/state/terminalSessions", async () => { + const { EMPTY_TERMINAL_SESSION_STATE: empty } = + await import("@t3tools/client-runtime/state/terminal"); + return { + useKnownTerminalSessions: () => [], + useAttachedTerminalSession: (input: unknown) => { + transport.attached.push(input); + return empty; + }, + }; +}); +// This client holds every scope, so the terminal accepts typing. +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +vi.mock("~/state/terminal", () => ({ + terminalEnvironment: { write: "write", resize: "resize", open: "open" }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => async (value: unknown) => { + if (typeof command === "string") transport.commands.push({ command, value }); + return { _tag: "Success" }; + }, +})); +vi.mock("~/terminal/ghostty/surface", async (importOriginal) => ({ + ...(await importOriginal()), + GhosttyTerminalSurface: { + create: async (_mount: HTMLElement, options: { onData: (data: string) => void }) => { + transport.surfaces.push(options); + // Every surface method is a no-op; the test only feeds keystrokes through onData. + return new Proxy({}, { get: () => () => undefined }); + }, + }, +})); +vi.mock("~/state/entities", () => { + const project = { workspaceRoot: "/repo" }; + return { + useThreadShell: (ref: ScopedThreadRef) => ({ + environmentId: ref.environmentId, + projectId: "project-a", + worktreePath: null, + }), + useProject: () => project, + }; +}); + +import type { RightPanelSurface } from "~/rightPanelStore"; + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +const threadA: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), +}; +const threadB: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-b"), + threadId: ThreadId.make("thread-b"), +}; +const surface: Extract = { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", +}; +const hostFor = (threadRef: ScopedThreadRef): PanelHost => ({ + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, +}); + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + // jsdom has no canvas; the terminal theme reader falls back without one. + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); + transport.attached = []; + transport.commands = []; + transport.surfaces = []; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +async function renderTerminal(threadRef: ScopedThreadRef) { + await act(async () => + root.render( + + + undefined} + onSplitTerminal={() => undefined} + onSplitTerminalVertical={() => undefined} + onNewTerminal={() => undefined} + onActiveTerminalChange={() => undefined} + onCloseTerminal={() => undefined} + /> + + , + ), + ); +} + +// The registry loads the body lazily; let that import settle before asserting. +async function settle() { + await act(async () => { + await import("./TerminalSidePanel"); + }); +} + +async function type(data: string) { + await act(async () => transport.surfaces.at(-1)!.onData(data)); +} + +describe("registered terminal panel", () => { + it("attaches the host thread's terminal and sends typing to it, then follows a thread switch", async () => { + await renderTerminal(threadA); + await settle(); + expect(transport.attached.at(-1)).toMatchObject({ + environmentId: threadA.environmentId, + terminal: { threadId: threadA.threadId, terminalId: "term-1", cwd: "/repo" }, + }); + await type("ls\r"); + expect(transport.commands).toContainEqual({ + command: "write", + value: { + environmentId: threadA.environmentId, + input: { threadId: threadA.threadId, terminalId: "term-1", data: "ls\r" }, + }, + }); + + await renderTerminal(threadB); + await settle(); + expect(transport.attached.at(-1)).toMatchObject({ + environmentId: threadB.environmentId, + terminal: { threadId: threadB.threadId, terminalId: "term-1", cwd: "/repo" }, + }); + transport.commands = []; + await type("pwd\r"); + expect(transport.commands.filter((entry) => entry.command === "write")).toEqual([ + { + command: "write", + value: { + environmentId: threadB.environmentId, + input: { threadId: threadB.threadId, terminalId: "term-1", data: "pwd\r" }, + }, + }, + ]); + }); +}); diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx new file mode 100644 index 000000000000..3f08fe2f9303 --- /dev/null +++ b/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx @@ -0,0 +1,82 @@ +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const drawerRenders = vi.hoisted(() => [] as Array<{ visible: boolean; threadRef: unknown }>); +vi.mock("~/components/ThreadTerminalDrawer", () => ({ + default: (props: { visible: boolean; threadRef: unknown }) => { + drawerRenders.push({ visible: props.visible, threadRef: props.threadRef }); + return null; + }, +})); +vi.mock("~/state/entities", () => { + const thread = { environmentId: "environment-a", projectId: "project-a", worktreePath: null }; + const project = { workspaceRoot: "/repo" }; + return { useThreadShell: () => thread, useProject: () => project }; +}); +vi.mock("~/state/terminalSessions", () => { + const sessions: never[] = []; + return { useKnownTerminalSessions: () => sessions }; +}); + +import type { RightPanelSurface } from "~/rightPanelStore"; + +import { PanelHostContext, type PanelHost } from "../panelHost"; +import TerminalSidePanel from "./TerminalSidePanel"; + +const threadRef: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), +}; +const surface: Extract = { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", +}; +const terminalProps = { + surface, + launchContext: null, + focusRequestId: 0, + onAddTerminalContext: () => undefined, + onSplitTerminal: () => undefined, + onSplitTerminalVertical: () => undefined, + onNewTerminal: () => undefined, + onActiveTerminalChange: () => undefined, + onCloseTerminal: () => undefined, +}; + +// ChatView builds a fresh host object on every render, as here. +const hostFor = (visible: boolean): PanelHost => ({ + threadRef, + visible, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, +}); +const panelIn = (host: PanelHost) => ( + + + +); + +describe("terminal side panel", () => { + it("renders the host thread, skips rebuilt hosts with the same inputs, and follows visibility", () => { + let renderer: ReactTestRenderer | undefined; + act(() => { + renderer = create(panelIn(hostFor(true))); + }); + expect(drawerRenders).toEqual([{ visible: true, threadRef }]); + + act(() => renderer!.update(panelIn(hostFor(true)))); + expect(drawerRenders).toHaveLength(1); + + act(() => renderer!.update(panelIn(hostFor(false)))); + expect(drawerRenders).toEqual([ + { visible: true, threadRef }, + { visible: false, threadRef }, + ]); + }); +}); diff --git a/apps/web/src/panels/terminal/PersistentThreadTerminalPanel.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.tsx similarity index 88% rename from apps/web/src/panels/terminal/PersistentThreadTerminalPanel.tsx rename to apps/web/src/panels/terminal/TerminalSidePanel.tsx index ee03633a6491..0ea65e1ebc0c 100644 --- a/apps/web/src/panels/terminal/PersistentThreadTerminalPanel.tsx +++ b/apps/web/src/panels/terminal/TerminalSidePanel.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import type { ResolvedKeybindingsConfig, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; @@ -9,8 +10,11 @@ import { useComposerDraftStore } from "~/composerDraftStore"; import type { TerminalContextSelection } from "~/lib/terminalContext"; import type { RightPanelSurface } from "~/rightPanelStore"; import { useProject, useThreadShell } from "~/state/entities"; +import { primaryServerKeybindingsAtom } from "~/state/server"; import { useKnownTerminalSessions } from "~/state/terminalSessions"; +import { usePanelHost } from "../panelHost"; + export interface TerminalLaunchContext { threadId: ThreadId; cwd: string; @@ -38,7 +42,7 @@ interface PersistentThreadTerminalPanelProps { closeShortcutLabel?: string | undefined; } -export const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ +const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ visible, threadRef, surface, @@ -190,3 +194,23 @@ export const PersistentThreadTerminalPanel = memo(function PersistentThreadTermi /> ); }); + +/** + * Registered right-panel body. ChatView rebuilds the host on every render, so + * the host is read here, outside the memo: renders that leave the thread, + * visibility and terminal props unchanged still skip the terminal. + */ +export default function TerminalSidePanel( + props: Omit, +) { + const { threadRef, visible } = usePanelHost(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + return ( + + ); +} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index b4218af1036b..25f4aa89f187 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -279,6 +279,7 @@ const EMPTY_TERMINAL_LABELS = new Map(); const UNAVAILABLE_SIDE_PANELS = { preview: { available: false, onOpen: () => undefined }, diff: { available: false, onOpen: () => undefined }, + terminal: { available: false, onOpen: () => undefined }, }; const EMPTY_PENDING_SURFACES = new Set(); const MAX_SEARCH_LABEL_CANDIDATES = 100; @@ -2290,12 +2291,10 @@ function PullRequestsRouteView() { onCopyFilePath={() => undefined} panels={UNAVAILABLE_SIDE_PANELS} onAddBrowserInProfile={() => undefined} - onAddTerminal={() => undefined} onAddFiles={() => undefined} onAddPullRequest={() => undefined} onAddPullRequests={() => undefined} onAddDevice={() => undefined} - terminalAvailable={false} filesAvailable={false} pullRequestAvailable={false} pullRequestsAvailable={false} From 02d7fe8a97461fe1bcc796dc133ec04ea0bc0b79 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:53:35 +1100 Subject: [PATCH 08/22] fix(web): keep a local-checkout terminal launch off the thread's worktree A terminal launch context with a null worktree path means the terminal was launched on the local checkout. The right-panel terminal treated that null as missing and fell back to the thread's worktree, so a thread that gained a worktree after the launch gave the drawer a worktree path and runtime env that did not match its cwd. Use the launch context whenever one exists, as the persistent drawer already does. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../terminal/TerminalSidePanel.test.tsx | 34 +++++++++++++++++-- .../src/panels/terminal/TerminalSidePanel.tsx | 18 +++++----- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx index 3f08fe2f9303..d4cd29363795 100644 --- a/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx +++ b/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx @@ -4,14 +4,20 @@ import { create, type ReactTestRenderer } from "react-test-renderer"; import { describe, expect, it, vi } from "vite-plus/test"; const drawerRenders = vi.hoisted(() => [] as Array<{ visible: boolean; threadRef: unknown }>); +const drawerWorktreePaths = vi.hoisted(() => [] as Array); +const thread = vi.hoisted(() => ({ + environmentId: "environment-a", + projectId: "project-a", + worktreePath: null as string | null, +})); vi.mock("~/components/ThreadTerminalDrawer", () => ({ - default: (props: { visible: boolean; threadRef: unknown }) => { + default: (props: { visible: boolean; threadRef: unknown; worktreePath: string | null }) => { drawerRenders.push({ visible: props.visible, threadRef: props.threadRef }); + drawerWorktreePaths.push(props.worktreePath); return null; }, })); vi.mock("~/state/entities", () => { - const thread = { environmentId: "environment-a", projectId: "project-a", worktreePath: null }; const project = { workspaceRoot: "/repo" }; return { useThreadShell: () => thread, useProject: () => project }; }); @@ -79,4 +85,28 @@ describe("terminal side panel", () => { { visible: false, threadRef }, ]); }); + + it("keeps a local-checkout launch on the checkout after the thread gains a worktree", () => { + thread.worktreePath = "/repo/.worktrees/feature"; + drawerWorktreePaths.length = 0; + const launchedLocally = ( + + + + ); + act(() => { + create(launchedLocally); + }); + expect(drawerWorktreePaths).toEqual([null]); + + drawerWorktreePaths.length = 0; + act(() => { + create(panelIn(hostFor(true))); + }); + expect(drawerWorktreePaths).toEqual(["/repo/.worktrees/feature"]); + thread.worktreePath = null; + }); }); diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.tsx index 0ea65e1ebc0c..ef655760db9b 100644 --- a/apps/web/src/panels/terminal/TerminalSidePanel.tsx +++ b/apps/web/src/panels/terminal/TerminalSidePanel.tsx @@ -76,8 +76,11 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane const activeSummary = knownTerminalSessions?.find((session) => session.target.terminalId === surface.activeTerminalId) ?.state.summary ?? null; + // A launch context's null worktree means the local checkout, not "unknown". const worktreePath = - launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath; + launchContext !== null + ? launchContext.worktreePath + : (activeSummary?.worktreePath ?? threadWorktreePath); const cwd = useMemo( () => launchContext?.cwd ?? @@ -124,7 +127,9 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state .summary ?? null; const terminalWorktreePath = - launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath; + launchContext !== null + ? launchContext.worktreePath + : (summary?.worktreePath ?? threadWorktreePath); const terminalCwd = launchContext?.cwd ?? summary?.cwd ?? @@ -145,14 +150,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane }); } return locations; - }, [ - knownTerminalSessions, - launchContext?.cwd, - launchContext?.worktreePath, - project, - surface.terminalIds, - threadWorktreePath, - ]); + }, [knownTerminalSessions, launchContext, project, surface.terminalIds, threadWorktreePath]); if (!project || !cwd) return null; From ca03345a96f66696b5886f4496cc888ec955a90f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:39:05 +1100 Subject: [PATCH 09/22] fix(web): keep a terminal the server opened on the checkout off the thread's worktree A terminal summary's null worktree path means the server opened the terminal on the project checkout. Without a launch context the right-panel terminal treated that null as missing and fell back to the thread's worktree, so a thread that gained a worktree later gave the drawer a checkout cwd with a worktree path and runtime env. Fall back to the thread's worktree only when there is neither a launch context nor a summary. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../terminal/TerminalSidePanel.test.tsx | 31 +++++++++++++--- .../src/panels/terminal/TerminalSidePanel.tsx | 35 ++++++++++++------- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx index d4cd29363795..e3e30694eb9a 100644 --- a/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx +++ b/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx @@ -21,10 +21,16 @@ vi.mock("~/state/entities", () => { const project = { workspaceRoot: "/repo" }; return { useThreadShell: () => thread, useProject: () => project }; }); -vi.mock("~/state/terminalSessions", () => { - const sessions: never[] = []; - return { useKnownTerminalSessions: () => sessions }; -}); +const terminalSessions = vi.hoisted( + () => + [] as Array<{ + target: { terminalId: string }; + state: { summary: { cwd: string; worktreePath: string | null } }; + }>, +); +vi.mock("~/state/terminalSessions", () => ({ + useKnownTerminalSessions: () => terminalSessions, +})); import type { RightPanelSurface } from "~/rightPanelStore"; @@ -54,7 +60,7 @@ const terminalProps = { onCloseTerminal: () => undefined, }; -// ChatView builds a fresh host object on every render, as here. +// A host rebuilt with the same inputs, as here, must not re-render the drawer. const hostFor = (visible: boolean): PanelHost => ({ threadRef, visible, @@ -109,4 +115,19 @@ describe("terminal side panel", () => { expect(drawerWorktreePaths).toEqual(["/repo/.worktrees/feature"]); thread.worktreePath = null; }); + + it("keeps a terminal the server opened on the checkout off the thread's later worktree", () => { + thread.worktreePath = "/repo/.worktrees/feature"; + terminalSessions.push({ + target: { terminalId: "term-1" }, + state: { summary: { cwd: "/repo", worktreePath: null } }, + }); + drawerWorktreePaths.length = 0; + act(() => { + create(panelIn(hostFor(true))); + }); + expect(drawerWorktreePaths).toEqual([null]); + terminalSessions.length = 0; + thread.worktreePath = null; + }); }); diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.tsx index ef655760db9b..e94284b7f5e4 100644 --- a/apps/web/src/panels/terminal/TerminalSidePanel.tsx +++ b/apps/web/src/panels/terminal/TerminalSidePanel.tsx @@ -23,6 +23,20 @@ export interface TerminalLaunchContext { export type PersistentTerminalLaunchContext = Pick; +/** + * A launch context's or summary's null worktree means the local checkout, not + * "unknown", so only fall back to the thread's worktree when neither exists. + */ +function terminalWorktreePath( + launchContext: PersistentTerminalLaunchContext | null, + summary: { readonly worktreePath: string | null } | null, + threadWorktreePath: string | null, +): string | null { + if (launchContext !== null) return launchContext.worktreePath; + if (summary !== null) return summary.worktreePath; + return threadWorktreePath; +} + interface PersistentThreadTerminalPanelProps { visible: boolean; threadRef: ScopedThreadRef; @@ -76,11 +90,7 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane const activeSummary = knownTerminalSessions?.find((session) => session.target.terminalId === surface.activeTerminalId) ?.state.summary ?? null; - // A launch context's null worktree means the local checkout, not "unknown". - const worktreePath = - launchContext !== null - ? launchContext.worktreePath - : (activeSummary?.worktreePath ?? threadWorktreePath); + const worktreePath = terminalWorktreePath(launchContext, activeSummary, threadWorktreePath); const cwd = useMemo( () => launchContext?.cwd ?? @@ -126,26 +136,27 @@ const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPane const summary = knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state .summary ?? null; - const terminalWorktreePath = - launchContext !== null - ? launchContext.worktreePath - : (summary?.worktreePath ?? threadWorktreePath); + const worktreePathForTerminal = terminalWorktreePath( + launchContext, + summary, + threadWorktreePath, + ); const terminalCwd = launchContext?.cwd ?? summary?.cwd ?? (project ? projectScriptCwd({ project: { cwd: project.workspaceRoot }, - worktreePath: terminalWorktreePath, + worktreePath: worktreePathForTerminal, }) : null); if (!terminalCwd || !project) continue; locations.set(terminalId, { cwd: terminalCwd, - worktreePath: terminalWorktreePath, + worktreePath: worktreePathForTerminal, runtimeEnv: projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, - worktreePath: terminalWorktreePath, + worktreePath: worktreePathForTerminal, }), }); } From 4b7aa2e4d54981c2719b55f3abde5ac73b185f48 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:22:30 +1100 Subject: [PATCH 10/22] fix(web): keep device results in the thread that started them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One Device panel instance stays mounted across a thread switch, so a device start or power-off that settled after the switch left its "Starting device…" spinner or its error in the next thread. The panel now resets its operation state when its thread changes, and a pick or power-off that settles after it moved to another thread (even back again) or unmounted no longer touches the panel's spinner or error. The tab it opens or closes still lands in the thread it started from, which is where the server opened or shut down the device. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/device/DevicePanel.test.tsx | 228 ++++++++++++++++++ .../web/src/components/device/DevicePanel.tsx | 51 +++- 2 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/device/DevicePanel.test.tsx diff --git a/apps/web/src/components/device/DevicePanel.test.tsx b/apps/web/src/components/device/DevicePanel.test.tsx new file mode 100644 index 000000000000..995b2a681025 --- /dev/null +++ b/apps/web/src/components/device/DevicePanel.test.tsx @@ -0,0 +1,228 @@ +import { + EnvironmentId, + ThreadId, + type DeviceServiceState, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { act, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { RightPanelSurface } from "~/rightPanelStore"; + +import { DevicePanel } from "./DevicePanel"; + +type CommandResult = + | { _tag: "Success"; value: { hostId: string; deviceId: string } } + | { _tag: "Failure"; cause: unknown }; +const mocks = vi.hoisted(() => ({ + open: vi.fn<(request: unknown) => Promise>(), + close: vi.fn<(request: unknown) => Promise>(), + openDevice: vi.fn(), + closeSurface: vi.fn(), +})); +const phone = { + hostId: "local", + id: "phone", + name: "Phone", + platform: "ios", + version: "iOS 19", + booted: false, + physical: false, +} as const; +const deviceState: DeviceServiceState = { + hosts: [ + { + id: "local", + kind: "local", + label: "This Mac", + platforms: [{ platform: "ios", available: true }], + hubInstalled: true, + agentDeviceInstalled: true, + }, + ], + hostStatus: "ready", + hostStatuses: { local: { status: "ready" } }, + devices: [phone], + sessions: [ + { + threadId: ThreadId.make("thread-1"), + hostId: "local", + deviceId: "phone", + platform: "ios", + openedAt: "2026-01-01T00:00:00.000Z", + }, + ], + onboardingCompleted: true, + agentAccessEnabled: true, + hubBasePath: "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/api/device-hub", + revision: 1, +}; +vi.mock("~/state/device", () => ({ + deviceEnvironment: { list: "list", open: "open", close: "close" }, + useDeviceState: () => ({ state: deviceState, loaded: true }), +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: string) => + command === "open" + ? mocks.open + : command === "close" + ? mocks.close + : () => Promise.resolve({ _tag: "Success", value: {} }), +})); +vi.mock("~/state/query", () => ({ formatEnvironmentQueryError: () => "Device failed" })); +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { + getState: () => ({ openDevice: mocks.openDevice, closeSurface: mocks.closeSurface }), + }, +})); +vi.mock("~/components/device/DeviceHostUpdates", () => ({ DeviceHostUpdates: () => null })); +vi.mock("~/components/device/DeviceLoadingView", () => ({ DeviceLoadingView: () => null })); +vi.mock("~/components/device/DeviceWorkspace", () => ({ DeviceWorkspace: () => null })); +vi.mock("~/components/preview/PreviewPanelShell", () => ({ + PreviewPanelShell: ({ children }: { children: ReactNode }) => children, +})); + +// Same thread id in another environment: the surface id collides, so the panel +// stays mounted across the switch, as it does under ChatView's surface key. +const picking = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-1"), +}; +const next = { environmentId: EnvironmentId.make("environment-b"), threadId: picking.threadId }; +const picker: Extract = { id: "device", kind: "device" }; +const streaming: Extract = { + id: "device:phone", + kind: "device", + target: { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" }, +}; + +const render = (threadRef: ScopedThreadRef, surface = picker) => ( + undefined} + /> +); +const deferred = () => { + let settle!: (result: CommandResult) => void; + const promise = new Promise((resolve) => (settle = resolve)); + return { promise, settle }; +}; +const startPhone = (renderer: ReactTestRenderer) => + act(async () => { + renderer.root.findByProps({ "aria-label": "Start Phone" }).props.onClick(); + }); +const errors = (renderer: ReactTestRenderer) => renderer.root.findAllByProps({ role: "alert" }); +const opened = { _tag: "Success", value: { hostId: "local", deviceId: "phone" } } as const; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("DevicePanel", () => { + it("opens a picked device when the pick settles in the same thread", async () => { + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + const renderer = await act(async () => create(render(picking))); + + await startPhone(renderer); + expect(mocks.open).toHaveBeenCalledWith({ + environmentId: picking.environmentId, + input: { threadId: picking.threadId, hostId: "local", deviceId: "phone", platform: "ios" }, + }); + await act(async () => pick.settle(opened)); + + expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(picking, { + hostId: "local", + deviceId: "phone", + platform: "ios", + name: "Phone", + }); + }); + + it("keeps the next thread's picker usable while an earlier pick is pending", async () => { + mocks.open.mockReturnValue(deferred().promise); + const renderer = await act(async () => create(render(picking))); + + await startPhone(renderer); + await act(async () => renderer.update(render(next))); + + expect(renderer.root.findByProps({ "aria-label": "Start Phone" }).props.disabled).toBe(false); + }); + + it("opens a pick that settles after a switch in the thread it started in", async () => { + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + const renderer = await act(async () => create(render(picking))); + + await startPhone(renderer); + await act(async () => renderer.update(render(next))); + await act(async () => pick.settle(opened)); + + expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(picking, { + hostId: "local", + deviceId: "phone", + platform: "ios", + name: "Phone", + }); + expect(renderer.root.findByProps({ "aria-label": "Start Phone" }).props.disabled).toBe(false); + }); + + it("hides a failed pick that settles after the panel moved, even back again", async () => { + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + const renderer = await act(async () => create(render(picking))); + + await startPhone(renderer); + await act(async () => renderer.update(render(next))); + await act(async () => renderer.update(render(picking))); + await act(async () => pick.settle({ _tag: "Failure", cause: new Error("boom") })); + + expect(errors(renderer)).toHaveLength(0); + expect(mocks.openDevice).not.toHaveBeenCalled(); + }); + + it("does not carry an operation error into another thread", async () => { + mocks.open.mockResolvedValue({ _tag: "Failure", cause: new Error("boom") }); + const renderer = await act(async () => create(render(picking))); + + await startPhone(renderer); + expect(errors(renderer)).toHaveLength(1); + await act(async () => renderer.update(render(next))); + + expect(errors(renderer)).toHaveLength(0); + }); + + it("closes a powered-off surface in its own thread after the panel moved", async () => { + const powerOff = deferred(); + mocks.close.mockReturnValue(powerOff.promise); + const renderer = await act(async () => create(render(picking, streaming))); + + await act(async () => { + renderer.root.findByProps({ hostLabel: "This Mac" }).props.onPowerOff(); + }); + expect(mocks.close).toHaveBeenCalledWith({ + environmentId: picking.environmentId, + input: { threadId: picking.threadId, hostId: "local", deviceId: "phone", shutdown: true }, + }); + await act(async () => renderer.update(render(next, streaming))); + await act(async () => powerOff.settle(opened)); + + expect(mocks.closeSurface).toHaveBeenCalledExactlyOnceWith(picking, streaming.id); + }); + + it("closes the surface when a power-off settles in the same thread", async () => { + mocks.close.mockResolvedValue(opened); + const renderer = await act(async () => create(render(picking, streaming))); + + await act(async () => { + renderer.root.findByProps({ hostLabel: "This Mac" }).props.onPowerOff(); + }); + + expect(mocks.closeSurface).toHaveBeenCalledExactlyOnceWith(picking, streaming.id); + }); +}); diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index 7e6804043f44..bee404728c96 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -6,7 +6,7 @@ import type { ScopedThreadRef, } from "@t3tools/contracts"; import { Smartphone, X } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore"; @@ -45,6 +45,15 @@ export function DevicePanel(props: { const close = useAtomCommand(deviceEnvironment.close); const [operationError, setOperationError] = useState(null); const [pendingDevice, setPendingDevice] = useState(null); + // Operation state belongs to one scoped thread; drop it when the panel moves on. + const scopeKey = `${environmentId}\u0000${threadId}`; + const [operationScopeKey, setOperationScopeKey] = useState(scopeKey); + if (operationScopeKey !== scopeKey) { + setOperationScopeKey(scopeKey); + setOperationError(null); + setPendingDevice(null); + } + const isCurrentScope = useScopeGuard(scopeKey); const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null; const hostDisabled = state.hostStatus === "disabled"; @@ -79,6 +88,7 @@ export function DevicePanel(props: { if (!device) return; setOperationError(null); setPendingDevice(device); + const stillCurrent = isCurrentScope(); try { const result = await open({ environmentId, @@ -89,16 +99,20 @@ export function DevicePanel(props: { platform: device.platform, }, }); - if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); - else + // The server opened the device for the starting thread, so its tab opens + // there even after a switch; only this panel's own state is scope-guarded. + if (result._tag === "Failure") { + if (stillCurrent()) setOperationError(formatEnvironmentQueryError(result.cause)); + } else { useRightPanelStore.getState().openDevice(props.threadRef, { hostId: result.value.hostId, deviceId: result.value.deviceId, platform: device.platform, name: device.name, }); + } } finally { - setPendingDevice(null); + if (stillCurrent()) setPendingDevice(null); } }; @@ -122,6 +136,7 @@ export function DevicePanel(props: { } if (!activeSession) return; setOperationError(null); + const stillCurrent = isCurrentScope(); void close({ environmentId, input: { @@ -131,8 +146,11 @@ export function DevicePanel(props: { shutdown: powerOff, }, }).then((result) => { - if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); - else useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); + if (result._tag === "Failure") { + if (stillCurrent()) setOperationError(formatEnvironmentQueryError(result.cause)); + } else { + useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); + } }); }; @@ -313,6 +331,27 @@ export function DevicePanel(props: { ); } +/** + * Binds async work to the committed scope. Call the returned function when the + * work starts; the check it returns is false once the panel moved to another + * thread (even back again) or unmounted, so late results are dropped. + */ +function useScopeGuard(scopeKey: string) { + const scopeRef = useRef<{ readonly key: string } | null>(null); + useLayoutEffect(() => { + // A fresh token per commit of a scope, so returning to a thread is a new scope. + const scope = { key: scopeKey }; + scopeRef.current = scope; + return () => { + scopeRef.current = null; + }; + }, [scopeKey]); + return () => { + const started = scopeRef.current; + return () => started !== null && scopeRef.current === started; + }; +} + function groupDevices(state: DeviceServiceState) { const groups: Array<{ platform: DevicePlatform; devices: DeviceSummary[] }> = []; for (const platform of ["ios", "android"] as const) { From a1d256b69021a22ffa773e9cd6e499e582d8d3e8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:24:51 +1100 Subject: [PATCH 11/22] refactor(web): open the Device side panel through the panel host Register Device in the bundled panel registry like Browser, Diff and Terminal. The body moves to panels/device and reads its thread and visibility from the panel host; the launcher row, tab title and icon read the definition, so the copy, letter and order are unchanged. The Device tests now mount it through the registered lazy path. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatView.tsx | 30 ++++------- .../RightPanelTabs.browserProfile.test.tsx | 3 +- .../RightPanelTabs.keyboard.test.tsx | 3 +- .../RightPanelTabs.terminal.test.tsx | 3 +- .../src/components/RightPanelTabs.test.tsx | 6 +-- apps/web/src/components/RightPanelTabs.tsx | 27 +++------- apps/web/src/panels/bundledPanels.test.tsx | 20 ++++++- apps/web/src/panels/bundledPanels.tsx | 11 +++- .../device/DeviceSidePanel.test.tsx} | 52 +++++++++++++------ .../device/DeviceSidePanel.tsx} | 52 +++++++++---------- apps/web/src/routes/_chat.pull-requests.tsx | 3 +- 11 files changed, 111 insertions(+), 99 deletions(-) rename apps/web/src/{components/device/DevicePanel.test.tsx => panels/device/DeviceSidePanel.test.tsx} (85%) rename apps/web/src/{components/device/DevicePanel.tsx => panels/device/DeviceSidePanel.tsx} (91%) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ee016473c2f7..47b73b58ee03 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -709,9 +709,6 @@ function useDraftHeroLayoutTransition( const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) => settings.browserAutoShowFloatingPreview; -const DevicePanel = lazy(() => - import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })), -); const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ @@ -10579,19 +10576,15 @@ export default function ChatView(props: ChatViewProps) { ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( ) : renderedRightPanelSurface?.kind === "device" ? ( - - { - closeRightPanelSurface(renderedRightPanelSurface); - useRightPanelStore.getState().show(activeThreadRef); - }} - /> - + { + closeRightPanelSurface(renderedRightPanelSurface); + useRightPanelStore.getState().show(activeThreadRef); + }} + /> ) : (renderedRightPanelSurface?.kind === "files" || renderedRightPanelSurface?.kind === "file") && ((activeProject && activeWorkspaceRoot) || @@ -10649,6 +10642,7 @@ export default function ChatView(props: ChatViewProps) { available: activeProject !== null && canOperateTerminal, onOpen: addTerminalSurface, }, + device: { available: activeThreadRef !== null, onOpen: addDeviceSurface }, }; const threadDetailsPanelProps: ThreadDetailsPanelProps = { anchor: threadPanelPopoverAnchorRef, @@ -11475,11 +11469,9 @@ export default function ChatView(props: ChatViewProps) { onAddFiles={addFilesSurface} onAddPullRequest={addPullRequestSurface} onAddPullRequests={addPullRequestsSurface} - onAddDevice={addDeviceSurface} filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} pullRequestsAvailable={pullRequestsSurfaceAvailable} - deviceAvailable={activeThreadRef !== null} > {rightPanelContent} @@ -11529,11 +11521,9 @@ export default function ChatView(props: ChatViewProps) { onAddFiles={addFilesSurface} onAddPullRequest={addPullRequestSurface} onAddPullRequests={addPullRequestsSurface} - onAddDevice={addDeviceSurface} filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} pullRequestsAvailable={pullRequestsSurfaceAvailable} - deviceAvailable={activeThreadRef !== null} > {rightPanelContent} diff --git a/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx b/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx index 4d26b0a9e249..c511f5668101 100644 --- a/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx +++ b/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx @@ -84,16 +84,15 @@ function Harness() { preview: { available: true, onOpen: openBrowser }, diff: { available: false, onOpen: () => undefined }, terminal: { available: false, onOpen: () => undefined }, + device: { available: false, onOpen: () => undefined }, }} onAddBrowserInProfile={openBrowser} onAddPullRequest={() => undefined} onAddPullRequests={() => undefined} onAddFiles={() => undefined} - onAddDevice={() => undefined} filesAvailable={false} pullRequestAvailable={false} pullRequestsAvailable={false} - deviceAvailable={false} > {null} diff --git a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx index ac4c836d226f..801a84984353 100644 --- a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx +++ b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx @@ -80,16 +80,15 @@ async function renderPanel(overrides: Partial content diff --git a/apps/web/src/components/RightPanelTabs.terminal.test.tsx b/apps/web/src/components/RightPanelTabs.terminal.test.tsx index 6b0b69cfe4fd..f89a1ce03119 100644 --- a/apps/web/src/components/RightPanelTabs.terminal.test.tsx +++ b/apps/web/src/components/RightPanelTabs.terminal.test.tsx @@ -80,16 +80,15 @@ function Harness({ terminalAvailable }: { terminalAvailable: boolean }) { available: terminalAvailable, onOpen: () => useRightPanelStore.getState().openTerminal(threadRef, "term-1"), }, + device: { available: false, onOpen: () => undefined }, }} onAddBrowserInProfile={() => undefined} onAddPullRequest={() => undefined} onAddPullRequests={() => undefined} onAddFiles={() => undefined} - onAddDevice={() => undefined} filesAvailable={false} pullRequestAvailable={false} pullRequestsAvailable={false} - deviceAvailable={false} > {null} diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index dd4bee2eefcb..23982c0bb2de 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -32,15 +32,14 @@ describe("right panel surface actions", () => { preview: { available: true, onOpen: () => undefined }, diff: { available: true, onOpen: () => undefined }, terminal: { available: true, onOpen: () => undefined }, + device: { available: true, onOpen: () => undefined }, }, onAddFiles: () => undefined, onAddPullRequest: () => undefined, onAddPullRequests: () => undefined, - onAddDevice: () => undefined, filesAvailable: true, pullRequestAvailable: true, pullRequestsAvailable: true, - deviceAvailable: true, }); it("keeps launcher order, letters and copy for registered and local surfaces", () => { @@ -169,16 +168,15 @@ function renderTabs( preview: { available: true, onOpen: () => undefined }, diff: { available: false, onOpen: () => undefined }, terminal: { available: false, onOpen: () => undefined }, + device: { available: false, onOpen: () => undefined }, }} onAddBrowserInProfile={() => undefined} onAddPullRequest={() => undefined} onAddPullRequests={() => undefined} onAddFiles={() => undefined} - onAddDevice={() => undefined} filesAvailable={false} pullRequestAvailable={false} pullRequestsAvailable={false} - deviceAvailable={false} >
content
, diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index b4eb3660f831..63f1913dbb59 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -25,7 +25,7 @@ import { import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers"; import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { Smartphone, ChevronDown, ChevronLeft, ChevronRight, Files, Plus } from "lucide-react"; +import { ChevronDown, ChevronLeft, ChevronRight, Files, Plus } from "lucide-react"; import { Volume2, VolumeOff } from "lucide"; import { type ComponentProps, @@ -128,11 +128,9 @@ interface RightPanelTabsProps { onAddFiles: () => void; onAddPullRequest: () => void; onAddPullRequests: () => void; - onAddDevice: () => void; filesAvailable: boolean; pullRequestAvailable: boolean; pullRequestsAvailable: boolean; - deviceAvailable: boolean; pullRequestStatusSeeds?: Readonly>; children: ReactNode; } @@ -173,7 +171,6 @@ interface SidePanelLauncher { interface SurfaceAction { id: string; label: string; - description?: string; icon: ComponentType<{ className?: string }>; shortcut: string; available: boolean; @@ -190,11 +187,9 @@ type SurfaceActionInputs = Pick< | "onAddFiles" | "onAddPullRequest" | "onAddPullRequests" - | "onAddDevice" | "filesAvailable" | "pullRequestAvailable" | "pullRequestsAvailable" - | "deviceAvailable" >; /** @@ -252,17 +247,7 @@ export function rightPanelSurfaceActions(props: SurfaceActionInputs): SurfaceAct unavailableReason: "No linked pull requests are available for this thread.", onClick: props.onAddPullRequests, }, - { - id: "device", - label: "Device", - description: "Watch an iOS Simulator or Android Emulator.", - icon: Smartphone, - shortcut: "M", - available: props.deviceAvailable, - unavailableHint: "Available from a thread.", - unavailableReason: "Devices are only available from a thread.", - onClick: props.onAddDevice, - }, + registered("device"), ]; } @@ -608,7 +593,7 @@ function surfaceTitle( case "pull-requests": return "Pull requests"; case "device": - return surface.title ?? surface.target?.name ?? "Device"; + return surface.title ?? surface.target?.name ?? getSidePanelMetadata("device").title; case "preview": { const fallback = getSidePanelMetadata("preview").title; const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; @@ -696,14 +681,16 @@ function SurfaceIcon({ ); case "pull-requests": return ; - case "device": + case "device": { + const DeviceIcon = getSidePanelMetadata("device").icon; return surface.target?.platform === "ios" ? ( ) : surface.target?.platform === "android" ? ( ) : ( - + ); + } } } diff --git a/apps/web/src/panels/bundledPanels.test.tsx b/apps/web/src/panels/bundledPanels.test.tsx index b721ff28646f..863aaf2fc288 100644 --- a/apps/web/src/panels/bundledPanels.test.tsx +++ b/apps/web/src/panels/bundledPanels.test.tsx @@ -7,6 +7,7 @@ const loaded = vi.hoisted(() => ({ diff: 0, preview: 0, terminal: 0, + device: 0, previewRenders: [] as unknown[], })); vi.mock("./diff/DiffSidePanel", () => { @@ -17,6 +18,10 @@ vi.mock("./terminal/TerminalSidePanel", () => { loaded.terminal += 1; return { default: () => null }; }); +vi.mock("./device/DeviceSidePanel", () => { + loaded.device += 1; + return { default: () => null }; +}); vi.mock("./preview/PreviewSidePanel", () => { loaded.preview += 1; return { @@ -46,7 +51,7 @@ const host: PanelHost = { describe("bundled side panels", () => { it("loads only the selected panel body and lends it the host", async () => { - expect(loaded).toMatchObject({ diff: 0, preview: 0, terminal: 0 }); + expect(loaded).toMatchObject({ diff: 0, preview: 0, terminal: 0, device: 0 }); await act(async () => { create( @@ -56,7 +61,7 @@ describe("bundled side panels", () => { , ); }); - expect(loaded).toMatchObject({ diff: 0, preview: 1, terminal: 0 }); + expect(loaded).toMatchObject({ diff: 0, preview: 1, terminal: 0, device: 0 }); expect(loaded.previewRenders).toEqual([{ props: { tabId: "tab-1" }, host }]); }); @@ -97,6 +102,8 @@ describe("bundled side panels", () => { export function typeFixtures( widenedId: "diff" | "preview", terminalSurface: Extract, + deviceSurface: Extract, + dismiss: () => void, ) { const terminalProps = { surface: terminalSurface, @@ -120,8 +127,17 @@ export function typeFixtures( {/* @ts-expect-error Terminal props on Preview. */} + {/* @ts-expect-error Preview props on Diff. */} + {/* @ts-expect-error Device props on Preview. */} + + {/* @ts-expect-error Preview props on Device. */} + + {/* @ts-expect-error Device needs its surface and setup dismissal. */} + + {/* @ts-expect-error The host owns visibility; panels do not take it as a prop. */} + {/* @ts-expect-error The host owns the thread; panels do not take it as a prop. */} {/* @ts-expect-error Wrong input shape. */} diff --git a/apps/web/src/panels/bundledPanels.tsx b/apps/web/src/panels/bundledPanels.tsx index e4ef4db5839f..366383a5ea35 100644 --- a/apps/web/src/panels/bundledPanels.tsx +++ b/apps/web/src/panels/bundledPanels.tsx @@ -1,4 +1,4 @@ -import { FileDiff, Globe2, TerminalSquare } from "lucide-react"; +import { FileDiff, Globe2, Smartphone, TerminalSquare } from "lucide-react"; import { Suspense, type ComponentType } from "react"; import { createPanelRegistry, type PanelMetadata, type PanelProps } from "./panelRegistry"; @@ -31,6 +31,15 @@ const bundledPanels = createPanelRegistry([ unavailableReason: "Terminal surfaces are only available from a project thread.", load: () => import("./terminal/TerminalSidePanel"), }, + { + id: "device", + title: "Device", + icon: Smartphone, + launcherKey: "M", + unavailableHint: "Available from a thread.", + unavailableReason: "Devices are only available from a thread.", + load: () => import("./device/DeviceSidePanel"), + }, ]); export type SidePanelId = (typeof bundledPanels.definitions)[number]["id"]; diff --git a/apps/web/src/components/device/DevicePanel.test.tsx b/apps/web/src/panels/device/DeviceSidePanel.test.tsx similarity index 85% rename from apps/web/src/components/device/DevicePanel.test.tsx rename to apps/web/src/panels/device/DeviceSidePanel.test.tsx index 995b2a681025..828d50d6c904 100644 --- a/apps/web/src/components/device/DevicePanel.test.tsx +++ b/apps/web/src/panels/device/DeviceSidePanel.test.tsx @@ -10,7 +10,8 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { RightPanelSurface } from "~/rightPanelStore"; -import { DevicePanel } from "./DevicePanel"; +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext } from "../panelHost"; type CommandResult = | { _tag: "Success"; value: { hostId: string; deviceId: string } } @@ -97,16 +98,33 @@ const streaming: Extract = { target: { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" }, }; +// Mounted the way ChatView mounts it: through the registry, lazily, on the panel host. const render = (threadRef: ScopedThreadRef, surface = picker) => ( - undefined} - /> + undefined, + }} + > + undefined} + /> + ); +const mount = async (threadRef: ScopedThreadRef, surface = picker) => { + const renderer = await act(async () => create(render(threadRef, surface))); + // Wait for the registered lazy body to load and replace the Suspense fallback. + await act(async () => { + await import("./DeviceSidePanel"); + }); + return renderer; +}; const deferred = () => { let settle!: (result: CommandResult) => void; const promise = new Promise((resolve) => (settle = resolve)); @@ -123,11 +141,11 @@ beforeEach(() => { vi.clearAllMocks(); }); -describe("DevicePanel", () => { +describe("registered Device panel", () => { it("opens a picked device when the pick settles in the same thread", async () => { const pick = deferred(); mocks.open.mockReturnValue(pick.promise); - const renderer = await act(async () => create(render(picking))); + const renderer = await mount(picking); await startPhone(renderer); expect(mocks.open).toHaveBeenCalledWith({ @@ -146,7 +164,7 @@ describe("DevicePanel", () => { it("keeps the next thread's picker usable while an earlier pick is pending", async () => { mocks.open.mockReturnValue(deferred().promise); - const renderer = await act(async () => create(render(picking))); + const renderer = await mount(picking); await startPhone(renderer); await act(async () => renderer.update(render(next))); @@ -157,7 +175,7 @@ describe("DevicePanel", () => { it("opens a pick that settles after a switch in the thread it started in", async () => { const pick = deferred(); mocks.open.mockReturnValue(pick.promise); - const renderer = await act(async () => create(render(picking))); + const renderer = await mount(picking); await startPhone(renderer); await act(async () => renderer.update(render(next))); @@ -175,7 +193,7 @@ describe("DevicePanel", () => { it("hides a failed pick that settles after the panel moved, even back again", async () => { const pick = deferred(); mocks.open.mockReturnValue(pick.promise); - const renderer = await act(async () => create(render(picking))); + const renderer = await mount(picking); await startPhone(renderer); await act(async () => renderer.update(render(next))); @@ -188,7 +206,7 @@ describe("DevicePanel", () => { it("does not carry an operation error into another thread", async () => { mocks.open.mockResolvedValue({ _tag: "Failure", cause: new Error("boom") }); - const renderer = await act(async () => create(render(picking))); + const renderer = await mount(picking); await startPhone(renderer); expect(errors(renderer)).toHaveLength(1); @@ -200,7 +218,7 @@ describe("DevicePanel", () => { it("closes a powered-off surface in its own thread after the panel moved", async () => { const powerOff = deferred(); mocks.close.mockReturnValue(powerOff.promise); - const renderer = await act(async () => create(render(picking, streaming))); + const renderer = await mount(picking, streaming); await act(async () => { renderer.root.findByProps({ hostLabel: "This Mac" }).props.onPowerOff(); @@ -217,7 +235,7 @@ describe("DevicePanel", () => { it("closes the surface when a power-off settles in the same thread", async () => { mocks.close.mockResolvedValue(opened); - const renderer = await act(async () => create(render(picking, streaming))); + const renderer = await mount(picking, streaming); await act(async () => { renderer.root.findByProps({ hostLabel: "This Mac" }).props.onPowerOff(); diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/panels/device/DeviceSidePanel.tsx similarity index 91% rename from apps/web/src/components/device/DevicePanel.tsx rename to apps/web/src/panels/device/DeviceSidePanel.tsx index bee404728c96..c3dd34d8ff77 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/panels/device/DeviceSidePanel.tsx @@ -1,10 +1,5 @@ -import { DeviceHostUpdates } from "./DeviceHostUpdates"; -import type { - DevicePlatform, - DeviceServiceState, - DeviceSummary, - ScopedThreadRef, -} from "@t3tools/contracts"; +import { DeviceHostUpdates } from "~/components/device/DeviceHostUpdates"; +import type { DevicePlatform, DeviceServiceState, DeviceSummary } from "@t3tools/contracts"; import { Smartphone, X } from "lucide-react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -19,10 +14,12 @@ import { cn } from "~/lib/utils"; import { deviceEnvironment, useDeviceState } from "~/state/device"; import { formatEnvironmentQueryError } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; -import { DeviceLoadingView } from "./DeviceLoadingView"; -import { DeviceSetup } from "./DeviceSetup"; -import { DeviceWorkspace } from "./DeviceWorkspace"; -import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell"; +import { DeviceLoadingView } from "~/components/device/DeviceLoadingView"; +import { DeviceSetup } from "~/components/device/DeviceSetup"; +import { DeviceWorkspace } from "~/components/device/DeviceWorkspace"; +import { PreviewPanelShell } from "~/components/preview/PreviewPanelShell"; + +import { usePanelHost } from "../panelHost"; const platformLabel = (platform: DevicePlatform) => platform === "ios" ? "iOS Simulators" : "Android Emulators"; @@ -30,15 +27,16 @@ const platformLabel = (platform: DevicePlatform) => const deviceKey = (device: Pick) => `${device.hostId}\u0000${device.id}`; -/** Each surface owns one host/device; only the visible surface streams. */ -export function DevicePanel(props: { - readonly mode: PreviewPanelMode; - readonly threadRef: ScopedThreadRef; +/** + * Each surface owns one host/device; only the visible surface streams. + * RightPanelTabs owns placement, so the side panel is always embedded. + */ +export default function DeviceSidePanel(props: { readonly surface: Extract; - readonly visible: boolean; readonly onDismissSetup: () => void; }) { - const { environmentId, threadId } = props.threadRef; + const { threadRef, visible } = usePanelHost(); + const { environmentId, threadId } = threadRef; const { state, loaded } = useDeviceState(environmentId); const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); const open = useAtomCommand(deviceEnvironment.open); @@ -60,9 +58,9 @@ export function DevicePanel(props: { // Opening setup never grants permission to install or start helpers. useEffect(() => { - if (!props.visible || !loaded || hostDisabled) return; + if (!visible || !loaded || hostDisabled) return; void list({ environmentId, input: {} }); - }, [environmentId, list, loaded, props.visible, hostDisabled]); + }, [environmentId, list, loaded, visible, hostDisabled]); const sessions = useMemo( () => state.sessions.filter((session) => session.threadId === threadId), @@ -104,7 +102,7 @@ export function DevicePanel(props: { if (result._tag === "Failure") { if (stillCurrent()) setOperationError(formatEnvironmentQueryError(result.cause)); } else { - useRightPanelStore.getState().openDevice(props.threadRef, { + useRightPanelStore.getState().openDevice(threadRef, { hostId: result.value.hostId, deviceId: result.value.deviceId, platform: device.platform, @@ -119,19 +117,19 @@ export function DevicePanel(props: { // Floating the device closes the panel, like the browser's floating preview. const floatActive = () => { if (!activeDevice) return; - usePreviewMiniPlayerStore.getState().open(props.threadRef, { + usePreviewMiniPlayerStore.getState().open(threadRef, { kind: "device", hostId: activeDevice.hostId, deviceId: activeDevice.id, platform: activeDevice.platform, name: activeDevice.name, }); - useRightPanelStore.getState().close(props.threadRef); + useRightPanelStore.getState().close(threadRef); }; const closeActive = (powerOff: boolean) => { if (!powerOff) { - useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); + useRightPanelStore.getState().closeSurface(threadRef, props.surface.id); return; } if (!activeSession) return; @@ -149,7 +147,7 @@ export function DevicePanel(props: { if (result._tag === "Failure") { if (stillCurrent()) setOperationError(formatEnvironmentQueryError(result.cause)); } else { - useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); + useRightPanelStore.getState().closeSurface(threadRef, props.surface.id); } }); }; @@ -171,7 +169,7 @@ export function DevicePanel(props: { if (loaded && (!state.onboardingCompleted || hostDisabled)) { return ( { if (!isOpen) props.onDismissSetup(); }} @@ -184,7 +182,7 @@ export function DevicePanel(props: { } return ( - + {hostReady && !activeDevice && state.hostStatusDetail ? (
host.id === activeDevice.hostId)?.label ?? "Device host" } hostDiagnostics={state.hostStatusDetail} - visible={props.visible} + visible={visible} onFloat={floatActive} onClose={() => closeActive(false)} onPowerOff={() => closeActive(true)} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 25f4aa89f187..5966f548852c 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -280,6 +280,7 @@ const UNAVAILABLE_SIDE_PANELS = { preview: { available: false, onOpen: () => undefined }, diff: { available: false, onOpen: () => undefined }, terminal: { available: false, onOpen: () => undefined }, + device: { available: false, onOpen: () => undefined }, }; const EMPTY_PENDING_SURFACES = new Set(); const MAX_SEARCH_LABEL_CANDIDATES = 100; @@ -2294,11 +2295,9 @@ function PullRequestsRouteView() { onAddFiles={() => undefined} onAddPullRequest={() => undefined} onAddPullRequests={() => undefined} - onAddDevice={() => undefined} filesAvailable={false} pullRequestAvailable={false} pullRequestsAvailable={false} - deviceAvailable={false} pullRequestStatusSeeds={listedPullRequestTabStatuses} > Date: Sun, 4 Oct 2026 21:21:21 +1100 Subject: [PATCH 12/22] refactor(web): open the pull request side panels through the panel host The thread's pull request detail and its linked pull requests list now register on the panel host like Diff, Browser, Terminal and Device. Each body reads its thread, environment and composer draft target from the host, and the detail gates on the host environment's pull request capability itself, with the same loading ghost and unavailable copy. Reference, context, back, shortcut enablement and shortcut context stay props. Which pull request the P entry opens, including a linked pull request with no legacy link, is still decided in ChatView. Launcher letters, order, copy, tab titles and keys are unchanged, and the pull requests page keeps rendering the detail panel directly. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatView.tsx | 48 +-- .../RightPanelTabs.browserProfile.test.tsx | 6 +- .../RightPanelTabs.keyboard.test.tsx | 6 +- .../RightPanelTabs.terminal.test.tsx | 6 +- .../src/components/RightPanelTabs.test.tsx | 12 +- apps/web/src/components/RightPanelTabs.tsx | 43 +-- apps/web/src/panels/bundledPanels.test.tsx | 55 ++- apps/web/src/panels/bundledPanels.tsx | 26 +- apps/web/src/panels/panelRegistry.ts | 2 + .../pullRequest/PullRequestPanelPending.tsx | 15 + .../pullRequest/PullRequestSidePanel.test.tsx | 350 ++++++++++++++++++ .../pullRequest/PullRequestSidePanel.tsx | 62 ++++ .../PullRequestsSidePanel.test.tsx | 178 +++++++++ .../pullRequest/PullRequestsSidePanel.tsx | 7 + apps/web/src/routes/_chat.pull-requests.tsx | 6 +- 15 files changed, 719 insertions(+), 103 deletions(-) create mode 100644 apps/web/src/panels/pullRequest/PullRequestPanelPending.tsx create mode 100644 apps/web/src/panels/pullRequest/PullRequestSidePanel.test.tsx create mode 100644 apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx create mode 100644 apps/web/src/panels/pullRequest/PullRequestsSidePanel.test.tsx create mode 100644 apps/web/src/panels/pullRequest/PullRequestsSidePanel.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 47b73b58ee03..041c96ddcc46 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -290,12 +290,8 @@ import { pullRequestPanelContext, threadPullRequestPanelTarget, } from "./pullRequest/pullRequestDetail.logic"; -import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; -import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; -import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; import { RightPanelTabs } from "./RightPanelTabs"; import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; -import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; import { useDeviceState } from "~/state/device"; import { DeviceSetup } from "./device/DeviceSetup"; import { Dialog } from "./ui/dialog"; @@ -10520,36 +10516,17 @@ export default function ChatView(props: ChatViewProps) { /> ) : renderedRightPanelSurface?.kind === "diff" ? ( - ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( - - ) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( - ) : renderedRightPanelSurface?.kind === "pull-request" ? ( - // No onClose: the surface tab's own X owns closing here, and a second X in the header - // would be the same action twice. The thread context also drops the checkout button, so it - // is only right for the thread's own pull request, whose branch is already under the - // reader's feet. A link the agent wrote can open any other one here, and that one has to be - // checkable out like it is anywhere else. - { - if (activeThreadRef) - useRightPanelStore.getState().openPullRequest(activeThreadRef, { - projectId: reference.projectId, - repository: reference.repository, - number: reference.number, - ...(reference.host ? { host: reference.host } : {}), - }); - }} - threadRef={activeThreadRef} reference={{ projectId: renderedRightPanelSurface.projectId as ProjectId, ...(renderedRightPanelSurface.host ? { host: renderedRightPanelSurface.host } : {}), @@ -10566,15 +10543,14 @@ export default function ChatView(props: ChatViewProps) { }, renderedRightPanelSurface, )} - composerDraftTarget={composerDraftTarget} onBack={ activeThreadRef !== null && pullRequestsSurfaceAvailable && visiblePullRequestCount > 1 ? addPullRequestsSurface : undefined } /> - ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( - + ) : renderedRightPanelSurface?.kind === "pull-requests" ? ( + ) : renderedRightPanelSurface?.kind === "device" ? ( {rightPanelContent} @@ -11519,11 +11493,7 @@ export default function ChatView(props: ChatViewProps) { panels={sidePanelLaunchers} onAddBrowserInProfile={createBrowserSurface} onAddFiles={addFilesSurface} - onAddPullRequest={addPullRequestSurface} - onAddPullRequests={addPullRequestsSurface} filesAvailable={activeProject !== null} - pullRequestAvailable={pullRequestSurfaceAvailable} - pullRequestsAvailable={pullRequestsSurfaceAvailable} > {rightPanelContent} diff --git a/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx b/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx index c511f5668101..0c9d6c535b97 100644 --- a/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx +++ b/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx @@ -85,14 +85,12 @@ function Harness() { diff: { available: false, onOpen: () => undefined }, terminal: { available: false, onOpen: () => undefined }, device: { available: false, onOpen: () => undefined }, + "pull-request": { available: false, onOpen: () => undefined }, + "pull-requests": { available: false, onOpen: () => undefined }, }} onAddBrowserInProfile={openBrowser} - onAddPullRequest={() => undefined} - onAddPullRequests={() => undefined} onAddFiles={() => undefined} filesAvailable={false} - pullRequestAvailable={false} - pullRequestsAvailable={false} > {null} diff --git a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx index 801a84984353..5b3b12e41489 100644 --- a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx +++ b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx @@ -81,14 +81,12 @@ async function renderPanel(overrides: Partial content diff --git a/apps/web/src/components/RightPanelTabs.terminal.test.tsx b/apps/web/src/components/RightPanelTabs.terminal.test.tsx index f89a1ce03119..87ff9938c65c 100644 --- a/apps/web/src/components/RightPanelTabs.terminal.test.tsx +++ b/apps/web/src/components/RightPanelTabs.terminal.test.tsx @@ -81,14 +81,12 @@ function Harness({ terminalAvailable }: { terminalAvailable: boolean }) { onOpen: () => useRightPanelStore.getState().openTerminal(threadRef, "term-1"), }, device: { available: false, onOpen: () => undefined }, + "pull-request": { available: false, onOpen: () => undefined }, + "pull-requests": { available: false, onOpen: () => undefined }, }} onAddBrowserInProfile={() => undefined} - onAddPullRequest={() => undefined} - onAddPullRequests={() => undefined} onAddFiles={() => undefined} filesAvailable={false} - pullRequestAvailable={false} - pullRequestsAvailable={false} > {null} diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 23982c0bb2de..fba251bc7e02 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -33,13 +33,11 @@ describe("right panel surface actions", () => { diff: { available: true, onOpen: () => undefined }, terminal: { available: true, onOpen: () => undefined }, device: { available: true, onOpen: () => undefined }, + "pull-request": { available: true, onOpen: () => undefined }, + "pull-requests": { available: true, onOpen: () => undefined }, }, onAddFiles: () => undefined, - onAddPullRequest: () => undefined, - onAddPullRequests: () => undefined, filesAvailable: true, - pullRequestAvailable: true, - pullRequestsAvailable: true, }); it("keeps launcher order, letters and copy for registered and local surfaces", () => { @@ -169,14 +167,12 @@ function renderTabs( diff: { available: false, onOpen: () => undefined }, terminal: { available: false, onOpen: () => undefined }, device: { available: false, onOpen: () => undefined }, + "pull-request": { available: false, onOpen: () => undefined }, + "pull-requests": { available: false, onOpen: () => undefined }, }} onAddBrowserInProfile={() => undefined} - onAddPullRequest={() => undefined} - onAddPullRequests={() => undefined} onAddFiles={() => undefined} filesAvailable={false} - pullRequestAvailable={false} - pullRequestsAvailable={false} >
content
, diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 63f1913dbb59..adb004060cd2 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -126,11 +126,7 @@ interface RightPanelTabsProps { panels: Readonly>; onAddBrowserInProfile: (profileId: string) => void; onAddFiles: () => void; - onAddPullRequest: () => void; - onAddPullRequests: () => void; filesAvailable: boolean; - pullRequestAvailable: boolean; - pullRequestsAvailable: boolean; pullRequestStatusSeeds?: Readonly>; children: ReactNode; } @@ -181,16 +177,7 @@ interface SurfaceAction { onClick: () => void; } -type SurfaceActionInputs = Pick< - RightPanelTabsProps, - | "panels" - | "onAddFiles" - | "onAddPullRequest" - | "onAddPullRequests" - | "filesAvailable" - | "pullRequestAvailable" - | "pullRequestsAvailable" ->; +type SurfaceActionInputs = Pick; /** * The surfaces the empty launcher and the add menu offer, in launcher order. @@ -227,26 +214,8 @@ export function rightPanelSurfaceActions(props: SurfaceActionInputs): SurfaceAct onClick: props.onAddFiles, }, registered("diff"), - { - id: "pull-request", - label: "Pull request", - icon: PullRequestGlyph.pullRequest, - shortcut: "P", - available: props.pullRequestAvailable, - unavailableHint: "No pull request on this branch yet.", - unavailableReason: "This thread's branch has no pull request yet.", - onClick: props.onAddPullRequest, - }, - { - id: "pull-requests", - label: "Linked pull requests", - icon: PullRequestGlyph.link, - shortcut: "L", - available: props.pullRequestsAvailable, - unavailableHint: "No linked pull requests available.", - unavailableReason: "No linked pull requests are available for this thread.", - onClick: props.onAddPullRequests, - }, + registered("pull-request"), + registered("pull-requests"), registered("device"), ]; } @@ -679,8 +648,10 @@ function SurfaceIcon({ seed={pullRequestStatusSeeds?.[surface.id]} /> ); - case "pull-requests": - return ; + case "pull-requests": { + const Icon = getSidePanelMetadata("pull-requests").icon; + return ; + } case "device": { const DeviceIcon = getSidePanelMetadata("device").icon; return surface.target?.platform === "ios" ? ( diff --git a/apps/web/src/panels/bundledPanels.test.tsx b/apps/web/src/panels/bundledPanels.test.tsx index 863aaf2fc288..7d33dd57c6a4 100644 --- a/apps/web/src/panels/bundledPanels.test.tsx +++ b/apps/web/src/panels/bundledPanels.test.tsx @@ -1,4 +1,4 @@ -import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; import { act, Suspense } from "react"; import { create } from "react-test-renderer"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -8,6 +8,8 @@ const loaded = vi.hoisted(() => ({ preview: 0, terminal: 0, device: 0, + pullRequest: 0, + pullRequests: 0, previewRenders: [] as unknown[], })); vi.mock("./diff/DiffSidePanel", () => { @@ -32,6 +34,15 @@ vi.mock("./preview/PreviewSidePanel", () => { }; }); +vi.mock("./pullRequest/PullRequestSidePanel", () => { + loaded.pullRequest += 1; + return { default: () => null }; +}); +vi.mock("./pullRequest/PullRequestsSidePanel", () => { + loaded.pullRequests += 1; + return { default: () => null }; +}); + import type { RightPanelSurface } from "~/rightPanelStore"; import { RegisteredSidePanel } from "./bundledPanels"; @@ -51,7 +62,14 @@ const host: PanelHost = { describe("bundled side panels", () => { it("loads only the selected panel body and lends it the host", async () => { - expect(loaded).toMatchObject({ diff: 0, preview: 0, terminal: 0, device: 0 }); + expect(loaded).toMatchObject({ + diff: 0, + preview: 0, + terminal: 0, + device: 0, + pullRequest: 0, + pullRequests: 0, + }); await act(async () => { create( @@ -61,7 +79,14 @@ describe("bundled side panels", () => { , ); }); - expect(loaded).toMatchObject({ diff: 0, preview: 1, terminal: 0, device: 0 }); + expect(loaded).toMatchObject({ + diff: 0, + preview: 1, + terminal: 0, + device: 0, + pullRequest: 0, + pullRequests: 0, + }); expect(loaded.previewRenders).toEqual([{ props: { tabId: "tab-1" }, host }]); }); @@ -116,6 +141,20 @@ export function typeFixtures( onActiveTerminalChange: () => undefined, onCloseTerminal: () => undefined, }; + const reference = { projectId: ProjectId.make("project"), repository: "owner/repo", number: 7 }; + const pullRequest = { + reference, + context: "thread" as const, + shortcutsEnabled: true, + getShortcutContext: () => ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + }), + }; return ( <> @@ -128,6 +167,16 @@ export function typeFixtures( {/* @ts-expect-error Terminal props on Preview. */} + + + {/* @ts-expect-error Pull request detail needs its reference and shortcut inputs. */} + + {/* @ts-expect-error Pull request props on the linked list. */} + + {/* @ts-expect-error Pull request props on Preview. */} + + {/* @ts-expect-error The host owns the composer draft target. */} + {/* @ts-expect-error Preview props on Diff. */} {/* @ts-expect-error Device props on Preview. */} diff --git a/apps/web/src/panels/bundledPanels.tsx b/apps/web/src/panels/bundledPanels.tsx index 366383a5ea35..5b369f13f169 100644 --- a/apps/web/src/panels/bundledPanels.tsx +++ b/apps/web/src/panels/bundledPanels.tsx @@ -1,6 +1,9 @@ import { FileDiff, Globe2, Smartphone, TerminalSquare } from "lucide-react"; import { Suspense, type ComponentType } from "react"; +import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; + +import { PullRequestPanelPending } from "./pullRequest/PullRequestPanelPending"; import { createPanelRegistry, type PanelMetadata, type PanelProps } from "./panelRegistry"; const bundledPanels = createPanelRegistry([ @@ -40,6 +43,27 @@ const bundledPanels = createPanelRegistry([ unavailableReason: "Devices are only available from a thread.", load: () => import("./device/DeviceSidePanel"), }, + { + id: "pull-request", + title: "Pull request", + icon: PullRequestGlyph.pullRequest, + launcherKey: "P", + unavailableHint: "No pull request on this branch yet.", + unavailableReason: "This thread's branch has no pull request yet.", + // The detail's code is large; the first open would otherwise show an empty panel. + fallback: , + load: () => import("./pullRequest/PullRequestSidePanel"), + }, + { + id: "pull-requests", + title: "Linked pull requests", + icon: PullRequestGlyph.link, + launcherKey: "L", + unavailableHint: "No linked pull requests available.", + unavailableReason: "No linked pull requests are available for this thread.", + fallback: , + load: () => import("./pullRequest/PullRequestsSidePanel"), + }, ]); export type SidePanelId = (typeof bundledPanels.definitions)[number]["id"]; @@ -73,7 +97,7 @@ export function RegisteredSidePanel({ id, ...props }: RegisteredSidePanelProps) // The union caller already paired id with its props; destructuring loses that correlation. const Component = panel.Component as ComponentType; return ( - + ); diff --git a/apps/web/src/panels/panelRegistry.ts b/apps/web/src/panels/panelRegistry.ts index c2ef77aaa883..b3b925413a50 100644 --- a/apps/web/src/panels/panelRegistry.ts +++ b/apps/web/src/panels/panelRegistry.ts @@ -16,6 +16,8 @@ export interface PanelDefinition { unavailableHint: string; /** Full reason shown in the add menu tooltip while unavailable. */ unavailableReason: string; + /** Shown while the body's code loads; null when the panel has nothing lighter to show. */ + fallback?: ReactNode; load: () => Promise<{ default: Body }>; } diff --git a/apps/web/src/panels/pullRequest/PullRequestPanelPending.tsx b/apps/web/src/panels/pullRequest/PullRequestPanelPending.tsx new file mode 100644 index 000000000000..2331e8aec07f --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestPanelPending.tsx @@ -0,0 +1,15 @@ +const BAR_WIDTHS = ["h-4 w-2/5", "h-3 w-3/5", "h-3 w-1/2"]; + +/** + * Shown while a pull request panel's code loads. It stays this small because the registry + * imports it eagerly; the panel's own ghosts take over once its code has arrived. + */ +export function PullRequestPanelPending({ label }: { label: string }) { + return ( +
+ {BAR_WIDTHS.map((width) => ( +
+ ))} +
+ ); +} diff --git a/apps/web/src/panels/pullRequest/PullRequestSidePanel.test.tsx b/apps/web/src/panels/pullRequest/PullRequestSidePanel.test.tsx new file mode 100644 index 000000000000..3a3b2c4cb8a9 --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestSidePanel.test.tsx @@ -0,0 +1,350 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + EnvironmentId, + ProjectId, + ThreadId, + type PullRequestDetailView, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { act, Suspense, type ComponentProps, type ReactElement, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useComposerDraftStore } from "~/composerDraftStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +const { bodyImport, capabilities, Wrapper, Trigger } = vi.hoisted(() => ({ + // Holds the body's code until a test lets it arrive, like a slow first chunk load. + bodyImport: (() => { + let resolve = () => {}; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve: () => resolve() }; + })(), + // Pull request capability per environment; null means its server config has not arrived. + capabilities: new Map(), + Wrapper: ({ children }: { children?: ReactNode }) => children, + Trigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => ( + <> + {render} + {children} + + ), +})); +const configFor = (environmentId: string) => { + const pullRequests = capabilities.get(environmentId); + return pullRequests == null + ? null + : { environment: { capabilities: { pullRequests, threadPullRequests: pullRequests } } }; +}; +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); +vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: {} })); +vi.mock("~/state/entities", () => ({ + useProjects: () => [], + useServerConfigs: () => + new Map( + [...capabilities.keys()].map((environmentId) => [environmentId, configFor(environmentId)]), + ), +})); +vi.mock("~/state/environments", () => ({ + useEnvironment: (environmentId: string) => { + const serverConfig = configFor(environmentId); + return serverConfig ? { serverConfig } : null; + }, + useEnvironments: () => ({ environments: [] }), + usePrimaryEnvironmentId: () => EnvironmentId.make("environment-new"), +})); +vi.mock("~/hooks/useSettings", () => ({ + useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + select(DEFAULT_CLIENT_SETTINGS), + useEnvironmentSettings: () => undefined, +})); +vi.mock("~/hooks/useLiveRefresh", () => ({ useLiveRefresh: () => {} })); +vi.mock("~/hooks/useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); +vi.mock("~/lib/sourceControlActions", () => ({ + usePreparePullRequestThreadAction: () => ({ run: vi.fn() }), +})); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("~/state/pullRequests", async (importOriginal) => ({ + ...(await importOriginal()), + pullRequestEnvironment: { detail: () => "detail", activity: () => "activity" }, + usePullRequestTurnRefresh: () => 0, + useSharedPullRequestSummary: () => null, +})); +vi.mock("~/state/vcs", () => ({ vcsEnvironment: { listRefs: () => null } })); +vi.mock("~/state/query", () => ({ + useEnvironmentQuery: (query: string) => ({ + data: query === "detail" ? detail : null, + isPending: false, + isSuccess: true, + error: null, + refresh: vi.fn(), + }), +})); +vi.mock("~/state/usePullRequestStack", () => ({ + usePullRequestStack: () => ({ + data: { layers: [] }, + isSuccess: true, + isPending: false, + isFresh: true, + error: null, + notice: null, + refresh: vi.fn(), + }), +})); +// The stack menu's popup is chrome; its layer row is the entry point under test. +vi.mock("~/components/pullRequest/PullRequestStackMenu", () => ({ + PullRequestStackMenu: ({ + reference, + onSelect, + }: ComponentProps< + typeof import("~/components/pullRequest/PullRequestStackMenu").PullRequestStackMenu + >) => , +})); +vi.mock("~/components/ui/toast", () => ({ toastManager: { add: vi.fn(), update: vi.fn() } })); +vi.mock("~/components/ui/tooltip", () => ({ + TooltipProvider: Wrapper, + Tooltip: Wrapper, + TooltipTrigger: Trigger, + TooltipPopup: () => null, +})); +vi.mock("~/components/ui/menu", () => ({ + Menu: Wrapper, + MenuPopup: Wrapper, + MenuTrigger: Trigger, + MenuItem: "button", + MenuRadioGroup: Wrapper, + MenuRadioItem: "button", + MenuSeparator: () => null, + MenuShortcut: () => null, +})); +vi.mock("~/components/ui/alert-dialog", () => ({ + AlertDialog: () => null, + AlertDialogPopup: Wrapper, + AlertDialogHeader: Wrapper, + AlertDialogTitle: Wrapper, + AlertDialogDescription: Wrapper, + AlertDialogFooter: Wrapper, + AlertDialogClose: Wrapper, +})); +vi.mock("~/components/pullRequest/PullRequestMarkdown", () => ({ + PullRequestMarkdownContext: Wrapper, + PullRequestMarkdown: () => null, +})); +vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => vi.fn() })); +vi.mock("~/components/pullRequest/PullRequestThreadLinks", () => ({ + PullRequestThreadLinks: () => null, +})); +vi.mock("~/components/pullRequest/PullRequestSummaryTab", () => ({ + PullRequestSummaryTab: () => null, +})); +vi.mock("~/components/pullRequest/PullRequestCodeTab", () => ({ default: () => null })); +vi.mock("./PullRequestSidePanel", async (importOriginal) => { + await bodyImport.promise; + return importOriginal(); +}); + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +const detail: PullRequestDetailView = { + provider: "github", + projectId: ProjectId.make("project"), + projectTitle: "Project", + workspaceRoot: "/workspace", + repository: "owner/repo", + number: 7, + title: "Test pull request", + body: "Original description", + url: "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/owner/repo/pull/7", + author: { login: "author", name: null, avatarUrl: null }, + viewer: "author", + state: "open", + isDraft: false, + mergeability: "mergeable", + additions: 1, + deletions: 0, + changedFiles: 1, + headBranch: "feature", + baseBranch: "main", + createdAt: "2026-09-01T00:00:00Z", + updatedAt: "2026-09-01T00:00:00Z", + mergedAt: null, + closedAt: null, + reviewers: [], + labels: [], + checks: [], + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + mergeCapabilities: { merge: false, squash: false, rebase: false }, + capabilities: { + diff: true, + comment: false, + search: true, + stacks: true, + actions: [], + mergeMethods: [], + review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, + reviewers: { request: false, listCandidates: false }, + edit: { changeRequest: true, comment: false }, + }, + viewerPermissions: { + actions: [], + comment: false, + resolve: false, + verdicts: [], + requestReviewers: false, + }, +}; + +// The same thread id on two environments is two threads. +const threadId = ThreadId.make("thread-a"); +const refOn = (environmentId: string): ScopedThreadRef => ({ + environmentId: EnvironmentId.make(environmentId), + threadId, +}); +const reference = { projectId: detail.projectId, repository: detail.repository, number: 7 }; +let renderer: ReactTestRenderer; + +// Lets the body's code arrive and settles it before rendering, so mounts finish inside one act(). +async function loadBody() { + bodyImport.resolve(); + await import("./PullRequestSidePanel"); +} + +async function renderFor(threadRef: ScopedThreadRef) { + const host: PanelHost = { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, + }; + await act(async () => { + renderer = create( + + + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + /> + + , + ); + }); +} + +const hasText = (text: string) => + renderer.root.findAll((node) => node.children.includes(text)).length > 0; + +async function click(label: string) { + const button = renderer.root + .findAllByType("button") + .find( + (node) => + node.props["aria-label"] === label || + node.findAll((child) => child.children.includes(label)).length > 0, + ); + expect(button, label).toBeDefined(); + await act(async () => + button!.props.onClick({ + nativeEvent: new Event("click"), + preventDefault() {}, + stopPropagation() {}, + }), + ); +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); + capabilities.clear(); + capabilities.set("environment-old", false); + capabilities.set("environment-new", true); + capabilities.set("environment-loading", null); + useComposerDraftStore.setState({ draftsByThreadKey: {} }); + useRightPanelStore.setState({ + byThreadKey: {}, + threadPanelVisibilityByThreadKey: {}, + userActionRevisionByThreadKey: {}, + }); +}); +afterEach(() => { + act(() => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +const isLoading = () => + renderer.root.findAll( + (node) => node.props.role === "status" && node.props["aria-label"] === "Loading pull request", + ).length > 0; + +describe("pull request side panel", () => { + // Runs first, while the body's code is still held back. + it("shows the pull request loading state until its code arrives", async () => { + await renderFor(refOn("environment-new")); + expect(isLoading()).toBe(true); + expect(hasText("Test pull request")).toBe(false); + + await act(loadBody); + expect(isLoading()).toBe(false); + expect(hasText("Test pull request")).toBe(true); + await click("Ask a question"); + expect( + useComposerDraftStore.getState().getComposerDraft(refOn("environment-new"))?.reviewComments, + ).toHaveLength(1); + }); + + it("follows the host environment's pull request support", async () => { + await loadBody(); + await renderFor(refOn("environment-loading")); + expect(isLoading()).toBe(true); + expect(hasText("Test pull request")).toBe(false); + expect(hasText("Pull requests unavailable")).toBe(false); + + await renderFor(refOn("environment-old")); + expect(hasText("Pull requests unavailable")).toBe(true); + expect(hasText("Update this environment's T3 Code server to browse pull requests.")).toBe(true); + expect(hasText("Test pull request")).toBe(false); + + await renderFor(refOn("environment-new")); + expect(hasText("Test pull request")).toBe(true); + }); + + it("writes a question into the host thread's composer only", async () => { + await loadBody(); + const threadRef = refOn("environment-new"); + useComposerDraftStore.getState().setPrompt(threadRef, "Keep my draft"); + await renderFor(threadRef); + await click("Ask a question"); + const draft = useComposerDraftStore.getState().getComposerDraft(threadRef); + expect(draft?.prompt).toContain("Keep my draft"); + expect(draft?.reviewComments?.length).toBeGreaterThan(0); + expect(useComposerDraftStore.getState().getComposerDraft(refOn("environment-old"))).toBeNull(); + }); + + it("opens a stack layer as a tab in the host's own thread", async () => { + await loadBody(); + const threadRef = refOn("environment-new"); + await renderFor(threadRef); + await click("Open #8"); + const { byThreadKey } = useRightPanelStore.getState(); + expect(byThreadKey[scopedThreadKey(threadRef)]?.surfaces).toMatchObject([ + { kind: "pull-request", repository: "owner/repo", number: 8 }, + ]); + expect(byThreadKey[scopedThreadKey(refOn("environment-old"))]).toBeUndefined(); + }); +}); diff --git a/apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx b/apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx new file mode 100644 index 000000000000..d5376e5a7cd6 --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx @@ -0,0 +1,62 @@ +import type { PullRequestRef } from "@t3tools/contracts"; + +import { PullRequestDetailPanel } from "~/components/pullRequest/PullRequestDetailPanel"; +import { PullRequestDetailGhost } from "~/components/pullRequest/PullRequestGhosts"; +import { PullRequestsUnavailableState } from "~/components/pullRequest/PullRequestsUnavailableState"; +import type { ShortcutMatchContext } from "~/keybindings"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { useEnvironment } from "~/state/environments"; + +import { usePanelHost } from "../panelHost"; + +interface PullRequestSidePanelProps { + reference: PullRequestRef; + context: "page" | "thread"; + /** True only while this surface is the visible, active tab. */ + shortcutsEnabled: boolean; + getShortcutContext: () => ShortcutMatchContext; + /** Back to the thread's linked pull requests; only offered when there is more than one. */ + onBack?: (() => void) | undefined; +} + +// No onClose: the surface tab's own X owns closing here, and a second X in the header would be +// the same action twice. +export default function PullRequestSidePanel({ + reference, + context, + shortcutsEnabled, + getShortcutContext, + onBack, +}: PullRequestSidePanelProps) { + const { threadRef, composerDraftTarget } = usePanelHost(); + const serverConfig = useEnvironment(threadRef.environmentId)?.serverConfig ?? null; + if (serverConfig === null) return ; + if (serverConfig.environment.capabilities.pullRequests !== true) { + return ( + + ); + } + return ( + { + useRightPanelStore.getState().openPullRequest(threadRef, { + projectId: selected.projectId, + repository: selected.repository, + number: selected.number, + ...(selected.host ? { host: selected.host } : {}), + }); + }} + threadRef={threadRef} + reference={reference} + context={context} + composerDraftTarget={composerDraftTarget} + onBack={onBack} + /> + ); +} diff --git a/apps/web/src/panels/pullRequest/PullRequestsSidePanel.test.tsx b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.test.tsx new file mode 100644 index 000000000000..294aa7470d15 --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.test.tsx @@ -0,0 +1,178 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + EnvironmentId, + ThreadId, + type ScopedThreadRef, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { act, Suspense, type ReactElement, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { commands, linksByThread, Wrapper, Trigger } = vi.hoisted(() => ({ + commands: [] as Array<{ command: string; request: unknown }>, + // Linked pull requests per scoped thread key. + linksByThread: new Map(), + Wrapper: ({ children }: { children?: ReactNode }) => children, + Trigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => ( + <> + {render} + {children} + + ), +})); +vi.mock("~/state/entities", () => ({ + // No projects, so rows offer no project-scoped fast actions. + useProjects: () => [], + // Only environment-new supports linked pull requests. + useServerConfigs: () => + new Map( + ["environment-new", "environment-old"].map((environmentId) => [ + environmentId, + { + environment: { + capabilities: { threadPullRequests: environmentId === "environment-new" }, + }, + }, + ]), + ), + useThreadShell: (threadRef: ScopedThreadRef) => ({ + pullRequests: linksByThread.get(scopedThreadKey(threadRef)) ?? [], + }), +})); +vi.mock("~/state/threads", () => ({ + threadEnvironment: { unlinkPullRequest: "unlink", watchPullRequest: "watch" }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: string) => (request: unknown) => { + commands.push({ command, request }); + }, +})); +vi.mock("~/lib/openPullRequestLink", () => ({ + findProjectForChangeRequest: () => null, + useOpenPrLink: () => vi.fn(), +})); +vi.mock("~/shortcutModifierState", () => ({ + useShortcutModifierState: () => ({ + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }), +})); +vi.mock("~/components/pullRequest/LinkPullRequestDialog", () => ({ + openLinkPullRequestDialog: vi.fn(), +})); +vi.mock("~/components/ui/tooltip", () => ({ + Tooltip: Wrapper, + TooltipTrigger: Trigger, + TooltipPopup: () => null, +})); +vi.mock("~/components/ui/menu", () => ({ + Menu: Wrapper, + MenuPopup: Wrapper, + MenuTrigger: Trigger, + MenuItem: "button", +})); + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +// The same thread id on two environments is two threads. +const threadId = ThreadId.make("thread-a"); +const refOn = (environmentId: string): ScopedThreadRef => ({ + environmentId: EnvironmentId.make(environmentId), + threadId, +}); +const link = (repository: string, number: number): ThreadPullRequestLink => ({ + host: "github.com", + repository, + number, + url: `https://github.com/${repository}/pull/${number}`, + source: "manual", + linkedAt: "2026-09-01T00:00:00.000Z", + snapshot: null, + stack: null, +}); +let renderer: ReactTestRenderer | undefined; + +const panelFor = (threadRef: ScopedThreadRef) => { + const host: PanelHost = { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, + }; + return ( + + + + + + ); +}; + +// One renderer across hosts, so a host change reaches the already mounted list. +async function renderFor(threadRef: ScopedThreadRef) { + await act(async () => { + if (renderer) renderer.update(panelFor(threadRef)); + else renderer = create(panelFor(threadRef)); + }); +} + +const hasText = (text: string) => + renderer!.root.findAll((node) => node.children.includes(text)).length > 0; + +// Transform the lazy body once up front, so mounting it settles inside one act(). +beforeAll(() => import("./PullRequestsSidePanel")); +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + commands.length = 0; + linksByThread.clear(); + linksByThread.set(scopedThreadKey(refOn("environment-new")), [link("owner/alpha", 11)]); + linksByThread.set(scopedThreadKey(refOn("environment-old")), [link("owner/beta", 22)]); +}); +afterEach(() => { + act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +describe("linked pull requests side panel", () => { + it("lists and unlinks the host thread's pull requests", async () => { + await renderFor(refOn("environment-new")); + expect(hasText("owner/alpha")).toBe(true); + expect(hasText("owner/beta")).toBe(false); + + const unlink = renderer!.root + .findAllByType("button") + .find( + (node) => node.findAll((child) => child.children.includes("Unlink from thread")).length, + ); + await act(async () => unlink!.props.onClick()); + expect(commands).toEqual([ + { + command: "unlink", + request: { + environmentId: "environment-new", + input: { threadId, host: "github.com", repository: "owner/alpha", number: 11 }, + }, + }, + ]); + }); + + it("follows the host to a thread whose environment cannot link pull requests", async () => { + await renderFor(refOn("environment-new")); + expect(hasText("owner/alpha")).toBe(true); + + await renderFor(refOn("environment-old")); + expect(hasText("Linked pull requests unavailable")).toBe(true); + expect(hasText("owner/alpha")).toBe(false); + expect(hasText("owner/beta")).toBe(false); + + linksByThread.set(scopedThreadKey(refOn("environment-new")), [link("owner/gamma", 33)]); + await renderFor(refOn("environment-new")); + expect(hasText("owner/gamma")).toBe(true); + }); +}); diff --git a/apps/web/src/panels/pullRequest/PullRequestsSidePanel.tsx b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.tsx new file mode 100644 index 000000000000..b5e49f45f2fc --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.tsx @@ -0,0 +1,7 @@ +import { ThreadPullRequestsPanel } from "~/components/pullRequest/ThreadPullRequestsPanel"; + +import { usePanelHost } from "../panelHost"; + +export default function PullRequestsSidePanel() { + return ; +} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 5966f548852c..c3d8a12feff0 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -281,6 +281,8 @@ const UNAVAILABLE_SIDE_PANELS = { diff: { available: false, onOpen: () => undefined }, terminal: { available: false, onOpen: () => undefined }, device: { available: false, onOpen: () => undefined }, + "pull-request": { available: false, onOpen: () => undefined }, + "pull-requests": { available: false, onOpen: () => undefined }, }; const EMPTY_PENDING_SURFACES = new Set(); const MAX_SEARCH_LABEL_CANDIDATES = 100; @@ -2293,11 +2295,7 @@ function PullRequestsRouteView() { panels={UNAVAILABLE_SIDE_PANELS} onAddBrowserInProfile={() => undefined} onAddFiles={() => undefined} - onAddPullRequest={() => undefined} - onAddPullRequests={() => undefined} filesAvailable={false} - pullRequestAvailable={false} - pullRequestsAvailable={false} pullRequestStatusSeeds={listedPullRequestTabStatuses} > Date: Mon, 12 Oct 2026 10:31:46 +1100 Subject: [PATCH 13/22] test(web): build the pull request panel fixture against current main Main brands the pull request provider kind and gives the detail panel a review guide tab, so the side panel test now makes the provider with SourceControlProviderKind, stubs the guide queries, and mocks the guide tab that would load the diff worker. Co-Authored-By: Claude Opus 5.5 --- .../pullRequest/PullRequestSidePanel.test.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/web/src/panels/pullRequest/PullRequestSidePanel.test.tsx b/apps/web/src/panels/pullRequest/PullRequestSidePanel.test.tsx index 3a3b2c4cb8a9..5fab05ddc83d 100644 --- a/apps/web/src/panels/pullRequest/PullRequestSidePanel.test.tsx +++ b/apps/web/src/panels/pullRequest/PullRequestSidePanel.test.tsx @@ -2,6 +2,7 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ProjectId, + SourceControlProviderKind, ThreadId, type PullRequestDetailView, type ScopedThreadRef, @@ -67,7 +68,12 @@ vi.mock("~/lib/sourceControlActions", () => ({ vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); vi.mock("~/state/pullRequests", async (importOriginal) => ({ ...(await importOriginal()), - pullRequestEnvironment: { detail: () => "detail", activity: () => "activity" }, + pullRequestEnvironment: { + detail: () => "detail", + activity: () => "activity", + reviewGuide: () => "review-guide", + generateReviewGuide: { permissionAtom: () => "generate-review-guide" }, + }, usePullRequestTurnRefresh: () => 0, useSharedPullRequestSummary: () => null, })); @@ -139,6 +145,9 @@ vi.mock("~/components/pullRequest/PullRequestSummaryTab", () => ({ PullRequestSummaryTab: () => null, })); vi.mock("~/components/pullRequest/PullRequestCodeTab", () => ({ default: () => null })); +vi.mock("~/components/pullRequest/PullRequestGuideTab", () => ({ + PullRequestGuideTab: () => null, +})); vi.mock("./PullRequestSidePanel", async (importOriginal) => { await bodyImport.promise; return importOriginal(); @@ -148,7 +157,7 @@ import { RegisteredSidePanel } from "../bundledPanels"; import { PanelHostContext, type PanelHost } from "../panelHost"; const detail: PullRequestDetailView = { - provider: "github", + provider: SourceControlProviderKind.make("github"), projectId: ProjectId.make("project"), projectTitle: "Project", workspaceRoot: "/workspace", From 4a908827f25f933616aba42f6971abb020563653 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:28:23 +1100 Subject: [PATCH 14/22] refactor(web): open the Files side panel through the panel host The Files explorer and single-file surfaces now render through the panel registry, like Diff, Preview, Terminal, Device and the pull request panels. The body moves to panels/files/FilesSidePanel and reads the thread, composer draft target and workspace mutation id from the panel host, keybindings from the server atom, and opens files through the right panel store for the host's thread. The launcher entry, tab title and tab icon read the one definition; ChatView keeps the surface inputs, editors and the pending-file pair as props. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatView.tsx | 86 +++--- .../RightPanelTabs.browserProfile.test.tsx | 3 +- .../RightPanelTabs.keyboard.test.tsx | 3 +- .../RightPanelTabs.terminal.test.tsx | 3 +- .../src/components/RightPanelTabs.test.tsx | 6 +- apps/web/src/components/RightPanelTabs.tsx | 25 +- apps/web/src/panels/bundledPanels.test.tsx | 25 ++ apps/web/src/panels/bundledPanels.tsx | 11 +- .../src/panels/files/FilesSidePanel.test.tsx | 252 ++++++++++++++++++ .../files/FilesSidePanel.tsx} | 68 ++--- apps/web/src/routes/_chat.pull-requests.tsx | 3 +- 11 files changed, 366 insertions(+), 119 deletions(-) create mode 100644 apps/web/src/panels/files/FilesSidePanel.test.tsx rename apps/web/src/{components/files/FilePreviewPanel.tsx => panels/files/FilesSidePanel.tsx} (96%) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 041c96ddcc46..6003215f4f8e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -150,9 +150,7 @@ import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/reactivity"; import { Fragment, - lazy, type SetStateAction, - Suspense, useCallback, useEffect, useEffectEvent, @@ -705,7 +703,6 @@ function useDraftHeroLayoutTransition( const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) => settings.browserAutoShowFloatingPreview; -const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ "input", @@ -4970,13 +4967,6 @@ export default function ChatView(props: ChatViewProps) { ); if (!sessionStillExists) usePreviewMiniPlayerStore.getState().close(activeThreadRef); }, [activePreviewMiniPlayer, activeThreadRef, deviceState.sessions, deviceStateLoaded]); - const openFileSurface = useCallback( - (relativePath: string) => { - if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().openFile(activeThreadRef, relativePath); - }, - [activeProject, activeThreadRef], - ); // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. const persistedLinkedThreadPullRequest = isServerThread @@ -10565,47 +10555,36 @@ export default function ChatView(props: ChatViewProps) { renderedRightPanelSurface?.kind === "file") && ((activeProject && activeWorkspaceRoot) || (renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment)) ? ( - - - + ) : null ) : null; const rightPanelContent = ( @@ -10618,6 +10597,7 @@ export default function ChatView(props: ChatViewProps) { available: activeProject !== null && canOperateTerminal, onOpen: addTerminalSurface, }, + files: { available: activeProject !== null, onOpen: addFilesSurface }, device: { available: activeThreadRef !== null, onOpen: addDeviceSurface }, "pull-request": { available: pullRequestSurfaceAvailable, onOpen: addPullRequestSurface }, "pull-requests": { available: pullRequestsSurfaceAvailable, onOpen: addPullRequestsSurface }, @@ -11444,8 +11424,6 @@ export default function ChatView(props: ChatViewProps) { onCopyFilePath={copyRightPanelFilePath} panels={sidePanelLaunchers} onAddBrowserInProfile={createBrowserSurface} - onAddFiles={addFilesSurface} - filesAvailable={activeProject !== null} > {rightPanelContent} @@ -11492,8 +11470,6 @@ export default function ChatView(props: ChatViewProps) { onCopyFilePath={copyRightPanelFilePath} panels={sidePanelLaunchers} onAddBrowserInProfile={createBrowserSurface} - onAddFiles={addFilesSurface} - filesAvailable={activeProject !== null} > {rightPanelContent} diff --git a/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx b/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx index 0c9d6c535b97..96ae673b849d 100644 --- a/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx +++ b/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx @@ -87,10 +87,9 @@ function Harness() { device: { available: false, onOpen: () => undefined }, "pull-request": { available: false, onOpen: () => undefined }, "pull-requests": { available: false, onOpen: () => undefined }, + files: { available: false, onOpen: () => undefined }, }} onAddBrowserInProfile={openBrowser} - onAddFiles={() => undefined} - filesAvailable={false} > {null} diff --git a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx index 5b3b12e41489..d7b38b0e5a75 100644 --- a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx +++ b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx @@ -83,10 +83,9 @@ async function renderPanel(overrides: Partial content diff --git a/apps/web/src/components/RightPanelTabs.terminal.test.tsx b/apps/web/src/components/RightPanelTabs.terminal.test.tsx index 87ff9938c65c..df3ab0a2f327 100644 --- a/apps/web/src/components/RightPanelTabs.terminal.test.tsx +++ b/apps/web/src/components/RightPanelTabs.terminal.test.tsx @@ -83,10 +83,9 @@ function Harness({ terminalAvailable }: { terminalAvailable: boolean }) { device: { available: false, onOpen: () => undefined }, "pull-request": { available: false, onOpen: () => undefined }, "pull-requests": { available: false, onOpen: () => undefined }, + files: { available: false, onOpen: () => undefined }, }} onAddBrowserInProfile={() => undefined} - onAddFiles={() => undefined} - filesAvailable={false} > {null} diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index fba251bc7e02..f45ca4e0011d 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -35,9 +35,8 @@ describe("right panel surface actions", () => { device: { available: true, onOpen: () => undefined }, "pull-request": { available: true, onOpen: () => undefined }, "pull-requests": { available: true, onOpen: () => undefined }, + files: { available: true, onOpen: () => undefined }, }, - onAddFiles: () => undefined, - filesAvailable: true, }); it("keeps launcher order, letters and copy for registered and local surfaces", () => { @@ -169,10 +168,9 @@ function renderTabs( device: { available: false, onOpen: () => undefined }, "pull-request": { available: false, onOpen: () => undefined }, "pull-requests": { available: false, onOpen: () => undefined }, + files: { available: false, onOpen: () => undefined }, }} onAddBrowserInProfile={() => undefined} - onAddFiles={() => undefined} - filesAvailable={false} >
content
, diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index adb004060cd2..4cf3653e42ef 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -25,7 +25,7 @@ import { import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers"; import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { ChevronDown, ChevronLeft, ChevronRight, Files, Plus } from "lucide-react"; +import { ChevronDown, ChevronLeft, ChevronRight, Plus } from "lucide-react"; import { Volume2, VolumeOff } from "lucide"; import { type ComponentProps, @@ -125,8 +125,6 @@ interface RightPanelTabsProps { /** Whether each registered panel can open here, and how; titles and icons come from its definition. */ panels: Readonly>; onAddBrowserInProfile: (profileId: string) => void; - onAddFiles: () => void; - filesAvailable: boolean; pullRequestStatusSeeds?: Readonly>; children: ReactNode; } @@ -177,7 +175,7 @@ interface SurfaceAction { onClick: () => void; } -type SurfaceActionInputs = Pick; +type SurfaceActionInputs = Pick; /** * The surfaces the empty launcher and the add menu offer, in launcher order. @@ -203,16 +201,7 @@ export function rightPanelSurfaceActions(props: SurfaceActionInputs): SurfaceAct return [ registered("preview"), registered("terminal"), - { - id: "files", - label: "Files", - icon: Files, - shortcut: "F", - available: props.filesAvailable, - unavailableHint: "Available when a project is open.", - unavailableReason: "Files are only available when a project is open.", - onClick: props.onAddFiles, - }, + registered("files"), registered("diff"), registered("pull-request"), registered("pull-requests"), @@ -547,7 +536,7 @@ function surfaceTitle( case "diff": return getSidePanelMetadata("diff").title; case "files": - return "Files"; + return getSidePanelMetadata("files").title; case "file": return surface.relativePath.slice( Math.max(surface.relativePath.lastIndexOf("/"), surface.relativePath.lastIndexOf("\\")) + 1, @@ -625,8 +614,10 @@ function SurfaceIcon({ const Icon = getSidePanelMetadata("diff").icon; return ; } - case "files": - return ; + case "files": { + const Icon = getSidePanelMetadata("files").icon; + return ; + } case "file": return ( ({ device: 0, pullRequest: 0, pullRequests: 0, + files: 0, previewRenders: [] as unknown[], })); vi.mock("./diff/DiffSidePanel", () => { @@ -24,6 +25,10 @@ vi.mock("./device/DeviceSidePanel", () => { loaded.device += 1; return { default: () => null }; }); +vi.mock("./files/FilesSidePanel", () => { + loaded.files += 1; + return { default: () => null }; +}); vi.mock("./preview/PreviewSidePanel", () => { loaded.preview += 1; return { @@ -69,6 +74,7 @@ describe("bundled side panels", () => { device: 0, pullRequest: 0, pullRequests: 0, + files: 0, }); await act(async () => { create( @@ -86,6 +92,7 @@ describe("bundled side panels", () => { device: 0, pullRequest: 0, pullRequests: 0, + files: 0, }); expect(loaded.previewRenders).toEqual([{ props: { tabId: "tab-1" }, host }]); }); @@ -122,6 +129,17 @@ describe("bundled side panels", () => { }); }); +const filesProps = { + cwd: "/repo", + projectName: "repo", + relativePath: null, + availableEditors: [], + revealLine: null, + revealRequestId: 0, + onPendingChange: () => undefined, + selectedFilePending: false, +}; + // Never called. The project typecheck compiles these pairings, and each // expect-error directive fails it if a wrong pairing starts to compile. export function typeFixtures( @@ -177,6 +195,13 @@ export function typeFixtures( {/* @ts-expect-error The host owns the composer draft target. */} + + {/* @ts-expect-error Files props on Preview. */} + + {/* @ts-expect-error Files needs its surface inputs. */} + + {/* @ts-expect-error The host owns the composer draft target. */} + {/* @ts-expect-error Preview props on Diff. */} {/* @ts-expect-error Device props on Preview. */} diff --git a/apps/web/src/panels/bundledPanels.tsx b/apps/web/src/panels/bundledPanels.tsx index 5b369f13f169..b8211ca1b505 100644 --- a/apps/web/src/panels/bundledPanels.tsx +++ b/apps/web/src/panels/bundledPanels.tsx @@ -1,4 +1,4 @@ -import { FileDiff, Globe2, Smartphone, TerminalSquare } from "lucide-react"; +import { FileDiff, Files, Globe2, Smartphone, TerminalSquare } from "lucide-react"; import { Suspense, type ComponentType } from "react"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; @@ -64,6 +64,15 @@ const bundledPanels = createPanelRegistry([ fallback: , load: () => import("./pullRequest/PullRequestsSidePanel"), }, + { + id: "files", + title: "Files", + icon: Files, + launcherKey: "F", + unavailableHint: "Available when a project is open.", + unavailableReason: "Files are only available when a project is open.", + load: () => import("./files/FilesSidePanel"), + }, ]); export type SidePanelId = (typeof bundledPanels.definitions)[number]["id"]; diff --git a/apps/web/src/panels/files/FilesSidePanel.test.tsx b/apps/web/src/panels/files/FilesSidePanel.test.tsx new file mode 100644 index 000000000000..dc44969ecf44 --- /dev/null +++ b/apps/web/src/panels/files/FilesSidePanel.test.tsx @@ -0,0 +1,252 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { act, cloneElement, Suspense, use, type ReactElement, type ReactNode } from "react"; +import { create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useRightPanelStore } from "~/rightPanelStore"; + +const { siblingsByEnvironment, Wrapper, MenuContext, RadioContext } = await vi.hoisted(async () => { + const { createContext: createHoistedContext } = await import("react"); + return { + // Files in the `src` folder, per environment. + siblingsByEnvironment: new Map>(), + Wrapper: ({ children }: { children?: ReactNode }) => children, + MenuContext: createHoistedContext<(open: boolean) => void>(() => undefined), + RadioContext: createHoistedContext<(value: string) => void>(() => undefined), + }; +}); + +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); +vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: {} })); +// This client holds every scope, so files can be read, written and previewed. +vi.mock("~/state/filesystem", () => ({ + useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }), +})); +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +// The desktop app hosts these browsers itself, so no environment's server browser is consulted. +vi.mock("~/browser/previewRuntime", async (importOriginal) => ({ + ...(await importOriginal()), + previewRuntimeFor: () => undefined, + isPreviewAvailableFor: () => true, + usePreviewAvailable: () => true, +})); +vi.mock("~/state/environments", () => ({ + useEnvironmentHttpBaseUrl: () => null, + usePrimaryEnvironmentId: () => null, +})); +vi.mock("~/remoteOpen", () => ({ useRemoteOpenState: () => ({ mode: "local-exec" }) })); +vi.mock("~/hooks/useSettings", () => ({ + useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + select(DEFAULT_CLIENT_SETTINGS), + useUpdateClientSettings: () => vi.fn(), +})); +vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }) })); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("~/state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); +vi.mock("~/components/files/projectFilesQueryState", async (importOriginal) => ({ + ...(await importOriginal()), + useProjectFileQuery: () => ({ + data: null, + error: null, + isPending: true, + isNotFile: false, + refresh: vi.fn(), + }), + useProjectEntriesQuery: (environmentId: string, _cwd: string, directoryPath?: string) => ({ + data: + directoryPath === "src" + ? { + entries: (siblingsByEnvironment.get(environmentId) ?? []).map((path) => ({ + path, + kind: "file", + parentPath: "src", + })), + truncated: false, + } + : null, + error: null, + isPending: false, + refresh: vi.fn(), + }), +})); +vi.mock("~/components/DiffWorkerPoolProvider", () => ({ DiffWorkerPoolProvider: Wrapper })); +// The tree is the Pierre file tree web component; the breadcrumbs are the entry point under test. +vi.mock("~/components/files/FileBrowserPanel", () => ({ default: () => null })); +vi.mock("~/components/chat/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); +vi.mock("~/components/ui/tooltip", () => ({ + Tooltip: Wrapper, + TooltipTrigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => + render ? cloneElement(render, {}, children) : children, + TooltipPopup: () => null, +})); +// Menus render through a portal; these stand-ins keep their open and pick semantics. +vi.mock("~/components/ui/menu", () => ({ + Menu: ({ + children, + onOpenChange, + }: { + children?: ReactNode; + onOpenChange: (open: boolean) => void; + }) => {children}, + MenuTrigger: function MenuTrigger({ render }: { render: ReactElement }) { + const onOpenChange = use(MenuContext); + return cloneElement(render as ReactElement<{ onClick: () => void }>, { + onClick: () => onOpenChange(true), + }); + }, + MenuPopup: Wrapper, + MenuGroup: Wrapper, + MenuItem: Wrapper, + MenuSeparator: () => null, + MenuRadioGroup: ({ + children, + onValueChange, + }: { + children?: ReactNode; + onValueChange: (value: string) => void; + }) => {children}, + MenuRadioItem: function MenuRadioItem({ + children, + value, + }: { + children?: ReactNode; + value: string; + }) { + const onValueChange = use(RadioContext); + return ( + + ); + }, +})); + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +// The same thread id on two environments is two threads. +const threadId = ThreadId.make("thread-a"); +const refOn = (environmentId: string): ScopedThreadRef => ({ + environmentId: EnvironmentId.make(environmentId), + threadId, +}); +let renderer: ReactTestRenderer | undefined; + +async function renderFileFor(threadRef: ScopedThreadRef) { + const host: PanelHost = { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, + }; + const element = ( + + + undefined} + selectedFilePending={false} + /> + + + ); + await act(async () => { + if (renderer) renderer.update(element); + else renderer = create(element); + }); +} + +async function press(node: ReactTestInstance) { + await act(async () => node.props.onClick()); +} + +async function openSrcFolder() { + const browse = renderer!.root.findAll( + (node) => node.type === "button" && node.props["aria-label"] === "Browse src", + ); + expect(browse).toHaveLength(1); + await press(browse[0]!); +} + +const siblingValues = () => + renderer!.root + .findAll((node) => node.type === "button" && node.props["data-value"] !== undefined) + .map((node) => node.props["data-value"] as string); + +const surfacesOf = (threadRef: ScopedThreadRef) => + useRightPanelStore.getState().byThreadKey[scopedThreadKey(threadRef)]?.surfaces; + +// Transform the lazy body once up front, so mounting it settles inside one act(). +beforeAll(() => import("./FilesSidePanel"), 30_000); +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const storage = new Map(); + vi.stubGlobal("window", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, + }); + siblingsByEnvironment.clear(); + siblingsByEnvironment.set("environment-a", ["src/a.ts", "src/b.ts"]); + siblingsByEnvironment.set("environment-b", ["src/a.ts", "src/c.ts"]); + useRightPanelStore.setState({ + byThreadKey: {}, + threadPanelVisibilityByThreadKey: {}, + userActionRevisionByThreadKey: {}, + }); +}); +afterEach(() => { + act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +describe("files side panel", () => { + it("opens a sibling file as a tab in the host's own thread", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef); + await openSrcFolder(); + expect(siblingValues()).toEqual(["src/a.ts", "src/b.ts"]); + + await press( + renderer!.root.find( + (node) => node.type === "button" && node.props["data-value"] === "src/b.ts", + ), + ); + expect(surfacesOf(threadRef)).toMatchObject([{ kind: "file", relativePath: "src/b.ts" }]); + expect(surfacesOf(refOn("environment-b"))).toBeUndefined(); + }); + + it("follows the host to another environment's thread with the same id", async () => { + await renderFileFor(refOn("environment-a")); + const threadRef = refOn("environment-b"); + await renderFileFor(threadRef); + await openSrcFolder(); + expect(siblingValues()).toEqual(["src/a.ts", "src/c.ts"]); + + await press( + renderer!.root.find( + (node) => node.type === "button" && node.props["data-value"] === "src/c.ts", + ), + ); + expect(surfacesOf(threadRef)).toMatchObject([{ kind: "file", relativePath: "src/c.ts" }]); + expect(surfacesOf(refOn("environment-a"))).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/panels/files/FilesSidePanel.tsx similarity index 96% rename from apps/web/src/components/files/FilePreviewPanel.tsx rename to apps/web/src/panels/files/FilesSidePanel.tsx index efd7a7fbc90f..9c6540425fde 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/panels/files/FilesSidePanel.tsx @@ -3,9 +3,9 @@ import { AuthPreviewOperateScope, type EditorId, type EnvironmentId, - type ResolvedKeybindingsConfig, type ScopedThreadRef, } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview"; import { AuthFilesystemWriteScope } from "@t3tools/contracts"; import { @@ -30,7 +30,7 @@ import { } from "@pierre/diffs/edit"; import type { WorkerPoolManager } from "@pierre/diffs/worker"; import { EditProvider, File, Virtualizer, useWorkerPool } from "@pierre/diffs/react"; -import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; +import { DiffWorkerPoolProvider } from "~/components/DiffWorkerPoolProvider"; import { useFilesystemReadAccess } from "~/state/filesystem"; import { isAtomCommandInterrupted, @@ -63,21 +63,23 @@ import { ScrollArea } from "~/components/ui/scroll-area"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { buildFileReviewComment } from "~/reviewCommentContext"; +import { useRightPanelStore } from "~/rightPanelStore"; import { assetEnvironment } from "~/state/assets"; import { usePreviewAvailable } from "~/browser/previewRuntime"; import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { useEnvironmentScope } from "~/state/session"; +import { primaryServerKeybindingsAtom } from "~/state/server"; import { useAtomCommand } from "~/state/use-atom-command"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; -import { AttachmentFilePreview } from "./AttachmentFilePreview"; -import { AudioPreview } from "./AudioPreview"; -import { BrowserDocumentFrame, isPdfPreviewFile } from "./BrowserDocumentFrame"; -import { DelimitedTablePreview } from "./DelimitedTablePreview"; -import FileBrowserPanel from "./FileBrowserPanel"; -import { FileBreadcrumbs } from "./FileBreadcrumbs"; -import { FileMarkdownPreview } from "./FileMarkdownPreview"; +import { AttachmentFilePreview } from "~/components/files/AttachmentFilePreview"; +import { AudioPreview } from "~/components/files/AudioPreview"; +import { BrowserDocumentFrame, isPdfPreviewFile } from "~/components/files/BrowserDocumentFrame"; +import { DelimitedTablePreview } from "~/components/files/DelimitedTablePreview"; +import FileBrowserPanel from "~/components/files/FileBrowserPanel"; +import { FileBreadcrumbs } from "~/components/files/FileBreadcrumbs"; +import { FileMarkdownPreview } from "~/components/files/FileMarkdownPreview"; import { type FileCommentAnnotationEntry, type FileCommentAnnotationGroup, @@ -86,8 +88,8 @@ import { nextFileCommentId, normalizeFileCommentRange, remapFileCommentAnnotations, -} from "./fileCommentAnnotations"; -import { installFileEditorDismissal } from "./fileEditorDismissal"; +} from "~/components/files/fileCommentAnnotations"; +import { installFileEditorDismissal } from "~/components/files/fileEditorDismissal"; import { FILE_LINK_REVEAL_ATTRIBUTE, FILE_LINK_REVEAL_UNSAFE_CSS, @@ -95,11 +97,11 @@ import { FileSurfaceAction, FileSurfaceFailure, FileSurfaceLoading, -} from "./fileSurfaceChrome"; -import SourceFilePreview from "./ReadOnlySourcePreview"; -import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; -import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; -import { projectFileCacheKey } from "./fileContentRevision"; +} from "~/components/files/fileSurfaceChrome"; +import SourceFilePreview from "~/components/files/ReadOnlySourcePreview"; +import { resolveCenteredFileLineScrollTop } from "~/components/files/fileLineReveal"; +import { DiffCommentAnnotation } from "~/components/diffs/DiffCommentAnnotation"; +import { projectFileCacheKey } from "~/components/files/fileContentRevision"; import { filePreviewReadErrorMessage, isMarkdownPreviewFile, @@ -107,31 +109,27 @@ import { setMarkdownTaskChecked, shouldShowFileExplorer, workspaceAssetResource, -} from "./filePreviewMode"; -import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; +} from "~/components/files/filePreviewMode"; +import { useFileSaveCoordinator } from "~/components/files/useFileSaveCoordinator"; import { getOptimisticProjectFileQueryData, getProjectFileContents, setProjectFileQueryData, useProjectFileQuery, -} from "./projectFilesQueryState"; +} from "~/components/files/projectFilesQueryState"; -interface FilePreviewPanelProps { - environmentId: EnvironmentId; +import { usePanelHost } from "../panelHost"; + +interface FilesSidePanelProps { cwd: string; projectName: string; relativePath: string | null; attachment?: ChatFileAttachment; - threadRef: ScopedThreadRef; - composerDraftTarget: ScopedThreadRef | DraftId; - keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; revealLine: number | null; revealRequestId: number; - onOpenFile: (relativePath: string) => void; onPendingChange: (relativePath: string, pending: boolean) => void; selectedFilePending: boolean; - workspaceMutationId: string | null; } const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; @@ -1025,23 +1023,25 @@ function initialExplorerOpen(): boolean { } } -export default function FilePreviewPanel({ - environmentId, +// Renders both the Files explorer surface and single file surfaces. +export default function FilesSidePanel({ cwd, projectName, relativePath: requestedPath, attachment, - threadRef, - composerDraftTarget, - keybindings, availableEditors, revealLine, revealRequestId, - onOpenFile, onPendingChange, selectedFilePending, - workspaceMutationId, -}: FilePreviewPanelProps) { +}: FilesSidePanelProps) { + const { threadRef, composerDraftTarget, workspaceMutationId } = usePanelHost(); + const environmentId = threadRef.environmentId; + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const onOpenFile = useCallback( + (path: string) => useRightPanelStore.getState().openFile(threadRef, path), + [threadRef], + ); const relativePath = attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath; // A draft's composer target is its draft id; a thread the server knows is a ref. diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index c3d8a12feff0..53f9b11a2ccb 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -283,6 +283,7 @@ const UNAVAILABLE_SIDE_PANELS = { device: { available: false, onOpen: () => undefined }, "pull-request": { available: false, onOpen: () => undefined }, "pull-requests": { available: false, onOpen: () => undefined }, + files: { available: false, onOpen: () => undefined }, }; const EMPTY_PENDING_SURFACES = new Set(); const MAX_SEARCH_LABEL_CANDIDATES = 100; @@ -2294,8 +2295,6 @@ function PullRequestsRouteView() { onCopyFilePath={() => undefined} panels={UNAVAILABLE_SIDE_PANELS} onAddBrowserInProfile={() => undefined} - onAddFiles={() => undefined} - filesAvailable={false} pullRequestStatusSeeds={listedPullRequestTabStatuses} > Date: Mon, 12 Oct 2026 11:10:05 +1100 Subject: [PATCH 15/22] test(web): stub the server environment atoms in the Files panel test Main's project atoms now read `serverEnvironment.configValueAtom` from `~/state/server`, so the Files panel test's mock of that module provides it. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/panels/files/FilesSidePanel.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/panels/files/FilesSidePanel.test.tsx b/apps/web/src/panels/files/FilesSidePanel.test.tsx index dc44969ecf44..43c945d69219 100644 --- a/apps/web/src/panels/files/FilesSidePanel.test.tsx +++ b/apps/web/src/panels/files/FilesSidePanel.test.tsx @@ -19,7 +19,10 @@ const { siblingsByEnvironment, Wrapper, MenuContext, RadioContext } = await vi.h }); vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); -vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: {} })); +vi.mock("~/state/server", () => ({ + primaryServerKeybindingsAtom: {}, + serverEnvironment: { configValueAtom: () => null }, +})); // This client holds every scope, so files can be read, written and previewed. vi.mock("~/state/filesystem", () => ({ useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }), From 8cf75b73972ca4561bea7c41ec93c449fce1e8f1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:48:28 +1100 Subject: [PATCH 16/22] fix(web): drop Files actions that settle after leaving the thread A tree "Add to chat" waits for the native menu and then read the chat layout's shared composer ref, so a pick that settled after a thread switch landed in whichever thread was showing by then, including the same thread id in another environment. "Open in browser" waits for an asset URL and settings, and then still asked the server for a Browser tab in the thread it started in, after you had left it. The Files panel now gives each visit to a thread a lifetime that ends when the panel moves to another thread or draft, or unmounts. The tree's Add to chat goes through an insert bound to that lifetime, and Open in browser checks it before asking for a browser. A browser the server already opened is applied to the thread it was opened for, so its session never lingers unseen; only its error stays with the visit that started it. Work from an earlier visit stays dropped if you come back before it settles; work started on the return visit still lands. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/browser/openFileInPreview.ts | 15 + .../src/components/files/FileBrowserPanel.tsx | 15 +- .../src/panels/files/FilesSidePanel.test.tsx | 367 ++++++++++++++++-- apps/web/src/panels/files/FilesSidePanel.tsx | 18 +- apps/web/src/panels/files/fileScope.ts | 46 +++ 5 files changed, 420 insertions(+), 41 deletions(-) create mode 100644 apps/web/src/panels/files/fileScope.ts diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index c582e5a5b6c8..63ef0c3d0778 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -54,6 +54,16 @@ export type OpenPreviewMutation = (input: { readonly input: PreviewOpenInput; }) => Promise>; +/** + * False once the caller has left the thread it started in. Work that has not + * asked the server for a browser by then ends as an interruption. A browser the + * server already opened is still applied to the thread it was opened for, so + * its session never lingers unseen. + */ +type ScopeCheck = (() => boolean) | undefined; + +const leftScope = (isScopeCurrent: ScopeCheck) => isScopeCurrent?.() === false; + export async function openUrlInPreview(input: { readonly threadRef: ScopedThreadRef; readonly url: string; @@ -62,10 +72,12 @@ export async function openUrlInPreview(input: { readonly profileId?: PreviewOpenInput["profileId"]; /** Open the tab without switching the thread to it. */ readonly background?: boolean; + readonly isScopeCurrent?: ScopeCheck; }): Promise> { const defaults = await resolveBrowserDefaults().catch( (cause: unknown) => new BrowserSettingsReadError({ cause }), ); + if (leftScope(input.isScopeCurrent)) return AsyncResult.failure(Cause.interrupt()); if (defaults instanceof BrowserSettingsReadError) { return AsyncResult.failure(Cause.fail(defaults)); } @@ -124,6 +136,7 @@ export async function openFileInPreview(input: { readonly input: { readonly resource: AssetResource }; }) => Promise>; readonly openPreview: OpenPreviewMutation; + readonly isScopeCurrent?: ScopeCheck; }): Promise< AtomCommandResult< void, @@ -151,6 +164,7 @@ export async function openFileInPreview(input: { }, }, }); + if (leftScope(input.isScopeCurrent)) return AsyncResult.failure(Cause.interrupt()); if (assetResult._tag === "Failure") { return AsyncResult.failure(assetResult.cause); } @@ -164,5 +178,6 @@ export async function openFileInPreview(input: { threadRef: input.threadRef, url: assetUrl, openPreview: input.openPreview, + isScopeCurrent: input.isScopeCurrent, }); } diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index d9722303ab72..c736e38a77f9 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -14,7 +14,6 @@ import { InputGroup, InputGroupInput } from "~/components/ui/input-group"; import { MorphIcon } from "~/components/MorphIcon"; import { toastManager } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; -import { useComposerHandleContext } from "~/composerHandleContext"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { useTheme } from "~/hooks/useTheme"; import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; @@ -40,8 +39,13 @@ interface FileBrowserPanelProps { onOpenFile: (relativePath: string) => void; onRefreshSelectedFile?: () => void; workspaceMutationId: string | null; + /** Inserts a mention into the chat composer of the thread this panel belongs to. */ + addToChat: (text: string) => AddToChatResult; } +/** "dropped" means the panel left the thread before the action settled. */ +export type AddToChatResult = "inserted" | "no-composer" | "not-ready" | "dropped"; + function treePath(entry: ProjectEntry): string { return entry.kind === "directory" ? `${entry.path}/` : entry.path; } @@ -104,9 +108,9 @@ export default function FileBrowserPanel({ onOpenFile, onRefreshSelectedFile, workspaceMutationId, + addToChat, }: FileBrowserPanelProps) { const { resolvedTheme } = useTheme(); - const composerRef = useComposerHandleContext(); const fileContextMenu = useFileContextMenu(environmentId); const { entries: directoryEntries, @@ -213,8 +217,8 @@ export default function FileBrowserPanel({ return; } if (clicked === "add-to-chat") { - const composer = composerRef?.current; - if (!composer) { + const result = addToChat(`${mention} `); + if (result === "no-composer") { toastManager.add({ type: "error", title: "Unable to add to chat", @@ -222,8 +226,7 @@ export default function FileBrowserPanel({ }); return; } - const inserted = composer.insertTextAtEnd(`${mention} `, { ensureLeadingBoundary: true }); - if (!inserted) { + if (result === "not-ready") { toastManager.add({ type: "error", title: "Unable to add to chat", diff --git a/apps/web/src/panels/files/FilesSidePanel.test.tsx b/apps/web/src/panels/files/FilesSidePanel.test.tsx index 43c945d69219..295fbc954eb8 100644 --- a/apps/web/src/panels/files/FilesSidePanel.test.tsx +++ b/apps/web/src/panels/files/FilesSidePanel.test.tsx @@ -1,22 +1,48 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/reactivity"; import { act, cloneElement, Suspense, use, type ReactElement, type ReactNode } from "react"; import { create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ChatComposerHandle } from "~/components/chat/ChatComposer"; +import { toastManager } from "~/components/ui/toast"; +import { ComposerHandleContext, type ComposerHandleRef } from "~/composerHandleContext"; +import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; -const { siblingsByEnvironment, Wrapper, MenuContext, RadioContext } = await vi.hoisted(async () => { - const { createContext: createHoistedContext } = await import("react"); - return { - // Files in the `src` folder, per environment. - siblingsByEnvironment: new Map>(), - Wrapper: ({ children }: { children?: ReactNode }) => children, - MenuContext: createHoistedContext<(open: boolean) => void>(() => undefined), - RadioContext: createHoistedContext<(value: string) => void>(() => undefined), - }; -}); +const { siblingsByEnvironment, late, Wrapper, MenuContext, RadioContext } = await vi.hoisted( + async () => { + const { createContext: createHoistedContext } = await import("react"); + // Settles when a test says so, like a native menu or a server round trip. + const deferred = () => { + let resolve!: (value: A) => void; + const promise = new Promise((settle) => (resolve = settle)); + return { promise, resolve }; + }; + const late = { + deferred, + // The tree's right-click handler, as handed to the Pierre tree. + openTreeMenu: null as null | ((item: unknown, context: unknown) => void), + menuChoice: deferred(), + assetUrl: deferred(), + session: deferred(), + // Saved browser settings; already loaded unless a test holds them. + browserDefaults: null as null | Promise, + previewRequests: 0, + }; + return { + late, + // Files in the `src` folder, per environment. + siblingsByEnvironment: new Map>(), + Wrapper: ({ children }: { children?: ReactNode }) => children, + MenuContext: createHoistedContext<(open: boolean) => void>(() => undefined), + RadioContext: createHoistedContext<(value: string) => void>(() => undefined), + }; + }, +); vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); vi.mock("~/state/server", () => ({ @@ -40,7 +66,7 @@ vi.mock("~/browser/previewRuntime", async (importOriginal) => ({ usePreviewAvailable: () => true, })); vi.mock("~/state/environments", () => ({ - useEnvironmentHttpBaseUrl: () => null, + useEnvironmentHttpBaseUrl: () => "http://localhost:3773/", usePrimaryEnvironmentId: () => null, })); vi.mock("~/remoteOpen", () => ({ useRemoteOpenState: () => ({ mode: "local-exec" }) })); @@ -50,8 +76,21 @@ vi.mock("~/hooks/useSettings", () => ({ useUpdateClientSettings: () => vi.fn(), })); vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }) })); -vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); -vi.mock("~/state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); +// The panel's only command and query runner: open a preview session, create an asset URL. +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: () => () => { + late.previewRequests += 1; + return late.session.promise; + }, +})); +vi.mock("~/state/use-atom-query-runner", () => ({ + useAtomQueryRunner: () => () => late.assetUrl.promise, +})); +vi.mock("~/browser/browserDefaults", async (importOriginal) => ({ + ...(await importOriginal()), + resolveBrowserDefaults: () => + late.browserDefaults ?? Promise.resolve({ viewport: { _tag: "fill" }, profileId: "default" }), +})); vi.mock("~/components/files/projectFilesQueryState", async (importOriginal) => ({ ...(await importOriginal()), useProjectFileQuery: () => ({ @@ -79,8 +118,49 @@ vi.mock("~/components/files/projectFilesQueryState", async (importOriginal) => ( }), })); vi.mock("~/components/DiffWorkerPoolProvider", () => ({ DiffWorkerPoolProvider: Wrapper })); -// The tree is the Pierre file tree web component; the breadcrumbs are the entry point under test. -vi.mock("~/components/files/FileBrowserPanel", () => ({ default: () => null })); +// The Pierre tree is a web component; this stand-in keeps the real panel's right-click handler. +vi.mock("@pierre/trees/react", () => ({ + FileTree: () => null, + useFileTree: (options: { + composition: { contextMenu: { onOpen: (item: unknown, context: unknown) => void } }; + }) => { + late.openTreeMenu = options.composition.contextMenu.onOpen; + return { + model: { + isSearchOpen: () => false, + getItem: () => null, + getSelectedPaths: () => [], + subscribe: () => () => undefined, + setGitStatus: () => undefined, + resetPaths: () => undefined, + batch: () => undefined, + closeSearch: () => undefined, + scrollToPath: () => undefined, + }, + }; + }, + useFileTreeSearch: () => ({ value: "", close: () => undefined, setValue: () => undefined }), + useFileTreeSelector: () => false, +})); +vi.mock("~/components/files/useDirectoryEntries", () => ({ + useDirectoryEntries: () => ({ + entries: [{ path: "src", kind: "directory" }], + load: () => undefined, + refresh: () => undefined, + isPending: false, + ready: true, + error: null, + }), +})); +vi.mock("~/state/queries", () => ({ + useProjectPathSearch: () => ({ entries: [], isPending: false, refresh: () => undefined }), +})); +vi.mock("~/fileContextMenu", () => ({ + useFileContextMenu: () => ({ buildItems: () => [], activate: vi.fn() }), +})); +vi.mock("~/localApi", () => ({ + readLocalApi: () => ({ contextMenu: { show: () => late.menuChoice.promise } }), +})); vi.mock("~/components/chat/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); vi.mock("~/components/ui/tooltip", () => ({ Tooltip: Wrapper, @@ -134,14 +214,29 @@ import { RegisteredSidePanel } from "../bundledPanels"; import { PanelHostContext, type PanelHost } from "../panelHost"; // The same thread id on two environments is two threads. -const threadId = ThreadId.make("thread-a"); -const refOn = (environmentId: string): ScopedThreadRef => ({ +const refOn = (environmentId: string, thread = "thread-a"): ScopedThreadRef => ({ environmentId: EnvironmentId.make(environmentId), - threadId, + threadId: ThreadId.make(thread), }); let renderer: ReactTestRenderer | undefined; -async function renderFileFor(threadRef: ScopedThreadRef) { +// The chat layout owns one composer ref; navigation swaps the composer behind it. +const composerRef: ComposerHandleRef = { current: null }; +const composers = new Map< + string, + ReturnType> +>(); +function composerOf(threadRef: ScopedThreadRef) { + const key = scopedThreadKey(threadRef); + if (!composers.has(key)) + composers.set( + key, + vi.fn(() => true), + ); + return composers.get(key)!; +} + +async function renderFileFor(threadRef: ScopedThreadRef, relativePath: string | null = "src/a.ts") { const host: PanelHost = { threadRef, visible: true, @@ -150,23 +245,28 @@ async function renderFileFor(threadRef: ScopedThreadRef) { sendAnnotation: () => undefined, }; const element = ( - - - undefined} - selectedFilePending={false} - /> - - + + + + undefined} + selectedFilePending={false} + /> + + + ); await act(async () => { + composerRef.current = { + insertTextAtEnd: composerOf(threadRef), + } as unknown as ChatComposerHandle; if (renderer) renderer.update(element); else renderer = create(element); }); @@ -192,12 +292,80 @@ const siblingValues = () => const surfacesOf = (threadRef: ScopedThreadRef) => useRightPanelStore.getState().byThreadKey[scopedThreadKey(threadRef)]?.surfaces; +// Right-clicks a tree row and returns a way to pick from the native menu later. +function rightClickTreeRow() { + late.menuChoice = late.deferred(); + const closed = late.deferred(); + late.openTreeMenu!( + { path: "src/a.ts" }, + { + anchorElement: { getBoundingClientRect: () => ({ left: 0, bottom: 0 }) }, + close: closed.resolve, + }, + ); + return (choice: string) => + act(async () => { + late.menuChoice.resolve(choice); + await closed.promise; + }); +} + +const insertedAnywhere = () => [...composers.values()].some((insert) => insert.mock.calls.length); + +const browsersOf = (threadRef: ScopedThreadRef) => + (surfacesOf(threadRef) ?? []).filter((surface) => surface.kind === "preview"); + +// Presses "Open file in preview browser" with the asset URL and preview session still pending. +async function openInBrowser() { + late.assetUrl = late.deferred(); + late.session = late.deferred(); + await press( + renderer!.root.find( + (node) => + node.type === "button" && node.props["aria-label"] === "Open file in preview browser", + ), + ); +} + +const settleAsset = () => + act(async () => { + late.assetUrl.resolve(AsyncResult.success({ relativeUrl: "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/a/index.html", expiresAt: 0 })); + }); +const settleSession = () => + act(async () => { + late.session.resolve( + AsyncResult.success({ + threadId: ThreadId.make("thread-a"), + tabId: "tab-1", + navStatus: { _tag: "Loading", url: "http://localhost:3773/a/index.html", title: "" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-10-04T00:00:00.000Z", + }), + ); + }); + +const failAsset = () => + act(async () => { + late.assetUrl.resolve(AsyncResult.failure(Cause.fail(new Error("asset unavailable")))); + }); +const failSession = () => + act(async () => { + late.session.resolve(AsyncResult.failure(Cause.fail(new Error("preview unavailable")))); + }); +const browserErrorToasts = () => + vi + .mocked(toastManager.add) + .mock.calls.filter(([toast]) => toast.title === "Unable to open file in browser"); + // Transform the lazy body once up front, so mounting it settles inside one act(). beforeAll(() => import("./FilesSidePanel"), 30_000); beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const storage = new Map(); + vi.stubGlobal("document", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); vi.stubGlobal("window", { + desktopBridge: { preview: {} }, addEventListener: vi.fn(), removeEventListener: vi.fn(), localStorage: { @@ -207,6 +375,11 @@ beforeEach(() => { }, }); siblingsByEnvironment.clear(); + composers.clear(); + late.previewRequests = 0; + late.browserDefaults = null; + vi.spyOn(toastManager, "add"); + resetPreviewStateForTests(); siblingsByEnvironment.set("environment-a", ["src/a.ts", "src/b.ts"]); siblingsByEnvironment.set("environment-b", ["src/a.ts", "src/c.ts"]); useRightPanelStore.setState({ @@ -219,6 +392,7 @@ afterEach(() => { act(() => renderer?.unmount()); renderer = undefined; vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); describe("files side panel", () => { @@ -253,3 +427,128 @@ describe("files side panel", () => { expect(surfacesOf(refOn("environment-a"))).toBeUndefined(); }); }); + +describe("files actions that settle late", () => { + it("adds a tree entry to the chat of the thread it was picked in", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, null); + const pick = rightClickTreeRow(); + await pick("add-to-chat"); + expect(composerOf(threadRef)).toHaveBeenCalledExactlyOnceWith("[a.ts](src/a.ts) ", { + ensureLeadingBoundary: true, + }); + }); + + it("drops a late Add to chat after moving to the same thread id in another environment", async () => { + await renderFileFor(refOn("environment-a"), null); + const pick = rightClickTreeRow(); + await renderFileFor(refOn("environment-b"), null); + await pick("add-to-chat"); + expect(insertedAnywhere()).toBe(false); + }); + + it("drops a late Add to chat after moving to another thread in the project", async () => { + await renderFileFor(refOn("environment-a"), null); + const pick = rightClickTreeRow(); + await renderFileFor(refOn("environment-a", "thread-other"), null); + await pick("add-to-chat"); + expect(insertedAnywhere()).toBe(false); + + // The new thread's own menu still reaches its composer. + await rightClickTreeRow()("add-to-chat"); + expect(composerOf(refOn("environment-a", "thread-other"))).toHaveBeenCalledOnce(); + }); + + it("keeps a late Add to chat dropped after leaving and returning to the thread", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, null); + const pick = rightClickTreeRow(); + await renderFileFor(refOn("environment-a", "thread-other"), null); + await renderFileFor(threadRef, null); + await pick("add-to-chat"); + expect(insertedAnywhere()).toBe(false); + }); + + it("opens a Browser tab in the thread that is still showing", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, "index.html"); + await openInBrowser(); + await settleAsset(); + await settleSession(); + expect(browsersOf(threadRef).map((surface) => surface.id)).toEqual(["browser:tab-1"]); + }); + + it("never asks for a browser when the thread was left while the file was prepared", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, "index.html"); + await openInBrowser(); + await renderFileFor(refOn("environment-b"), "index.html"); + await settleAsset(); + expect(late.previewRequests).toBe(0); + expect(browsersOf(threadRef)).toEqual([]); + expect(browsersOf(refOn("environment-b"))).toEqual([]); + }); + + it("keeps a browser the server opened after the thread was left in that thread", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, "index.html"); + await openInBrowser(); + await settleAsset(); + expect(late.previewRequests).toBe(1); + await renderFileFor(refOn("environment-a", "thread-other"), "index.html"); + await settleSession(); + expect(readThreadPreviewState(threadRef).snapshot?.tabId).toBe("tab-1"); + expect(browsersOf(threadRef).map((surface) => surface.id)).toEqual(["browser:tab-1"]); + expect(browsersOf(refOn("environment-a", "thread-other"))).toEqual([]); + }); + + it("keeps a late open dropped after leaving and returning to the thread", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, "index.html"); + await openInBrowser(); + await renderFileFor(refOn("environment-a", "thread-other"), "index.html"); + await renderFileFor(threadRef, "index.html"); + await settleAsset(); + expect(late.previewRequests).toBe(0); + expect(browsersOf(threadRef)).toEqual([]); + }); + + it("reports a browser failure in the thread that is still showing", async () => { + await renderFileFor(refOn("environment-a"), "index.html"); + await openInBrowser(); + await failAsset(); + expect(browserErrorToasts()).toHaveLength(1); + }); + + it("stays silent when the file fails to prepare after the thread was left", async () => { + await renderFileFor(refOn("environment-a"), "index.html"); + await openInBrowser(); + await renderFileFor(refOn("environment-b"), "index.html"); + await failAsset(); + expect(browserErrorToasts()).toEqual([]); + }); + + it("stays silent when browser settings fail to load after the panel closed", async () => { + await renderFileFor(refOn("environment-a"), "index.html"); + let rejectSettings!: (error: Error) => void; + late.browserDefaults = new Promise((_, reject) => (rejectSettings = reject)); + await openInBrowser(); + await settleAsset(); + act(() => renderer!.unmount()); + renderer = undefined; + await act(async () => rejectSettings(new Error("settings unreadable"))); + expect(late.previewRequests).toBe(0); + expect(browserErrorToasts()).toEqual([]); + }); + + it("stays silent when a browser fails to open after leaving and returning", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, "index.html"); + await openInBrowser(); + await settleAsset(); + await renderFileFor(refOn("environment-a", "thread-other"), "index.html"); + await renderFileFor(threadRef, "index.html"); + await failSession(); + expect(browserErrorToasts()).toEqual([]); + }); +}); diff --git a/apps/web/src/panels/files/FilesSidePanel.tsx b/apps/web/src/panels/files/FilesSidePanel.tsx index 9c6540425fde..607107998c57 100644 --- a/apps/web/src/panels/files/FilesSidePanel.tsx +++ b/apps/web/src/panels/files/FilesSidePanel.tsx @@ -36,6 +36,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { FolderTree, Globe2, WrapTextIcon } from "lucide-react"; import { Code2, Eye, Table2 } from "lucide"; @@ -119,6 +120,7 @@ import { } from "~/components/files/projectFilesQueryState"; import { usePanelHost } from "../panelHost"; +import { useScopedComposerInsert, useScopeLifetime } from "./fileScope"; interface FilesSidePanelProps { cwd: string; @@ -1042,6 +1044,16 @@ export default function FilesSidePanel({ (path: string) => useRightPanelStore.getState().openFile(threadRef, path), [threadRef], ); + // Menu and browser actions settle late; they are dropped once the host moves + // to another thread or draft, never applied to the newer one. + const isScopeCurrent = useScopeLifetime( + `${scopedThreadKey(threadRef)}|${ + typeof composerDraftTarget === "string" + ? composerDraftTarget + : scopedThreadKey(composerDraftTarget) + }`, + ); + const addToChat = useScopedComposerInsert(isScopeCurrent); const relativePath = attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath; // A draft's composer target is its draft id; a thread the server knows is a ref. @@ -1203,13 +1215,15 @@ export default function FilesSidePanel({ void (async () => { const result = await openFileInPreview({ threadRef, + isScopeCurrent, filePath: absolutePath, workspaceRoot: cwd, httpBaseUrl: environmentHttpBaseUrl, createAssetUrl, openPreview, }); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + // Errors are reported only to the visit that started the open. + if (result._tag === "Success" || isAtomCommandInterrupted(result) || !isScopeCurrent()) { return; } const error = squashAtomCommandFailure(result); @@ -1228,6 +1242,7 @@ export default function FilesSidePanel({ createAssetUrl, cwd, environmentHttpBaseUrl, + isScopeCurrent, openPreview, threadRef, ]); @@ -1495,6 +1510,7 @@ export default function FilesSidePanel({ selectedPathRevealId={revealRequestId} onOpenFile={onOpenFile} workspaceMutationId={workspaceMutationId} + addToChat={addToChat} {...(previewPath && !isMedia && !isPdf ? { onRefreshSelectedFile: file.refresh } : {})} diff --git a/apps/web/src/panels/files/fileScope.ts b/apps/web/src/panels/files/fileScope.ts new file mode 100644 index 000000000000..7ba1fbc93385 --- /dev/null +++ b/apps/web/src/panels/files/fileScope.ts @@ -0,0 +1,46 @@ +import { useCallback, useLayoutEffect, useRef, useState } from "react"; + +import type { AddToChatResult } from "~/components/files/FileBrowserPanel"; +import { useComposerHandleContext } from "~/composerHandleContext"; + +/** + * Tells async work started in one visit to a scope whether the panel is still + * on that visit. The returned check is stable for the visit, so work keeps the + * check from the visit it started in. It turns false once the panel moves to + * another scope (another thread, or the same thread id in another environment) + * or unmounts, and stays false if the panel later returns to the same scope. + */ +export function useScopeLifetime(scopeKey: string): () => boolean { + // A fresh visit object each time the scope changes, so A -> B -> A is a new visit. + const [visit, setVisit] = useState(() => ({ scopeKey })); + if (visit.scopeKey !== scopeKey) setVisit({ scopeKey }); + const liveRef = useRef(null); + // Set on commit, so a discarded render never retires live work. + useLayoutEffect(() => { + liveRef.current = visit; + return () => { + liveRef.current = null; + }; + }, [visit]); + return useCallback(() => liveRef.current === visit, [visit]); +} + +/** + * Inserts text at the end of the layout's chat composer while `isScopeCurrent` + * holds. The composer ref is shared by the whole chat layout, so an action that + * settles after navigation is dropped instead of reaching the newer thread. + */ +export function useScopedComposerInsert(isScopeCurrent: () => boolean) { + const composerRef = useComposerHandleContext(); + return useCallback( + (text: string): AddToChatResult => { + if (!isScopeCurrent()) return "dropped"; + const composer = composerRef?.current; + if (!composer) return "no-composer"; + return composer.insertTextAtEnd(text, { ensureLeadingBoundary: true }) + ? "inserted" + : "not-ready"; + }, + [composerRef, isScopeCurrent], + ); +} From 4f15c628d012d813ed194deec8cb580edc43943a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:55:18 +1100 Subject: [PATCH 17/22] fix(web): open tree clicks in the thread the Files panel shows now The Pierre file tree keeps the selection callback from its first render. Threads in the same project share one tree, so after switching threads a click opened the file in the thread the tree first showed. The tree now reads the current opener through a ref, as its context menu already does. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/files/FileBrowserPanel.tsx | 7 ++++++- .../src/panels/files/FilesSidePanel.test.tsx | 21 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index c736e38a77f9..3db730c5d53c 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -239,8 +239,13 @@ export default function FileBrowserPanel({ } }; const showEntryContextMenuRef = useRef(showEntryContextMenu); + // The tree keeps its first selection callback, so it reads the current + // opener through a ref; otherwise a click after a thread switch opens the + // file in the thread the panel first showed. + const onOpenFileRef = useRef(onOpenFile); useEffect(() => { showEntryContextMenuRef.current = showEntryContextMenu; + onOpenFileRef.current = onOpenFile; }); // The tree reads decorations at render time; a folder still loading its @@ -286,7 +291,7 @@ export default function FileBrowserPanel({ const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { treeSelectionPathRef.current = selectedPath; - onOpenFile(selectedPath); + onOpenFileRef.current(selectedPath); } }, paths: [], diff --git a/apps/web/src/panels/files/FilesSidePanel.test.tsx b/apps/web/src/panels/files/FilesSidePanel.test.tsx index 295fbc954eb8..9d88ddfff8e1 100644 --- a/apps/web/src/panels/files/FilesSidePanel.test.tsx +++ b/apps/web/src/panels/files/FilesSidePanel.test.tsx @@ -26,6 +26,8 @@ const { siblingsByEnvironment, late, Wrapper, MenuContext, RadioContext } = awai deferred, // The tree's right-click handler, as handed to the Pierre tree. openTreeMenu: null as null | ((item: unknown, context: unknown) => void), + // The tree's selection handler from its first render; the real tree keeps that one. + selectTreeRows: null as null | ((paths: ReadonlyArray) => void), menuChoice: deferred(), assetUrl: deferred(), session: deferred(), @@ -123,8 +125,10 @@ vi.mock("@pierre/trees/react", () => ({ FileTree: () => null, useFileTree: (options: { composition: { contextMenu: { onOpen: (item: unknown, context: unknown) => void } }; + onSelectionChange: (paths: ReadonlyArray) => void; }) => { late.openTreeMenu = options.composition.contextMenu.onOpen; + late.selectTreeRows ??= options.onSelectionChange; return { model: { isSearchOpen: () => false, @@ -144,7 +148,10 @@ vi.mock("@pierre/trees/react", () => ({ })); vi.mock("~/components/files/useDirectoryEntries", () => ({ useDirectoryEntries: () => ({ - entries: [{ path: "src", kind: "directory" }], + entries: [ + { path: "src", kind: "directory" }, + { path: "README.md", kind: "file" }, + ], load: () => undefined, refresh: () => undefined, isPending: false, @@ -361,6 +368,7 @@ const browserErrorToasts = () => // Transform the lazy body once up front, so mounting it settles inside one act(). beforeAll(() => import("./FilesSidePanel"), 30_000); beforeEach(() => { + late.selectTreeRows = null; vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); const storage = new Map(); vi.stubGlobal("document", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); @@ -426,6 +434,17 @@ describe("files side panel", () => { expect(surfacesOf(threadRef)).toMatchObject([{ kind: "file", relativePath: "src/c.ts" }]); expect(surfacesOf(refOn("environment-a"))).toBeUndefined(); }); + + it("opens a tree click in the thread showing now, not the one the tree first showed", async () => { + // Threads in the same project share one tree, so it outlives the switch. + await renderFileFor(refOn("environment-a")); + const threadRef = refOn("environment-a", "thread-b"); + await renderFileFor(threadRef); + + await act(async () => late.selectTreeRows!(["README.md"])); + expect(surfacesOf(threadRef)).toMatchObject([{ kind: "file", relativePath: "README.md" }]); + expect(surfacesOf(refOn("environment-a"))).toBeUndefined(); + }); }); describe("files actions that settle late", () => { From 0e86f374427e786c50daf50c8969b6c0252d6d1d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:48:48 +1100 Subject: [PATCH 18/22] fix(web): keep a Files browser open across composer draft switches Open in browser shared the Add to chat lifetime, which ends when the composer moves to another draft. Entering or leaving queued-message editing before the file was ready therefore dropped the browser even though the thread stayed on screen. Opening a browser now follows a lifetime keyed by the thread alone. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/panels/files/FilesSidePanel.test.tsx | 19 +++++++++++++++++-- apps/web/src/panels/files/FilesSidePanel.tsx | 17 ++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/apps/web/src/panels/files/FilesSidePanel.test.tsx b/apps/web/src/panels/files/FilesSidePanel.test.tsx index 9d88ddfff8e1..de7b81ee8f77 100644 --- a/apps/web/src/panels/files/FilesSidePanel.test.tsx +++ b/apps/web/src/panels/files/FilesSidePanel.test.tsx @@ -243,11 +243,15 @@ function composerOf(threadRef: ScopedThreadRef) { return composers.get(key)!; } -async function renderFileFor(threadRef: ScopedThreadRef, relativePath: string | null = "src/a.ts") { +async function renderFileFor( + threadRef: ScopedThreadRef, + relativePath: string | null = "src/a.ts", + composerDraftTarget: PanelHost["composerDraftTarget"] = threadRef, +) { const host: PanelHost = { threadRef, visible: true, - composerDraftTarget: threadRef, + composerDraftTarget, workspaceMutationId: null, sendAnnotation: () => undefined, }; @@ -497,6 +501,17 @@ describe("files actions that settle late", () => { expect(browsersOf(threadRef).map((surface) => surface.id)).toEqual(["browser:tab-1"]); }); + it("still opens a browser after the composer switches drafts in the same thread", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, "index.html"); + await openInBrowser(); + // Editing a queued message moves the composer to a draft; the thread stays. + await renderFileFor(threadRef, "index.html", "draft-1" as PanelHost["composerDraftTarget"]); + await settleAsset(); + await settleSession(); + expect(browsersOf(threadRef).map((surface) => surface.id)).toEqual(["browser:tab-1"]); + }); + it("never asks for a browser when the thread was left while the file was prepared", async () => { const threadRef = refOn("environment-a"); await renderFileFor(threadRef, "index.html"); diff --git a/apps/web/src/panels/files/FilesSidePanel.tsx b/apps/web/src/panels/files/FilesSidePanel.tsx index 607107998c57..c3b97c4c5909 100644 --- a/apps/web/src/panels/files/FilesSidePanel.tsx +++ b/apps/web/src/panels/files/FilesSidePanel.tsx @@ -1044,8 +1044,8 @@ export default function FilesSidePanel({ (path: string) => useRightPanelStore.getState().openFile(threadRef, path), [threadRef], ); - // Menu and browser actions settle late; they are dropped once the host moves - // to another thread or draft, never applied to the newer one. + // Menu actions settle late; they are dropped once the host moves to another + // thread or draft, never applied to the newer one. const isScopeCurrent = useScopeLifetime( `${scopedThreadKey(threadRef)}|${ typeof composerDraftTarget === "string" @@ -1054,6 +1054,9 @@ export default function FilesSidePanel({ }`, ); const addToChat = useScopedComposerInsert(isScopeCurrent); + // Opening a browser belongs to the thread, not the draft: entering or + // leaving queued-message editing keeps the thread on screen. + const isThreadVisitCurrent = useScopeLifetime(scopedThreadKey(threadRef)); const relativePath = attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath; // A draft's composer target is its draft id; a thread the server knows is a ref. @@ -1215,7 +1218,7 @@ export default function FilesSidePanel({ void (async () => { const result = await openFileInPreview({ threadRef, - isScopeCurrent, + isScopeCurrent: isThreadVisitCurrent, filePath: absolutePath, workspaceRoot: cwd, httpBaseUrl: environmentHttpBaseUrl, @@ -1223,7 +1226,11 @@ export default function FilesSidePanel({ openPreview, }); // Errors are reported only to the visit that started the open. - if (result._tag === "Success" || isAtomCommandInterrupted(result) || !isScopeCurrent()) { + if ( + result._tag === "Success" || + isAtomCommandInterrupted(result) || + !isThreadVisitCurrent() + ) { return; } const error = squashAtomCommandFailure(result); @@ -1242,7 +1249,7 @@ export default function FilesSidePanel({ createAssetUrl, cwd, environmentHttpBaseUrl, - isScopeCurrent, + isThreadVisitCurrent, openPreview, threadRef, ]); From 419306de07925c487fc358217acab540712febac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:43:16 +1100 Subject: [PATCH 19/22] fix(web): point tree clicks at the new thread as soon as it commits The Files tree read its file opener through a ref that a passive effect updated. A selection that landed after a thread switch committed but before passive effects ran still opened the file in the previous thread. Update the ref in a layout effect, so the opener follows the committed thread. Co-Authored-By: Claude Opus 5.5 --- .../src/components/files/FileBrowserPanel.tsx | 13 +++++---- .../src/panels/files/FilesSidePanel.test.tsx | 29 ++++++++++++++++++- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 3db730c5d53c..548042d140bb 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -7,7 +7,7 @@ import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { ChevronsDownUp, ChevronsUpDown } from "lucide"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Button } from "~/components/ui/button"; import { InputGroup, InputGroupInput } from "~/components/ui/input-group"; @@ -239,14 +239,17 @@ export default function FileBrowserPanel({ } }; const showEntryContextMenuRef = useRef(showEntryContextMenu); + useEffect(() => { + showEntryContextMenuRef.current = showEntryContextMenu; + }); // The tree keeps its first selection callback, so it reads the current // opener through a ref; otherwise a click after a thread switch opens the - // file in the thread the panel first showed. + // file in the thread the panel first showed. Updated at commit, so a click + // that lands before passive effects run already opens in the new thread. const onOpenFileRef = useRef(onOpenFile); - useEffect(() => { - showEntryContextMenuRef.current = showEntryContextMenu; + useLayoutEffect(() => { onOpenFileRef.current = onOpenFile; - }); + }, [onOpenFile]); // The tree reads decorations at render time; a folder still loading its // children shows a spinner in its row instead of a banner that shifts the tree. diff --git a/apps/web/src/panels/files/FilesSidePanel.test.tsx b/apps/web/src/panels/files/FilesSidePanel.test.tsx index de7b81ee8f77..a1a6f3329d12 100644 --- a/apps/web/src/panels/files/FilesSidePanel.test.tsx +++ b/apps/web/src/panels/files/FilesSidePanel.test.tsx @@ -3,7 +3,15 @@ import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contract import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; -import { act, cloneElement, Suspense, use, type ReactElement, type ReactNode } from "react"; +import { + act, + cloneElement, + Suspense, + use, + useLayoutEffect, + type ReactElement, + type ReactNode, +} from "react"; import { create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -243,10 +251,17 @@ function composerOf(threadRef: ScopedThreadRef) { return composers.get(key)!; } +// Runs `run` inside the commit, after the panel's layout effects and before any passive effect. +function DuringCommit({ run }: { run: (() => void) | undefined }) { + useLayoutEffect(() => run?.(), [run]); + return null; +} + async function renderFileFor( threadRef: ScopedThreadRef, relativePath: string | null = "src/a.ts", composerDraftTarget: PanelHost["composerDraftTarget"] = threadRef, + duringCommit?: () => void, ) { const host: PanelHost = { threadRef, @@ -271,6 +286,7 @@ async function renderFileFor( selectedFilePending={false} /> + ); @@ -449,6 +465,17 @@ describe("files side panel", () => { expect(surfacesOf(threadRef)).toMatchObject([{ kind: "file", relativePath: "README.md" }]); expect(surfacesOf(refOn("environment-a"))).toBeUndefined(); }); + + it("opens a tree click that lands before the switch's passive effects in the new thread", async () => { + await renderFileFor(refOn("environment-a")); + const threadRef = refOn("environment-a", "thread-b"); + await renderFileFor(threadRef, "src/a.ts", threadRef, () => + late.selectTreeRows!(["README.md"]), + ); + + expect(surfacesOf(threadRef)).toMatchObject([{ kind: "file", relativePath: "README.md" }]); + expect(surfacesOf(refOn("environment-a"))).toBeUndefined(); + }); }); describe("files actions that settle late", () => { From 3b7aa412d1b0b440679ccd0de1e4a3e7437324df Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:48:25 +1100 Subject: [PATCH 20/22] feat(pi): show extension statuses in the thread header Pi extensions call `ctx.ui.setStatus` to show a short status line. T3 Code dropped it, so the hint never reached the user. The Pi adapter now forwards `setStatus` into an in-memory, advisory status store owned by the provider session. A new `subscribeContributionStatus` stream (orchestration read scope, behind the `contributionStatus` capability) sends the environment's statuses as full snapshots. The web and desktop thread header shows them as chips with a popover listing every status and where it came from. Statuses are cleared at T3-initiated session switches, never persisted, and can lag an extension-initiated change. A cancelled rollback restores the statuses the store held for the unchanged session, without replacing keys written or cleared while its hook decided; the kept copy is dropped once Pi answers the fork either way. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/auth/RpcAuthorization.ts | 1 + .../ContributionStatusRpc.test.ts | 206 +++++++ .../src/environment/ServerEnvironment.ts | 1 + .../src/observability/RpcInstrumentation.ts | 1 + ...viderOrchestrationAdapterInfrastructure.ts | 5 +- apps/server/src/server.ts | 4 + apps/server/src/ws.ts | 6 +- apps/web/src/components/chat/ChatHeader.tsx | 7 + .../ThreadContributionStatus.logic.test.ts | 212 ++++++++ .../chat/ThreadContributionStatus.logic.ts | 47 ++ .../chat/ThreadContributionStatus.test.tsx | 143 +++++ .../chat/ThreadContributionStatus.tsx | 106 ++++ apps/web/src/state/contributionStatus.ts | 19 + docs/user/providers-pi.md | 6 +- packages/client-runtime/package.json | 4 + packages/client-runtime/src/rpc/client.ts | 1 + .../src/state/contributionStatus.test.ts | 241 +++++++++ .../src/state/contributionStatus.ts | 108 ++++ .../contracts/src/contributionStatus.test.ts | 47 ++ packages/contracts/src/contributionStatus.ts | 104 ++++ packages/contracts/src/environment.ts | 3 + packages/contracts/src/index.ts | 1 + packages/contracts/src/rpc.ts | 11 + packages/provider-core/package.json | 4 + .../server/ContributionStatusStore.test.ts | 280 ++++++++++ .../src/server/ContributionStatusStore.ts | 347 ++++++++++++ .../provider-pi/src/server/adapter.test.ts | 505 +++++++++++++++++- packages/provider-pi/src/server/adapter.ts | 175 +++++- 28 files changed, 2563 insertions(+), 32 deletions(-) create mode 100644 apps/server/src/contributions/ContributionStatusRpc.test.ts create mode 100644 apps/web/src/components/chat/ThreadContributionStatus.logic.test.ts create mode 100644 apps/web/src/components/chat/ThreadContributionStatus.logic.ts create mode 100644 apps/web/src/components/chat/ThreadContributionStatus.test.tsx create mode 100644 apps/web/src/components/chat/ThreadContributionStatus.tsx create mode 100644 apps/web/src/state/contributionStatus.ts create mode 100644 packages/client-runtime/src/state/contributionStatus.test.ts create mode 100644 packages/client-runtime/src/state/contributionStatus.ts create mode 100644 packages/contracts/src/contributionStatus.test.ts create mode 100644 packages/contracts/src/contributionStatus.ts create mode 100644 packages/provider-core/src/server/ContributionStatusStore.test.ts create mode 100644 packages/provider-core/src/server/ContributionStatusStore.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5b8d483dc102..fc1eaf2f89f0 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -173,6 +173,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeResourceTelemetry]: AuthDiagnosticsReadScope, + [WS_METHODS.subscribeContributionStatus]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope, [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, diff --git a/apps/server/src/contributions/ContributionStatusRpc.test.ts b/apps/server/src/contributions/ContributionStatusRpc.test.ts new file mode 100644 index 000000000000..34d999adf280 --- /dev/null +++ b/apps/server/src/contributions/ContributionStatusRpc.test.ts @@ -0,0 +1,206 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + AuthOrchestrationReadScope, + AuthRelayReadScope, + type AuthEnvironmentScope, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ThreadId, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as TestClock from "effect/testing/TestClock"; +import { RpcMessage, RpcSerialization, RpcServer } from "effect/rpc"; + +import * as RpcAuthorization from "../auth/RpcAuthorization.ts"; +import { RPC_REQUIRED_SCOPES } from "../auth/RpcAuthorization.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import * as ProviderOrchestrationAdapterInfrastructure from "../provider/ProviderOrchestrationAdapterInfrastructure.ts"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; + +const TAG = WS_METHODS.subscribeContributionStatus; +const THREAD = ThreadId.make("thread-1"); +const SOURCE = { + kind: "provider-session", + providerSessionId: ProviderSessionId.make("session-1"), + providerInstanceId: ProviderInstanceId.make("pi"), + driver: ProviderDriverKind.make("pi"), +} as const; +const encodedEntry = (texts: ReadonlyArray) => ({ + threadId: THREAD, + source: SOURCE, + items: texts.map((text, index) => ({ key: `k${index}`, text })), +}); + +// The server group narrowed to this RPC; it keeps the group's scope middleware. +const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => tag !== TAG, + ), +); + +/** + * Serves `subscribeContributionStatus` as ws.ts does (the store's subscription + * stream behind the connection's scope middleware) over an in-memory RPC protocol. + */ +const serveStatus = Effect.fn("serveStatus")(function* ( + store: ContributionStatusStore.ContributionStatusStoreShape, + scopes: ReadonlyArray, +) { + const responses = yield* Queue.unbounded(); + const receive = yield* Deferred.make[0]>(); + const protocol = yield* RpcServer.Protocol.make((write) => + Effect.gen(function* () { + yield* Deferred.succeed(receive, write); + const serialization = yield* RpcSerialization.RpcSerialization; + return { + disconnects: yield* Queue.unbounded(), + send: (_clientId, response) => Queue.offer(responses, response), + end: () => Effect.void, + clientIds: Effect.succeed(new Set([0])), + initialMessage: Effect.succeedNone, + supportsAck: false, + supportsTransferables: false, + supportsSpanPropagation: false, + supportsNotifications: true, + codecFor: serialization.codecFor, + }; + }), + ); + yield* RpcServer.make(group).pipe( + Effect.provide( + Layer.merge( + group.toLayerHandler(TAG, () => ContributionStatusStore.subscriptionStream(store)), + RpcAuthorization.layer(scopes), + ), + ), + Effect.provideService(RpcServer.Protocol, protocol), + Effect.forkScoped, + ); + const write = yield* Deferred.await(receive); + return { + subscribe: (id: string) => + write(0, { _tag: "Request", id, tag: TAG, payload: {}, headers: [] }), + interrupt: (id: string) => write(0, { _tag: "Interrupt", requestId: id }), + /** The next frame the client receives; the store publishes one per visible change. */ + next: Queue.take(responses), + /** Lets server fibers run so a frame that would be sent is in the queue. */ + pending: TestClock.adjust(0).pipe(Effect.andThen(Queue.size(responses))), + }; +}); + +const chunk = ( + requestId: string, + entries: ReadonlyArray>, +): RpcMessage.FromServerEncoded => ({ _tag: "Chunk", requestId, values: [{ entries }] }); + +describe("subscribeContributionStatus", () => { + it.effect("streams the current snapshot, replacements, and a fresh snapshot on resubscribe", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const handle = yield* store.openSource(SOURCE); + yield* handle.bindThread(THREAD); + yield* handle.set({ key: "k0", text: "plan" }); + const client = yield* serveStatus(store, [AuthOrchestrationReadScope]); + + yield* client.subscribe("1"); + assert.deepStrictEqual(yield* client.next, chunk("1", [encodedEntry(["plan"])])); + + yield* handle.set({ key: "k0", text: "build" }); + assert.deepStrictEqual(yield* client.next, chunk("1", [encodedEntry(["build"])])); + yield* handle.clear("k0"); + assert.deepStrictEqual(yield* client.next, chunk("1", [])); + + // A reconnect is a new subscription whose first frame is the current state. + yield* handle.set({ key: "k0", text: "review" }); + assert.deepStrictEqual(yield* client.next, chunk("1", [encodedEntry(["review"])])); + yield* client.subscribe("2"); + assert.deepStrictEqual(yield* client.next, chunk("2", [encodedEntry(["review"])])); + + // An interrupted subscription stops receiving frames; the other keeps going. + yield* client.interrupt("1"); + assert.strictEqual((yield* client.next)._tag, "Exit"); + yield* handle.set({ key: "k0", text: "done" }); + assert.deepStrictEqual(yield* client.next, chunk("2", [encodedEntry(["done"])])); + assert.strictEqual(yield* client.pending, 0); + }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), + ); + + it.effect("refuses a client without the orchestration read scope", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const handle = yield* store.openSource(SOURCE); + yield* handle.bindThread(THREAD); + yield* handle.set({ key: "k0", text: "plan" }); + const client = yield* serveStatus(store, [AuthRelayReadScope]); + + yield* client.subscribe("1"); + assert.deepStrictEqual(yield* client.next, { + _tag: "Exit", + requestId: "1", + exit: { + _tag: "Failure", + cause: [ + { + _tag: "Fail", + error: { + _tag: "EnvironmentAuthorizationError", + message: `The authenticated token is missing required scope: ${AuthOrchestrationReadScope}.`, + requiredPermission: AuthOrchestrationReadScope, + requiredScope: AuthOrchestrationReadScope, + }, + }, + ], + }, + }); + }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), + ); + + it.effect("shares one store between provider adapters and the WebSocket stream", () => + Effect.gen(function* () { + // Mirrors server.ts: adapters get the store through the provider + // infrastructure inside an unwrapped instance-registry layer, while the + // WebSocket layer reads it from the runtime's own reference. + const producer = Layer.unwrap( + Effect.succeed( + Layer.effectDiscard( + Effect.gen(function* () { + const store = yield* ContributionStatusStore.ContributionStatusStore; + const handle = yield* store.openSource(SOURCE); + yield* handle.bindThread(THREAD); + yield* handle.set({ key: "k0", text: "from adapter" }); + }), + ).pipe(Layer.provide(ProviderOrchestrationAdapterInfrastructure.layer)), + ), + ); + // As in server.ts, the registry layer sits below the store reference, so + // the producer only sees the store its own infrastructure provides. + const runtime = ContributionStatusStore.layer.pipe( + Layer.provideMerge(producer), + Layer.provide( + Layer.merge( + NodeServices.layer, + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ), + ); + + const snapshot = yield* Effect.gen(function* () { + const store = yield* ContributionStatusStore.ContributionStatusStore; + return yield* store.snapshot; + }).pipe(Effect.provide(runtime)); + assert.deepStrictEqual(snapshot.entries, [ + { threadId: THREAD, source: SOURCE, items: [{ key: "k0", text: "from adapter" }] }, + ]); + }), + ); +}); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index a55b1098d5f0..2fa19b4d42bf 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -253,6 +253,7 @@ export const make = Effect.gen(function* () { serverResolvedCommandContext: true, environmentIcon: true, projectCloneTracking: true, + contributionStatus: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverInstallation === null ? {} : { serverInstallation }), // V2 restart recovery uses the environment-owned opt-in. The old diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 167ce8d11fff..3f725fff4a71 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -160,6 +160,7 @@ const RPC_AGGREGATES = { [WS_METHODS.subscribeWorktreeSetup]: "vcs", [WS_METHODS.worktreeSetupCancel]: "vcs", [WS_METHODS.subscribeResourceTelemetry]: "server", + [WS_METHODS.subscribeContributionStatus]: "server", [WS_METHODS.vcsRefreshStatus]: "vcs", [WS_METHODS.vcsPull]: "git", [WS_METHODS.gitRunStackedAction]: "vcs", diff --git a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts index f312b3d58320..12b71f401d2e 100644 --- a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts +++ b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts @@ -1,5 +1,6 @@ import * as Layer from "effect/Layer"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk"; @@ -20,7 +21,8 @@ export type ProviderOrchestrationAdapterInfrastructure = * Infrastructure shared by the V2 adapters materialized inside provider * instances. `providerContinuationRequestsLayer` must be the same layer * reference the orchestration runtime provides to its continuation worker so - * Effect layer memoization yields one shared queue. + * Effect layer memoization yields one shared queue. The contribution status + * store follows the same rule with the WebSocket server that streams it. */ export const layer = Layer.mergeAll( ClaudeAdapterV2.layerQueryRunner, @@ -29,4 +31,5 @@ export const layer = Layer.mergeAll( CursorKeychain.layer, IdAllocator.layer, ProviderContinuationRequests.layer, + ContributionStatusStore.layer, ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3c1539a70985..807b9395c3a3 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -26,6 +26,7 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as HostPowerMonitor from "./background/HostPowerMonitor.ts"; import * as ServerConfig from "./config.ts"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; import { withUntracedRequests } from "./http.ts"; import * as ServerHttp from "./http.ts"; import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; @@ -595,6 +596,9 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(layerGit), Layer.provideMerge(layerVcs), Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), + // The same layer reference provider adapters write through, so memoization + // gives producers and the WebSocket stream one store. + Layer.provideMerge(ContributionStatusStore.layer), Layer.provideMerge(layerPersistence), // Both read a user-owned file out of the state directory and stream changes // to clients; neither depends on the other. diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 375c2ac78aa1..88b7e452addc 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -89,7 +89,7 @@ import { ChatAttachmentId, PersistChatAttachmentsError, RpcClientId, - EnvironmentAuthorizationError, + type EnvironmentAuthorizationError, type ProjectId, type ProviderDriverKind, type ProviderInstanceId, @@ -212,6 +212,7 @@ import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as DefectReporter from "./observability/DefectReporter.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { requiredScopeForDeviceList, rpcAuthorizationError } from "./auth/RpcAuthorization.ts"; import * as RpcAuthorization from "./auth/RpcAuthorization.ts"; @@ -1327,6 +1328,7 @@ const layerWsRpc = ( const hostResources = yield* HostResources.HostResources; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; + const contributionStatus = yield* ContributionStatusStore.ContributionStatusStore; const relayClient = yield* RelayClient.RelayClient; // A webhook URL starts agent runs, so only sessions that may operate // see it; read-only sessions still see the task itself. @@ -3133,6 +3135,8 @@ const layerWsRpc = ( Stream.concat(Stream.make(latest), changes), ), ), + [WS_METHODS.subscribeContributionStatus]: (_input) => + ContributionStatusStore.subscriptionStream(contributionStatus), }); return handlers; }), diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index c2b1a805011b..d0dcd23b50d9 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -39,6 +39,7 @@ import { import { observeResize } from "~/lib/observeResize"; import { cn } from "~/lib/utils"; import { useClientSettings } from "../../hooks/useSettings"; +import { ThreadContributionStatus } from "./ThreadContributionStatus"; interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; @@ -436,6 +437,12 @@ export const ChatHeader = memo(function ChatHeader({ )} + {isServerThread ? ( + + ) : null}
); }); diff --git a/apps/web/src/components/chat/ThreadContributionStatus.logic.test.ts b/apps/web/src/components/chat/ThreadContributionStatus.logic.test.ts new file mode 100644 index 000000000000..d7a1cb968ab3 --- /dev/null +++ b/apps/web/src/components/chat/ThreadContributionStatus.logic.test.ts @@ -0,0 +1,212 @@ +import { assert, describe, it } from "@effect/vitest"; +import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; +import { createContributionStatusEnvironmentAtoms } from "@t3tools/client-runtime/state/contribution-status"; +import { + type ContributionStatusEntry, + type ContributionStatusSnapshot, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + type ServerConfig, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import { Atom, AtomRegistry } from "effect/reactivity"; + +import { contributionStatusChips } from "./ThreadContributionStatus.logic"; + +const THREAD = ThreadId.make("thread-1"); +const OTHER_THREAD = ThreadId.make("thread-2"); + +const entry = ( + items: ContributionStatusEntry["items"], + options: { session?: string; instance?: string; threadId?: ThreadId } = {}, +): ContributionStatusEntry => ({ + threadId: options.threadId ?? THREAD, + source: { + kind: "provider-session", + providerSessionId: ProviderSessionId.make(options.session ?? "session-1"), + providerInstanceId: ProviderInstanceId.make(options.instance ?? "pi"), + driver: ProviderDriverKind.make("pi"), + }, + items, +}); + +describe("contributionStatusChips", () => { + it("keeps the server's order and keys each chip by source plus item key", () => { + const chips = contributionStatusChips([ + entry([ + { key: "mode", text: "● plan" }, + { key: "tokens", text: "12k" }, + ]), + entry([{ key: "mode", text: "● plan" }], { session: "session-2", instance: "pi_work" }), + ]); + assert.deepStrictEqual( + chips.map((chip) => chip.text), + ["● plan", "12k", "● plan"], + ); + // The same item key from another source is a different chip. + assert.strictEqual(new Set(chips.map((chip) => chip.id)).size, 3); + + // A new provider session taking over with identical text still re-keys the chip. + const [before] = contributionStatusChips([entry([{ key: "mode", text: "● plan" }])]); + const [after] = contributionStatusChips([ + entry([{ key: "mode", text: "● plan" }], { session: "session-3" }), + ]); + assert.notStrictEqual(before!.id, after!.id); + }); + + it("defaults the tone, ignores empty tooltips, and names the producing instance", () => { + const [plain, styled] = contributionStatusChips([ + entry([ + { key: "a", text: "idle", tooltip: "" }, + { key: "b", text: "failing", tone: "error", tooltip: "2 checks failed" }, + ]), + ]); + assert.deepStrictEqual([plain!.tone, plain!.tooltip], ["neutral", null]); + assert.deepStrictEqual([styled!.tone, styled!.tooltip], ["error", "2 checks failed"]); + assert.strictEqual(plain!.origin, "From a Pi extension. It can lag a session change."); + + const [custom] = contributionStatusChips([ + entry([{ key: "a", text: "x" }], { instance: "pi_work" }), + ]); + assert.strictEqual(custom!.origin, "From a Pi Work extension. It can lag a session change."); + }); + + it("renders no chips for no entries", () => { + assert.deepStrictEqual(contributionStatusChips([]), []); + }); +}); + +const config = (contributionStatus: boolean) => + ({ + environment: { + serverVersion: "0.0.1", + capabilities: contributionStatus ? { contributionStatus: true } : {}, + }, + }) as ServerConfig; + +/** Environments whose servers push status frames, read through the real client selector. */ +const makeHarness = Effect.fn("makeHarness")(function* () { + const makeServer = Effect.fn("makeServer")(function* (id: string, supported: boolean) { + const frames = yield* Queue.unbounded(); + let subscriptions = 0; + return { + environmentId: EnvironmentId.make(id), + config: Atom.make(config(supported)), + subscriptions: () => subscriptions, + push: (frame: ContributionStatusSnapshot) => Queue.offer(frames, frame), + frames: Stream.suspend(() => { + subscriptions += 1; + return Stream.fromQueue(frames); + }), + }; + }); + const servers = [ + yield* makeServer("env-a", true), + yield* makeServer("env-b", true), + yield* makeServer("env-old", false), + ] as const; + const byId = new Map(servers.map((server) => [server.environmentId, server])); + // Each environment's subscription reads its own server's frames. + const registryService = { + followStream: (environmentId: EnvironmentId) => byId.get(environmentId)!.frames, + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]; + const runtime = Atom.runtime( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, registryService), + ); + const atoms = createContributionStatusEnvironmentAtoms(runtime, { + configValueAtom: (environmentId) => byId.get(environmentId)!.config, + }); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const chipTexts = (environmentId: EnvironmentId, threadId = THREAD) => + contributionStatusChips(registry.get(atoms.threadStatus(environmentId, threadId))).map( + (chip) => chip.text, + ); + /** Waits until the header's chips match: the client-side receipt for a frame. */ + const waitForChips = (environmentId: EnvironmentId, expected: ReadonlyArray) => + AtomRegistry.toStream(registry, atoms.threadStatus(environmentId, THREAD)).pipe( + Stream.map((entries) => contributionStatusChips(entries).map((chip) => chip.text)), + Stream.filter((actual) => actual.join("\n") === expected.join("\n")), + Stream.runHead, + ); + const mount = (environmentId: EnvironmentId, threadId = THREAD) => + Effect.acquireRelease( + Effect.sync(() => registry.mount(atoms.threadStatus(environmentId, threadId))), + (unmount) => Effect.sync(unmount), + ); + return { servers, chipTexts, waitForChips, mount }; +}); + +describe("thread status chips from the client selector", () => { + it.effect("shows each environment's own statuses for the same thread id", () => + Effect.scoped( + Effect.gen(function* () { + const { servers, chipTexts, waitForChips, mount } = yield* makeHarness(); + const [a, b] = servers; + yield* mount(a.environmentId); + yield* mount(b.environmentId); + yield* mount(a.environmentId, OTHER_THREAD); + + yield* a.push({ + entries: [ + entry([{ key: "mode", text: "● plan" }]), + entry([{ key: "mode", text: "● other" }], { threadId: OTHER_THREAD }), + ], + }); + yield* b.push({ entries: [entry([{ key: "mode", text: "● build" }])] }); + yield* waitForChips(a.environmentId, ["● plan"]); + yield* waitForChips(b.environmentId, ["● build"]); + assert.deepStrictEqual(chipTexts(a.environmentId, OTHER_THREAD), ["● other"]); + }), + ), + ); + + it.effect("replaces and clears chips with each frame", () => + Effect.scoped( + Effect.gen(function* () { + const { servers, waitForChips, mount } = yield* makeHarness(); + const [a] = servers; + yield* mount(a.environmentId); + + yield* a.push({ entries: [entry([{ key: "mode", text: "● startup" }])] }); + yield* waitForChips(a.environmentId, ["● startup"]); + yield* a.push({ + entries: [ + entry([ + { key: "mode", text: "● resume" }, + { key: "tokens", text: "12k" }, + ]), + ], + }); + yield* waitForChips(a.environmentId, ["● resume", "12k"]); + yield* a.push({ entries: [] }); + yield* waitForChips(a.environmentId, []); + }), + ), + ); + + it.effect("shows nothing and never subscribes when the server lacks the capability", () => + Effect.scoped( + Effect.gen(function* () { + const { servers, chipTexts, waitForChips, mount } = yield* makeHarness(); + const [current, , old] = servers; + yield* mount(old.environmentId); + yield* mount(current.environmentId); + + yield* old.push({ entries: [entry([{ key: "mode", text: "● stale" }])] }); + // Once a supported environment has delivered, any subscription would have started. + yield* current.push({ entries: [entry([{ key: "mode", text: "● plan" }])] }); + yield* waitForChips(current.environmentId, ["● plan"]); + assert.strictEqual(old.subscriptions(), 0); + assert.deepStrictEqual(chipTexts(old.environmentId), []); + }), + ), + ); +}); diff --git a/apps/web/src/components/chat/ThreadContributionStatus.logic.ts b/apps/web/src/components/chat/ThreadContributionStatus.logic.ts new file mode 100644 index 000000000000..0393585f2345 --- /dev/null +++ b/apps/web/src/components/chat/ThreadContributionStatus.logic.ts @@ -0,0 +1,47 @@ +import { resolveProviderInstanceDisplayName } from "@t3tools/client-runtime/state/provider-instance-display"; +import { + type ContributionStatusEntry, + type ContributionStatusSource, + type ContributionStatusTone, + contributionStatusSourceKey, + ProviderDriverKind, +} from "@t3tools/contracts"; + +const PI_DRIVER = ProviderDriverKind.make("pi"); + +export interface ContributionStatusChip { + /** Source key plus item key, so a provider-session takeover re-keys the chip. */ + readonly id: string; + readonly text: string; + readonly tone: ContributionStatusTone; + readonly tooltip: string | null; + /** Static help naming where the status came from. Never claims it is current. */ + readonly origin: string; +} + +function contributionStatusOrigin(source: ContributionStatusSource): string { + const name = resolveProviderInstanceDisplayName({ + instanceId: source.providerInstanceId, + driver: source.driver, + }); + return source.driver === PI_DRIVER + ? `From a ${name} extension. It can lag a session change.` + : `From ${name}. It can lag a session change.`; +} + +/** One chip per status item, in the server's order. */ +export function contributionStatusChips( + entries: ReadonlyArray, +): ReadonlyArray { + return entries.flatMap((entry) => { + const sourceKey = contributionStatusSourceKey(entry.source); + const origin = contributionStatusOrigin(entry.source); + return entry.items.map((item) => ({ + id: JSON.stringify([sourceKey, item.key]), + text: item.text, + tone: item.tone ?? "neutral", + tooltip: item.tooltip || null, + origin, + })); + }); +} diff --git a/apps/web/src/components/chat/ThreadContributionStatus.test.tsx b/apps/web/src/components/chat/ThreadContributionStatus.test.tsx new file mode 100644 index 000000000000..e62607d06bd1 --- /dev/null +++ b/apps/web/src/components/chat/ThreadContributionStatus.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom + +import { + type ContributionStatusEntry, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ThreadId, +} from "@t3tools/contracts"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ + entries: [] as ReadonlyArray, +})); + +vi.mock("../../state/contributionStatus", () => ({ + useThreadContributionStatus: () => testState.entries, +})); + +import { ThreadContributionStatus } from "./ThreadContributionStatus"; + +const THREAD = ThreadId.make("thread-1"); +const ENVIRONMENT = EnvironmentId.make("environment-1"); +const PI_ORIGIN = "From a Pi extension. It can lag a session change."; + +const entry = (items: ContributionStatusEntry["items"]): ContributionStatusEntry => ({ + threadId: THREAD, + source: { + kind: "provider-session", + providerSessionId: ProviderSessionId.make("session-1"), + providerInstanceId: ProviderInstanceId.make("pi"), + driver: ProviderDriverKind.make("pi"), + }, + items, +}); + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + testState.entries = []; + vi.unstubAllGlobals(); +}); + +async function renderStatus(entries: ReadonlyArray) { + testState.entries = entries; + await act(async () => { + root.render(); + }); +} + +/** The trigger's accessible name: its explicit label, else its text. */ +function accessibleName(element: Element) { + return element.getAttribute("aria-label") ?? element.textContent ?? ""; +} + +function statusButton() { + const button = [...container.querySelectorAll("button")].find((candidate) => + accessibleName(candidate).startsWith("Provider status"), + ); + if (!button) throw new Error("Status button was not rendered"); + return button; +} + +function popupText() { + return document.querySelector('[data-slot="popover-popup"]')?.textContent ?? null; +} + +describe("ThreadContributionStatus", () => { + it("counts statuses past the inline ones and lists every one when tapped", async () => { + await renderStatus([ + entry([ + { key: "a", text: "● plan" }, + { key: "b", text: "build 3/9" }, + { key: "c", text: "lint clean", tone: "success" }, + { key: "d", text: "disk almost full", tone: "warning" }, + ]), + ]); + + const button = statusButton(); + expect(button.textContent).toBe("● planbuild 3/9+2"); + expect(popupText()).toBeNull(); + + await act(async () => { + button.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" }), + ); + button.click(); + }); + + const text = popupText(); + for (const status of ["● plan", "build 3/9", "lint clean", "disk almost full", PI_ORIGIN]) { + expect(text).toContain(status); + } + }); + + it("is a focusable native button, so keyboard users reach the same list", async () => { + await renderStatus([entry([{ key: "mode", text: "● plan" }])]); + + const button = statusButton(); + button.focus(); + expect(document.activeElement).toBe(button); + expect(button.tagName).toBe("BUTTON"); + }); + + it("names the control with every status text, even when an item has a tooltip", async () => { + const longText = `● ${"x".repeat(78)}`; + await renderStatus([ + entry([ + { key: "a", text: "Plan", tooltip: "12 files to change" }, + { key: "b", text: longText }, + { key: "c", text: "disk almost full", tone: "warning" }, + ]), + ]); + + expect(accessibleName(statusButton())).toBe( + `Provider status: Plan, ${longText}, disk almost full`, + ); + + await act(async () => statusButton().click()); + const text = popupText(); + expect(text).toContain("Plan"); + expect(text).toContain("12 files to change"); + expect(text).toContain(longText); + }); + + it("renders nothing without statuses", async () => { + await renderStatus([]); + expect(container.childElementCount).toBe(0); + }); +}); diff --git a/apps/web/src/components/chat/ThreadContributionStatus.tsx b/apps/web/src/components/chat/ThreadContributionStatus.tsx new file mode 100644 index 000000000000..404dcbe83252 --- /dev/null +++ b/apps/web/src/components/chat/ThreadContributionStatus.tsx @@ -0,0 +1,106 @@ +import type { ContributionStatusTone, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { memo, useMemo } from "react"; + +import { useThreadContributionStatus } from "../../state/contributionStatus"; +import { Badge } from "../ui/badge"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { + type ContributionStatusChip, + contributionStatusChips, +} from "./ThreadContributionStatus.logic"; + +/** Chips shown inline; the rest are counted in a "+N" badge and listed in the popover. */ +const VISIBLE_CHIPS = 2; + +const TONE_VARIANT = { + neutral: "outline", + info: "info", + success: "success", + warning: "warning", + error: "error", +} as const satisfies Record; + +const TONE_TEXT = { + neutral: "", + info: "text-info-foreground", + success: "text-success-foreground", + warning: "text-warning-foreground", + error: "text-destructive-foreground", +} as const satisfies Record; + +/** + * One trigger for every status: the first chips inline, then a "+N" count. + * Click, tap, Enter or hover opens a list with each status's full text, + * tooltip and origin, so a truncated or hidden status is always reachable. + */ +function ContributionStatusChips(props: { readonly chips: ReadonlyArray }) { + const { chips } = props; + const visible = chips.slice(0, VISIBLE_CHIPS); + const hiddenCount = chips.length - visible.length; + const origins = [...new Set(chips.map((chip) => chip.origin))]; + return ( + + chip.text).join(", ")}`} + data-thread-contribution-status + className="relative isolate flex min-w-0 max-w-[45%] shrink cursor-pointer items-center gap-1 rounded-sm outline-none before:pointer-events-none before:absolute before:inset-0 before:z-10 before:rounded-sm focus-visible:before:ring-2 focus-visible:before:ring-inset focus-visible:before:ring-ring pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11" + /> + } + > + {visible.map((chip) => ( + + {chip.text} + + ))} + {hiddenCount > 0 ? ( + + +{hiddenCount} + + ) : null} + + +
    + {chips.map((chip) => ( +
  • + + {chip.text} + + {chip.tooltip ? ( + {chip.tooltip} + ) : null} +
  • + ))} +
+ {origins.map((origin) => ( +

+ {origin} +

+ ))} +
+
+ ); +} + +/** + * Advisory statuses a provider (today, Pi extensions) set on the open thread. + * Renders nothing when there are none or the server predates the channel. + */ +export const ThreadContributionStatus = memo(function ThreadContributionStatus(props: { + environmentId: EnvironmentId; + threadId: ThreadId; +}) { + const entries = useThreadContributionStatus(props.environmentId, props.threadId); + const chips = useMemo(() => contributionStatusChips(entries), [entries]); + if (chips.length === 0) return null; + return ; +}); diff --git a/apps/web/src/state/contributionStatus.ts b/apps/web/src/state/contributionStatus.ts new file mode 100644 index 000000000000..20192a2f38f6 --- /dev/null +++ b/apps/web/src/state/contributionStatus.ts @@ -0,0 +1,19 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createContributionStatusEnvironmentAtoms } from "@t3tools/client-runtime/state/contribution-status"; +import type { ContributionStatusEntry, EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { serverEnvironment } from "./server"; + +const contributionStatusEnvironment = createContributionStatusEnvironmentAtoms( + connectionAtomRuntime, + { configValueAtom: serverEnvironment.configValueAtom }, +); + +/** A thread's advisory statuses, empty when its server lacks the capability. */ +export function useThreadContributionStatus( + environmentId: EnvironmentId, + threadId: ThreadId, +): ReadonlyArray { + return useAtomValue(contributionStatusEnvironment.threadStatus(environmentId, threadId)); +} diff --git a/docs/user/providers-pi.md b/docs/user/providers-pi.md index 123258f6ccb4..ab024f4812ea 100644 --- a/docs/user/providers-pi.md +++ b/docs/user/providers-pi.md @@ -48,8 +48,10 @@ Pi skills appear in the composer's `$` menu. This includes user skills and proje loads for the current workspace; selecting one uses Pi's native skill expansion. Pi loads its normal user and project extensions. Blocking `select`, `confirm`, `input`, and `editor` -dialogs work in T3 Code. Notifications appear in the work log. Pi terminal decoration such as -titles, status lines, and widgets does not have a T3 Code equivalent. +dialogs work in T3 Code. Notifications appear in the work log. In the web and desktop apps, +extension statuses (`setStatus`) appear beside the thread title. They are hints from the running +extensions and can lag a session change, such as a resume or fork, until the extension updates +them. Pi terminal decoration such as titles and widgets does not have a T3 Code equivalent. ## Permission Modes diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 672f19927026..f774b3bd36c1 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -175,6 +175,10 @@ "types": "./src/state/presentation.ts", "default": "./src/state/presentation.ts" }, + "./state/contribution-status": { + "types": "./src/state/contributionStatus.ts", + "default": "./src/state/contributionStatus.ts" + }, "./state/device": { "types": "./src/state/device.ts", "default": "./src/state/device.ts" diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index c11a49897b88..d5c2fe2cfd51 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -65,6 +65,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeDiscoveredLocalServers | typeof WS_METHODS.subscribeDeviceState | typeof WS_METHODS.subscribeResourceTelemetry + | typeof WS_METHODS.subscribeContributionStatus | typeof WS_METHODS.pullRequestsSubscribeRefreshes | typeof WS_METHODS.subscribeVcsStatus | typeof WS_METHODS.subscribeWorktreeSetup diff --git a/packages/client-runtime/src/state/contributionStatus.test.ts b/packages/client-runtime/src/state/contributionStatus.test.ts new file mode 100644 index 000000000000..1dc9df8ebc6f --- /dev/null +++ b/packages/client-runtime/src/state/contributionStatus.test.ts @@ -0,0 +1,241 @@ +import { + type ContributionStatusSnapshot, + contributionStatusSourceKey, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + type ServerConfig, + ThreadId, + WS_METHODS, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { Atom, AtomRegistry } from "effect/reactivity"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { createContributionStatusEnvironmentAtoms } from "./contributionStatus.ts"; + +const THREAD = ThreadId.make("thread-1"); + +const OTHER_THREAD = ThreadId.make("thread-2"); + +const entry = (text: string, session = `session-${text}`, threadId = THREAD) => ({ + threadId, + source: { + kind: "provider-session" as const, + providerSessionId: ProviderSessionId.make(session), + providerInstanceId: ProviderInstanceId.make("pi"), + driver: ProviderDriverKind.make("pi"), + }, + items: [{ key: "mode", text }], +}); + +const snapshot = (text: string): ContributionStatusSnapshot => ({ entries: [entry(text)] }); + +const config = (contributionStatus: boolean) => + ({ + environment: { + serverVersion: "0.0.1", + capabilities: contributionStatus ? { contributionStatus: true } : {}, + }, + }) as ServerConfig; + +/** One environment whose server pushes status snapshots from a queue. */ +const makeEnvironment = Effect.fn("makeEnvironment")(function* (id: string, supported: boolean) { + const environmentId = EnvironmentId.make(id); + let subscriptions = 0; + const makeSession = Effect.fn("makeSession")(function* () { + const frames = yield* Queue.unbounded(); + const client = { + [WS_METHODS.subscribeContributionStatus]: () => { + subscriptions += 1; + return Stream.fromQueue(frames); + }, + } as unknown as WsRpcProtocolClient; + const session: RpcSession = { + client, + initialConfig: Effect.succeed(config(supported)), + subscribeServerConfig: () => Stream.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + return { session, push: (frame: ContributionStatusSnapshot) => Queue.offer(frames, frame) }; + }); + const first = yield* makeSession(); + const session = yield* SubscriptionRef.make(Option.some(first.session)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: new PrimaryConnectionTarget({ + environmentId, + label: id, + httpBaseUrl: `https://${id}.example.test`, + wsBaseUrl: `wss://${id}.example.test`, + }), + state: yield* SubscriptionRef.make({ + ...AVAILABLE_CONNECTION_STATE, + phase: "connected", + }), + session, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + return { + environmentId, + supervisor, + config: Atom.make(config(supported)), + subscriptions: () => subscriptions, + push: first.push, + /** Swaps in a new connection, as a reconnect does, and returns its push. */ + reconnect: Effect.gen(function* () { + const next = yield* makeSession(); + yield* SubscriptionRef.set(session, Option.some(next.session)); + return next.push; + }), + }; +}); + +const makeHarness = Effect.fn("makeHarness")(function* () { + const environments = [ + yield* makeEnvironment("env-a", true), + yield* makeEnvironment("env-b", true), + yield* makeEnvironment("env-old", false), + ] as const; + const byId = new Map(environments.map((environment) => [environment.environmentId, environment])); + const supervisorFor = (environmentId: EnvironmentId) => byId.get(environmentId)!.supervisor; + const registryService = EnvironmentRegistry.EnvironmentRegistry.of({ + run: (environmentId, effect) => + Effect.provideService( + effect, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisorFor(environmentId), + ), + followStream: (environmentId, stream) => + Stream.provideService( + stream, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisorFor(environmentId), + ), + } as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const runtime = Atom.runtime( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, registryService), + ); + const atoms = createContributionStatusEnvironmentAtoms(runtime, { + configValueAtom: (environmentId) => byId.get(environmentId)!.config, + }); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const texts = (environmentId: EnvironmentId) => + registry + .get(atoms.threadStatus(environmentId, THREAD)) + .flatMap((entry) => entry.items.map((item) => item.text)); + /** Waits until a thread's rendered texts match, the client-side receipt for a frame. */ + const waitForTexts = (environmentId: EnvironmentId, expected: ReadonlyArray) => + AtomRegistry.toStream(registry, atoms.threadStatus(environmentId, THREAD)).pipe( + Stream.map((entries) => entries.flatMap((entry) => entry.items.map((item) => item.text))), + Stream.filter((actual) => actual.join("\n") === expected.join("\n")), + Stream.runHead, + ); + return { environments, atoms, registry, texts, waitForTexts }; +}); + +it.effect("keeps each environment's statuses apart and replaces them on reconnect", () => + Effect.scoped( + Effect.gen(function* () { + const { environments, atoms, registry, texts, waitForTexts } = yield* makeHarness(); + const [a, b] = environments; + const unmountA = registry.mount(atoms.threadStatus(a.environmentId, THREAD)); + const unmountB = registry.mount(atoms.threadStatus(b.environmentId, THREAD)); + + yield* a.push(snapshot("plan")); + yield* b.push(snapshot("build")); + yield* waitForTexts(a.environmentId, ["plan"]); + yield* waitForTexts(b.environmentId, ["build"]); + + // The status ended while disconnected; the new connection's first frame drops it. + const pushAfterReconnect = yield* a.reconnect; + yield* pushAfterReconnect({ entries: [] }); + yield* waitForTexts(a.environmentId, []); + assert.strictEqual(a.subscriptions(), 2); + assert.deepStrictEqual(texts(b.environmentId), ["build"]); + unmountA(); + unmountB(); + }), + ), +); + +it.effect("never subscribes to a server without the capability", () => + Effect.scoped( + Effect.gen(function* () { + const { environments, atoms, registry, waitForTexts } = yield* makeHarness(); + const [current, , old] = environments; + const unmountOld = registry.mount(atoms.threadStatus(old.environmentId, THREAD)); + const unmountCurrent = registry.mount(atoms.threadStatus(current.environmentId, THREAD)); + + // Once a supported environment has delivered, any subscription would have started. + yield* current.push(snapshot("plan")); + yield* waitForTexts(current.environmentId, ["plan"]); + assert.strictEqual(old.subscriptions(), 0); + assert.deepStrictEqual(registry.get(atoms.threadStatus(old.environmentId, THREAD)), []); + unmountOld(); + unmountCurrent(); + }), + ), +); + +it.effect("keeps each entry's source so a takeover with the same text re-keys the row", () => + Effect.scoped( + Effect.gen(function* () { + const { environments, atoms, registry } = yield* makeHarness(); + const [a] = environments; + const status = atoms.threadStatus(a.environmentId, THREAD); + const unmount = registry.mount(status); + const waitForSession = (session: string) => + AtomRegistry.toStream(registry, status).pipe( + Stream.filter((entries) => entries[0]?.source.providerSessionId === session), + Stream.runHead, + ); + + yield* a.push({ entries: [entry("plan", "session-old")] }); + yield* waitForSession("session-old"); + const before = registry.get(status); + + // Another thread changing keeps this thread's array, so its row does not re-render. + yield* a.push({ + entries: [entry("plan", "session-old"), entry("x", "session-x", OTHER_THREAD)], + }); + yield* AtomRegistry.toStream(registry, atoms.snapshot(a.environmentId)).pipe( + Stream.filter((snapshot) => snapshot.entries.length === 2), + Stream.runHead, + ); + assert.strictEqual(registry.get(status), before); + + yield* a.push({ entries: [entry("plan", "session-new")] }); + yield* waitForSession("session-new"); + const after = registry.get(status); + assert.notStrictEqual(after, before); + assert.notStrictEqual( + contributionStatusSourceKey(after[0]!.source), + contributionStatusSourceKey(before[0]!.source), + ); + unmount(); + }), + ), +); diff --git a/packages/client-runtime/src/state/contributionStatus.ts b/packages/client-runtime/src/state/contributionStatus.ts new file mode 100644 index 000000000000..e35f1d52b64e --- /dev/null +++ b/packages/client-runtime/src/state/contributionStatus.ts @@ -0,0 +1,108 @@ +import { + type ContributionStatusEntry, + type ContributionStatusSnapshot, + contributionStatusSourceKey, + type EnvironmentId, + type ServerConfig, + type ThreadId, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createEnvironmentRpcSubscriptionAtomFamily } from "./runtime.ts"; + +const EMPTY_SNAPSHOT: ContributionStatusSnapshot = { entries: [] }; +const NO_ENTRIES: ReadonlyArray = []; + +/** Older servers lack the stream; clients show nothing for them and never subscribe. */ +function supportsContributionStatus(config: ServerConfig | null): boolean { + return config?.environment.capabilities.contributionStatus === true; +} + +/** A thread's entries, one per source, in the server's order. */ +function threadContributionStatusEntries( + snapshot: ContributionStatusSnapshot, + threadId: ThreadId, +): ReadonlyArray { + const entries = snapshot.entries.filter((entry) => entry.threadId === threadId); + return entries.length === 0 ? NO_ENTRIES : entries; +} + +/** Equal when every entry has the same source identity and the same items. */ +function sameEntries( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((entry, index) => { + const other = right[index]; + return ( + other !== undefined && + contributionStatusSourceKey(entry.source) === contributionStatusSourceKey(other.source) && + entry.items.length === other.items.length && + entry.items.every((item, itemIndex) => { + const otherItem = other.items[itemIndex]; + return ( + otherItem !== undefined && + item.key === otherItem.key && + item.text === otherItem.text && + item.tone === otherItem.tone && + item.tooltip === otherItem.tooltip + ); + }) + ); + }) + ); +} + +/** + * Live thread statuses per environment for the web and mobile renderers. + * Nothing subscribes until a renderer reads an atom, and only against servers + * that advertise the capability. Each server frame is a full replacement, so a + * reconnect's first frame drops statuses that ended while disconnected. + * + * `threadStatus` keeps each entry's source: renderers key an entry by + * `contributionStatusSourceKey(entry.source)` and an item by that plus its + * key, so a provider session taking over a thread re-keys its rows even when + * the text is unchanged. + */ +export function createContributionStatusEnvironmentAtoms( + runtime: Atom.AtomRuntime, + options: { + readonly configValueAtom: (environmentId: EnvironmentId) => Atom.Atom; + }, +) { + const subscription = createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:contribution-status", + tag: WS_METHODS.subscribeContributionStatus, + }); + const snapshotAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): ContributionStatusSnapshot => { + if (!supportsContributionStatus(get(options.configValueAtom(environmentId)))) { + return EMPTY_SNAPSHOT; + } + return Option.getOrElse( + AsyncResult.value(get(subscription({ environmentId, input: {} }))), + () => EMPTY_SNAPSHOT, + ); + }).pipe(Atom.withLabel(`environment-data:contribution-status:snapshot:${environmentId}`)), + ); + const threadStatusFamily = Atom.family((key: string) => { + const [environmentId, threadId] = JSON.parse(key) as [EnvironmentId, ThreadId]; + // A change on another thread keeps this thread's array, so its row does not re-render. + let previous = NO_ENTRIES; + return Atom.make((get) => { + const next = threadContributionStatusEntries(get(snapshotAtom(environmentId)), threadId); + if (!sameEntries(previous, next)) previous = next; + return previous; + }).pipe(Atom.withLabel(`environment-data:contribution-status:thread:${key}`)); + }); + return { + snapshot: snapshotAtom, + threadStatus: (environmentId: EnvironmentId, threadId: ThreadId) => + threadStatusFamily(JSON.stringify([environmentId, threadId])), + }; +} diff --git a/packages/contracts/src/contributionStatus.test.ts b/packages/contracts/src/contributionStatus.test.ts new file mode 100644 index 000000000000..ba18aaed1e38 --- /dev/null +++ b/packages/contracts/src/contributionStatus.test.ts @@ -0,0 +1,47 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { ContributionStatusSnapshot } from "./contributionStatus.ts"; + +const decodeSnapshot = Schema.decodeUnknownSync(ContributionStatusSnapshot); + +const piEntry = (items: ReadonlyArray) => ({ + threadId: "thread-1", + source: { + kind: "provider-session", + providerSessionId: "session-1", + providerInstanceId: "pi", + driver: "pi", + }, + items, +}); + +describe("ContributionStatusSnapshot", () => { + it("decodes a tone from a newer server as neutral instead of dropping the item", () => { + const snapshot = decodeSnapshot({ + entries: [piEntry([{ key: "build", text: "Building", tone: "celebrate" }])], + }); + expect(snapshot.entries[0]?.items).toEqual([ + { key: "build", text: "Building", tone: "neutral" }, + ]); + }); + + it("drops entries an older client cannot decode and keeps the rest", () => { + const snapshot = decodeSnapshot({ + entries: [ + { + ...piEntry([{ key: "a", text: "From a plugin" }]), + source: { kind: "plugin", pluginId: "x" }, + }, + piEntry([{ key: "a", text: "x".repeat(81) }]), + piEntry([{ key: "mode", text: "plan" }]), + ], + }); + expect(snapshot.entries.map((entry) => entry.items)).toEqual([[{ key: "mode", text: "plan" }]]); + }); + + it("rejects an entry with more items than one source may set", () => { + const items = Array.from({ length: 9 }, (_, index) => ({ key: `k${index}`, text: "on" })); + expect(decodeSnapshot({ entries: [piEntry(items)] }).entries).toEqual([]); + }); +}); diff --git a/packages/contracts/src/contributionStatus.ts b/packages/contracts/src/contributionStatus.ts new file mode 100644 index 000000000000..7f0ef1b73a4f --- /dev/null +++ b/packages/contracts/src/contributionStatus.ts @@ -0,0 +1,104 @@ +import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; + +import { ForwardCompatibleArray, ProviderSessionId, ThreadId } from "./baseSchemas.ts"; +import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; + +/** + * Short status text a producer attaches to a thread, such as a Pi + * extension's `ctx.ui.setStatus(key, text)`. The server owns these in memory + * only: they are never persisted and disappear with their producer. + */ +export const CONTRIBUTION_STATUS_KEY_MAX_LENGTH = 64; +export const CONTRIBUTION_STATUS_TEXT_MAX_LENGTH = 80; +export const CONTRIBUTION_STATUS_TOOLTIP_MAX_LENGTH = 240; +export const CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE = 8; +/** + * Server-owned limits on a whole snapshot. They bound counts, not bytes: frame + * size also depends on identifier lengths and on how JSON encodes the text. + * Clients never enforce them; raising one needs a new capability. + */ +export const CONTRIBUTION_STATUS_MAX_SOURCES_PER_THREAD = 4; +export const CONTRIBUTION_STATUS_MAX_THREADS = 64; +export const CONTRIBUTION_STATUS_MAX_ITEMS = 128; + +const CONTRIBUTION_STATUS_TONES = ["neutral", "info", "success", "warning", "error"] as const; +const ContributionStatusToneLiteral = Schema.Literals(CONTRIBUTION_STATUS_TONES); +const isContributionStatusTone = Schema.is(ContributionStatusToneLiteral); + +/** Tones a newer server adds decode as `neutral` instead of dropping the item. */ +export const ContributionStatusTone = Schema.String.pipe( + Schema.decodeTo( + ContributionStatusToneLiteral, + SchemaTransformation.transform({ + decode: (tone) => (isContributionStatusTone(tone) ? tone : "neutral"), + encode: (tone) => tone, + }), + ), +); +export type ContributionStatusTone = typeof ContributionStatusTone.Type; + +export const ContributionStatusItem = Schema.Struct({ + /** Stable per source; setting an existing key replaces its item. */ + key: Schema.String.check( + Schema.isNonEmpty(), + Schema.isMaxLength(CONTRIBUTION_STATUS_KEY_MAX_LENGTH), + ), + /** Single line of plain text. Producers strip control characters and ANSI styling. */ + text: Schema.String.check( + Schema.isNonEmpty(), + Schema.isMaxLength(CONTRIBUTION_STATUS_TEXT_MAX_LENGTH), + ), + /** Absent means neutral. */ + tone: Schema.optionalKey(ContributionStatusTone), + tooltip: Schema.optionalKey( + Schema.String.check(Schema.isMaxLength(CONTRIBUTION_STATUS_TOOLTIP_MAX_LENGTH)), + ), +}); +export type ContributionStatusItem = typeof ContributionStatusItem.Type; + +/** + * Who set an entry's items. A thread has at most one provider-session source: + * a new provider session on the thread takes over the previous one's entry. + * Plugin sources are reserved for a later `kind`; each plugin will own its own + * entry beside the provider's. + */ +export const ContributionStatusSource = Schema.Struct({ + kind: Schema.Literal("provider-session"), + providerSessionId: ProviderSessionId, + providerInstanceId: ProviderInstanceId, + driver: ProviderDriverKind, +}); +export type ContributionStatusSource = typeof ContributionStatusSource.Type; + +/** + * Identity of a source, stable for its lifetime and distinct across a takeover. + * Renderers key an entry by it and an item by it plus the item key. + */ +export const contributionStatusSourceKey = (source: ContributionStatusSource): string => + JSON.stringify([source.kind, source.providerInstanceId, source.providerSessionId]); + +/** One source's items on one thread. Entry identity is the thread plus the source. */ +export const ContributionStatusEntry = Schema.Struct({ + threadId: ThreadId, + source: ContributionStatusSource, + /** Sorted by key, at most CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE, never empty. */ + items: Schema.Array(ContributionStatusItem).check( + Schema.isMinLength(1), + Schema.isMaxLength(CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE), + ), +}); +export type ContributionStatusEntry = typeof ContributionStatusEntry.Type; + +/** + * Every live status in one environment, ordered by thread id, then provider + * sources before any other kind, then source key. `subscribeContributionStatus` + * sends one on subscribe and a full replacement after each change, so a + * client replaces its copy and never merges. Entries an older client cannot + * decode, such as a future source kind, are dropped rather than failing the + * stream. + */ +export const ContributionStatusSnapshot = Schema.Struct({ + entries: ForwardCompatibleArray(ContributionStatusEntry), +}); +export type ContributionStatusSnapshot = typeof ContributionStatusSnapshot.Type; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index a4763076639b..60ebc71014d2 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -220,6 +220,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ "server"`) and streams them over `/api/preview-stream`. Clients without a local browser runtime open server tabs here. */ serverBrowser: Schema.optionalKey(Schema.Boolean), + /** Server serves subscribeContributionStatus. Absent on older servers, which + lack the method, so clients must not subscribe and show no statuses. */ + contributionStatus: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 600d503ade39..b9feb676a04c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -57,6 +57,7 @@ export * from "./device.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; +export * from "./contributionStatus.ts"; export * from "./usage.ts"; export * from "./scheduledTask.ts"; export * from "./worktreeMcp.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 61304c52f860..7549874ab865 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -26,6 +26,7 @@ import * as Rpc from "effect/rpc/Rpc"; import * as RpcGroup from "effect/rpc/RpcGroup"; import * as RpcMiddleware from "effect/rpc/RpcMiddleware"; import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ContributionStatusSnapshot } from "./contributionStatus.ts"; import { CodexAuthCallbackInput, CodexAuthCallbackState, @@ -584,6 +585,7 @@ export const WS_METHODS = { subscribeAuthAccess: "subscribeAuthAccess", subscribeBackgroundPolicy: "subscribeBackgroundPolicy", subscribeResourceTelemetry: "subscribeResourceTelemetry", + subscribeContributionStatus: "subscribeContributionStatus", } as const; const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { @@ -1865,6 +1867,14 @@ const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTel stream: true, }); +/** Streams every live thread status in the environment: one snapshot on subscribe, then a full replacement after each change. Gated by the `contributionStatus` capability. */ +const WsSubscribeContributionStatusRpc = Rpc.make(WS_METHODS.subscribeContributionStatus, { + payload: Schema.Struct({}), + success: ContributionStatusSnapshot, + error: EnvironmentAuthorizationError, + stream: true, +}); + /** * Checks the connection's scopes against the scope each RPC declares, before * the handler runs. Every RPC in `WsRpcGroup` carries it, so a handler cannot @@ -2054,6 +2064,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeAuthAccessRpc, WsSubscribeBackgroundPolicyRpc, WsSubscribeResourceTelemetryRpc, + WsSubscribeContributionStatusRpc, WsOrchestrationV2DispatchCommandRpc, WsOrchestrationV2GetWorkflowScriptRpc, WsOrchestrationV2StopWorkflowRpc, diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json index c45de9a9b149..5586621a8613 100644 --- a/packages/provider-core/package.json +++ b/packages/provider-core/package.json @@ -23,6 +23,10 @@ "types": "./src/server/collectStreamText.ts", "import": "./src/server/collectStreamText.ts" }, + "./server/ContributionStatusStore": { + "types": "./src/server/ContributionStatusStore.ts", + "import": "./src/server/ContributionStatusStore.ts" + }, "./server/driver": { "types": "./src/server/driver.ts", "import": "./src/server/driver.ts" diff --git a/packages/provider-core/src/server/ContributionStatusStore.test.ts b/packages/provider-core/src/server/ContributionStatusStore.test.ts new file mode 100644 index 000000000000..16ef272350bc --- /dev/null +++ b/packages/provider-core/src/server/ContributionStatusStore.test.ts @@ -0,0 +1,280 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + CONTRIBUTION_STATUS_MAX_ITEMS, + CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE, + CONTRIBUTION_STATUS_MAX_THREADS, + type ContributionStatusSource, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as ContributionStatusStore from "./ContributionStatusStore.ts"; + +const THREAD_A = ThreadId.make("thread-a"); +const THREAD_B = ThreadId.make("thread-b"); + +const source = (session: string): ContributionStatusSource => ({ + kind: "provider-session", + providerSessionId: ProviderSessionId.make(session), + providerInstanceId: ProviderInstanceId.make("pi"), + driver: ProviderDriverKind.make("pi"), +}); + +/** Opens a handle in its own scope so a test can end that producer on demand. */ +const openHandle = Effect.fn("openHandle")(function* ( + store: ContributionStatusStore.ContributionStatusStoreShape, + session: string, + threadId: ThreadId | null, +) { + const scope = yield* Scope.make(); + const handle = yield* store.openSource(source(session)).pipe(Scope.provide(scope)); + yield* handle.bindThread(threadId); + return { handle, close: Scope.close(scope, Exit.void) }; +}); + +const itemsByThread = (store: ContributionStatusStore.ContributionStatusStoreShape) => + Effect.map(store.snapshot, (snapshot) => + Object.fromEntries( + snapshot.entries.map((entry) => [ + entry.threadId, + entry.items.map((item) => `${item.key}=${item.text}`), + ]), + ), + ); + +describe("ContributionStatusStore", () => { + it.effect("sets, replaces, and clears statuses as plain single-line text", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const { handle } = yield* openHandle(store, "session-1", THREAD_A); + + yield* handle.set({ key: "mode", text: "\u001b[32m● plan\u001b[39m\nmode" }); + yield* handle.set({ key: "branch", text: "main" }); + assert.deepStrictEqual(yield* itemsByThread(store), { + [THREAD_A]: ["branch=main", "mode=● plan mode"], + }); + + yield* handle.set({ key: "mode", text: "build" }); + yield* handle.clear("branch"); + assert.deepStrictEqual(yield* itemsByThread(store), { [THREAD_A]: ["mode=build"] }); + + // A lone surrogate would encode as a six-byte JSON escape; it becomes U+FFFD. + yield* handle.set({ key: "lone", text: "a\uD800b", tooltip: "\uDC00" }); + const lone = (yield* store.snapshot).entries[0]?.items.find((item) => item.key === "lone"); + assert.deepStrictEqual(lone, { key: "lone", text: "a\uFFFDb", tooltip: "\uFFFD" }); + + yield* handle.set({ key: "mode", text: " \t " }); + yield* handle.clear("lone"); + assert.deepStrictEqual(yield* itemsByThread(store), {}); + }), + ); + + it.effect("bounds keys, text, and items per source", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const { handle } = yield* openHandle(store, "session-1", THREAD_A); + + for (let index = 0; index < 9; index += 1) { + yield* handle.set({ key: `k${index}`, text: "on" }); + } + yield* handle.set({ key: "k0", text: `${"a".repeat(78)}😀tail` }); + + const [entry] = (yield* store.snapshot).entries; + assert.strictEqual(entry?.items.length, 8); + assert.isFalse(entry?.items.some((item) => item.key === "k8")); + // The emoji would straddle the limit, so it is dropped rather than split. + assert.strictEqual(entry?.items[0]?.text, `${"a".repeat(78)}…`); + }), + ); + + it.effect("treats keys as identities that never collide", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const { handle } = yield* openHandle(store, "session-1", THREAD_A); + const prefix = "x".repeat(63); + + yield* handle.set({ key: `${prefix}A`, text: "first" }); + yield* handle.set({ key: `${prefix}B`, text: "second" }); + yield* handle.set({ key: "a b", text: "spaced" }); + yield* handle.set({ key: "a b", text: "double spaced" }); + // Overlong or control-character keys are rejected, never rewritten into another key. + yield* handle.set({ key: `${prefix}AB`, text: "overlong" }); + yield* handle.set({ key: "a\nb", text: "control" }); + yield* handle.set({ key: "a\uD800", text: "lone surrogate" }); + assert.deepStrictEqual(yield* itemsByThread(store), { + [THREAD_A]: ["a b=double spaced", "a b=spaced", `${prefix}A=first`, `${prefix}B=second`], + }); + + // Clearing applies the same rule, so it cannot remove a neighbouring key. + yield* handle.clear(`${prefix}AB`); + yield* handle.clear("a\tb"); + yield* handle.clear("a\uD800"); + yield* handle.clear(`${prefix}A`); + yield* handle.clear("a b"); + assert.deepStrictEqual(yield* itemsByThread(store), { + [THREAD_A]: ["a b=double spaced", `${prefix}B=second`], + }); + }), + ); + + it.effect("clears a producer's statuses when its scope closes and ignores it afterwards", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const { handle, close } = yield* openHandle(store, "session-1", THREAD_A); + yield* handle.set({ key: "mode", text: "plan" }); + + yield* close; + yield* handle.set({ key: "mode", text: "late" }); + yield* handle.bindThread(THREAD_B); + + assert.deepStrictEqual(yield* itemsByThread(store), {}); + }), + ); + + it.effect("moves a producer between threads and keeps items when rebinding its own thread", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const { handle } = yield* openHandle(store, "session-1", THREAD_A); + yield* handle.set({ key: "mode", text: "plan" }); + + yield* handle.bindThread(THREAD_A); + assert.deepStrictEqual(yield* itemsByThread(store), { [THREAD_A]: ["mode=plan"] }); + + yield* handle.bindThread(THREAD_B); + yield* handle.set({ key: "mode", text: "fork" }); + assert.deepStrictEqual(yield* itemsByThread(store), { [THREAD_B]: ["mode=fork"] }); + }), + ); + + it.effect("rejects a replaced producer so late updates cannot touch the replacement", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const old = yield* openHandle(store, "session-old", THREAD_A); + yield* old.handle.set({ key: "mode", text: "old" }); + + const replacement = yield* openHandle(store, "session-new", THREAD_A); + assert.deepStrictEqual(yield* itemsByThread(store), {}); + yield* replacement.handle.set({ key: "mode", text: "new" }); + + yield* old.handle.set({ key: "mode", text: "stale" }); + yield* old.handle.clear("mode"); + yield* old.handle.clearAll; + yield* old.close; + + const snapshot = yield* store.snapshot; + assert.deepStrictEqual(yield* itemsByThread(store), { [THREAD_A]: ["mode=new"] }); + assert.strictEqual( + snapshot.entries[0]?.source.providerSessionId, + ProviderSessionId.make("session-new"), + ); + }), + ); + + it.effect("gives each subscriber the current state, then only the latest change", () => + Effect.scoped( + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const { handle } = yield* openHandle(store, "session-1", THREAD_A); + yield* handle.set({ key: "mode", text: "plan" }); + + const subscription = yield* store.subscribe; + assert.deepStrictEqual( + subscription.latest.entries.map((entry) => entry.threadId), + [THREAD_A], + ); + + yield* handle.set({ key: "mode", text: "build" }); + yield* handle.set({ key: "mode", text: "review" }); + yield* handle.set({ key: "turn", text: "3" }); + const next = yield* Stream.runHead(subscription.changes); + assert.deepStrictEqual( + next._tag === "Some" ? next.value.entries[0]?.items.map((item) => item.text) : [], + ["review", "3"], + ); + + // A reconnect is a fresh subscription; its first frame is the current state. + const reconnect = yield* store.subscribe; + assert.deepStrictEqual(reconnect.latest, yield* store.snapshot); + }), + ), + ); + + it.effect("caps threads showing a status without counting silent producers", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const max = CONTRIBUTION_STATUS_MAX_THREADS; + // More bound producers than the cap that never set anything take no capacity. + for (let index = 0; index <= max; index += 1) { + yield* openHandle(store, `silent-${index}`, ThreadId.make(`silent-${index}`)); + } + const visible = []; + for (let index = 0; index < max; index += 1) { + const opened = yield* openHandle(store, `visible-${index}`, ThreadId.make(`t-${index}`)); + yield* opened.handle.set({ key: "mode", text: "on" }); + visible.push(opened.handle); + } + assert.strictEqual((yield* store.snapshot).entries.length, max); + + const late = yield* openHandle(store, "late", ThreadId.make("late")); + yield* late.handle.set({ key: "mode", text: "rejected" }); + assert.isUndefined( + (yield* store.snapshot).entries.find((entry) => entry.threadId === "late"), + ); + + // Clearing a thread's last item returns its capacity, and the rejected producer's next set lands. + yield* visible[0]!.clear("mode"); + yield* late.handle.set({ key: "mode", text: "admitted" }); + const snapshot = yield* store.snapshot; + assert.strictEqual(snapshot.entries.length, max); + assert.deepStrictEqual(snapshot.entries.find((entry) => entry.threadId === "late")?.items, [ + { key: "mode", text: "admitted" }, + ]); + // A thread already showing a status can still replace it at the cap. + yield* visible[1]!.set({ key: "mode", text: "replaced" }); + assert.strictEqual( + (yield* store.snapshot).entries.find((entry) => entry.threadId === "t-1")?.items[0]?.text, + "replaced", + ); + }), + ); + + it.effect("orders entries by thread and caps the items in one snapshot", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const fullThreads = CONTRIBUTION_STATUS_MAX_ITEMS / CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE; + const handles = []; + // Bind in reverse so the snapshot order cannot come from insertion order. + for (let index = fullThreads; index >= 0; index -= 1) { + const opened = yield* openHandle(store, `s-${index}`, ThreadId.make(`t-${index}`)); + handles[index] = opened.handle; + } + for (let index = 0; index <= fullThreads; index += 1) { + for (let item = 0; item < CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE; item += 1) { + yield* handles[index]!.set({ key: `k${item}`, text: "on" }); + } + } + const snapshot = yield* store.snapshot; + const threadIds = snapshot.entries.map((entry) => entry.threadId); + assert.deepStrictEqual(threadIds, threadIds.toSorted()); + assert.strictEqual(threadIds.length, fullThreads); + assert.notInclude(threadIds, ThreadId.make(`t-${fullThreads}`)); + assert.strictEqual( + snapshot.entries.reduce((total, entry) => total + entry.items.length, 0), + CONTRIBUTION_STATUS_MAX_ITEMS, + ); + + yield* handles[0]!.clear("k0"); + yield* handles[fullThreads]!.set({ key: "k0", text: "admitted" }); + assert.include( + (yield* store.snapshot).entries.map((entry) => entry.threadId), + ThreadId.make(`t-${fullThreads}`), + ); + }), + ); +}); diff --git a/packages/provider-core/src/server/ContributionStatusStore.ts b/packages/provider-core/src/server/ContributionStatusStore.ts new file mode 100644 index 000000000000..8730f2f6b388 --- /dev/null +++ b/packages/provider-core/src/server/ContributionStatusStore.ts @@ -0,0 +1,347 @@ +import * as NodeUtil from "node:util"; + +import { + CONTRIBUTION_STATUS_KEY_MAX_LENGTH, + CONTRIBUTION_STATUS_MAX_ITEMS, + CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE, + CONTRIBUTION_STATUS_MAX_SOURCES_PER_THREAD, + CONTRIBUTION_STATUS_MAX_THREADS, + CONTRIBUTION_STATUS_TEXT_MAX_LENGTH, + CONTRIBUTION_STATUS_TOOLTIP_MAX_LENGTH, + type ContributionStatusItem, + type ContributionStatusSnapshot, + type ContributionStatusSource, + type ContributionStatusTone, + contributionStatusSourceKey, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import type * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +/** + * One producer's statuses. A handle writes to at most one thread at a time and + * is retired when the scope that opened it closes, clearing what it set. After + * another handle with the same kind of source binds the same thread, this one + * is stale: its writes are ignored until it binds again, so a late update from + * a replaced provider session never reaches the replacement's statuses. + */ +export interface ContributionStatusSourceHandle { + /** + * Moves this handle to `threadId`, clearing what it set on its previous + * thread. Rebinding the thread it still owns keeps its items; binding a + * thread where another provider session owns the provider entry takes that + * entry over and clears its items. + */ + readonly bindThread: (threadId: ThreadId | null) => Effect.Effect; + /** Sets or replaces `key`. Text that is empty after normalization clears the key. */ + readonly set: (input: { + readonly key: string; + readonly text: string; + readonly tone?: ContributionStatusTone; + readonly tooltip?: string; + }) => Effect.Effect; + readonly clear: (key: string) => Effect.Effect; + readonly clearAll: Effect.Effect; + /** What this handle currently shows, as stored: admitted, normalized, and capped. */ + readonly items: Effect.Effect>; +} + +export interface ContributionStatusStoreShape { + readonly openSource: ( + source: ContributionStatusSource, + ) => Effect.Effect; + readonly snapshot: Effect.Effect; + /** Latest snapshot plus later full replacements; a slow subscriber only skips intermediate ones. */ + readonly subscribe: Effect.Effect< + { + readonly latest: ContributionStatusSnapshot; + readonly changes: Stream.Stream; + }, + never, + Scope.Scope + >; +} + +const emptySnapshot: ContributionStatusSnapshot = { entries: [] }; + +const noopHandle: ContributionStatusSourceHandle = { + bindThread: () => Effect.void, + set: () => Effect.void, + clear: () => Effect.void, + clearAll: Effect.void, + items: Effect.succeed([]), +}; + +/** + * Thread statuses produced by live provider sessions. The default drops every + * update so adapters construct without it in tests; the live layer must be the + * same reference the WebSocket server reads, so layer memoization shares one + * store between producers and subscribers. + */ +export class ContributionStatusStore extends Context.Reference( + "@t3tools/provider-core/server/ContributionStatusStore", + { + defaultValue: () => ({ + openSource: () => Effect.succeed(noopHandle), + snapshot: Effect.succeed(emptySnapshot), + subscribe: Effect.succeed({ latest: emptySnapshot, changes: Stream.empty }), + }), + }, +) {} + +// Line breaks, tabs, other C0/C1 controls, DEL, and Unicode line/paragraph separators. +// eslint-disable-next-line no-control-regex +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g; +// eslint-disable-next-line no-control-regex -- matches the control characters on purpose, as above +const HAS_CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; + +/** + * Keys are identities, not display text, so they are never rewritten: two + * different keys must never collide. A key that is empty, longer than the + * contract allows, or contains a control character or lone UTF-16 surrogate + * is rejected on set and clear alike. + */ +const isValidKey = (key: string) => + key.length > 0 && + key.length <= CONTRIBUTION_STATUS_KEY_MAX_LENGTH && + key.isWellFormed() && + !HAS_CONTROL_CHARACTER.test(key); + +/** + * Producer text as one plain line: terminal styling removed, lone surrogates + * replaced with U+FFFD, controls turned into spaces, whitespace collapsed, and + * at most `maxLength` UTF-16 units without splitting a surrogate pair. A + * truncated value ends in `…`. + */ +function normalizeContributionStatusText(raw: string, maxLength: number): string { + const text = NodeUtil.stripVTControlCharacters(raw.toWellFormed()) + .replace(CONTROL_CHARACTERS, " ") + .replace(/\s+/g, " ") + .trim(); + if (text.length <= maxLength) return text; + let truncated = ""; + for (const codePoint of text) { + if (truncated.length + codePoint.length > maxLength - 1) break; + truncated += codePoint; + } + return `${truncated.trimEnd()}…`; +} + +interface SourceSlot { + readonly generation: number; + readonly source: ContributionStatusSource; + readonly items: Map; +} + +/** + * Which handles compete for one entry on a thread. A thread hosts one provider + * session at a time, so every provider-session source shares a slot and a new + * session takes the old one over; plugins will each get their own slot. + */ +const slotKey = (source: ContributionStatusSource) => source.kind; + +const sameItem = (left: ContributionStatusItem | undefined, right: ContributionStatusItem) => + left !== undefined && + left.text === right.text && + left.tone === right.tone && + left.tooltip === right.tooltip; + +const compareStrings = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0); + +const sourceRank = (source: ContributionStatusSource) => + source.kind === "provider-session" ? 0 : 1; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.fn("contributions.status.make")(function* () { + const threads = new Map>(); + const changes = yield* PubSub.sliding(1); + const mutex = yield* Semaphore.make(1); + let nextGeneration = 0; + + /** + * Binding a thread only records ownership; capacity is taken by the first + * visible item and returned by the last clear, so silent producers never + * crowd out ones that show something, and a rejected producer's later set + * is admitted once capacity frees up. + */ + const admitsNewItem = (threadId: ThreadId, slot: SourceSlot) => { + if (slot.items.size >= CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE) return false; + let items = 0; + let visibleThreads = 0; + for (const slots of threads.values()) { + let visible = false; + for (const other of slots.values()) { + items += other.items.size; + visible ||= other.items.size > 0; + } + if (visible) visibleThreads += 1; + } + if (items >= CONTRIBUTION_STATUS_MAX_ITEMS) return false; + if (slot.items.size > 0) return true; + let visibleSources = 0; + for (const other of threads.get(threadId)?.values() ?? []) { + if (other.items.size > 0) visibleSources += 1; + } + if (visibleSources >= CONTRIBUTION_STATUS_MAX_SOURCES_PER_THREAD) return false; + return visibleSources > 0 || visibleThreads < CONTRIBUTION_STATUS_MAX_THREADS; + }; + + const currentSnapshot = (): ContributionStatusSnapshot => ({ + entries: Array.from(threads.keys()) + .toSorted(compareStrings) + .flatMap((threadId) => + Array.from(threads.get(threadId)?.values() ?? []) + .filter((slot) => slot.items.size > 0) + .toSorted( + (left, right) => + sourceRank(left.source) - sourceRank(right.source) || + compareStrings( + contributionStatusSourceKey(left.source), + contributionStatusSourceKey(right.source), + ), + ) + .map((slot) => ({ + threadId, + source: slot.source, + items: Array.from(slot.items.values()).toSorted((left, right) => + compareStrings(left.key, right.key), + ), + })), + ), + }); + + /** Runs `mutate` under the store lock and publishes once when it reports a visible change. */ + const update = (mutate: () => boolean) => + mutex.withPermits(1)( + Effect.suspend(() => + mutate() ? PubSub.publish(changes, currentSnapshot()).pipe(Effect.asVoid) : Effect.void, + ), + ); + + const openSource: ContributionStatusStoreShape["openSource"] = (source) => + Effect.gen(function* () { + let threadId: ThreadId | null = null; + let generation = 0; + let retired = false; + + const slotId = slotKey(source); + + /** The slot this handle still owns, or undefined when it is unbound, replaced, or retired. */ + const ownedSlot = () => { + if (retired || threadId === null) return undefined; + const slot = threads.get(threadId)?.get(slotId); + return slot?.generation === generation ? slot : undefined; + }; + + const release = () => { + const slot = ownedSlot(); + if (slot === undefined || threadId === null) return false; + const slots = threads.get(threadId); + slots?.delete(slotId); + if (slots?.size === 0) threads.delete(threadId); + return slot.items.size > 0; + }; + + const handle: ContributionStatusSourceHandle = { + bindThread: (nextThreadId) => + update(() => { + if (retired) return false; + if (nextThreadId !== null && nextThreadId === threadId && ownedSlot() !== undefined) { + return false; + } + const released = release(); + threadId = nextThreadId; + if (nextThreadId === null) return released; + let slots = threads.get(nextThreadId); + if (slots === undefined) { + slots = new Map(); + threads.set(nextThreadId, slots); + } + const replaced = slots.get(slotId); + generation = ++nextGeneration; + slots.set(slotId, { generation, source, items: new Map() }); + return released || (replaced !== undefined && replaced.items.size > 0); + }), + set: (input) => + update(() => { + const slot = ownedSlot(); + const key = input.key; + if (slot === undefined || threadId === null || !isValidKey(key)) return false; + const text = normalizeContributionStatusText( + input.text, + CONTRIBUTION_STATUS_TEXT_MAX_LENGTH, + ); + if (text.length === 0) return slot.items.delete(key); + const previous = slot.items.get(key); + if (previous === undefined && !admitsNewItem(threadId, slot)) return false; + const tooltip = + input.tooltip === undefined + ? "" + : normalizeContributionStatusText( + input.tooltip, + CONTRIBUTION_STATUS_TOOLTIP_MAX_LENGTH, + ); + const item: ContributionStatusItem = { + key, + text, + ...(input.tone === undefined || input.tone === "neutral" ? {} : { tone: input.tone }), + ...(tooltip.length === 0 ? {} : { tooltip }), + }; + if (sameItem(previous, item)) return false; + slot.items.set(key, item); + return true; + }), + clear: (key) => + update(() => { + const slot = ownedSlot(); + if (slot === undefined || !isValidKey(key)) return false; + return slot.items.delete(key); + }), + clearAll: update(() => { + const slot = ownedSlot(); + if (slot === undefined || slot.items.size === 0) return false; + slot.items.clear(); + return true; + }), + items: mutex.withPermits(1)( + Effect.sync(() => Array.from(ownedSlot()?.items.values() ?? [])), + ), + }; + + yield* Effect.addFinalizer(() => + update(() => { + const released = release(); + retired = true; + return released; + }), + ); + return handle; + }); + + return ContributionStatusStore.of({ + openSource, + snapshot: mutex.withPermits(1)(Effect.sync(currentSnapshot)), + // Subscribes under the lock so no change lands between the snapshot and the stream. + subscribe: mutex.withPermits(1)( + Effect.map(PubSub.subscribe(changes), (subscription) => ({ + latest: currentSnapshot(), + changes: Stream.fromSubscription(subscription), + })), + ), + }); +}); + +export const layer = Layer.effect(ContributionStatusStore, make()); + +/** The `subscribeContributionStatus` stream: the current snapshot, then each replacement. */ +export const subscriptionStream = (store: ContributionStatusStoreShape) => + Stream.unwrap( + Effect.map(store.subscribe, ({ latest, changes }) => + Stream.concat(Stream.make(latest), changes), + ), + ); diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index cab0043ab185..2084b92af8aa 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -12,6 +12,7 @@ import { RunId, ThreadId, type ChatAttachment, + type ContributionStatusSnapshot, type ModelSelection, type OrchestrationV2AppThread, type OrchestrationV2ProviderThread, @@ -19,13 +20,17 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -40,6 +45,7 @@ import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter" import { handoffBudget } from "@t3tools/provider-core/server/handoffBudget"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { toolOutputImages, compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; import { makePiAdapterV2, PiAdapterV2Driver, @@ -99,8 +105,12 @@ interface FakePi { readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork") => void; /** Hold a model-select extension hook until its UI request is answered. */ readonly deferNextModelSelection: () => void; + /** Send the held lifecycle request's normal response. */ + readonly resolveDeferredLifecycle: Effect.Effect; readonly queueModels: (models: ReadonlyArray) => void; readonly vetoNextNewSession: () => void; + /** Make the next `fork` ack report an extension veto. */ + readonly vetoNextFork: () => void; /** Every request received by the fake process. */ readonly allRequests: () => ReadonlyArray; /** Data returned by the next `get_session_stats` acks, consumed in order. */ @@ -150,7 +160,9 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { let failState = false; let vetoSwitch = false; let vetoNewSession = false; + let vetoFork = false; let deferredLifecycle: string | undefined; + let deferredLifecycleRequest: PiRpcRecord | undefined; let sessionFile = FAKE_SESSION_FILE; let sessionGeneration = 0; let models: ReadonlyArray = []; @@ -201,8 +213,11 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { return { ...base, data: messagesQueue.shift() ?? { messages: [] } }; case "get_session_stats": return { ...base, data: statsQueue.shift() ?? {} }; - case "fork": - return { ...base, data: { text: "Hello pi", cancelled: false } }; + case "fork": { + const cancelled = vetoFork; + vetoFork = false; + return { ...base, data: { text: "Hello pi", cancelled } }; + } default: return base; } @@ -227,6 +242,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { } if (record["type"] === deferredLifecycle) { deferredLifecycle = undefined; + deferredLifecycleRequest = record; continue; } const response = respondTo(record); @@ -301,12 +317,22 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { deferNextModelSelection: () => { deferredLifecycle = "set_model"; }, + resolveDeferredLifecycle: Effect.suspend(() => { + const record = deferredLifecycleRequest; + assert.isDefined(record); + deferredLifecycleRequest = undefined; + const response = respondTo(record!); + return response === null ? Effect.void : emit(response); + }), queueModels: (value) => { models = value; }, vetoNextNewSession: () => { vetoNewSession = true; }, + vetoNextFork: () => { + vetoFork = true; + }, allRequests: () => allRequests, vetoNextSwitch: () => { vetoSwitch = true; @@ -387,6 +413,95 @@ const openRuntime = Effect.fnUntraced(function* ( return { runtime, takeEvent }; }); +const statusMap = (snapshot: ContributionStatusSnapshot) => + Object.fromEntries( + snapshot.entries.map((entry) => [ + entry.threadId, + entry.items.map((item) => `${item.key}=${item.text}`), + ]), + ); + +/** Pi's fire-and-forget `ctx.ui.setStatus`; a missing text clears the key. */ +const emitStatus = (fake: FakePi, statusKey: string, statusText?: string) => + fake.emit({ + type: "extension_ui_request", + id: `status-${statusKey}-${statusText ?? "clear"}`, + method: "setStatus", + statusKey, + ...(statusText === undefined ? {} : { statusText }), + }); + +/** Waits for the store to publish a snapshot matching `predicate`, the receipt for status events. */ +const waitForStatuses = ( + subscription: { readonly changes: Stream.Stream }, + predicate: (statuses: Record>) => boolean, +) => + subscription.changes.pipe( + Stream.map(statusMap), + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + +/** + * A status store whose "gate" status holds the session event pump until + * released. The pump handles records in order, so `pumpHeld` is also the + * receipt that every earlier record has been handled. + */ +const makeGatedStatusStore = Effect.fnUntraced(function* () { + const store = yield* ContributionStatusStore.make(); + const pumpHeld = yield* Deferred.make(); + const releasePump = yield* Deferred.make(); + const gated: ContributionStatusStore.ContributionStatusStoreShape = { + ...store, + openSource: (source) => + Effect.map(store.openSource(source), (handle) => ({ + ...handle, + set: (input) => + input.key === "gate" + ? Deferred.succeed(pumpHeld, undefined).pipe( + Effect.andThen(Deferred.await(releasePump)), + Effect.andThen(handle.set(input)), + ) + : handle.set(input), + })), + }; + return { + store, + provide: Effect.provideService(ContributionStatusStore.ContributionStatusStore, gated), + holdPump: (fake: FakePi) => + emitStatus(fake, "gate", "held").pipe(Effect.andThen(Deferred.await(pumpHeld))), + releasePump: Deferred.succeed(releasePump, undefined), + }; +}); + +/** Rolls `providerThread` back to its start, which forks Pi's session before the first turn. */ +const rollbackToThreadStart = ( + runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime, + providerThread: OrchestrationV2ProviderThread, +) => + runtime.rollbackThread({ + providerThread, + providerThreadTurns: [ + { + id: ProviderTurnId.make("turn-1"), + providerThreadId: providerThread.id, + nodeId: NodeId.make("node-1"), + runAttemptId: null, + nativeTurnRef: { driver: PI_PROVIDER, nativeId: "u1", strength: "strong" }, + ordinal: 1, + status: "completed", + startedAt: null, + completedAt: null, + }, + ], + target: { + type: "thread_start", + checkpointId: CheckpointId.make("checkpoint-pi-rollback"), + appRunOrdinal: 0, + }, + }); + const makeAppThread = Effect.fnUntraced(function* (model: string, threadId = THREAD_ID) { const now = yield* DateTime.now; return { @@ -1333,6 +1448,392 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect( + "shows extension statuses on the session's thread until Pi rebinds or the session ends", + () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const store = yield* ContributionStatusStore.ContributionStatusStore; + const subscription = yield* store.subscribe; + // Each status change publishes one snapshot; waiting on it is the receipt. + const nextStatuses = Stream.runHead(subscription.changes).pipe( + Effect.map((snapshot) => + Option.match(snapshot, { + onNone: () => ({}), + onSome: (value) => + Object.fromEntries( + value.entries.map((entry) => [ + entry.threadId, + entry.items.map((item) => `${item.key}=${item.text}`), + ]), + ), + }), + ), + ); + const setStatus = (statusKey: string, statusText?: string) => + fake.emit({ + type: "extension_ui_request", + id: `status-${statusKey}-${statusText ?? "clear"}`, + method: "setStatus", + statusKey, + ...(statusText === undefined ? {} : { statusText }), + }); + + const sessionScope = yield* Scope.make(); + const { runtime } = yield* openRuntime(fake).pipe(Scope.provide(sessionScope)); + + // Extensions set statuses from session_start, before T3 registers a thread. + yield* setStatus("plan", "\u001b[33m⏸ plan\u001b[39m"); + assert.deepStrictEqual(yield* nextStatuses, { [THREAD_ID]: ["plan=⏸ plan"] }); + + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* setStatus("mode", "build"); + assert.deepStrictEqual(yield* nextStatuses, { [THREAD_ID]: ["mode=build", "plan=⏸ plan"] }); + yield* setStatus("plan"); + assert.deepStrictEqual(yield* nextStatuses, { [THREAD_ID]: ["mode=build"] }); + + yield* runtime.resumeThread({ providerThread }); + assert.deepStrictEqual(yield* nextStatuses, {}); + yield* setStatus("mode", "resumed"); + assert.deepStrictEqual(yield* nextStatuses, { [THREAD_ID]: ["mode=resumed"] }); + + yield* Scope.close(sessionScope, Exit.void); + assert.deepStrictEqual(yield* nextStatuses, {}); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerTest, ContributionStatusStore.layer))), + ); + + it.effect("lands a fork's startup statuses on the target thread before Pi answers", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const forkFake = yield* makeFakePi; + const store = yield* ContributionStatusStore.ContributionStatusStore; + const statuses = yield* store.subscribe; + const { runtime } = yield* openRuntime(fake, "default", THREAD_ID, SESSION_ID, forkFake); + const source = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* emitStatus(fake, "legacy", "old"); + yield* waitForStatuses(statuses, (current) => current[THREAD_ID]?.[0] === "legacy=old"); + + const forkFile = "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/fake/forked.jsonl"; + forkFake.queueState({ sessionFile: forkFile }); + fake.queueState({ sessionFile: forkFile }); + fake.deferNextLifecycle("switch_session"); + const target = ThreadId.make("fork-target"); + const forked = yield* runtime + .forkThread({ sourceProviderThread: source, targetThreadId: target }) + .pipe(Effect.forkChild); + yield* fake.takeRequest("switch_session"); + // Pi rebinds the extensions, which set their statuses before the switch response. + yield* emitStatus(fake, "mode", "forked"); + yield* waitForStatuses(statuses, (current) => current[target]?.[0] === "mode=forked"); + yield* fake.resolveDeferredLifecycle; + yield* Fiber.join(forked); + + assert.deepStrictEqual(statusMap(yield* store.snapshot), { [target]: ["mode=forked"] }); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerTest, ContributionStatusStore.layer))), + ); + + it.effect("drops statuses the old session queued before a switch the new one never resets", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const gate = yield* makeGatedStatusStore(); + const statuses = yield* gate.store.subscribe; + const { runtime } = yield* openRuntime(fake).pipe(gate.provide); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* emitStatus(fake, "legacy", "old"); + yield* waitForStatuses(statuses, (current) => current[THREAD_ID]?.[0] === "legacy=old"); + + // Hold the event pump so the stale update below queues behind it. + yield* gate.holdPump(fake); + yield* emitStatus(fake, "legacy", "stale"); + // Responses resolve in stdout order, so once this answer arrives the stale update is queued. + yield* runtime.readThreadSnapshot({ providerThread }); + + fake.deferNextLifecycle("switch_session"); + const resumed = yield* runtime.resumeThread({ providerThread }).pipe(Effect.forkChild); + yield* fake.takeRequest("switch_session"); + yield* emitStatus(fake, "mode", "new"); + yield* fake.resolveDeferredLifecycle; + yield* Fiber.join(resumed); + yield* gate.releasePump; + + // "mode=new" is the last status event, so the first snapshot showing it is final. + const settled = yield* waitForStatuses(statuses, (current) => + (current[THREAD_ID] ?? []).includes("mode=new"), + ); + assert.deepStrictEqual(settled, { [THREAD_ID]: ["mode=new"] }); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("keeps a vetoed switch's later statuses off every thread until one registers", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const gate = yield* makeGatedStatusStore(); + const statuses = yield* gate.store.subscribe; + const { runtime } = yield* openRuntime(fake).pipe(gate.provide); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* emitStatus(fake, "legacy", "old"); + yield* waitForStatuses(statuses, (current) => current[THREAD_ID]?.[0] === "legacy=old"); + + fake.vetoNextSwitch(); + yield* runtime.resumeThread({ providerThread }).pipe(Effect.flip); + // Pi stays on a session no thread is bound to; its updates go nowhere. + yield* emitStatus(fake, "orphan", "dropped"); + yield* gate.holdPump(fake); + yield* gate.releasePump; + + yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* emitStatus(fake, "mode", "fresh"); + const settled = yield* waitForStatuses(statuses, (current) => + (current[THREAD_ID] ?? []).includes("mode=fresh"), + ); + assert.deepStrictEqual(settled, { [THREAD_ID]: ["mode=fresh"] }); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect.each([ + { failure: "a rejected get_state", sessionFile: true }, + { failure: "a missing sessionFile", sessionFile: false }, + ])("keeps statuses off the thread after a rollback fork with $failure", ({ sessionFile }) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const gate = yield* makeGatedStatusStore(); + const statuses = yield* gate.store.subscribe; + const { runtime } = yield* openRuntime(fake).pipe(gate.provide); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* emitStatus(fake, "legacy", "old"); + yield* waitForStatuses(statuses, (current) => current[THREAD_ID]?.[0] === "legacy=old"); + + if (sessionFile) fake.failNextState(); + else fake.queueState({ sessionFile: undefined }); + const turn: OrchestrationV2ProviderTurn = { + id: ProviderTurnId.make("turn-1"), + providerThreadId: providerThread.id, + nodeId: NodeId.make("node-1"), + runAttemptId: null, + nativeTurnRef: { driver: PI_PROVIDER, nativeId: "u1", strength: "strong" }, + ordinal: 1, + status: "completed", + startedAt: null, + completedAt: null, + }; + const error = yield* runtime + .rollbackThread({ + providerThread, + providerThreadTurns: [turn], + target: { + type: "thread_start", + checkpointId: CheckpointId.make("checkpoint-pi-rollback"), + appRunOrdinal: 0, + }, + }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "ProviderAdapterRollbackThreadError"); + // The forked session's updates go nowhere until a thread registers. + yield* emitStatus(fake, "orphan", "after-failed-rollback"); + yield* gate.holdPump(fake); + yield* gate.releasePump; + assert.deepStrictEqual(statusMap(yield* gate.store.snapshot), {}); + + yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* emitStatus(fake, "mode", "fresh"); + const settled = yield* waitForStatuses(statuses, (current) => + (current[THREAD_ID] ?? []).includes("mode=fresh"), + ); + assert.deepStrictEqual(settled, { [THREAD_ID]: ["mode=fresh"] }); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect("puts back the session's statuses when an extension cancels a rollback", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const store = yield* ContributionStatusStore.ContributionStatusStore; + const statuses = yield* store.subscribe; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* emitStatus(fake, "mode", "build"); + yield* emitStatus(fake, "plan", "on"); + yield* waitForStatuses(statuses, (current) => current[THREAD_ID]?.length === 2); + + fake.vetoNextFork(); + fake.deferNextLifecycle("fork"); + const turn: OrchestrationV2ProviderTurn = { + id: ProviderTurnId.make("turn-1"), + providerThreadId: providerThread.id, + nodeId: NodeId.make("node-1"), + runAttemptId: null, + nativeTurnRef: { driver: PI_PROVIDER, nativeId: "u1", strength: "strong" }, + ordinal: 1, + status: "completed", + startedAt: null, + completedAt: null, + }; + const rollback = yield* runtime + .rollbackThread({ + providerThread, + providerThreadTurns: [turn], + target: { + type: "thread_start", + checkpointId: CheckpointId.make("checkpoint-pi-rollback"), + appRunOrdinal: 0, + }, + }) + .pipe(Effect.flip, Effect.forkChild); + yield* fake.takeRequest("fork"); + // The session Pi stays on keeps writing while its hook decides. + yield* emitStatus(fake, "plan", "off"); + yield* fake.resolveDeferredLifecycle; + const error = yield* Fiber.join(rollback); + assert.strictEqual(error._tag, "ProviderAdapterRollbackThreadError"); + yield* emitStatus(fake, "zz", "receipt"); + + // "zz" is the last status event, so the first snapshot showing it is final. + const settled = yield* waitForStatuses(statuses, (current) => + (current[THREAD_ID] ?? []).includes("zz=receipt"), + ); + assert.deepStrictEqual(settled, { [THREAD_ID]: ["mode=build", "plan=off", "zz=receipt"] }); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerTest, ContributionStatusStore.layer))), + ); + + it.effect("restores only the statuses the store held when an extension cancels a rollback", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const store = yield* ContributionStatusStore.ContributionStatusStore; + const statuses = yield* store.subscribe; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + // The source holds at most eight items, so k9 is rejected; clearing k1 frees a slot. + for (let index = 1; index <= 9; index++) yield* emitStatus(fake, `k${index}`, `v${index}`); + yield* emitStatus(fake, "k1"); + const before = yield* waitForStatuses( + statuses, + (current) => current[THREAD_ID]?.length === 7 && !current[THREAD_ID].includes("k1=v1"), + ); + + assert.deepStrictEqual(before, { + [THREAD_ID]: ["k2=v2", "k3=v3", "k4=v4", "k5=v5", "k6=v6", "k7=v7", "k8=v8"], + }); + + fake.vetoNextFork(); + const error = yield* rollbackToThreadStart(runtime, providerThread).pipe(Effect.flip); + assert.strictEqual(error._tag, "ProviderAdapterRollbackThreadError"); + yield* emitStatus(fake, "k8", "receipt"); + + // "k8=receipt" is the last status event, so the first snapshot showing it is final. + const settled = yield* waitForStatuses(statuses, (current) => + (current[THREAD_ID] ?? []).includes("k8=receipt"), + ); + assert.deepStrictEqual(settled, { + [THREAD_ID]: ["k2=v2", "k3=v3", "k4=v4", "k5=v5", "k6=v6", "k7=v7", "k8=receipt"], + }); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerTest, ContributionStatusStore.layer))), + ); + + it.effect("drops a successful rollback's kept statuses and restores stored text", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const store = yield* ContributionStatusStore.ContributionStatusStore; + const statuses = yield* store.subscribe; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* emitStatus(fake, "mode", "before"); + yield* waitForStatuses(statuses, (current) => current[THREAD_ID]?.[0] === "mode=before"); + + // A successful fork clears the session's statuses and keeps nothing for later. + yield* rollbackToThreadStart(runtime, providerThread); + for (let index = 0; index < 50; index++) { + yield* emitStatus(fake, `churn${index}`, "on"); + yield* emitStatus(fake, `churn${index}`); + } + const longText = `a\nb ${"x".repeat(200)}`; + yield* emitStatus(fake, "mode", longText); + const stored = `a b ${"x".repeat(75)}…`; + yield* waitForStatuses(statuses, (current) => current[THREAD_ID]?.[0] === `mode=${stored}`); + + fake.vetoNextFork(); + yield* rollbackToThreadStart(runtime, providerThread).pipe(Effect.flip); + yield* emitStatus(fake, "zz", "receipt"); + + // "zz" is the last status event, so the first snapshot showing it is final. + const settled = yield* waitForStatuses(statuses, (current) => + (current[THREAD_ID] ?? []).includes("zz=receipt"), + ); + assert.deepStrictEqual(settled, { [THREAD_ID]: [`mode=${stored}`, "zz=receipt"] }); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerTest, ContributionStatusStore.layer))), + ); + + // Known residual, not a fix: Pi runs the old session's session_shutdown + // handlers after it reads switch_session and reports no rebind on stdout, so + // those writes are indistinguishable from the new session's and persist. + // Flip this test if Pi ever marks the native-session boundary. + it.effect("keeps an old session's shutdown status written after the switch marker", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const store = yield* ContributionStatusStore.ContributionStatusStore; + const statuses = yield* store.subscribe; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + + fake.deferNextLifecycle("switch_session"); + const resumed = yield* runtime.resumeThread({ providerThread }).pipe(Effect.forkChild); + yield* fake.takeRequest("switch_session"); + yield* emitStatus(fake, "legacy", "old-shutdown-write"); + yield* emitStatus(fake, "mode", "new-startup"); + yield* fake.resolveDeferredLifecycle; + yield* Fiber.join(resumed); + + const settled = yield* waitForStatuses(statuses, (current) => + (current[THREAD_ID] ?? []).includes("mode=new-startup"), + ); + assert.deepStrictEqual(settled, { + [THREAD_ID]: ["legacy=old-shutdown-write", "mode=new-startup"], + }); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerTest, ContributionStatusStore.layer))), + ); + it.effect("rejects a resume while a turn is active", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 96d2783ecf3f..7d7d8ffad5ed 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -20,8 +20,20 @@ * Dialog methods become v2 runtime requests (`confirm` → approval_request, * `select`/`input`/`editor` → user_input_request); answers travel back as * `extension_ui_response`. `notify` becomes a completed activity item. - * Terminal-only decoration such as status, widget, title, and editor-text - * updates has no matching T3 surface and is ignored. + * `setStatus` feeds the thread's contribution status, an advisory channel + * owned by this Pi process for the T3 provider session's lifetime. Before a + * T3-initiated switch, new session, or fork, a queued marker clears the + * statuses read so far and sends later ones to the target thread; a failed + * registration or rollback sends them nowhere until a thread registers. A + * rollback's marker keeps the statuses the store held for this session; if an + * extension cancels the fork, the keys the session has not written since come + * back, and once Pi answers either way the kept copy is dropped. Pi's + * RPC stdout marks no native-session boundary, so old-session writes after + * the marker (such as session_shutdown handlers) look like the new session's + * and may persist, and extension-initiated switches or reloads are not + * tracked. Closing this session clears its statuses. + * Other terminal decoration (widget, title, editor text) has no matching T3 + * surface and is ignored. */ import * as HostProcess from "@t3tools/shared/HostProcess"; import { AgentScope } from "@t3tools/shared/AgentScope"; @@ -32,6 +44,7 @@ import { defaultInstanceIdForDriver, ProviderDriverKind, type ChatAttachment, + type ContributionStatusItem, type ModelSelection, type OrchestrationV2ExecutionNode, type OrchestrationV2ProviderCapabilities, @@ -55,6 +68,7 @@ import * as Deferred from "effect/Deferred"; import * as Option from "effect/Option"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -72,6 +86,7 @@ import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; import { ProviderAdapterDriverCreateError, type ProviderAdapterDriver, @@ -498,6 +513,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( const idAllocator = yield* IdAllocator.IdAllocatorV2; const host = yield* ProviderHost.ProviderHost; const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + const statusStore = yield* ContributionStatusStore.ContributionStatusStore; const { continuationRequests } = options; const protocolError = (detail: string, payload?: unknown) => @@ -594,6 +610,37 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( // serialize the two paths to stop `turn.terminal` from overtaking the // dialog's own resolution updates. const sessionEventPermit = yield* Semaphore.make(1); + // Extensions set statuses from session_start, before the first thread + // registration, so the source starts on the thread this session opened for. + const statusSource = yield* statusStore.openSource({ + kind: "provider-session", + providerSessionId: input.providerSessionId, + providerInstanceId: options.instanceId, + driver: PI_PROVIDER, + }); + yield* statusSource.bindThread(input.threadId); + // The statuses a pending rollback's marker cleared, as the store held + // them, minus every key the session has written since. At most one + // source's capped items, dropped once the rollback's fork is answered. + let rollbackStatuses: Map | null = null; + // Targets of `t3.status_generation` markers still queued, oldest first. + const statusGenerationTargets: Array = []; + /** + * Starts a new status generation on `threadId` in event order: statuses + * read before the marker are cleared, later ones land on `threadId`, or + * nowhere when it is null. A rollback's marker keeps the cleared statuses + * until its `t3.status_settle`. + */ + const queueStatusGeneration = ( + threadId: OrchestrationV2ProviderThread["appThreadId"], + rollback = false, + ) => + Effect.sync(() => statusGenerationTargets.push(threadId)).pipe( + Effect.andThen( + Queue.offer(connection.events, { type: "t3.status_generation", rollback }), + ), + Effect.asVoid, + ); let threadState: PiThreadState | null = null; let registrationAttempted = false; let lastNativeThreadId: string | null = null; @@ -725,22 +772,36 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( return null; }; - const lifecycleRequest = (record: PiRpcRecord) => - request(record, PI_SESSION_TIMEOUT_MS).pipe( - // A local timeout does not cancel Pi's lifecycle hook. Retire the - // process before fallback can race its eventual switch/new-session. - Effect.tapError((error) => - Effect.logWarning("Pi session lifecycle request failed", { - providerSessionId: input.providerSessionId, - operation: record["type"], - errorTag: error._tag, - }), + /** + * Switches Pi to another native session whose statuses belong to + * `statusThreadId`. Pi rebinds every extension for the new session, and + * their startup statuses can arrive before the response, so the status + * generation starts before the request is written. + */ + const lifecycleRequest = ( + record: PiRpcRecord, + statusThreadId: OrchestrationV2ProviderThread["appThreadId"], + rollback = false, + ) => + queueStatusGeneration(statusThreadId, rollback).pipe( + Effect.andThen( + request(record, PI_SESSION_TIMEOUT_MS).pipe( + // A local timeout does not cancel Pi's lifecycle hook. Retire the + // process before fallback can race its eventual switch/new-session. + Effect.tapError((error) => + Effect.logWarning("Pi session lifecycle request failed", { + providerSessionId: input.providerSessionId, + operation: record["type"], + errorTag: error._tag, + }), + ), + Effect.catchTags({ + PiRpcTimeoutError: (error) => + connection.terminate.pipe(Effect.andThen(Effect.fail(error))), + }), + Effect.onInterrupt(() => connection.terminate), + ), ), - Effect.catchTags({ - PiRpcTimeoutError: (error) => - connection.terminate.pipe(Effect.andThen(Effect.fail(error))), - }), - Effect.onInterrupt(() => connection.terminate), ); const tokenUsageFromStats = ( @@ -1348,6 +1409,16 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }); return; } + if (method === "setStatus") { + // Pi serializes a cleared status as a missing `statusText`. + const key = recordString(event, "statusKey"); + if (key === undefined) return; + const text = recordString(event, "statusText"); + // The session has replaced or cleared this key since the rollback marker. + rollbackStatuses?.delete(key); + yield* text === undefined ? statusSource.clear(key) : statusSource.set({ key, text }); + return; + } if ( method !== "select" && method !== "confirm" && @@ -2170,6 +2241,28 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( yield* cancelPrompt(pending, yield* DateTime.now); return; } + case "t3.status_generation": { + // Statuses queued before this marker came from the session Pi is + // leaving. Its shutdown writes can still follow; see the header. + rollbackStatuses = + event["rollback"] === true + ? new Map((yield* statusSource.items).map((item) => [item.key, item])) + : null; + yield* statusSource.clearAll; + yield* statusSource.bindThread(statusGenerationTargets.shift() ?? null); + return; + } + case "t3.status_settle": { + // Queued once Pi answers a rollback's fork, behind every status it + // wrote before answering. A cancelled fork kept Pi on its session + // and the marker's target was the same thread, so put back what + // the marker kept; otherwise just drop it. + const kept = rollbackStatuses; + rollbackStatuses = null; + if (kept === null || event["restore"] !== true) return; + for (const item of kept.values()) yield* statusSource.set(item); + return; + } case "t3.flush_extension_errors": { // Startup extension failures are informational and do not block // Pi, so attach them to the next real turn instead of creating a @@ -2291,7 +2384,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( // ── session runtime ─────────────────────────────────── - const registerThread = Effect.fnUntraced(function* ( + const registerThreadUnguarded = Effect.fnUntraced(function* ( threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, publish = true, ) { @@ -2318,8 +2411,9 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( const existing = threadInput.existingProviderThread; const resumeId = existing?.nativeThreadRef?.nativeId; const needsNewSession = resumeId == null && registrationAttempted; + const switchesSession = resumeId != null || needsNewSession; registrationAttempted = true; - if (resumeId != null || needsNewSession) { + if (switchesSession) { lastNativeThreadId = resumeId ?? lastNativeThreadId; // Even a failed lifecycle operation can change Pi's native session. // Never leave the old app binding or model defaults usable afterward. @@ -2334,6 +2428,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( resumeId != null ? { type: "switch_session", sessionPath: resumeId } : { type: "new_session" }, + existing === undefined ? threadInput.threadId : existing.appThreadId, ); if (recordField(result, "cancelled") === true) { return yield* protocolError("A Pi extension cancelled the session switch"); @@ -2407,6 +2502,8 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( updatedAt: createdAt, }; threadState = { providerThread, activeTurn: null }; + // A session switch moved the statuses in event order already. + if (!switchesSession) yield* statusSource.bindThread(providerThread.appThreadId); // Baseline the session-tree leaf so the first turn's user entry can // be located with a `since` cursor instead of a full entry scan. const baselineEntries = yield* request({ type: "get_entries" }).pipe( @@ -2426,6 +2523,16 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( return providerThread; }); + // A failed registration leaves no thread bound, so no thread shows this + // session's statuses until a later registration succeeds. + const registerThread = ( + threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, + publish = true, + ) => + registerThreadUnguarded(threadInput, publish).pipe( + Effect.onError(() => (threadState === null ? queueStatusGeneration(null) : Effect.void)), + ); + const applySelection = Effect.fnUntraced(function* (modelSelection: ModelSelection) { const thinking = getModelSelectionStringOptionValue(modelSelection, "thinking"); if (modelSelection.model === PI_INHERIT_MODEL_SLUG) { @@ -3062,19 +3169,35 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ), (barrier) => Effect.gen(function* () { - const forkData = yield* lifecycleRequest({ type: "fork", entryId: forkEntryId }); + const forkData = yield* lifecycleRequest( + { type: "fork", entryId: forkEntryId }, + state.providerThread.appThreadId, + true, + ).pipe( + Effect.onExit((exit) => + Queue.offer(connection.events, { + type: "t3.status_settle", + restore: + Exit.isSuccess(exit) && recordField(exit.value, "cancelled") === true, + }), + ), + ); if (recordField(forkData, "cancelled") === true) return yield* protocolError("A Pi extension cancelled the session fork"); + // Without the fork's identity the thread is unusable, so, like a + // failed registration, no thread shows this session's statuses + // until one registers. An interrupted read leaves the identity + // just as unknown as a failed one. + const invalidateThread = Effect.suspend(() => { + threadState = null; + return queueStatusGeneration(null); + }); const forkState = yield* request({ type: "get_state" }).pipe( - Effect.onError(() => - Effect.sync(() => { - threadState = null; - }), - ), + Effect.onError(() => invalidateThread), ); const forkSessionFile = recordString(forkState, "sessionFile"); if (forkSessionFile === undefined) { - threadState = null; + yield* invalidateThread; return yield* protocolError("Pi fork did not return a persisted session file"); } const entriesData = yield* request({ type: "get_entries" }).pipe( From 083f6ab011fb77995875ce5590bc71ac2fb3012b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:41:59 +1100 Subject: [PATCH 21/22] fix(pi): keep statuses when Pi refuses a rollback fork A rollback holds the session's statuses until Pi answers the fork, and put them back only when an extension cancelled it. When Pi refused the fork outright it also stayed on its session, but the held statuses were dropped. They are now restored for a refusal too; a timeout, interruption or dead transport still drops them. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../provider-pi/src/server/adapter.test.ts | 65 +++++++++++++++++++ packages/provider-pi/src/server/adapter.ts | 13 +++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 2084b92af8aa..153083cd02da 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -111,6 +111,8 @@ interface FakePi { readonly vetoNextNewSession: () => void; /** Make the next `fork` ack report an extension veto. */ readonly vetoNextFork: () => void; + /** Make the next `fork` fail with `success: false`, leaving Pi on its session. */ + readonly rejectNextFork: () => void; /** Every request received by the fake process. */ readonly allRequests: () => ReadonlyArray; /** Data returned by the next `get_session_stats` acks, consumed in order. */ @@ -161,6 +163,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { let vetoSwitch = false; let vetoNewSession = false; let vetoFork = false; + let rejectFork = false; let deferredLifecycle: string | undefined; let deferredLifecycleRequest: PiRpcRecord | undefined; let sessionFile = FAKE_SESSION_FILE; @@ -214,6 +217,10 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { case "get_session_stats": return { ...base, data: statsQueue.shift() ?? {} }; case "fork": { + if (rejectFork) { + rejectFork = false; + return { ...base, success: false, error: "fork refused" }; + } const cancelled = vetoFork; vetoFork = false; return { ...base, data: { text: "Hello pi", cancelled } }; @@ -330,6 +337,9 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { vetoNextNewSession: () => { vetoNewSession = true; }, + rejectNextFork: () => { + rejectFork = true; + }, vetoNextFork: () => { vetoFork = true; }, @@ -1726,6 +1736,61 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerTest, ContributionStatusStore.layer))), ); + it.effect("puts back the session's statuses when Pi refuses a rollback fork", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const store = yield* ContributionStatusStore.ContributionStatusStore; + const statuses = yield* store.subscribe; + const { runtime } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* emitStatus(fake, "mode", "build"); + yield* emitStatus(fake, "plan", "on"); + yield* waitForStatuses(statuses, (current) => current[THREAD_ID]?.length === 2); + + fake.rejectNextFork(); + fake.deferNextLifecycle("fork"); + const turn: OrchestrationV2ProviderTurn = { + id: ProviderTurnId.make("turn-1"), + providerThreadId: providerThread.id, + nodeId: NodeId.make("node-1"), + runAttemptId: null, + nativeTurnRef: { driver: PI_PROVIDER, nativeId: "u1", strength: "strong" }, + ordinal: 1, + status: "completed", + startedAt: null, + completedAt: null, + }; + const rollback = yield* runtime + .rollbackThread({ + providerThread, + providerThreadTurns: [turn], + target: { + type: "thread_start", + checkpointId: CheckpointId.make("checkpoint-pi-rollback"), + appRunOrdinal: 0, + }, + }) + .pipe(Effect.flip, Effect.forkChild); + yield* fake.takeRequest("fork"); + // The session Pi stays on keeps writing while its hook decides. + yield* emitStatus(fake, "plan", "off"); + yield* fake.resolveDeferredLifecycle; + const error = yield* Fiber.join(rollback); + assert.strictEqual(error._tag, "ProviderAdapterRollbackThreadError"); + yield* emitStatus(fake, "zz", "receipt"); + + // "zz" is the last status event, so the first snapshot showing it is final. + const settled = yield* waitForStatuses(statuses, (current) => + (current[THREAD_ID] ?? []).includes("zz=receipt"), + ); + assert.deepStrictEqual(settled, { [THREAD_ID]: ["mode=build", "plan=off", "zz=receipt"] }); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerTest, ContributionStatusStore.layer))), + ); + it.effect("restores only the statuses the store held when an extension cancels a rollback", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 7d7d8ffad5ed..798799449c19 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -3177,8 +3177,17 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( Effect.onExit((exit) => Queue.offer(connection.events, { type: "t3.status_settle", - restore: - Exit.isSuccess(exit) && recordField(exit.value, "cancelled") === true, + // Pi stays on its session when an extension cancels the fork or + // Pi refuses it; a timeout, interruption or dead transport says nothing. + restore: Exit.isSuccess(exit) + ? recordField(exit.value, "cancelled") === true + : !Cause.hasInterrupts(exit.cause) && + Option.exists( + Cause.findErrorOption(exit.cause), + (error) => + error._tag === "PiRpcError" && + (error.operation === "fork" || error.operation === "request"), + ), }), ), ); From e8f8f57cbc369b7163b019c333ba836dc739edc6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:07:50 +1100 Subject: [PATCH 22/22] fix(pi): carry each status generation's thread on its marker A status generation marker was paired with its thread through a separate queue filled in another step, so two concurrent callers could pair a marker with the other caller's thread. The marker now carries its target. Co-Authored-By: Claude Opus 5.5 --- packages/provider-pi/src/server/adapter.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 798799449c19..fe35e62430ba 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -623,8 +623,6 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( // them, minus every key the session has written since. At most one // source's capped items, dropped once the rollback's fork is answered. let rollbackStatuses: Map | null = null; - // Targets of `t3.status_generation` markers still queued, oldest first. - const statusGenerationTargets: Array = []; /** * Starts a new status generation on `threadId` in event order: statuses * read before the marker are cleared, later ones land on `threadId`, or @@ -635,10 +633,9 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( threadId: OrchestrationV2ProviderThread["appThreadId"], rollback = false, ) => - Effect.sync(() => statusGenerationTargets.push(threadId)).pipe( - Effect.andThen( - Queue.offer(connection.events, { type: "t3.status_generation", rollback }), - ), + // The marker carries its own target, so concurrent callers cannot + // pair a marker with another caller's thread. + Queue.offer(connection.events, { type: "t3.status_generation", rollback, threadId }).pipe( Effect.asVoid, ); let threadState: PiThreadState | null = null; @@ -2249,7 +2246,10 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ? new Map((yield* statusSource.items).map((item) => [item.key, item])) : null; yield* statusSource.clearAll; - yield* statusSource.bindThread(statusGenerationTargets.shift() ?? null); + yield* statusSource.bindThread( + (event["threadId"] as OrchestrationV2ProviderThread["appThreadId"] | undefined) ?? + null, + ); return; } case "t3.status_settle": {