diff --git a/.github/scripts/ci-changes.cjs b/.github/scripts/ci-changes.cjs index 77a526076de4d..0f817be60c35d 100644 --- a/.github/scripts/ci-changes.cjs +++ b/.github/scripts/ci-changes.cjs @@ -27,6 +27,8 @@ const EXTRA_OWNERS = [ [/^native\/browser-secret\//, ["@t3tools/desktop"]], [/^native\/kde-snap-shot\//, ["@t3tools/desktop"]], [/^native\/libghostty-vt\//, ["@t3tools/web"]], + // apps/server/src/cloud/ConnectReachability.test.ts checks the guide's headings. + [/^docs\/user\/t3-connect-troubleshooting\.md$/, ["t3"]], ]; const RUST = /^native\/(resource-monitor|kde-snap-shot|hyprland-snap-shot)\//; @@ -92,11 +94,11 @@ function readWorkspace() { // [] means inert; null means unknown and requires every job. function ownersOf(file, packages) { - if (INERT.some((pattern) => pattern.test(file))) return []; + const extra = EXTRA_OWNERS.flatMap(([pattern, names]) => (pattern.test(file) ? names : [])); + if (INERT.some((pattern) => pattern.test(file))) return extra; // A manifest can change dependencies or versions other packages read. if (path.posix.basename(file) === "package.json") return null; if (RUNS_EVERYTHING.some((pattern) => pattern.test(file))) return null; - const extra = EXTRA_OWNERS.flatMap(([pattern, names]) => (pattern.test(file) ? names : [])); // scripts/build-desktop-artifact.test.ts reads the crates' sources. if (RUST.test(file)) return ["@t3tools/scripts", ...extra]; diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index 047dafbdd4be1..905823d1d1a9e 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -44,7 +44,7 @@ The reference for how this codebase wants UI built is the composer banner system - `ComposerBanner.tsx` exports one object of small slot components (`Root`, `Row`, `Icon`, `Content`, `Actions`, `Dismiss`, `Children`, and so on). Each slot owns its own Tailwind classes, exposes a `data-slot` attribute, takes `className` and spreads the rest of its props, and uses `cn` so callers can adjust without overriding. Variants and density are props on `Root`, not ad hoc class strings at call sites. Where a slot needs a real control it renders `Button` from `ui/` rather than a styled ` - {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; } } } @@ -609,10 +543,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" /> ); @@ -657,10 +592,14 @@ function SurfaceIcon({ switch (surface.kind) { case "preview": return ; - case "diff": - return ; - case "files": - return ; + case "diff": { + const Icon = getSidePanelMetadata("diff").icon; + return ; + } + case "files": { + const Icon = getSidePanelMetadata("files").icon; + return ; + } case "file": return ( ); - case "terminal": - return ; + case "terminal": { + const Icon = getSidePanelMetadata("terminal").icon; + return ; + } case "pull-request": return ( ); - case "pull-requests": - return ; + case "pull-requests": { + const Icon = getSidePanelMetadata("pull-requests").icon; + return ; + } case "side-chat": return ; - case "device": + case "device": { + const DeviceIcon = getSidePanelMetadata("device").icon; return surface.target?.platform === "ios" ? ( ) : surface.target?.platform === "android" ? ( ) : ( - + ); + } } } @@ -1090,76 +1035,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, - }, - ...(props.onAddSideChat - ? [ - { - label: "Side chat", - icon: MessagesSquare, - shortcut: "S", - available: props.sideChatAvailable === true && !props.sideChatDisabledReason, - disabledReason: props.sideChatDisabledReason ?? SURFACE_DISABLED_REASONS.sideChat, - onClick: props.onAddSideChat, - }, - ] - : []), - ] as const; + const addSurfaceActions = rightPanelSurfaceActions(props); const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { const action = surfaceShortcutActionForKey(addSurfaceActions, event.nativeEvent); @@ -1413,9 +1289,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) { > {addSurfaceActions.map((action) => { const Icon = action.icon; - if (action.label === "Browser" && action.available) { + if (action.id === "preview" && action.available) { return ( - + @@ -1524,25 +1400,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
{props.activeSurfaceId === null ? ( ) : ( props.children diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4fa3f06e71757..0ed17f40f3ace 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -176,6 +176,7 @@ import { buildThreadActionMenuItems, threadActionRequiresOperate, } from "./threadActionMenu.logic"; +import { runPluginAction, threadMenuPluginActions } from "../pluginActions"; import { applySidebarThreadDrop, filterSidebarV2VisibleThreads, @@ -2610,6 +2611,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve timestampFormat, thread.environmentId, ); + const pluginActions = threadMenuPluginActions(threadRef, thread.projectId); const threadProjectGroup = projectGroupsRef.current.find((project) => project.memberProjectRefs.some( @@ -2653,12 +2655,18 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve titleRegeneration: configLoaded, }, snoozePresets, + pluginActions, }), position, ), ); if (clicked._tag === "Failure" || clicked.value === null) return; if (threadActionRequiresOperate(clicked.value) && !checkThreadOperations([thread])) return; + const pluginAction = pluginActions.find((entry) => entry.id === clicked.value); + if (pluginAction) { + await runPluginAction(pluginAction); + return; + } if (clicked.value?.startsWith("snooze:")) { const preset = clicked.value === "snooze:custom" diff --git a/apps/web/src/components/chat/ChatComposer.pluginActions.browser.test.tsx b/apps/web/src/components/chat/ChatComposer.pluginActions.browser.test.tsx new file mode 100644 index 0000000000000..064765b6b5952 --- /dev/null +++ b/apps/web/src/components/chat/ChatComposer.pluginActions.browser.test.tsx @@ -0,0 +1,408 @@ +import { act, render as renderScreen, stubGlobal, type Screen } from "../../../test/render"; +import { + EnvironmentId, + PluginActionId, + ProjectId, + ProviderInstanceId, + ThreadId, + type PluginAction, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { createModelSelection } from "@t3tools/shared/model"; +import type { Editor } from "@tiptap/core"; +import { createRef } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { buildLocalDraftThread } from "../ChatView.logic"; +import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { ChatComposer, type ChatComposerHandle, type ChatComposerProps } from "./ChatComposer"; +const environmentId = EnvironmentId.make("environment-plugins"); +const threadId = ThreadId.make("thread-plugins"); +const projectId = ProjectId.make("project-plugins"); +const instanceId = ProviderInstanceId.make("codex"); +const deploy: PluginAction = { + id: PluginActionId.make("plugin-deploy:deploy"), + pluginId: "plugin-deploy", + pluginName: "Deploy", + name: "deploy", + title: "Deploy this thread", + target: "thread", + placements: ["composer-slash"], +}; +const openDashboard: PluginAction = { + id: PluginActionId.make("plugin-dashboard:open-dashboard"), + pluginId: "plugin-dashboard", + pluginName: "Dashboard", + name: "open-dashboard", + title: "Open the project dashboard", + target: "project", + placements: ["composer-slash"], +}; +const pluginActionsMock = vi.hoisted(() => ({ + runPluginAction: vi.fn<(input: unknown) => Promise>(async () => true), + // The live operate grant, which can change after the menu was offered. + canRunNow: true, +})); +// The environment's action list and the RPC that runs one are the boundaries. +vi.mock("../../state/pluginActions", () => ({ + usePluginActions: () => [deploy, openDashboard], +})); +vi.mock("../../pluginActions", () => ({ + runPluginAction: pluginActionsMock.runPluginAction, + canRunPluginActionsNow: () => pluginActionsMock.canRunNow, +})); +const modelSelection = createModelSelection(instanceId, "gpt-5.4"); +const thread = buildLocalDraftThread( + threadId, + { + threadId, + environmentId, + projectId, + logicalProjectKey: "project-plugins", + createdAt: "2026-10-04T00:00:00.000Z", + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + envMode: "local", + startFromOrigin: false, + }, + modelSelection, +); +const threadRef: ScopedThreadRef = { environmentId, threadId }; +const draftId = DraftId.make("draft-plugins"); +const noop = () => undefined; +function composerProps( + route: "server" | "draft", + onSend: ChatComposerProps["onSend"], + promptRef: React.RefObject, + canOperateThread: boolean, +): ChatComposerProps { + const isServer = route === "server"; + return { + composerDraftTarget: isServer ? threadRef : draftId, + environmentId, + canOperateThread, + attachmentUploadsCapabilityKnown: true, + supportsAttachmentUploads: false, + supportsQuestionAttachments: false, + maxFileAttachmentBytes: null, + routeKind: route, + routeThreadRef: threadRef, + draftId: isServer ? null : draftId, + multipleModelSelections: null, + supportsMultipleModels: false, + onMultipleModelSelectionsChange: noop, + activeThreadId: isServer ? threadId : null, + activeThread: thread, + activeThreadShell: isServer ? thread : null, + promptHistoryMessages: [], + forceExpandedOnMobile: false, + projectSelectionRequired: false, + phase: "ready", + canInterrupt: false, + isSendBusy: false, + isCompacting: false, + canResume: false, + sendDisabledReason: null, + isPreparingWorktree: false, + bannerItems: [], + resumeCompactionTokens: null, + keepFullHistory: false, + onToggleKeepFullHistory: noop, + environmentUnavailable: null, + activePendingApproval: null, + pendingApprovals: [], + onHeldRequestIdsChange: noop, + pendingUserInputs: [], + activePendingProgress: null, + activePendingResolvedAnswers: null, + activePendingIsResponding: false, + activePendingDraftAnswers: {}, + activePendingQuestionIndex: 0, + respondingRequestIds: [], + showPlanFollowUpPrompt: false, + activeProposedPlan: null, + threadSyncPhase: null, + runtimeMode: "full-access", + interactionMode: "default", + lockedProvider: null, + providerStatuses: [], + providerCatalogKnown: true, + activeProjectDefaultModelSelection: null, + activeThreadModelSelection: modelSelection, + activeProjectId: projectId, + activeContextWindow: null, + compactThreadUnavailable: true, + compactDisabled: true, + compactDisabledReason: null, + resolvedTheme: "light", + settings: DEFAULT_UNIFIED_SETTINGS, + keybindings: [], + terminalOpen: false, + gitCwd: null, + pullRequestProjectId: null, + pullRequestRepository: null, + threadPullRequests: [], + onComposerOverlayHeightChange: noop, + permissionsShownInStrip: false, + promptRef, + composerImagesRef: { current: [] }, + composerFilesRef: { current: [] }, + composerTerminalContextsRef: { current: [] }, + composerRef: createRef(), + onPageScrollKeyDown: noop, + onPageScrollKeyUp: noop, + onPageScrollRelease: noop, + editingQueuedAttachments: null, + onRemoveEditingQueuedAttachment: noop, + onCompactContext: noop, + onSend, + onResume: noop, + onInterrupt: noop, + onImplementPlanInNewThread: noop, + onSelectActivePendingUserInputOption: noop, + onAdvanceActivePendingUserInput: noop, + onDismissActivePendingUserInput: noop, + onPreviousActivePendingUserInputQuestion: noop, + onChangeActivePendingUserInputCustomAnswer: noop, + onProviderModelSelect: noop, + onOpenProviderSetup: noop, + getModelDisabledReason: () => null, + toggleInteractionMode: noop, + handleRuntimeModeChange: noop, + handleInteractionModeChange: noop, + focusComposer: noop, + scheduleComposerFocus: noop, + setThreadError: noop, + onExpandImage: noop, + onFileOpen: noop, + }; +} +async function renderComposer(screen: Screen, route: "server" | "draft", canOperateThread = true) { + const onSend = vi.fn(); + const promptRef: React.RefObject = { current: "" }; + const render = (canOperate: boolean) => + act( + async () => + await screen.rerender( + , + ), + ); + await render(canOperateThread); + return { onSend, promptRef, render }; +} +function promptEditor(screen: Screen): HTMLElement & { + editor?: Editor; +} { + const element = screen.container.querySelector('[data-testid="composer-editor"]'); + if (!element) throw new Error("The composer editor did not render"); + return element; +} +// Types `beforeCaret` then `afterCaret` into the real editor ("\n" starts a +// new line), leaving the caret between them as if the user moved it back. +async function typePrompt(screen: Screen, beforeCaret: string, afterCaret = "") { + const editor = promptEditor(screen).editor; + if (!editor) throw new Error("The composer editor has no Tiptap instance"); + const type = (text: string) => + text.split("\n").forEach((line, index) => { + if (index > 0) editor.commands.splitBlock(); + if (line) editor.commands.insertContent(line); + }); + let caret = 0; + await act(async () => { + editor.commands.focus(); + type(beforeCaret); + caret = editor.state.selection.from; + type(afterCaret); + }); + await act(async () => { + editor.commands.setTextSelection(caret); + }); +} +function editorLines(screen: Screen): string[] { + return [...promptEditor(screen).querySelectorAll("p")].map((line) => line.textContent ?? ""); +} +function menuOptions(): HTMLElement[] { + return [...document.querySelectorAll("[data-composer-item-id]")]; +} +function menuOption(label: string): HTMLElement { + const option = menuOptions().find((element) => element.textContent?.includes(label)); + if (!option) throw new Error(`No composer menu option ${label}`); + return option; +} +async function pressEnter(screen: Screen) { + await act(async () => { + promptEditor(screen).dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), + ); + }); +} +async function click(element: Element) { + await act(async () => { + element.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); + element.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} +describe("ChatComposer plugin actions in the slash menu", () => { + it("runs a thread action picked with Enter and removes the typed command", async () => { + using _setup = await setup(); + // The composer uses explicit test layers; these action tests do not navigate. + await using screen = await renderScreen(null, { router: false }); + const { onSend, promptRef } = await renderComposer(screen, "server"); + await typePrompt(screen, "please\n/depl", " now"); + expect(menuOption("/deploy").textContent).toContain("Deploy this thread"); + await pressEnter(screen); + expect(pluginActionsMock.runPluginAction).toHaveBeenCalledTimes(1); + expect(pluginActionsMock.runPluginAction).toHaveBeenCalledWith({ + environmentId, + action: deploy, + target: { _tag: "thread", threadId }, + }); + expect(promptRef.current).toBe("please\n now"); + expect(editorLines(screen)).toEqual(["please", " now"]); + expect(onSend).not.toHaveBeenCalled(); + }); + it("runs a thread action picked with the pointer and removes the typed command", async () => { + using _setup = await setup(); + // The composer uses explicit test layers; these action tests do not navigate. + await using screen = await renderScreen(null, { router: false }); + const { onSend, promptRef } = await renderComposer(screen, "server"); + await typePrompt(screen, "please\n/depl", " now"); + await click(menuOption("/deploy")); + expect(pluginActionsMock.runPluginAction).toHaveBeenCalledTimes(1); + expect(pluginActionsMock.runPluginAction).toHaveBeenCalledWith({ + environmentId, + action: deploy, + target: { _tag: "thread", threadId }, + }); + expect(promptRef.current).toBe("please\n now"); + expect(editorLines(screen)).toEqual(["please", " now"]); + expect(onSend).not.toHaveBeenCalled(); + }); + it("offers only project actions on a draft and runs them on the project", async () => { + using _setup = await setup(); + // The composer uses explicit test layers; these action tests do not navigate. + await using screen = await renderScreen(null, { router: false }); + const { onSend, promptRef } = await renderComposer(screen, "draft"); + await typePrompt(screen, "/"); + const labels = menuOptions().map((element) => element.textContent ?? ""); + expect(labels.some((label) => label.includes("/open-dashboard"))).toBe(true); + expect(labels.some((label) => label.includes("/deploy"))).toBe(false); + await click(menuOption("/open-dashboard")); + expect(pluginActionsMock.runPluginAction).toHaveBeenCalledTimes(1); + expect(pluginActionsMock.runPluginAction).toHaveBeenCalledWith({ + environmentId, + action: openDashboard, + target: { _tag: "project", projectId }, + }); + expect(promptRef.current).toBe(""); + expect(onSend).not.toHaveBeenCalled(); + }); + it("offers no plugin actions to a read-only connection and keeps the typed command", async () => { + using _setup = await setup(); + // The composer uses explicit test layers; these action tests do not navigate. + await using screen = await renderScreen(null, { router: false }); + const { promptRef } = await renderComposer(screen, "server", false); + await typePrompt(screen, "please\n/depl", " now"); + expect(menuOptions().some((element) => element.textContent?.includes("/deploy"))).toBe(false); + await pressEnter(screen); + expect(pluginActionsMock.runPluginAction).not.toHaveBeenCalled(); + expect(promptRef.current).toBe("please\n/depl now"); + expect(editorLines(screen)).toEqual(["please", "/depl now"]); + }); + it("keeps the typed command when the grant is lost while the menu is open", async () => { + using _setup = await setup(); + // The composer uses explicit test layers; these action tests do not navigate. + await using screen = await renderScreen(null, { router: false }); + const { promptRef, render } = await renderComposer(screen, "server"); + await typePrompt(screen, "please\n/depl", " now"); + expect(menuOption("/deploy").textContent).toContain("Deploy this thread"); + await render(false); + await pressEnter(screen); + expect(pluginActionsMock.runPluginAction).not.toHaveBeenCalled(); + expect(promptRef.current).toBe("please\n/depl now"); + }); + it("reads the live grant when the action is picked and keeps the draft if it is gone", async () => { + using _setup = await setup(); + // The composer uses explicit test layers; these action tests do not navigate. + await using screen = await renderScreen(null, { router: false }); + const { promptRef } = await renderComposer(screen, "server"); + await typePrompt(screen, "please\n/depl", " now"); + expect(menuOption("/deploy").textContent).toContain("Deploy this thread"); + // The cached grant still offers the action, but the connection lost it. + pluginActionsMock.canRunNow = false; + await pressEnter(screen); + expect(pluginActionsMock.runPluginAction).not.toHaveBeenCalled(); + expect(promptRef.current).toBe("please\n/depl now"); + expect(editorLines(screen)).toEqual(["please", "/depl now"]); + }); + it("removes the command when it is picked, before the action settles, and runs it once", async () => { + using _setup = await setup(); + // The composer uses explicit test layers; these action tests do not navigate. + await using screen = await renderScreen(null, { router: false }); + let finish: (ran: boolean) => void = () => {}; + pluginActionsMock.runPluginAction.mockReturnValue(new Promise((resolve) => (finish = resolve))); + const { promptRef } = await renderComposer(screen, "server"); + await typePrompt(screen, "please\n/depl", " now"); + await pressEnter(screen); + expect(promptRef.current).toBe("please\n now"); + // The command is gone, so picking again has nothing to run. + await pressEnter(screen); + await act(async () => finish(true)); + expect(pluginActionsMock.runPluginAction).toHaveBeenCalledTimes(1); + }); + it("leaves the draft alone when the action settles after the composer is gone", async () => { + using _setup = await setup(); + // The composer uses explicit test layers; these action tests do not navigate. + await using screen = await renderScreen(null, { router: false }); + let finish: (ran: boolean) => void = () => {}; + pluginActionsMock.runPluginAction.mockReturnValue(new Promise((resolve) => (finish = resolve))); + await renderComposer(screen, "server"); + await typePrompt(screen, "please\n/depl", " now"); + await pressEnter(screen); + await act(async () => await screen.unmount()); + useComposerDraftStore.getState().setPrompt(threadRef, "/depl newer draft"); + await act(async () => finish(true)); + expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt).toBe( + "/depl newer draft", + ); + }); +}); + +async function setup() { + const global0 = stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + const global1 = stubGlobal("matchMedia", (media: string) => ({ + matches: false, + media, + addEventListener() {}, + removeEventListener() {}, + })); + if (!("fonts" in document)) { + Object.defineProperty(document, "fonts", { configurable: true, value: new EventTarget() }); + } + Element.prototype.scrollIntoView ??= () => undefined; + Element.prototype.getAnimations ??= () => []; + Range.prototype.getClientRects ??= () => document.createElement("div").getClientRects(); + Range.prototype.getBoundingClientRect ??= () => new DOMRect(); + pluginActionsMock.runPluginAction.mockReset().mockResolvedValue(true); + pluginActionsMock.canRunNow = true; + useComposerDraftStore.getState().setPrompt(threadRef, ""); + useComposerDraftStore.getState().setPrompt(draftId, ""); + return { + [Symbol.dispose]() { + global1[Symbol.dispose](); + global0[Symbol.dispose](); + }, + }; +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c423773fb6fc6..f0910d3180f0b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -191,6 +191,8 @@ import { ComposerCommandMenu, composerSuggestionOptionId, } from "./ComposerCommandMenu"; +import { canRunPluginActionsNow, runPluginAction } from "../../pluginActions"; +import { usePluginActions } from "../../state/pluginActions"; import { ComposerAttachButton, ComposerAttachmentFileList, @@ -934,11 +936,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const selectedProviderSlashCommands = selectedProviderStatus ? resolveProviderSlashCommandsForCwd(selectedProviderStatus, gitCwd) : NO_PROVIDER_SLASH_COMMANDS; - const refreshWorkspaceSnapshotOnTyping = useProviderWorkspaceSnapshotRefresh({ - environmentId, - gitCwd, - instanceId: selectedProviderEntry?.instanceId, - }); + const { refresh: refreshWorkspaceSnapshotOnTyping, scanning: workspaceSkillsScanning } = + useProviderWorkspaceSnapshotRefresh({ + environmentId, + gitCwd, + instanceId: selectedProviderEntry?.instanceId, + }); const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; @@ -1117,6 +1120,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContexts.length === 0 && composerPreviewAnnotations.length === 0 && composerReviewComments.length === 0; + const pluginActions = usePluginActions(environmentId); + const pluginActionThreadId = routeKind === "server" ? activeThreadId : null; + const pluginActionProjectId = activeThread?.projectId ?? pullRequestProjectId; const linkedEnvironments = useLinkedEnvironments(environmentId); const { items: composerMenuItems, @@ -1139,6 +1145,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProviderSkills, selectedProviderSlashCommands, showSkillsInSlashMenu: settings.showSkillsInSlashMenu, + pluginActions, + canOperateThread, + pluginActionThreadId, + pluginActionProjectId, + workspaceSkillsScanning, questionAttachmentTarget, }); @@ -1883,6 +1894,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } + if (item.type === "plugin-action") { + // Keep the typed command when this connection may no longer run actions. + // The live grant is read because the menu may predate a permission change. + if (!canOperateThread || !canRunPluginActionsNow(environmentId)) return; + // Runs now, like the built-ins; nothing reaches the agent as prompt text. + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (applied) { + setComposerHighlightedItemId(null); + void runPluginAction({ environmentId, action: item.action, target: item.target }); + } + return; + } if (item.type === "provider-slash-command") { if (item.command.name === USAGE_LIMITS_COMMAND.name && onUsageLimitsCommand) { const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { @@ -3289,7 +3314,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? isComposerMenuLoading ? composerTriggerKind === "pull-request" ? "Finding pull request..." - : "Searching workspace files..." + : composerTriggerKind === "skill" || composerTriggerKind === "slash-command" + ? "Searching workspace skills..." + : "Searching workspace files..." : composerMenuEmptyState : ""}
diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 49f21a1ddc829..bc91deb826995 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -41,6 +41,7 @@ import { observeResize } from "~/lib/observeResize"; import { cn } from "~/lib/utils"; import { textDirection } from "~/textDirection"; import { useClientSettings } from "../../hooks/useSettings"; +import { ThreadContributionStatus } from "./ThreadContributionStatus"; import { GuestShareIndicator } from "./GuestShareControls"; interface ChatHeaderProps { @@ -430,6 +431,12 @@ export const ChatHeader = memo(function ChatHeader({ ) : null} + {isServerThread ? ( + + ) : null} {isServerThread ? : null} ); diff --git a/apps/web/src/components/chat/ChatTerminalDrawers.tsx b/apps/web/src/components/chat/ChatTerminalDrawers.tsx index c6e14b90ec185..784925fb55d5e 100644 --- a/apps/web/src/components/chat/ChatTerminalDrawers.tsx +++ b/apps/web/src/components/chat/ChatTerminalDrawers.tsx @@ -18,7 +18,7 @@ import { import { PersistentThreadTerminalDrawer, type PersistentTerminalLaunchContext, -} from "../PersistentThreadTerminals"; +} from "~/panels/terminal/PersistentThreadTerminalDrawer"; interface ChatTerminalDrawersProps { readonly activeThreadKey: string | null; diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index afa058587b553..193d0a4d9c709 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -5,6 +5,8 @@ import { type ProviderSkillSourceKind, } from "@t3tools/client-runtime/providerSkills"; import { + type PluginAction, + type PluginActionTarget, type EnvironmentId, type EnvironmentMachineKind, type ProjectEntry, @@ -19,6 +21,7 @@ import { FolderIcon, MessagesSquareIcon, PackageIcon, + PlugIcon, SettingsIcon, UserRoundIcon, type LucideIcon, @@ -81,6 +84,14 @@ export type ComposerCommandItem = label: string; description: string; } + | { + id: string; + type: "plugin-action"; + action: PluginAction; + target: PluginActionTarget; + label: string; + description: string; + } | { id: string; type: "environment"; @@ -152,7 +163,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {

{props.isLoading - ? props.triggerKind === "skill" + ? props.triggerKind === "skill" || props.triggerKind === "slash-command" ? "Searching workspace skills..." : props.triggerKind === "pull-request" ? "Finding pull request..." @@ -248,6 +259,12 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { showSkillSuffix={props.triggerKind === "skill"} /> ) : null} + {props.item.type === "plugin-action" ? ( + + + ) : null} ); diff --git a/apps/web/src/components/chat/ComposerControl.tsx b/apps/web/src/components/chat/ComposerControl.tsx index 139e80d081e4e..9c1e92d93641f 100644 --- a/apps/web/src/components/chat/ComposerControl.tsx +++ b/apps/web/src/components/chat/ComposerControl.tsx @@ -12,7 +12,7 @@ function composerControlClassName(size: ComposerControlSize, className?: string) "relative inline-flex shrink-0 cursor-pointer items-center justify-center whitespace-nowrap rounded-(--control-radius) border border-transparent text-base outline-none hover:bg-accent data-pressed:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 data-disabled:pointer-events-none data-disabled:opacity-64 pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 [&:active:not([aria-haspopup])]:scale-[0.97] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:-mx-0.5 [&_svg[data-composer-control-icon]]:mx-0", "[&_:is([data-composer-control-label],[data-chat-provider-model-picker-label])]:[text-box:trim-both_cap_alphabetic] supports-[text-box:trim-both_cap_alphabetic]:[&_:is([data-composer-control-label],[data-chat-provider-model-picker-label])]:py-1.5", size === "xs" - ? "h-7 gap-1 px-1.75 font-normal text-foreground/70 text-sm hover:text-foreground dark:text-muted-foreground sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5 [&_svg]:mx-0 [&_svg[data-composer-control-chevron]]:ms-0 [&_svg[data-composer-control-chevron]]:-me-1" + ? "h-7 gap-1 has-[[data-composer-label]]:gap-1.5 px-1.75 font-normal text-foreground/70 text-sm hover:text-foreground dark:text-muted-foreground sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5 [&_svg]:mx-0 [&_svg[data-composer-control-chevron]]:ms-0 [&_svg[data-composer-control-chevron]]:-me-1" : "h-7 gap-1.5 px-2 font-medium text-foreground/80 [&_svg:not([class*='text-'])]:text-muted-foreground hover:text-foreground sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4", "aria-pressed:bg-accent aria-pressed:text-accent-foreground aria-pressed:hover:bg-accent/80", className, diff --git a/apps/web/src/components/chat/ComposerImageThumbnail.tsx b/apps/web/src/components/chat/ComposerImageThumbnail.tsx index e9de2aa93b302..68158c7151b2f 100644 --- a/apps/web/src/components/chat/ComposerImageThumbnail.tsx +++ b/apps/web/src/components/chat/ComposerImageThumbnail.tsx @@ -1,24 +1,15 @@ -import { memo, Suspense, use, type ReactNode } from "react"; +import { memo, useLayoutEffect, useState, type ReactNode } from "react"; + +import { cn } from "~/lib/utils"; import { createComposerImageThumbnail, + isHeicImageFile, + MAX_COMPRESSIBLE_SOURCE_BYTES, readComposerImageThumbnail, } from "../../lib/imageCompression"; -export const ComposerImageThumbnail = memo(function ComposerImageThumbnail(props: { - file: File; - alt: string; - className: string; - fallback: ReactNode; -}) { - return ( - - - - ); -}); - -function ThumbnailImage({ +export const ComposerImageThumbnail = memo(function ComposerImageThumbnail({ file, alt, className, @@ -29,8 +20,63 @@ function ThumbnailImage({ className: string; fallback: ReactNode; }) { - // A settled thumbnail paints synchronously instead of suspending for a tick. - const settled = readComposerImageThumbnail(file); - const src = settled === undefined ? use(createComposerImageThumbnail(file)) : settled; - return src ? {alt} : fallback; -} + const [preview, setPreview] = useState(() => ({ + file, + src: readComposerImageThumbnail(file), + })); + const [loadedSrc, setLoadedSrc] = useState(null); + const [failedSrc, setFailedSrc] = useState(null); + const src = preview.file === file ? preview.src : readComposerImageThumbnail(file); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- owns the local blob URL and decodes its replacement before swapping the visible image + useLayoutEffect(() => { + let disposed = false; + let localUrl: string | undefined; + const cached = readComposerImageThumbnail(file); + if ( + file.size <= MAX_COMPRESSIBLE_SOURCE_BYTES && + !isHeicImageFile(file) && + cached === undefined + ) { + localUrl = URL.createObjectURL(file); + // oxlint-disable-next-line react/set-state-in-effect -- the blob URL is created and revoked by this effect + setPreview({ file, src: localUrl }); + } + if (cached === undefined) { + void createComposerImageThumbnail(file).then(async (thumbnail) => { + if (disposed) return; + if (!thumbnail) { + if (!localUrl) setPreview({ file, src: null }); + return; + } + const image = new Image(); + image.src = thumbnail; + try { + await image.decode(); + if (!disposed) setPreview({ file, src: thumbnail }); + } catch { + // Keep the local preview if the thumbnail cannot be decoded. + } + }); + } + return () => { + disposed = true; + if (localUrl) URL.revokeObjectURL(localUrl); + }; + }, [file]); + + return src && failedSrc !== src ? ( + { + if (image?.complete && image.naturalWidth > 0) setLoadedSrc(src); + }} + src={src} + alt={alt} + onLoad={() => setLoadedSrc(src)} + onError={() => setFailedSrc(src)} + className={cn(className, loadedSrc !== src && "invisible")} + /> + ) : src === undefined ? null : ( + fallback + ); +}); diff --git a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx index 6ca50c824cb45..7333185148d3d 100644 --- a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx +++ b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx @@ -9,30 +9,17 @@ import type { ThreadPullRequestLink, } from "@t3tools/contracts"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import { lazy, Suspense } from "react"; import SideChatPane from "./SideChatPane"; import type { ComposerImageAttachment, DraftId } from "../../composerDraftStore"; import type { ShortcutMatchContext } from "../../keybindings"; import type { SideChatParentPanel } from "../../lib/sideChatPane"; import { type RightPanelSurface, useRightPanelStore } from "../../rightPanelStore"; -import { - PersistentThreadTerminalPanel, - type PersistentThreadTerminalPanelProps, -} from "../PersistentThreadTerminals"; +import { type PersistentThreadTerminalPanelProps } from "~/panels/terminal/PersistentThreadTerminalDrawer"; import { pullRequestPanelContext } from "../pullRequest/pullRequestDetail.logic"; -import { PullRequestDetailPanel } from "../pullRequest/PullRequestDetailPanel"; import { PullRequestDetailGhost } from "../pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "../pullRequest/PullRequestsUnavailableState"; -import { ThreadPullRequestsPanel } from "../pullRequest/ThreadPullRequestsPanel"; -const PreviewPanel = lazy(() => - import("../preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), -); -const DiffPanel = lazy(() => import("../DiffPanel")); -const DevicePanel = lazy(() => - import("../device/DevicePanel").then((module) => ({ default: module.DevicePanel })), -); -const FilePreviewPanel = lazy(() => import("../files/FilePreviewPanel")); +import { RegisteredSidePanel } from "~/panels/bundledPanels"; type RightPanelTerminalProps = Omit< PersistentThreadTerminalPanelProps, @@ -79,43 +66,21 @@ export function RightPanelSurfaceContent(props: { readonly files: RightPanelFileProps; readonly sideChatParentPanel: SideChatParentPanel; }) { - const { surface, threadRef, visible } = props; + const { surface, threadRef, visible, onCloseSurface } = props; if (surface === null) return null; switch (surface.kind) { case "preview": return ( - - - - ); - case "terminal": - return ( - ); + case "terminal": + return ; case "diff": - return ( - - - - ); + return ; case "pull-request": { const pullRequest = props.pullRequest; if (!pullRequest.capabilityKnown) return ; @@ -128,20 +93,11 @@ export function RightPanelSurfaceContent(props: { ); } return ( - { - useRightPanelStore.getState().openPullRequest(threadRef, { - projectId: reference.projectId, - repository: reference.repository, - number: reference.number, - ...(reference.host ? { host: reference.host } : {}), - }); - }} - threadRef={threadRef} reference={{ projectId: surface.projectId as ProjectId, ...(surface.host ? { host: surface.host } : {}), @@ -161,13 +117,12 @@ export function RightPanelSurfaceContent(props: { }, surface, )} - composerDraftTarget={props.composerDraftTarget} onBack={pullRequest.onBack} /> ); } case "pull-requests": - return ; + return ; case "side-chat": // Not lazy: revealing a suspended tiptap editor tears down its view and crashes the composer. return ( @@ -176,24 +131,20 @@ export function RightPanelSurfaceContent(props: { parentRef={threadRef} threadId={surface.threadId} parentPanel={props.sideChatParentPanel} - onClose={() => props.onCloseSurface(surface)} + onClose={() => onCloseSurface(surface)} /> ); case "device": return ( - - { - props.onCloseSurface(surface); - useRightPanelStore.getState().show(threadRef); - }} - /> - + { + props.onCloseSurface(surface); + useRightPanelStore.getState().show(threadRef); + }} + /> ); case "files": case "file": { @@ -201,35 +152,28 @@ export function RightPanelSurfaceContent(props: { const attachment = surface.kind === "file" ? surface.attachment : undefined; if (!attachment && (files.projectName === null || !files.workspaceRoot)) return null; return ( - - - useRightPanelStore - .getState() - .navigateFolder(threadRef, surface.id, relativePath, folderTrail, keepSource) - } - folderTrail={surface.folderTrail} - onPendingChange={files.onPendingChange} - selectedFilePending={surface.kind === "file" && files.pendingSurfaceIds.has(surface.id)} - workspaceMutationId={props.workspaceMutationId} - /> - + + useRightPanelStore + .getState() + .navigateFolder(threadRef, surface.id, relativePath, folderTrail, keepSource) + } + folderTrail={surface.folderTrail} + onPendingChange={files.onPendingChange} + selectedFilePending={surface.kind === "file" && files.pendingSurfaceIds.has(surface.id)} + /> ); } } diff --git a/apps/web/src/components/chat/ThreadContributionStatus.browser.test.tsx b/apps/web/src/components/chat/ThreadContributionStatus.browser.test.tsx new file mode 100644 index 0000000000000..16fe1914e61f9 --- /dev/null +++ b/apps/web/src/components/chat/ThreadContributionStatus.browser.test.tsx @@ -0,0 +1,122 @@ +import { act, render as renderScreen, type Screen } from "../../../test/render"; +import { + type ContributionStatusEntry, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ThreadId, +} from "@t3tools/contracts"; +import { 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, +}); +async function renderStatus(screen: Screen, entries: ReadonlyArray) { + testState.entries = entries; + await act(async () => { + await screen.rerender( + , + ); + }); +} +/** The trigger's accessible name: its explicit label, else its text. */ +function accessibleName(element: Element) { + return element.getAttribute("aria-label") ?? element.textContent ?? ""; +} +function statusButton(screen: Screen) { + const button = [...screen.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 () => { + using _setup = await setup(); + await using screen = await renderScreen(null, { router: false }); + await renderStatus(screen, [ + 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(screen); + 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 () => { + using _setup = await setup(); + await using screen = await renderScreen(null, { router: false }); + await renderStatus(screen, [entry([{ key: "mode", text: "● plan" }])]); + const button = statusButton(screen); + 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 () => { + using _setup = await setup(); + await using screen = await renderScreen(null, { router: false }); + const longText = `● ${"x".repeat(78)}`; + await renderStatus(screen, [ + 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(screen))).toBe( + `Provider status: Plan, ${longText}, disk almost full`, + ); + await act(async () => statusButton(screen).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 () => { + using _setup = await setup(); + await using screen = await renderScreen(null, { router: false }); + await renderStatus(screen, []); + expect(screen.container.childElementCount).toBe(0); + }); +}); + +async function setup() { + return { + [Symbol.dispose]() { + testState.entries = []; + }, + }; +} 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 0000000000000..d7a1cb968ab3e --- /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 0000000000000..0393585f23455 --- /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.tsx b/apps/web/src/components/chat/ThreadContributionStatus.tsx new file mode 100644 index 0000000000000..0449a2c34f0ff --- /dev/null +++ b/apps/web/src/components/chat/ThreadContributionStatus.tsx @@ -0,0 +1,101 @@ +import type { ContributionStatusTone, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { memo } 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; + 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} + {chip.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 = contributionStatusChips(entries); + if (chips.length === 0) return null; + return ; +}); diff --git a/apps/web/src/components/chat/TimelineMinimap.tsx b/apps/web/src/components/chat/TimelineMinimap.tsx index 4e87edfe59495..742657ba68c0d 100644 --- a/apps/web/src/components/chat/TimelineMinimap.tsx +++ b/apps/web/src/components/chat/TimelineMinimap.tsx @@ -217,7 +217,7 @@ export function TimelineMinimap({
{fileTreeOpen ? ( - + ) : null} {unstructured} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 1bade1b4e160f..00f161e99f223 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -100,7 +100,7 @@ import { PullRequestDetailCrumbs } from "./PullRequestDetailCrumbs"; import { PullRequestTitleEditor } from "./PullRequestTitleEditor"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; import { SourceControlAppAccessNotice } from "../SourceControlAppAccessNotice"; -import { DiffPanelLoadingState } from "../DiffPanelShell"; +import { DiffPanelLoadingState } from "../diffs/DiffLoadingState"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab"; import { openOnHostLabel } from "./pullRequestLinkContextMenu"; diff --git a/apps/web/src/components/pullRequest/PullRequestsPage.tsx b/apps/web/src/components/pullRequest/PullRequestsPage.tsx index 359d86b4fc766..e13956c14938b 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPage.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPage.tsx @@ -134,6 +134,15 @@ const MAX_PAGE_SIZE = 500; const EMPTY_VIEWERS: PullRequestListResult["viewers"] = {}; const EMPTY_PARTITIONS: PullRequestPartitionsSnapshot = { authored: [], reviewing: [] }; const NO_LIST_TARGETS: ReadonlyArray> = []; +const UNAVAILABLE_SIDE_PANELS = { + preview: { available: false, onOpen: () => undefined }, + 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 }, + files: { available: false, onOpen: () => undefined }, +}; const EMPTY_PREVIEW_SESSIONS = {}; const EMPTY_PREVIEW_DESKTOP_STATE = {}; const EMPTY_TERMINAL_LABELS = new Map(); @@ -1487,21 +1496,8 @@ export function PullRequestsPage() { 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} - deviceAvailable={false} pullRequestStatusSeeds={listedPullRequestTabStatuses} > entry.instanceId === selection?.instanceId); - const mixedModel = useScopedSettingsMixed(["defaultModelSelection"]); + const mixedModel = defaultModelSelectionsAreMixed( + clientSettings, + targets.map((candidate) => ({ + projectId: candidate.projectId, + settings: candidate.settings, + providers: providersByEnvironment.get(candidate.environmentId) ?? EMPTY_SERVER_PROVIDERS, + })), + ); + const automaticModel = + targets.length > 0 && + targets.every((candidate) => candidate.settings.defaultModelSelection === null); const mixedFallback = useScopedSettingsMixed(["fallbackModelSelection"]); const fallbackSelection = settings.fallbackModelSelection ?? selection; const fallbackEntry = entries.find((entry) => entry.instanceId === fallbackSelection?.instanceId); @@ -100,6 +114,9 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting : null; const modelDisabledReason = useScopedModelDisabledReason(settings, entries); + const effortDisabledReason = selection + ? modelDisabledReason(selection.instanceId, selection.model) + : null; const setModel = (value: ModelSelection | null) => { const reason = value ? modelDisabledReason(value.instanceId, value.model) : null; @@ -120,13 +137,15 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting description={ isProjectScope ? "Model for new threads in this project." - : !unavailable && - !mixedModel && - modelSource !== "project" && - settings.defaultModelSelection === null + : !unavailable && modelSource !== "project" && automaticModel ? "Default model for new threads, selected automatically. Projects can override it." : "Default model for new threads. Projects can override it." } + status={ + mixedModel + ? `${automaticModel ? "Automatic defaults differ." : "Defaults differ."} Select one environment or choose a shared model to edit effort.` + : effortDisabledReason + } resetAction={ settings.defaultModelSelection !== null ? ( setModel(null)} /> @@ -143,7 +162,9 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting instanceEntries={entries} modelOptionsByInstance={modelOptions} triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} - {...(mixedModel ? { triggerLabel: "Mixed" } : {})} + {...(mixedModel + ? { triggerLabel: automaticModel ? "Automatic (mixed)" : "Mixed" } + : {})} getModelDisabledReason={modelDisabledReason} onOpenProviderSetup={(instanceId) => { if (representative) @@ -156,7 +177,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting setModel(createModelSelection(instanceId, model)) } /> - {!mixedModel ? ( + {!mixedModel && !effortDisabledReason ? ( ) : ( - No providers available + + {mixedModel ? "Mixed" : "No providers available"} + ) } /> diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx new file mode 100644 index 0000000000000..c29932f5757e1 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx @@ -0,0 +1,68 @@ +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { Profiler } from "react"; +import { expect, it, vi } from "vite-plus/test"; + +import { act, render } from "../../../test/render"; +import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { SidebarDraftBlock } from "./SidebarDraftBlock"; + +it("keeps draft previews current through edits, promotion and removal", async () => { + const initial = useComposerDraftStore.getState(); + using _store = { + [Symbol.dispose]: () => useComposerDraftStore.setState(initial, true), + }; + useComposerDraftStore.setState({ + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + }); + const environmentId = EnvironmentId.make("draft-preview-environment"); + const projectRef = scopeProjectRef(environmentId, ProjectId.make("draft-preview-project")); + const draftId = DraftId.make("draft-preview"); + const threadId = ThreadId.make("draft-preview-thread"); + const onRender = vi.fn(); + await using screen = await render( + +
    + +
+
, + ); + const row = screen.getByTestId("sidebar-draft-row"); + await expect.element(row).not.toBeInTheDocument(); + + await act(() => { + initial.setProjectDraftThreadId(projectRef, draftId, { threadId }); + initial.setPrompt(draftId, "First draft preview"); + }); + await expect.element(row).toHaveAccessibleName("First draft preview, Unsent draft"); + + const rendersBeforeOtherComposer = onRender.mock.calls.length; + await act(() => + initial.setPrompt(scopeThreadRef(environmentId, ThreadId.make("other")), "Other composer"), + ); + expect(onRender).toHaveBeenCalledTimes(rendersBeforeOtherComposer); + await act(() => initial.setPrompt(draftId, "Updated draft preview")); + await expect.element(row).toHaveAccessibleName("Updated draft preview, Unsent draft"); + + await act(() => initial.markDraftThreadPromoting(draftId)); + await expect.element(row).not.toBeInTheDocument(); + await act(() => initial.clearDraftThread(draftId)); + + const nextDraftId = DraftId.make("next-draft-preview"); + await act(() => { + initial.setProjectDraftThreadId(projectRef, nextDraftId, { threadId: ThreadId.make("next") }); + initial.setPrompt(nextDraftId, "Next draft preview"); + }); + await expect.element(row).toHaveAccessibleName("Next draft preview, Unsent draft"); + await act(() => initial.clearDraftThread(nextDraftId)); + await expect.element(row).not.toBeInTheDocument(); +}); diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx index a9301f30cb0e5..10581ecb5a679 100644 --- a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx @@ -8,6 +8,7 @@ import { type MouseEvent as ReactMouseEvent, } from "react"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { useShallow } from "zustand/react/shallow"; import { cn } from "~/lib/utils"; import { resolveSidebarRowAccessibility } from "../Sidebar.logic"; import { ProjectFavicon, type ProjectFaviconProject } from "../ProjectFavicon"; @@ -147,7 +148,15 @@ export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; }) { const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); - const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); + const draftsByThreadKey = useComposerDraftStore( + useShallow((store) => + Object.fromEntries( + Object.keys(store.draftThreadsByThreadKey).map( + (key) => [key, store.draftsByThreadKey[key]] as const, + ), + ), + ), + ); const [frozenActive, setFrozenActive] = useState<{ routeDraftId: string | null; row: SidebarDraftRowData | null; diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index bd9dfdcdcc4b6..d0313396b5136 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -65,6 +65,32 @@ describe("buildThreadActionMenuItems", () => { }, ); + it("lists plugin actions as their own section before Copy", () => { + const items = buildThreadActionMenuItems({ + ...baseState, + pluginActions: [ + { id: "plugin-action:a", label: "Open dashboard" }, + { id: "plugin-action:b", label: "Deploy" }, + ], + }); + const first = items.findIndex((item) => item.id === "plugin-action:a"); + expect(items.slice(first, first + 3).map((item) => [item.id, item.separatorBefore])).toEqual([ + ["plugin-action:a", true], + ["plugin-action:b", undefined], + ["copy", true], + ]); + expect(buildThreadActionMenuItems(baseState).map((item) => item.id)).not.toContain( + "plugin-action:a", + ); + const denied = buildThreadActionMenuItems({ + ...baseState, + canOperate: false, + pluginActions: [{ id: "plugin-action:a", label: "Open dashboard" }], + }); + // Running a plugin action needs orchestration:operate, like the thread mutations. + expect(denied.find((item) => item.id === "plugin-action:a")?.disabled).toBe(true); + }); + it("disables archive and turning into a project while the thread is running", () => { const items = buildThreadActionMenuItems({ ...baseState, diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index ac4ef649ed07c..bd9025e7a8059 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -27,7 +27,8 @@ export type ThreadActionMenuId = | "copy-thread" | "copy-thread-id" | "archive" - | "delete"; + | "delete" + | `plugin-action:${string}`; export type DraftActionMenuId = | "copy" @@ -91,6 +92,11 @@ export interface ThreadActionMenuState { readonly titleRegeneration: boolean; }; readonly snoozePresets: ReadonlyArray; + /** Actions enabled plugins offer on this thread's menu, in listing order. */ + readonly pluginActions?: ReadonlyArray<{ + readonly id: `plugin-action:${string}`; + readonly label: string; + }>; /** Guest links need their own grant, so the caller checks it instead of `canOperate`. */ readonly canShareWithGuest?: boolean; /** A No project thread with its own folder, on a server that can promote it. */ @@ -223,6 +229,12 @@ export function buildThreadActionMenuItems( }, ] : []), + ...(state.pluginActions ?? []).map((action, index) => ({ + id: action.id, + label: action.label, + icon: "plug", + ...(index === 0 ? { separatorBefore: true } : {}), + })), { id: "copy", label: "Copy", diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index bd6aa9fd41651..4e6dfe04a7ebb 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -53,7 +53,11 @@ import type { import { waitForDraftHeroTransition } from "./components/chat/draftHeroTransition"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { readThreadPreviewState } from "./previewStateStore"; -import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection"; +import { + readInstanceCustomModels, + resolveAppModelSelection, + resolveAppModelSelectionForInstance, +} from "./modelSelection"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -1188,6 +1192,7 @@ function withFallbackInstanceOptions(input: { readonly selectedProvider: ProviderDriverKind; readonly selectedInstanceId?: ProviderInstanceId | null | undefined; readonly requestedInstanceId?: ProviderInstanceId | null | undefined; + readonly settings: UnifiedSettings; }): ProviderOptionSelectionsByProvider | null { const { modelOptions, selectedInstanceId, requestedInstanceId } = input; if ( @@ -1200,16 +1205,26 @@ function withFallbackInstanceOptions(input: { return modelOptions; } const requestedOptions = modelOptions[requestedInstanceId]; - const requestedDriver = input.providers.find( + const requestedProvider = input.providers.find( (provider) => provider.instanceId === requestedInstanceId, - )?.driver; - // The model of the selection the options came from, which can differ from the shown model's source. + ); const requestedModel = input.optionSelections?.[requestedInstanceId]?.model; - if ( - !requestedOptions || - requestedDriver !== input.selectedProvider || - requestedModel !== input.selectedModel - ) { + // Static aliases are shared across instances; exact custom slugs and catalog aliases are not. + const modelIdentity = (instanceId: ProviderInstanceId, model: string | undefined) => { + const isCustom = + input.providers + .find((provider) => provider.instanceId === instanceId) + ?.models.some((entry) => entry.slug === model && entry.isCustom) || + readInstanceCustomModels(input.settings, instanceId, input.selectedProvider).some( + (entry) => entry.slug === model, + ); + return isCustom ? model : normalizeModelSlug(model, input.selectedProvider); + }; + const sameModel = + requestedModel === input.selectedModel || + modelIdentity(requestedInstanceId, requestedModel) === + modelIdentity(selectedInstanceId, input.selectedModel); + if (!requestedOptions || requestedProvider?.driver !== input.selectedProvider || !sameModel) { return modelOptions; } return { ...modelOptions, [selectedInstanceId]: requestedOptions }; diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index a3e760ddc58ab..431a913a15fd6 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -103,6 +103,12 @@ const ICON_PATHS: Record + +
+ + ); +} + +describe("useResizableWidth", () => { + it("follows a responsive default until the user saves a width", async () => { + const previous = localStorage.getItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY); + try { + // This hook only needs browser storage and React; no navigation is involved. + await using screen = await render(, { router: false }); + expect(screen.container.textContent).toBe("320"); + await screen.rerender(); + expect(screen.container.textContent).toBe("200"); + + await act(() => screen.container.querySelector("button")!.click()); + await screen.rerender(); + expect(screen.container.textContent).toBe("350"); + + await screen.unmount(); + await using restored = await render(, { router: false }); + expect(restored.container.textContent).toBe("350"); + } finally { + if (previous === null) localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, previous); + } + }); + + it("keeps the default responsive after a stationary click and persists an actual drag", async () => { + const previous = localStorage.getItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY); + try { + // This hook only needs browser storage and React; no navigation is involved. + await using screen = await render(, { router: false }); + await screen.getByRole("separator").click(); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + await screen.rerender(); + expect(screen.container.textContent).toBe("256"); + + const handle = screen.container.querySelector('[role="separator"]')!; + // Synthetic drag events have no active browser pointer to capture. + using _capture = vi.spyOn(handle, "setPointerCapture").mockImplementation(() => {}); + const pointer = { bubbles: true, pointerId: 1, button: 0 }; + await act(() => { + handle.dispatchEvent(new PointerEvent("pointerdown", { ...pointer, clientX: 500 })); + }); + await screen.rerender(); + await act(() => { + handle.dispatchEvent(new PointerEvent("pointerup", { ...pointer, clientX: 500 })); + }); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + expect(screen.container.style.getPropertyValue(RESIZABLE_WIDTH_PROPERTY)).toBe("280px"); + + await act(() => { + handle.dispatchEvent(new PointerEvent("pointerdown", { ...pointer, clientX: 500 })); + handle.dispatchEvent(new PointerEvent("pointerup", { ...pointer, clientX: 450 })); + }); + expect(localStorage.getItem(STORAGE_KEY)).toBe("330"); + await screen.rerender(); + expect(screen.container.textContent).toBe("330"); + } finally { + if (previous === null) localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, previous); + } + }); +}); diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 50fdba38a9fc5..dc5fe8569d03e 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -3,6 +3,7 @@ import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useState } fro import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; import { useResizeDrag } from "./useResizeDrag"; +import { useStableCallback } from "./useStableCallback"; const WidthSchema = Schema.Finite; @@ -26,7 +27,7 @@ export interface ResizableWidthHandlers { readonly onLostPointerCapture: (event: ReactPointerEvent) => void; } -/** Persist on drag end to avoid a storage write on every animation frame. */ +/** Uses the current default until a width is chosen; persists on drag end, not every frame. */ export function useResizableWidth(options: UseResizableWidthOptions): { readonly width: number; readonly setWidth: (width: number) => void; @@ -39,14 +40,14 @@ export function useResizableWidth(options: UseResizableWidthOptions): { return Math.max(minWidth, Math.min(maxWidth, value)); }; - const readWidth = () => { - if (typeof window === "undefined") return defaultWidth; + const readWidth = (): number | null => { + if (typeof window === "undefined") return null; try { const stored = getLocalStorageItem(storageKey, WidthSchema); - return clamp(stored ?? defaultWidth); + return stored === null ? null : clamp(stored); } catch (error) { console.error("Could not read persisted panel width.", error); - return defaultWidth; + return null; } }; const [widthState, setWidthState] = useState(() => ({ storageKey, width: readWidth() })); @@ -54,7 +55,10 @@ export function useResizableWidth(options: UseResizableWidthOptions): { setWidthState({ storageKey, width: readWidth() }); } - const clampedWidth = clamp(widthState.width); + const clampedWidth = clamp(widthState.width ?? defaultWidth); + const restoreWidth = useStableCallback((host: HTMLElement | null) => { + host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${clampedWidth}px`); + }); const commitWidth = (nextStorageKey: string, nextWidth: number) => { // Resizing remains usable for this session when browser storage is unavailable. @@ -82,8 +86,9 @@ export function useResizableWidth(options: UseResizableWidthOptions): { host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${nextWidth}px`); return nextWidth; }, - finish(finalWidth) { - commitWidth(startStorageKey, finalWidth); + finish(finalWidth, moved) { + if (moved) commitWidth(startStorageKey, finalWidth); + else restoreWidth(host); }, cleanup(committed) { host?.style.removeProperty("transition-property"); diff --git a/apps/web/src/hooks/useThreadActionMenu.test.ts b/apps/web/src/hooks/useThreadActionMenu.test.ts index 3c8255b04c088..5b7d755099954 100644 --- a/apps/web/src/hooks/useThreadActionMenu.test.ts +++ b/apps/web/src/hooks/useThreadActionMenu.test.ts @@ -39,6 +39,11 @@ vi.mock("react", () => ({ useCallback: (callback: unknown) => callback, useMemo: (factory: () => unknown) => factory(), })); +// The hook reads plugin actions for the menu; these tests cover the built-in items only. +vi.mock("../pluginActions", () => ({ + threadMenuPluginActions: () => [], + runPluginAction: async () => undefined, +})); vi.mock("@tanstack/react-router", () => ({ useRouter: () => ({ navigate: async () => recordEffect("project-settings") }), })); diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index cddef6046cf8b..df577d3e633b1 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -35,6 +35,7 @@ import { } from "../state/entities"; import { usePrimaryEnvironmentId } from "../state/environments"; import { readLocalApi } from "../localApi"; +import { runPluginAction, threadMenuPluginActions } from "../pluginActions"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKey, @@ -132,6 +133,7 @@ export function useThreadActionMenu(input: { }; const isRegeneratingTitle = thread.titleRegeneration != null; const snoozePresets = resolveSnoozePresets(now, timestampFormat, threadRef.environmentId); + const pluginActions = threadMenuPluginActions(threadRef, thread.projectId); const items = buildThreadActionMenuItems({ canOperate: readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope), canFind: onFind !== undefined, @@ -146,6 +148,7 @@ export function useThreadActionMenu(input: { isRunning: !threadRuntimeCanArchive(thread.runtime), supports, snoozePresets, + pluginActions, canShareWithGuest, canTurnIntoProject: canTurnThreadIntoProject(threadRef), }); @@ -162,6 +165,11 @@ export function useThreadActionMenu(input: { ); return; } + const pluginAction = pluginActions.find((entry) => entry.id === action); + if (pluginAction) { + await runPluginAction(pluginAction); + return; + } if (action.startsWith("snooze:")) { const preset = action === "snooze:custom" diff --git a/apps/web/src/localEnvironment.ts b/apps/web/src/localEnvironment.ts index d9f4be0e29518..bccffd64ce23d 100644 --- a/apps/web/src/localEnvironment.ts +++ b/apps/web/src/localEnvironment.ts @@ -1,3 +1,5 @@ export function isLocalEnvironmentDisabled(): boolean { - return window.desktopBridge?.getLocalEnvironmentEnabled() === false; + return ( + typeof window !== "undefined" && window.desktopBridge?.getLocalEnvironmentEnabled() === false + ); } diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index b0152aa726a83..435854d54b393 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -1,11 +1,17 @@ import { ANTIGRAVITY_DEFAULT_MODEL, DEFAULT_TEXT_GENERATION_MODEL, + DEFAULT_SERVER_SETTINGS, + ProjectId, ProviderDriverKind, ProviderInstanceId, type ServerProvider, } from "@t3tools/contracts"; -import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; +import { + DEFAULT_CLIENT_SETTINGS, + DEFAULT_UNIFIED_SETTINGS, + type UnifiedSettings, +} from "@t3tools/contracts/settings"; import { describe, expect, it } from "vite-plus/test"; import { createModelSelection } from "@t3tools/shared/model"; import { deriveEffectiveComposerModelState } from "./composerDraftStore"; @@ -17,6 +23,7 @@ import { providerInstanceEntriesFor, } from "./providerInstances"; import { + defaultModelSelectionsAreMixed, getAppModelOptionsForInstance, resolveAppModelSelectionForInstance, resolveAppModelSelectionState, @@ -73,6 +80,142 @@ function settingsWithProviderInstances(): UnifiedSettings { }; } +describe("scoped default model selections", () => { + // regression: #27997 equal automatic defaults can resolve to different providers + it("compares automatic defaults using each environment's models", () => { + const codex = { + projectId: null, + settings: DEFAULT_UNIFIED_SETTINGS, + providers: [provider({ instanceId: "codex", models: ["gpt-6-astra"] })], + }; + const claude = { + projectId: null, + settings: DEFAULT_UNIFIED_SETTINGS, + providers: [ + provider({ + instanceId: "claudeAgent", + provider: ProviderDriverKind.make("claudeAgent"), + models: ["claude-sonnet-4-6"], + }), + ], + }; + + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [codex, claude])).toBe(true); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [claude, codex])).toBe(true); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [codex, codex])).toBe(false); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [claude])).toBe(false); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [])).toBe(false); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [codex, { ...codex, providers: [] }]), + ).toBe(true); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ + codex, + { ...codex, providers: [provider({ instanceId: "codex", models: ["gpt-6-sol"] })] }, + ]), + ).toBe(true); + + const projectId = ProjectId.make("claude-only"); + const project = { + projectId, + settings: { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { + [projectId]: { + providerInstances: { [ProviderInstanceId.make("codex")]: { enabled: false } }, + }, + }, + }, + providers: [...codex.providers, ...claude.providers], + }; + const remoteProject = { ...claude, projectId: ProjectId.make("remote-project") }; + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [project, remoteProject])).toBe( + false, + ); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [remoteProject, project])).toBe( + false, + ); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ + project, + { ...project, projectId: ProjectId.make("other-project") }, + ]), + ).toBe(true); + + const configured = { + projectId: null, + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerModelPreferences: { + [ProviderInstanceId.make("codex")]: { + hiddenModels: ["gpt-6-astra"], + modelOrder: [], + }, + }, + }, + providers: [provider({ instanceId: "codex", models: ["gpt-6-astra", "gpt-6-sol"] })], + }; + const inherited = { ...configured, settings: DEFAULT_SERVER_SETTINGS }; + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [configured, inherited])).toBe( + true, + ); + expect(defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [inherited, configured])).toBe( + true, + ); + const clientSettings = { + ...DEFAULT_CLIENT_SETTINGS, + providerModelPreferences: configured.settings.providerModelPreferences, + }; + expect(defaultModelSelectionsAreMixed(clientSettings, [configured, inherited])).toBe(false); + expect(defaultModelSelectionsAreMixed(clientSettings, [inherited, configured])).toBe(false); + expect( + defaultModelSelectionsAreMixed(clientSettings, [ + { ...inherited, settings: { ...DEFAULT_SERVER_SETTINGS, providerModelPreferences: {} } }, + inherited, + ]), + ).toBe(true); + }); + + it("keeps automatic, explicit, and different saved options distinct", () => { + const providers = [provider({ instanceId: "codex", models: ["gpt-6-astra"] })]; + const selected = { + projectId: null, + providers, + settings: { + ...DEFAULT_UNIFIED_SETTINGS, + defaultModelSelection: createModelSelection( + ProviderInstanceId.make("codex"), + "gpt-6-astra", + ), + }, + }; + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [selected, { ...selected }]), + ).toBe(false); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ + selected, + { projectId: null, providers, settings: DEFAULT_UNIFIED_SETTINGS }, + ]), + ).toBe(true); + expect( + defaultModelSelectionsAreMixed(DEFAULT_CLIENT_SETTINGS, [ + selected, + { + ...selected, + settings: { + ...selected.settings, + defaultModelSelection: { + ...selected.settings.defaultModelSelection, + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + }, + }, + ]), + ).toBe(true); + }); +}); + describe("instance-scoped model selection", () => { it("keeps custom models on the provider instance that declared them", () => { const providers = [ @@ -860,6 +1003,7 @@ describe("composer fallback to another instance of the same driver", () => { readonly providers: ReadonlyArray; readonly draft?: Parameters[0]["draft"]; readonly threadModelSelection: ReturnType; + readonly customModels?: Partial>>; }) { const settings = { ...DEFAULT_UNIFIED_SETTINGS, @@ -869,7 +1013,13 @@ describe("composer fallback to another instance of the same driver", () => { { driver: provider.driver, enabled: provider.enabled, - config: {}, + config: { + customModels: (input.customModels?.[provider.instanceId] ?? []).map((slug) => ({ + slug, + name: slug, + capabilities: null, + })), + }, }, ]), ), @@ -954,6 +1104,138 @@ describe("composer fallback to another instance of the same driver", () => { expect(traits.modelOptionsForDispatch).toBeUndefined(); }); + describe("with model aliases", () => { + const reasoningModel = ( + slug: string, + id: string, + extra: Partial = {}, + ): ServerProvider["models"][number] => ({ + slug, + name: slug, + isCustom: false, + ...extra, + capabilities: { + optionDescriptors: [ + { + id, + label: id, + type: "select", + options: ["medium", "high", "xhigh"].map((value) => ({ + id: value, + label: value, + isDefault: value === "medium", + })), + }, + ], + }, + }); + + it.each([ + ["5.4", "gpt-5.4"], + ["gpt-5-codex", "gpt-5.4"], + ["gpt-5.4", "5.4"], + ])("keeps the effort from %s on fallback %s", (savedModel, fallbackModel) => { + const disabledCodex = ProviderInstanceId.make("codex"); + const fallbackCodex = ProviderInstanceId.make("codex_team"); + const models = [reasoningModel(fallbackModel, "reasoningEffort")]; + const { selectedInstanceId, state, traits } = composerFor({ + providers: [ + { ...provider({ provider: codex, instanceId: disabledCodex }), enabled: false, models }, + { ...provider({ provider: codex, instanceId: fallbackCodex }), models }, + ], + threadModelSelection: createModelSelection(disabledCodex, savedModel, [ + { id: "reasoningEffort", value: "xhigh" }, + ]), + }); + + expect(selectedInstanceId).toBe(fallbackCodex); + expect(state.selectedModel).toBe(fallbackModel); + expect(traits.promptEffort).toBe("xhigh"); + }); + + it.each([ + { + // A disabled Codex instance reports only its custom models. + state: "disabled", + requested: { + enabled: false, + models: [reasoningModel("5.4", "reasoningEffort", { isCustom: true })], + }, + }, + { + // An unavailable instance reports no models; its custom models are only in settings. + state: "unavailable", + requested: { enabled: false, availability: "unavailable" as const, models: [] }, + }, + ])( + "does not alias-expand a custom model named 5.4 when the requested instance is $state", + ({ requested }) => { + const disabledCodex = ProviderInstanceId.make("codex"); + const { state, traits } = composerFor({ + providers: [ + { ...provider({ provider: codex, instanceId: disabledCodex }), ...requested }, + { + ...provider({ provider: codex, instanceId: "codex_team" }), + models: [reasoningModel("gpt-5.4", "reasoningEffort")], + }, + ], + customModels: { [disabledCodex]: ["5.4"] }, + threadModelSelection: createModelSelection(disabledCodex, "5.4", [ + { id: "reasoningEffort", value: "xhigh" }, + ]), + }); + + expect(state.selectedModel).toBe("gpt-5.4"); + expect(traits.modelOptionsForDispatch).toBeUndefined(); + }, + ); + + it("does not alias-expand the fallback instance's custom model", () => { + const { state, traits } = composerFor({ + providers: [ + { ...provider({ provider: codex, instanceId: "codex" }), enabled: false }, + { + ...provider({ provider: codex, instanceId: "codex_team" }), + models: [reasoningModel("5.4", "reasoningEffort", { isCustom: true })], + }, + ], + customModels: { codex_team: ["5.4"] }, + threadModelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "xhigh" }, + ]), + }); + + expect(state.selectedModel).toBe("5.4"); + expect(traits.modelOptionsForDispatch).toBeUndefined(); + }); + + it("does not carry options through an alias that names another model per instance", () => { + // "antigravity-default" is each Antigravity instance's current model. + const antigravity = ProviderDriverKind.make("antigravity"); + const disabledAntigravity = ProviderInstanceId.make("antigravity"); + const current = { isDefault: true, aliases: [ANTIGRAVITY_DEFAULT_MODEL] }; + const { state, traits } = composerFor({ + providers: [ + { + ...provider({ provider: antigravity, instanceId: disabledAntigravity }), + enabled: false, + models: [reasoningModel("gemini-3-pro", "thinking", current)], + }, + { + ...provider({ provider: antigravity, instanceId: "antigravity_work" }), + models: [reasoningModel("claude-sonnet-4-6", "thinking", current)], + }, + ], + threadModelSelection: createModelSelection(disabledAntigravity, ANTIGRAVITY_DEFAULT_MODEL, [ + { id: "thinking", value: "high" }, + ]), + }); + + expect(state.selectedModel).toBe("claude-sonnet-4-6"); + expect(traits.modelOptionsForDispatch).toBeUndefined(); + }); + }); + it("does not carry options to another driver", () => { const codexOnly: ReadonlyArray = [ { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index db52028c397e0..ec2816cc2849a 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -4,9 +4,11 @@ import { DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, type ModelSelection, + type ProjectId, ProviderDriverKind, ProviderInstanceId, type ServerProvider, + type ServerSettings, } from "@t3tools/contracts"; import { type CustomModelDefinition, @@ -16,8 +18,10 @@ import { resolveSelectableModel, } from "@t3tools/shared/model"; import { getComposerProviderState } from "./components/chat/composerProviderState"; -import { UnifiedSettings } from "@t3tools/contracts/settings"; +import { type ClientSettings, UnifiedSettings } from "@t3tools/contracts/settings"; +import { resolveModelPreferences } from "@t3tools/shared/serverSettings"; import * as Arr from "effect/Array"; +import * as Equal from "effect/Equal"; import * as Result from "effect/Result"; import { getDefaultServerModel, @@ -29,6 +33,8 @@ import { type ProviderInstanceEntry, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, + resolveDefaultProviderModelSelection, + resolveProjectDefaultProviderModelSelection, } from "./providerInstances"; import { sortModelsForProviderInstance } from "./modelOrdering"; import { resolveModelShortName } from "@t3tools/client-runtime/provider-clients"; @@ -37,7 +43,7 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256; const DEFAULT_TEXT_GENERATION_INSTANCE_ID = ProviderInstanceId.make("codex"); // Legacy per-driver buckets would leak custom-instance models into stock instances. -function readInstanceCustomModels( +export function readInstanceCustomModels( settings: UnifiedSettings, instanceId: ProviderInstanceId, driverKind: ProviderDriverKind, @@ -308,6 +314,38 @@ export function resolveEffectiveDefaultModelSelection( return model === null || model === selection.model ? selection : { ...selection, model }; } +export function defaultModelSelectionsAreMixed( + clientSettings: ClientSettings, + targets: ReadonlyArray<{ + projectId: ProjectId | null; + settings: ServerSettings; + providers: ReadonlyArray; + }>, +): boolean { + const selections = targets.map(({ projectId, settings, providers }) => { + const stored = settings.defaultModelSelection; + const selection = + stored === null + ? resolveProjectDefaultProviderModelSelection(providers, settings, projectId) + : resolveDefaultProviderModelSelection(providers, stored); + return { + stored, + effective: selection + ? resolveEffectiveDefaultModelSelection( + { + ...clientSettings, + ...settings, + ...resolveModelPreferences(settings, clientSettings), + }, + providers, + selection, + ) + : null, + }; + }); + return selections.some((selection) => !Equal.equals(selection, selections[0])); +} + export function getCustomModelOptionsByInstance( settings: UnifiedSettings, providers: ReadonlyArray, diff --git a/apps/web/src/panels/bundledPanels.browser.test.tsx b/apps/web/src/panels/bundledPanels.browser.test.tsx new file mode 100644 index 0000000000000..9faf130495129 --- /dev/null +++ b/apps/web/src/panels/bundledPanels.browser.test.tsx @@ -0,0 +1,257 @@ +import { act, render as renderScreen } from "../../test/render"; +import { EnvironmentId, ProjectId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { Suspense } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const loaded = vi.hoisted(() => ({ + diff: 0, + preview: 0, + terminal: 0, + device: 0, + pullRequest: 0, + pullRequests: 0, + files: 0, + previewRenders: [] as unknown[], +})); +const imports = vi.hoisted(() => { + const deferred = () => { + let resolve!: () => void; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve }; + }; + return { preview: deferred(), terminal: deferred() }; +}); +vi.mock("./diff/DiffSidePanel", () => { + loaded.diff += 1; + return { default: () => null }; +}); +vi.mock("./terminal/TerminalSidePanel", () => { + loaded.terminal += 1; + imports.terminal.resolve(); + return { default: () => Terminal body }; +}); +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; + imports.preview.resolve(); + return { + default: function PreviewSidePanel(props: unknown) { + loaded.previewRenders.push({ props, host: usePanelHost() }); + return Preview body; + }, + }; +}); + +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"; +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 () => { + await using screen = await renderScreen(null, { router: false }); + expect(loaded).toMatchObject({ + diff: 0, + preview: 0, + terminal: 0, + device: 0, + pullRequest: 0, + pullRequests: 0, + files: 0, + }); + await screen.rerender( + + + + + , + ); + // Await the selected chunk after the registry has requested it. + await act(() => imports.preview.promise); + expect(loaded).toMatchObject({ + diff: 0, + preview: 1, + terminal: 0, + device: 0, + pullRequest: 0, + pullRequests: 0, + files: 0, + }); + await expect.element(screen.getByText("Preview body", { exact: true })).toBeVisible(); + expect(loaded.previewRenders).toEqual([{ props: { tabId: "tab-1" }, host }]); + }); + + it("loads only the terminal body when the terminal is selected", async () => { + await using screen = await renderScreen(null, { router: false }); + const previewLoadsBefore = loaded.preview; + const surface: Extract = { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", + }; + await act(async () => { + await screen.rerender( + + + undefined} + onSplitTerminal={() => undefined} + onSplitTerminalVertical={() => undefined} + onNewTerminal={() => undefined} + onActiveTerminalChange={() => undefined} + onCloseTerminal={() => undefined} + /> + + , + ); + }); + await act(async () => { + await imports.terminal.promise; + }); + await expect.element(screen.getByText("Terminal body", { exact: true })).toBeVisible(); + expect(screen.container.textContent).toBe("Terminal body"); + expect(loaded).toMatchObject({ + diff: 0, + preview: previewLoadsBefore, + terminal: 1, + device: 0, + pullRequest: 0, + pullRequests: 0, + files: 0, + }); + }); +}); + +const filesProps = { + onNavigateFolder: () => undefined, + folderTrail: undefined, + 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( + widenedId: "diff" | "preview", + terminalSurface: Extract, + deviceSurface: Extract, + dismiss: () => void, +) { + const terminalProps = { + surface: terminalSurface, + launchContext: null, + onAddTerminalContext: () => undefined, + onSplitTerminal: () => undefined, + onSplitTerminalVertical: () => undefined, + onNewTerminal: () => undefined, + 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 ( + <> + + + + {/* @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 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 Files props on Preview. */} + + {/* @ts-expect-error Files needs its surface inputs. */} + + + {/* @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. */} + + {/* @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 new file mode 100644 index 0000000000000..b8211ca1b5055 --- /dev/null +++ b/apps/web/src/panels/bundledPanels.tsx @@ -0,0 +1,113 @@ +import { FileDiff, Files, 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([ + { + 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"), + }, + { + 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"), + }, + { + 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"), + }, + { + 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"), + }, + { + 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"]; + +/** 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); + // 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/device/DeviceSidePanel.browser.test.tsx b/apps/web/src/panels/device/DeviceSidePanel.browser.test.tsx new file mode 100644 index 0000000000000..47e31dd6eb6a6 --- /dev/null +++ b/apps/web/src/panels/device/DeviceSidePanel.browser.test.tsx @@ -0,0 +1,292 @@ +import { act, render as renderScreen, type Screen } from "../../../test/render"; +import { + EnvironmentId, + ThreadId, + type DeviceServiceState, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import type { RightPanelSurface } from "~/rightPanelStore"; + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext } from "../panelHost"; + +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: "/api/device-hub", + revision: 1, +}; +vi.mock("~/state/device", async () => { + const { Atom, AsyncResult } = await import("effect/reactivity"); + const Effect = await import("effect/Effect"); + return { + deviceHostProbeAtom: Atom.family(() => Atom.make(Effect.void)), + NO_DEVICE_PROBE_ATOM: Atom.make(AsyncResult.initial()), + 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", () => ({ + selectThreadRightPanelState: () => ({ activeSurfaceId: null }), + useRightPanelStore: { + getState: () => ({ openDevice: mocks.openDevice, closeSurface: mocks.closeSurface }), + }, +})); +vi.mock("~/components/device/DeviceHostUpdates", () => ({ DeviceHostUpdates: () => null })); +vi.mock("~/components/device/DeviceLoadingView", () => ({ + DeviceLoadingView: ({ name, message }: { name: string; message: string }) => ( +
+ {message} +
+ ), +})); +vi.mock("~/components/device/DeviceWorkspace", () => ({ + DeviceWorkspace: ({ onPowerOff }: { onPowerOff: () => void }) => ( + + ), +})); +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" }, +}; + +// Mounted the way ChatView mounts it: through the registry, lazily, on the panel host. +const render = (threadRef: ScopedThreadRef, surface = picker) => ( + undefined, + }} + > + undefined} + /> + +); +const mount = async (screen: Screen, threadRef: ScopedThreadRef, surface = picker) => { + await screen.rerender(render(threadRef, surface)); + // Wait for the registered lazy body to load and replace the Suspense fallback. + await act(async () => { + await import("./DeviceSidePanel"); + }); +}; +const deferred = () => { + let settle!: (result: CommandResult) => void; + const promise = new Promise((resolve) => (settle = resolve)); + return { promise, settle }; +}; +const startPhone = (screen: Screen) => + act(async () => { + screen.container.querySelector('[aria-label="Start Phone"]')!.click(); + }); +const errors = (screen: Screen) => screen.container.querySelectorAll('[role="alert"]'); +const opened = { _tag: "Success", value: { hostId: "local", deviceId: "phone" } } as const; + +describe("registered Device panel", () => { + it("opens a picked device when the pick settles in the same thread", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + await mount(screen, picking); + + await startPhone(screen); + 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 () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + mocks.open.mockReturnValue(deferred().promise); + await mount(screen, picking); + + await startPhone(screen); + await screen.rerender(render(next)); + + expect( + screen.container.querySelector('[aria-label="Start Phone"]')!.disabled, + ).toBe(false); + }); + + it("opens a pick that settles after a switch in the thread it started in", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + await mount(screen, picking); + + await startPhone(screen); + await screen.rerender(render(next)); + await act(async () => pick.settle(opened)); + + expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(picking, { + hostId: "local", + deviceId: "phone", + platform: "ios", + name: "Phone", + }); + expect( + screen.container.querySelector('[aria-label="Start Phone"]')!.disabled, + ).toBe(false); + }); + + it("shows a late failed pick when returning to its starting scope", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + await mount(screen, picking); + + await startPhone(screen); + await screen.rerender(render(next)); + await screen.rerender(render(picking)); + expect(screen.container.querySelector('[aria-label="Start Phone"]')).toBeNull(); + expect( + screen.container.querySelector('[role="status"][aria-label="Opening Phone"]')?.textContent, + ).toBe("Starting device…"); + expect(errors(screen)).toHaveLength(0); + await act(async () => pick.settle({ _tag: "Failure", cause: new Error("boom") })); + + expect(errors(screen)).toHaveLength(1); + expect(errors(screen)[0]?.textContent).toContain("Device failed"); + expect( + screen.container.querySelector('[role="status"][aria-label="Opening Phone"]'), + ).toBeNull(); + expect( + screen.container.querySelector('[aria-label="Start Phone"]')!.disabled, + ).toBe(false); + expect(mocks.openDevice).not.toHaveBeenCalled(); + }); + + it("does not carry an operation error into another thread", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + mocks.open.mockResolvedValue({ _tag: "Failure", cause: new Error("boom") }); + await mount(screen, picking); + + await startPhone(screen); + expect(errors(screen)).toHaveLength(1); + await screen.rerender(render(next)); + + expect(errors(screen)).toHaveLength(0); + }); + + it("closes a powered-off surface in its own thread after the panel moved", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + const powerOff = deferred(); + mocks.close.mockReturnValue(powerOff.promise); + await mount(screen, picking, streaming); + + await act(async () => { + screen.container.querySelector("button")!.click(); + }); + expect(mocks.close).toHaveBeenCalledWith({ + environmentId: picking.environmentId, + input: { threadId: picking.threadId, hostId: "local", deviceId: "phone", shutdown: true }, + }); + await screen.rerender(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 () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + mocks.close.mockResolvedValue(opened); + await mount(screen, picking, streaming); + + await act(async () => { + screen.container.querySelector("button")!.click(); + }); + + expect(mocks.closeSurface).toHaveBeenCalledExactlyOnceWith(picking, streaming.id); + }); +}); + +function resetFixture() { + vi.clearAllMocks(); +} diff --git a/apps/web/src/panels/device/DeviceSidePanel.thread-switches.browser.test.tsx b/apps/web/src/panels/device/DeviceSidePanel.thread-switches.browser.test.tsx new file mode 100644 index 0000000000000..09af0943d2c5f --- /dev/null +++ b/apps/web/src/panels/device/DeviceSidePanel.thread-switches.browser.test.tsx @@ -0,0 +1,340 @@ +import { + EnvironmentId, + ThreadId, + type DeviceServiceState, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import type { RightPanelSurface } from "~/rightPanelStore"; + +import { act, render } from "../../../test/render"; + +import DeviceSidePanel from "./DeviceSidePanel"; +import { PanelHostContext } from "../panelHost"; +import "~/index.css"; + +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: "/api/device-hub", + revision: 1, +}; +let currentState = deviceState; +vi.mock("~/state/device", async () => { + const { Atom, AsyncResult } = await import("effect/reactivity"); + const Effect = await import("effect/Effect"); + return { + deviceHostProbeAtom: Atom.family(() => Atom.make(Effect.void)), + NO_DEVICE_PROBE_ATOM: Atom.make(AsyncResult.initial()), + deviceEnvironment: { list: "list", open: "open", close: "close" }, + useDeviceState: () => ({ state: currentState, 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", () => ({ + selectThreadRightPanelState: () => ({ activeSurfaceId: null }), + useRightPanelStore: { + getState: () => ({ openDevice: mocks.openDevice, closeSurface: mocks.closeSurface }), + }, +})); +vi.mock("~/components/device/DeviceHostUpdates", () => ({ DeviceHostUpdates: () => null })); +vi.mock("~/components/device/DeviceWorkspace", () => ({ + DeviceWorkspace: ({ onPowerOff }: { onPowerOff: () => void }) => ( + + ), +})); +vi.mock("~/components/preview/PreviewPanelShell", () => ({ + PreviewPanelShell: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +const threadA = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-1"), +}; +// The panel stays mounted across both kinds of switch. +const switches = [ + ["another thread", { environmentId: threadA.environmentId, threadId: ThreadId.make("thread-2") }], + [ + "the same thread id in another environment", + { ...threadA, environmentId: EnvironmentId.make("environment-b") }, + ], +] as const; +const threadB = switches[0][1]; +const picker: Extract = { id: "device", kind: "device" }; +const phoneTarget = { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" }; +const opened = { _tag: "Success", value: { hostId: "local", deviceId: "phone" } } as const; +const failed = { _tag: "Failure", cause: new Error("boom") } as const; + +const deferred = () => { + let settle!: (result: CommandResult) => void; + const promise = new Promise((resolve) => (settle = resolve)); + return { promise, settle }; +}; + +let container: HTMLDivElement; +// Fixed key and a changing threadRef, as RightPanelSurfaceContent mounts it. +const devicePanel = (threadRef: ScopedThreadRef, surface = picker) => ( + undefined, + }} + > + undefined} /> + +); +const startButton = () => container.querySelector('[aria-label="Start Phone"]'); +const click = (button: HTMLButtonElement | null) => + act(async () => { + button!.click(); + }); +const errors = () => container.querySelectorAll('[role="alert"]'); + +function setup() { + vi.resetAllMocks(); + currentState = deviceState; + container = document.createElement("div"); + container.style.width = "480px"; + container.style.height = "800px"; +} + +describe("DevicePanel thread switches", () => { + it("keeps the latest pick pending across switches away and back", async () => { + setup(); + const first = deferred(); + const second = deferred(); + const latest = deferred(); + mocks.open + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + .mockReturnValueOnce(latest.promise); + await using screen = await render(devicePanel(threadA), { container }); + await click(startButton()); + await screen.rerender(devicePanel(threadB)); + await click(startButton()); + await screen.rerender(devicePanel(threadA)); + await click(startButton()); + + await act(async () => first.settle(failed)); + + expect(startButton()).toBeNull(); + expect(errors()).toHaveLength(0); + expect(mocks.openDevice).not.toHaveBeenCalled(); + + await act(async () => second.settle(opened)); + + expect(startButton()).toBeNull(); + expect(mocks.openDevice.mock.calls).toEqual([[threadB, phoneTarget]]); + + await act(async () => latest.settle(opened)); + + expect(startButton()?.disabled).toBe(false); + expect(errors()).toHaveLength(0); + expect(mocks.openDevice.mock.calls).toEqual([ + [threadB, phoneTarget], + [threadA, phoneTarget], + ]); + }); +}); + +it("keeps a late power-off error in the environment that started it", async () => { + setup(); + const streaming: Extract = { + id: "device:phone", + kind: "device", + target: { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" }, + }; + const powerOff = deferred(); + mocks.close.mockReturnValue(powerOff.promise); + await using screen = await render(devicePanel(threadA, streaming), { container }); + await screen.getByRole("button", { name: "Power off", exact: true }).click(); + expect(mocks.close).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadA.environmentId, + input: { threadId: threadA.threadId, hostId: "local", deviceId: "phone", shutdown: true }, + }); + await screen.rerender(devicePanel(switches[1][1], streaming)); + await act(async () => powerOff.settle(failed)); + expect(errors()).toHaveLength(0); + expect(mocks.closeSurface).not.toHaveBeenCalled(); + await screen.rerender(devicePanel(threadA, streaming)); + expect(errors()).toHaveLength(1); +}); + +const otherEnvironment = switches[1][1]; +const streaming: Extract = { + id: "device:phone", + kind: "device", + target: { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" }, +}; + +describe("DevicePanel notices", () => { + it("keeps discovery warnings dismissed until the diagnostic or environment changes", async () => { + setup(); + await using screen = await render(null, { container, router: false }); + currentState = { + ...deviceState, + hostStatusDetail: "Could not read simulator usage timestamps.", + }; + await screen.rerender(devicePanel(threadA)); + expect(errors()).toHaveLength(1); + expect(startButton()?.disabled).toBe(false); + await click(container.querySelector('[aria-label="Dismiss device warning"]')); + expect(errors()).toHaveLength(0); + await screen.rerender(devicePanel(threadA)); + expect(errors()).toHaveLength(0); + + currentState = { ...currentState, hostStatusDetail: "A different discovery warning." }; + await screen.rerender(devicePanel(threadA)); + expect(errors()).toHaveLength(1); + await click(container.querySelector('[aria-label="Dismiss device warning"]')); + await screen.rerender(devicePanel(otherEnvironment)); + expect(errors()).toHaveLength(1); + }); + + it("keeps the loading content centered when discovery and boot notices arrive or are dismissed", async () => { + setup(); + await using screen = await render(null, { container, router: false }); + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + await screen.rerender(devicePanel(threadA)); + await click(startButton()); + const loadingBounds = () => + container + .querySelector('[aria-label="Step 1 of 2: open device"]')! + .parentElement!.getBoundingClientRect(); + const before = loadingBounds(); + const panel = container.getBoundingClientRect(); + expect(before.height).toBeGreaterThan(0); + expect(before.top + before.height / 2).toBeCloseTo(panel.top + panel.height / 2, 0); + + currentState = { + ...deviceState, + hostStatusDetail: "Could not read CoreSimulator plist.\n".repeat(8), + bootingDevices: [{ ...phone, threadId: threadA.threadId }], + }; + await screen.rerender(devicePanel(threadA)); + expect(loadingBounds().top).toBe(before.top); + expect(loadingBounds().left).toBe(before.left); + await click(container.querySelector('[aria-label="Dismiss device warning"]')); + expect(loadingBounds().top).toBe(before.top); + expect(loadingBounds().left).toBe(before.left); + await act(async () => pick.settle(failed)); + }); +}); + +describe("DevicePanel thread switches", () => { + it.each(switches)( + "keeps a pending or failed pick in its starting scope when switching to %s", + async (_, target) => { + setup(); + await using screen = await render(null, { container, router: false }); + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + await screen.rerender(devicePanel(threadA)); + await click(startButton()); + expect(startButton()).toBeNull(); + + await screen.rerender(devicePanel(target)); + expect(startButton()?.disabled).toBe(false); + + await act(async () => pick.settle(failed)); + expect(errors()).toHaveLength(0); + expect(mocks.openDevice).not.toHaveBeenCalled(); + + await screen.rerender(devicePanel(threadA)); + expect(startButton()?.disabled).toBe(false); + expect(errors()).toHaveLength(1); + + await screen.rerender(devicePanel(target)); + expect(startButton()?.disabled).toBe(false); + expect(errors()).toHaveLength(0); + }, + ); + + it.each([ + [ + "pick", + picker, + () => mocks.open, + () => click(startButton()), + () => expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(threadA, phoneTarget), + ], + [ + "power-off", + streaming, + () => mocks.close, + () => click(container.querySelector("button")), + () => expect(mocks.closeSurface).toHaveBeenCalledExactlyOnceWith(threadA, streaming.id), + ], + ] as const)( + "applies a late %s to its starting thread after switching environments with the same thread id", + async (_, surface, command, start, expectApplied) => { + setup(); + await using screen = await render(null, { container, router: false }); + const late = deferred(); + command().mockReturnValue(late.promise); + await screen.rerender(devicePanel(threadA, surface)); + await start(); + + await screen.rerender(devicePanel(otherEnvironment, surface)); + await act(async () => late.settle(opened)); + + expectApplied(); + }, + ); +}); diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/panels/device/DeviceSidePanel.tsx similarity index 92% rename from apps/web/src/components/device/DevicePanel.tsx rename to apps/web/src/panels/device/DeviceSidePanel.tsx index cee7a3e4e4bcd..a4892da63669f 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/panels/device/DeviceSidePanel.tsx @@ -1,10 +1,6 @@ -import { DeviceHostUpdates } from "./DeviceHostUpdates"; -import type { - DevicePlatform, - DeviceServiceState, - DeviceSummary, - ScopedThreadRef, -} from "@t3tools/contracts"; +import { usePanelHost } from "../panelHost"; +import { DeviceHostUpdates } from "~/components/device/DeviceHostUpdates"; +import type { DevicePlatform, DeviceServiceState, DeviceSummary } from "@t3tools/contracts"; import { ArrowUpRight, CircleAlert, Play, Smartphone, X } from "lucide-react"; import { useAtomMount } from "@effect/atom-react"; import { type ReactNode, useState } from "react"; @@ -30,10 +26,14 @@ import { } from "~/state/device"; import { formatEnvironmentQueryError } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; -import { DeviceLoadingView } from "./DeviceLoadingView"; -import { DeviceSetup, deviceHubReadiness, isAndroidDiscoveryUncertain } from "./DeviceSetup"; -import { DeviceWorkspace } from "./DeviceWorkspace"; -import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell"; +import { DeviceLoadingView } from "~/components/device/DeviceLoadingView"; +import { + DeviceSetup, + deviceHubReadiness, + isAndroidDiscoveryUncertain, +} from "~/components/device/DeviceSetup"; +import { DeviceWorkspace } from "~/components/device/DeviceWorkspace"; +import { PreviewPanelShell } from "~/components/preview/PreviewPanelShell"; const platformLabel = (platform: DevicePlatform) => platform === "ios" ? "iOS Simulators" : "Android Emulators"; @@ -41,14 +41,12 @@ const platformLabel = (platform: DevicePlatform) => const deviceKey = (device: Pick) => `${device.hostId}\u0000${device.id}`; -export function DevicePanel(props: { - readonly mode: PreviewPanelMode; - readonly threadRef: ScopedThreadRef; +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, error: stateError, refresh: refreshState } = useDeviceState(environmentId); const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); const open = useAtomCommand(deviceEnvironment.open); @@ -68,9 +66,7 @@ export function DevicePanel(props: { const hostDisabled = state.hostStatus === "disabled"; useAtomMount( - props.visible && loaded && !hostDisabled - ? deviceHostProbeAtom(environmentId) - : NO_DEVICE_PROBE_ATOM, + visible && loaded && !hostDisabled ? deviceHostProbeAtom(environmentId) : NO_DEVICE_PROBE_ATOM, ); const sessions = state.sessions.filter((session) => session.threadId === threadId); @@ -118,7 +114,7 @@ export function DevicePanel(props: { if (result._tag === "Failure") settleOperation(formatEnvironmentQueryError(result.cause)); else { // The server opened the device for the starting thread, so its tab opens there. - useRightPanelStore.getState().openDevice(props.threadRef, { + useRightPanelStore.getState().openDevice(threadRef, { hostId: result.value.hostId, deviceId: result.value.deviceId, platform: device.platform, @@ -130,21 +126,21 @@ export function DevicePanel(props: { 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 closeSurface = () => { const store = useRightPanelStore.getState(); - const { activeSurfaceId } = selectThreadRightPanelState(store.byThreadKey, props.threadRef); - store.closeSurface(props.threadRef, props.surface.id); - focusPanelTerminalAfterClose(props.threadRef, activeSurfaceId); + const { activeSurfaceId } = selectThreadRightPanelState(store.byThreadKey, threadRef); + store.closeSurface(threadRef, props.surface.id); + focusPanelTerminalAfterClose(threadRef, activeSurfaceId); }; const closeActive = (powerOff: boolean) => { @@ -190,7 +186,7 @@ export function DevicePanel(props: { if (loaded && (!state.onboardingCompleted || hostDisabled)) { return ( { if (!isOpen) props.onDismissSetup(); }} @@ -203,7 +199,7 @@ export function DevicePanel(props: { } return ( - +
hostId === activeDevice.hostId)?.detail } - visible={props.visible} + visible={visible} onFloat={floatActive} onClose={() => closeActive(false)} onPowerOff={() => closeActive(true)} 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 7785c025e0fe2..2d019a67e10c4 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/panels/diff/DiffSidePanel.tsx @@ -6,7 +6,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; -import type { ReviewBranchCommitsResult, ScopedThreadRef, RunId } from "@t3tools/contracts"; +import type { ReviewBranchCommitsResult, RunId } from "@t3tools/contracts"; import { ArrowRightIcon, CheckIcon, @@ -20,14 +20,13 @@ import { } from "lucide-react"; import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide"; import * as Schema from "effect/Schema"; -import { type MouseEvent, useEffect, useRef, useState } from "react"; -import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; +import { type MouseEvent, type ReactNode, useEffect, useRef, useState } from "react"; +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 { toolCallDiff } from "../lib/toolCallDiff"; +import { useOpenInPreferredEditor } from "~/editorPreferences"; +import { useFileContextMenuHandler } from "~/fileContextMenu"; +import { openDiffFilePrimaryAction } from "~/diffFileActions"; +import { toolCallDiff } from "~/lib/toolCallDiff"; import { useCheckpointDiff, useTurnItemDetail } from "~/state/queries"; import { revisionedQuery } from "~/state/revisionedQuery"; import { cn } from "~/lib/utils"; @@ -37,12 +36,12 @@ import { selectThreadDiffPanelSelection, useDiffPanelSessionStore, useDiffPanelStore, -} from "../diffPanelStore"; -import { useLocalStorage } from "../hooks/useLocalStorage"; +} from "~/diffPanelStore"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; import { isTestChangedFile } from "@t3tools/shared/changedFileKind"; -import { useHideTestFiles } from "./diffs/useHideTestFiles"; -import { useRenderedAppearance } from "../hooks/useTheme"; -import { useStableCallback } from "../hooks/useStableCallback"; +import { useHideTestFiles } from "~/components/diffs/useHideTestFiles"; +import { useRenderedAppearance } from "~/hooks/useTheme"; +import { useStableCallback } from "~/hooks/useStableCallback"; import { buildFileDiffContentVersion, buildFileDiffIdentityKey, @@ -50,28 +49,30 @@ import { getRenderablePatch, resolveDiffThemeName, resolveFileDiffPath, -} from "../lib/diffRendering"; -import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; -import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; -import { LIVE_REFRESH_MIN_INTERVAL_MS, useLiveRefresh } from "../hooks/useLiveRefresh"; +} from "~/lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; +import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "~/lib/diffCollapse"; +import { LIVE_REFRESH_MIN_INTERVAL_MS, useLiveRefresh } from "~/hooks/useLiveRefresh"; import { useProject, useThreadCheckpointSummaries, useQuietThreadShell, useQuietThreadVisibleTurnItems, -} from "../state/entities"; -import { useClientSettings, updateClientSettings } from "../hooks/useSettings"; -import { formatRelativeTimeLabel, formatShortTimestamp } from "../timestampFormat"; -import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; -import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { DiffStatLabel } from "./chat/DiffStatLabel"; -import { AnnotatableCodeView } from "./diffs/AnnotatableCodeView"; -import { DiffFileTree } from "./diffs/DiffFileTree"; -import { diffFileTreeEntries } from "./diffs/diffFileTree.logic"; -import { Button } from "./ui/button"; +} from "~/state/entities"; +import { useClientSettings, updateClientSettings } from "~/hooks/useSettings"; +import { formatRelativeTimeLabel, formatShortTimestamp } from "~/timestampFormat"; +import { DiffFilePathCopyButton } from "~/components/DiffFilePathCopyButton"; +import { DiffPanelLoadingState } from "~/components/diffs/DiffLoadingState"; +import { usePanelHost } from "../panelHost"; +import { DiffStatLabel } from "~/components/chat/DiffStatLabel"; +import { AnnotatableCodeView } from "~/components/diffs/AnnotatableCodeView"; +import { DiffFileTree } from "~/components/diffs/DiffFileTree"; +import { diffFileTreeEntries } from "~/components/diffs/diffFileTree.logic"; +import { Button } from "~/components/ui/button"; +import { FileExplorerPane } from "~/components/files/FileExplorerPane"; 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, @@ -80,7 +81,7 @@ import { ComboboxList, ComboboxPopup, ComboboxTrigger, -} from "./ui/combobox"; +} from "~/components/ui/combobox"; import { DropdownMenu, DropdownMenuContent, @@ -91,22 +92,22 @@ 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 { 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"; -import { useReviewFilePatches } from "./diffs/useReviewFilePatches"; -import { DiffFileLoadingBoundary } from "./diffs/DiffFileLoadingBoundary"; -import { DiffFileStatus } from "./diffs/DiffFileStatus"; -import { useThreadRouteRef } from "../hooks/useThreadRouteTarget"; +import { useReviewFilePatches } from "~/components/diffs/useReviewFilePatches"; +import { DiffFileLoadingBoundary } from "~/components/diffs/DiffFileLoadingBoundary"; +import { DiffFileStatus } from "~/components/diffs/DiffFileStatus"; +import { useThreadRouteRef } from "~/hooks/useThreadRouteTarget"; import { projectCommitUrl } from "@t3tools/client-runtime/change-request-links"; -import { useOpenLink } from "../browser/useOpenLink"; +import { useOpenLink } from "~/browser/useOpenLink"; type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; @@ -196,19 +197,23 @@ function DiffFileHeaderSuffix({ ); } -interface DiffPanelProps { - mode?: DiffPanelMode; - composerDraftTarget: ScopedThreadRef | DraftId; - workspaceMutationId: string | null; - visible: boolean; +// 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", - composerDraftTarget, - workspaceMutationId, - visible, -}: DiffPanelProps) { +export default function DiffSidePanel() { + const { composerDraftTarget, workspaceMutationId, visible } = usePanelHost(); const resolvedTheme = useRenderedAppearance(); const settings = useClientSettings((settings) => ({ diffFilesCollapsed: settings.diffFilesCollapsed, @@ -1248,7 +1253,7 @@ export default function DiffPanel({ ); return ( - + {!activeThread ? (
Select a thread to inspect turn diffs. @@ -1451,7 +1456,12 @@ export default function DiffPanel({ />
{fileTreeOpen ? ( - + ) : null}
) : ( @@ -1484,6 +1494,6 @@ export default function DiffPanel({
)} - + ); } diff --git a/apps/web/src/panels/files/FilesSidePanel.browser.test.tsx b/apps/web/src/panels/files/FilesSidePanel.browser.test.tsx new file mode 100644 index 0000000000000..9f188731f646e --- /dev/null +++ b/apps/web/src/panels/files/FilesSidePanel.browser.test.tsx @@ -0,0 +1,726 @@ +import { act, render as renderScreen, type Screen } from "../../../test/render"; +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 { + cloneElement, + Suspense, + use, + useLayoutEffect, + type ReactElement, + type ReactNode, +} from "react"; +import { 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, 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), + // 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(), + // 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", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomValue: () => [], +})); +vi.mock("~/state/server", async () => { + const { Atom } = await import("effect/reactivity"); + return { + environmentServerConfigsAtom: Atom.make(new Map()), + primaryServerKeybindingsAtom: {}, + primaryServerEnvironmentThemesAtom: Atom.make([]), + serverEnvironment: { configValueAtom: () => null }, + }; +}); +// This client holds every scope, so files can be read, written and previewed. +vi.mock("~/state/filesystem", () => ({ + filesystemEnvironment: {}, + useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }), +})); +vi.mock("~/state/session", async () => { + const { Atom, AsyncResult } = await import("effect/reactivity"); + const Option = await import("effect/Option"); + return { + environmentSession: { + initialConfigValueAtom: Atom.family(() => Atom.make(null)), + preparedConnectionValueAtom: Atom.family(() => Atom.make(Option.none())), + sessionStateAtom: Atom.family(() => Atom.make(AsyncResult.initial())), + }, + environmentSessionStatesAtom: () => Atom.make([]), + readPreparedConnection: () => null, + usePreparedConnection: (): ReturnType => + Option.none(), + + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, + sessionHasScope: () => true, + }; +}); +// The desktop app hosts these browsers itself, so no environment's server browser is consulted. +vi.mock("~/browser/previewRuntime", () => ({ + previewRuntimeFor: () => undefined, + isPreviewAvailableFor: () => true, + usePreviewAvailable: () => true, +})); +vi.mock("~/state/environments", () => ({ + useEnvironmentHttpBaseUrl: () => "http://localhost:3773/", + usePrimaryEnvironmentId: () => null, + readPrimaryEnvironmentId: () => null, + useEnvironment: () => ({ label: "Local" }), +})); +vi.mock("~/remoteOpen", () => ({ + openRemoteEditorUrl: vi.fn(), + useRemoteCapableEditors: () => [], + useRemoteOpenHint: () => null, + resolveRemoteOpenState: () => ({ mode: "local-exec" }), + useRemoteOpenResolution: () => ({ mode: "local-exec" }), + useRemoteOpenState: () => ({ mode: "local-exec" }), +})); +vi.mock("~/hooks/useSettings", () => ({ + updateClientSettings: vi.fn(), + isClientSettingsHydrated: () => true, + ensureClientSettingsHydrated: () => Promise.resolve(), + getClientSettings: () => DEFAULT_CLIENT_SETTINGS, + useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + select(DEFAULT_CLIENT_SETTINGS), + useUpdateClientSettings: () => vi.fn(), +})); +vi.mock("~/hooks/useTheme", () => ({ + useRenderedAppearance: () => "light", + useTheme: () => ({ resolvedTheme: "light" }), +})); +// 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", () => ({ + browserDefaultOpenProfileId: () => "default", + browserDefaultOpenViewport: () => ({ _tag: "fill" }), + resolveBrowserDefaults: () => + late.browserDefaults ?? Promise.resolve({ viewport: { _tag: "fill" }, profileId: "default" }), +})); +vi.mock("~/components/files/projectFilesQueryState", async () => { + const { Atom } = await import("effect/reactivity"); + return { + confirmProjectFileQueryData: vi.fn(), + getUnsavedProjectFileQueryData: () => null, + getOptimisticProjectFileQueryData: () => null, + getProjectFileContents: () => undefined, + getProjectFileQueryAtom: () => Atom.make(null), + setProjectFileQueryData: vi.fn(), + 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 Pierre tree is a web component; this stand-in keeps the real panel's right-click handler. +vi.mock("@pierre/trees/react", () => ({ + FileTree: () => File tree ready, + 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, + 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" }, + { path: "README.md", kind: "file" }, + ], + 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/OpenInPicker", () => ({ OpenInPicker: () => 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", () => ({ + MenuItemLabel: Wrapper, + MenuShortcut: () => null, + MenuSub: Wrapper, + MenuSubTrigger: Wrapper, + MenuSubPopup: Wrapper, + 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 refOn = (environmentId: string, thread = "thread-a"): ScopedThreadRef => ({ + environmentId: EnvironmentId.make(environmentId), + threadId: ThreadId.make(thread), +}); + +// 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)!; +} + +// 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( + screen: Screen, + threadRef: ScopedThreadRef, + relativePath: string | null = "src/a.ts", + composerDraftTarget: PanelHost["composerDraftTarget"] = threadRef, + duringCommit?: () => void, +) { + const host: PanelHost = { + threadRef, + visible: true, + composerDraftTarget, + workspaceMutationId: null, + sendAnnotation: () => undefined, + }; + const element = ( + + + + undefined} + folderTrail={undefined} + cwd="/repo" + projectName="repo" + relativePath={relativePath} + availableEditors={[]} + revealLine={null} + revealRequestId={0} + onPendingChange={() => undefined} + selectedFilePending={false} + /> + + + + + ); + await act(async () => { + composerRef.current = { + insertTextAtEnd: composerOf(threadRef), + } as unknown as ChatComposerHandle; + await screen.rerender(element); + }); + if (relativePath === null) { + await expect.element(screen.getByText("File tree ready", { exact: true })).toBeVisible(); + } else { + await expect + .element(screen.getByRole("navigation", { name: "File path", exact: true })) + .toBeVisible(); + } +} + +async function press(node: HTMLButtonElement) { + await act(async () => node.click()); +} + +async function openSrcFolder(screen: Screen) { + await expect + .element(screen.getByRole("button", { name: "Browse src", exact: true })) + .toBeVisible(); + const browse = screen.container.querySelectorAll( + 'button[aria-label="Browse src"]', + ); + expect(browse).toHaveLength(1); + await press(browse[0]!); +} + +const siblingValues = (screen: Screen) => + Array.from(screen.container.querySelectorAll("button[data-value]")).map( + (node) => node.dataset.value, + ); + +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(screen: Screen) { + late.assetUrl = late.deferred(); + late.session = late.deferred(); + await press( + screen.container.querySelector( + 'button[aria-label="Open file in preview browser"]', + )!, + ); +} + +const settleAsset = () => + act(async () => { + late.assetUrl.resolve(AsyncResult.success({ relativeUrl: "/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"); + +describe("files side panel", () => { + it("opens a sibling file as a tab in the host's own thread", async () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadRef = refOn("environment-a"); + await renderFileFor(screen, threadRef); + await openSrcFolder(screen); + expect(siblingValues(screen)).toEqual(["src/a.ts", "src/b.ts"]); + + await press( + screen.container.querySelector('button[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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + await renderFileFor(screen, refOn("environment-a")); + const threadRef = refOn("environment-b"); + await renderFileFor(screen, threadRef); + await openSrcFolder(screen); + expect(siblingValues(screen)).toEqual(["src/a.ts", "src/c.ts"]); + + await press( + screen.container.querySelector('button[data-value="src/c.ts"]')!, + ); + 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + // Threads in the same project share one tree, so it outlives the switch. + await renderFileFor(screen, refOn("environment-a")); + const threadRef = refOn("environment-a", "thread-b"); + await renderFileFor(screen, threadRef); + + await act(async () => late.selectTreeRows!(["README.md"])); + 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + await renderFileFor(screen, refOn("environment-a")); + const threadRef = refOn("environment-a", "thread-b"); + await renderFileFor(screen, 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", () => { + it("adds a tree entry to the chat of the thread it was picked in", async () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadRef = refOn("environment-a"); + await renderFileFor(screen, 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + await renderFileFor(screen, refOn("environment-a"), null); + const pick = rightClickTreeRow(); + await renderFileFor(screen, 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + await renderFileFor(screen, refOn("environment-a"), null); + const pick = rightClickTreeRow(); + await renderFileFor(screen, 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadRef = refOn("environment-a"); + await renderFileFor(screen, threadRef, null); + const pick = rightClickTreeRow(); + await renderFileFor(screen, refOn("environment-a", "thread-other"), null); + await renderFileFor(screen, threadRef, null); + await pick("add-to-chat"); + expect(insertedAnywhere()).toBe(false); + }); + + it("opens a Browser tab in the thread that is still showing", async () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadRef = refOn("environment-a"); + await renderFileFor(screen, threadRef, "index.html"); + await openInBrowser(screen); + await settleAsset(); + await settleSession(); + 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadRef = refOn("environment-a"); + await renderFileFor(screen, threadRef, "index.html"); + await openInBrowser(screen); + // Editing a queued message moves the composer to a draft; the thread stays. + await renderFileFor( + screen, + 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadRef = refOn("environment-a"); + await renderFileFor(screen, threadRef, "index.html"); + await openInBrowser(screen); + await renderFileFor(screen, 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadRef = refOn("environment-a"); + await renderFileFor(screen, threadRef, "index.html"); + await openInBrowser(screen); + await settleAsset(); + expect(late.previewRequests).toBe(1); + await renderFileFor(screen, 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadRef = refOn("environment-a"); + await renderFileFor(screen, threadRef, "index.html"); + await openInBrowser(screen); + await renderFileFor(screen, refOn("environment-a", "thread-other"), "index.html"); + await renderFileFor(screen, 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + await renderFileFor(screen, refOn("environment-a"), "index.html"); + await openInBrowser(screen); + await failAsset(); + expect(browserErrorToasts()).toHaveLength(1); + }); + + it("stays silent when the file fails to prepare after the thread was left", async () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + await renderFileFor(screen, refOn("environment-a"), "index.html"); + await openInBrowser(screen); + await renderFileFor(screen, refOn("environment-b"), "index.html"); + await failAsset(); + expect(browserErrorToasts()).toEqual([]); + }); + + it("stays silent when browser settings fail to load after the panel closed", async () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + await renderFileFor(screen, refOn("environment-a"), "index.html"); + let rejectSettings!: (error: Error) => void; + late.browserDefaults = new Promise((_, reject) => (rejectSettings = reject)); + await openInBrowser(screen); + await settleAsset(); + await screen.unmount(); + 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 () => { + resetFixture(); + using _toast = vi.spyOn(toastManager, "add"); + await import("./FilesSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadRef = refOn("environment-a"); + await renderFileFor(screen, threadRef, "index.html"); + await openInBrowser(screen); + await settleAsset(); + await renderFileFor(screen, refOn("environment-a", "thread-other"), "index.html"); + await renderFileFor(screen, threadRef, "index.html"); + await failSession(); + expect(browserErrorToasts()).toEqual([]); + }); +}); + +function resetFixture() { + late.selectTreeRows = null; + localStorage.clear(); + siblingsByEnvironment.clear(); + composers.clear(); + late.previewRequests = 0; + late.browserDefaults = null; + resetPreviewStateForTests(); + 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: {}, + }); +} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/panels/files/FilesSidePanel.tsx similarity index 95% rename from apps/web/src/components/files/FilePreviewPanel.tsx rename to apps/web/src/panels/files/FilesSidePanel.tsx index b88d92fc4edcc..abcd00126baa7 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/panels/files/FilesSidePanel.tsx @@ -1,3 +1,8 @@ +import { usePanelHost } from "../panelHost"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { useScopedComposerInsert, useScopeLifetime } from "./fileScope"; +import { primaryServerKeybindingsAtom } from "~/state/server"; +import { useRightPanelStore } from "~/rightPanelStore"; import { DownloadIcon, CopyIcon, CheckIcon } from "lucide-react"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { Spinner } from "~/components/ui/spinner"; @@ -5,7 +10,6 @@ import { AuthPreviewOperateScope, type EditorId, type EnvironmentId, - type ResolvedKeybindingsConfig, type ScopedThreadRef, } from "@t3tools/contracts"; import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview"; @@ -37,7 +41,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, @@ -78,16 +82,20 @@ import { useEnvironmentScope } from "~/state/session"; 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 { PreviewImage } from "./PreviewImage"; -import FileBrowserPanel from "./FileBrowserPanel"; -import { FileExplorerPane } from "./FileExplorerPane"; -import { FileBreadcrumbs } from "./FileBreadcrumbs"; -import { breadcrumbPathOf, retainedBreadcrumbTrail, trailChildOf } from "./filePath"; -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 { PreviewImage } from "~/components/files/PreviewImage"; +import FileBrowserPanel from "~/components/files/FileBrowserPanel"; +import { FileExplorerPane } from "~/components/files/FileExplorerPane"; +import { FileBreadcrumbs } from "~/components/files/FileBreadcrumbs"; +import { + breadcrumbPathOf, + retainedBreadcrumbTrail, + trailChildOf, +} from "~/components/files/filePath"; +import { FileMarkdownPreview } from "~/components/files/FileMarkdownPreview"; import { type FileCommentAnnotationEntry, type FileCommentAnnotationGroup, @@ -96,8 +104,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, @@ -105,11 +113,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, @@ -117,8 +125,8 @@ import { setMarkdownTaskChecked, shouldShowFileExplorer, workspaceAssetResource, -} from "./filePreviewMode"; -import { changeFileSaveSession, useFileSaveSession } from "./fileSaveSessions"; +} from "~/components/files/filePreviewMode"; +import { changeFileSaveSession, useFileSaveSession } from "~/components/files/fileSaveSessions"; import { revisionedQuery } from "~/state/revisionedQuery"; import { getOptimisticProjectFileQueryData, @@ -126,34 +134,31 @@ import { getProjectFileQueryAtom, setProjectFileQueryData, useProjectFileQuery, -} from "./projectFilesQueryState"; +} from "~/components/files/projectFilesQueryState"; const NO_FILE_REVISION_ATOM = Atom.make(null); -interface FilePreviewPanelProps { - environmentId: EnvironmentId; +interface FilesSidePanelProps { cwd: string; projectName: string; relativePath: string | null; attachment?: ChatFileAttachment; - threadRef: ScopedThreadRef; - composerDraftTarget: ScopedThreadRef | DraftId; - keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; openInShortcutEnabled: boolean; revealLine: number | null; revealRequestId: number; - onOpenFile: (relativePath: string) => void; + /** Moves this folder browser to another folder in place; see `navigateFolder`. */ onNavigateFolder: (relativePath: string, folderTrail: string, keepSource: boolean) => void; - /** Retained descendant breadcrumb path after navigating up; see navigateFolder. */ + /** The deeper path breadcrumbs keep after the browser moved up; see `navigateFolder`. */ folderTrail: string | undefined; onPendingChange: (relativePath: string, pending: boolean) => void; selectedFilePending: boolean; - workspaceMutationId: string | null; } const MermaidFilePreview = lazy(() => - import("./MermaidFilePreview").then((module) => ({ default: module.MermaidFilePreview })), + import("~/components/files/MermaidFilePreview").then((module) => ({ + default: module.MermaidFilePreview, + })), ); const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; @@ -985,26 +990,29 @@ function initialExplorerOpen(): boolean { } } -export default function FilePreviewPanel({ - environmentId, +export default function FilesSidePanel({ cwd, projectName, relativePath: requestedPath, attachment, - threadRef, - composerDraftTarget, - keybindings, availableEditors, openInShortcutEnabled, revealLine, revealRequestId, - onOpenFile, onNavigateFolder, folderTrail, onPendingChange, selectedFilePending, - workspaceMutationId, -}: FilePreviewPanelProps) { +}: FilesSidePanelProps) { + const { threadRef, composerDraftTarget, workspaceMutationId } = usePanelHost(); + const environmentId = threadRef.environmentId; + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const onOpenFile = (path: string) => useRightPanelStore.getState().openFile(threadRef, path); + const isScopeCurrent = useScopeLifetime( + `${scopedThreadKey(threadRef)}|${typeof composerDraftTarget === "string" ? composerDraftTarget : scopedThreadKey(composerDraftTarget)}`, + ); + const addToChat = useScopedComposerInsert(isScopeCurrent); + const isThreadVisitCurrent = useScopeLifetime(scopedThreadKey(threadRef)); const relativePath = attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath; const draft = typeof composerDraftTarget === "string"; @@ -1208,13 +1216,18 @@ export default function FilePreviewPanel({ void (async () => { const result = await openFileInPreview({ threadRef, + isScopeCurrent: isThreadVisitCurrent, filePath: absolutePath, workspaceRoot: cwd, httpBaseUrl: environmentHttpBaseUrl, createAssetUrl, openPreview, }); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + if ( + result._tag === "Success" || + isAtomCommandInterrupted(result) || + !isThreadVisitCurrent() + ) { return; } const error = squashAtomCommandFailure(result); @@ -1541,6 +1554,7 @@ export default function FilePreviewPanel({ selectedPathRevealId={revealRequestId} onOpenFile={onOpenFile} workspaceMutationId={workspaceMutationId} + addToChat={addToChat} {...(previewPath && !isMedia && !isPdf ? { onRefreshSelectedFile: () => { @@ -1557,7 +1571,7 @@ export default function FilePreviewPanel({ ); } -const LargeTextViewer = lazy(() => import("./LargeTextViewer")); +const LargeTextViewer = lazy(() => import("~/components/files/LargeTextViewer")); export function WorkspaceLargeTextPreview(props: { environmentId: EnvironmentId; threadRef: ScopedThreadRef; diff --git a/apps/web/src/panels/files/fileScope.ts b/apps/web/src/panels/files/fileScope.ts new file mode 100644 index 0000000000000..3e1fe30e52185 --- /dev/null +++ b/apps/web/src/panels/files/fileScope.ts @@ -0,0 +1,34 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +import type { AddToChatResult } from "~/components/files/FileBrowserPanel"; +import { useComposerHandleContext } from "~/composerHandleContext"; + +// A late action belongs to one committed visit; A -> B -> A must retire the first visit. +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. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- The committed lifetime guards external actions after navigation or unmount. + useLayoutEffect(() => { + liveRef.current = visit; + return () => { + liveRef.current = null; + }; + }, [visit]); + return () => liveRef.current === visit; +} + +// The composer ref is shared by the layout, so a late action must verify its original visit. +export function useScopedComposerInsert(isScopeCurrent: () => boolean) { + const composerRef = useComposerHandleContext(); + return (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"; + }; +} diff --git a/apps/web/src/panels/panelHost.test.ts b/apps/web/src/panels/panelHost.test.ts new file mode 100644 index 0000000000000..5a2423868c5b2 --- /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 new file mode 100644 index 0000000000000..09805d1f32274 --- /dev/null +++ b/apps/web/src/panels/panelHost.ts @@ -0,0 +1,55 @@ +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; +} + +/** 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 { + 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.browser.test.tsx b/apps/web/src/panels/panelRegistry.browser.test.tsx new file mode 100644 index 0000000000000..d7e9cf4fdc8ca --- /dev/null +++ b/apps/web/src/panels/panelRegistry.browser.test.tsx @@ -0,0 +1,132 @@ +import { act, render as renderScreen } from "../../test/render"; +import { Suspense, useEffect } from "react"; +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", async () => { + const load = vi.fn(); + const definition = { ...metadata, id: "diff", title: "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 () => { + await using screen = await renderScreen(null, { router: false }); + 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([{ ...metadata, id: "diff", title: "Diff", load }]); + expect(load).not.toHaveBeenCalled(); + const Component = registry.get("diff").Component; + await screen.rerender( + + + , + ); + 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 screen.rerender( + + + , + ); + expect(load).toHaveBeenCalledTimes(1); + expect(mounted).toHaveBeenCalledTimes(1); + expect(disposed).not.toHaveBeenCalled(); + await screen.rerender(null); + expect(disposed).toHaveBeenCalledTimes(1); + }); + + it("keeps heterogeneous panels independent and their lazy identities stable", async () => { + await using screen = await renderScreen(null, { router: false }); + 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(); + + await screen.rerender( + + + , + ); + await screen.rerender( + + + , + ); + expect(loadNotes).toHaveBeenCalledTimes(1); + expect(loadCounter).not.toHaveBeenCalled(); + expect(events).toEqual(["mount notes:a"]); + + const CounterPanel = registry.get("counter").Component; + await screen.rerender( + + + , + ); + 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.test.ts b/apps/web/src/panels/panelRegistry.test.ts new file mode 100644 index 0000000000000..95f20edb356b0 --- /dev/null +++ b/apps/web/src/panels/panelRegistry.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { createPanelRegistry } from "./panelRegistry"; + +const metadata = { + title: "Diff", + icon: () => null, + launcherKey: "D", + unavailableHint: "Unavailable.", + unavailableReason: "Unavailable here.", +}; + +describe("panel registration", () => { + it("rejects duplicate ids without loading either panel", () => { + const load = vi.fn(); + const definition = { ...metadata, id: "diff", load } as const; + expect(() => createPanelRegistry([definition, definition])).toThrow("Duplicate panel id: diff"); + expect(load).not.toHaveBeenCalled(); + }); + it("retains lazy component identity without loading on lookup", () => { + const load = vi.fn(); + const registry = createPanelRegistry([{ ...metadata, id: "diff", load }]); + expect(registry.get("diff")?.Component).toBe(registry.get("diff")?.Component); + expect(() => { + // @ts-expect-error Unknown ids are refused at compile time and at runtime. + registry.get("missing"); + }).toThrow("Unknown panel id: missing"); + expect(load).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/panels/panelRegistry.ts b/apps/web/src/panels/panelRegistry.ts new file mode 100644 index 0000000000000..b3b925413a502 --- /dev/null +++ b/apps/web/src/panels/panelRegistry.ts @@ -0,0 +1,60 @@ +import { lazy, type ComponentType, type ReactNode } from "react"; + +/** Panel bodies are function components; their props are inferred per id. */ +type PanelBody = (props: never) => ReactNode; + +export interface PanelDefinition { + id: Id; + /** 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; + /** Shown while the body's code loads; null when the panel has nothing lighter to show. */ + fallback?: ReactNode; + 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 Definition[], +) { + const panels = new Map }>(); + for (const definition of definitions) { + if (panels.has(definition.id)) throw new Error(`Duplicate panel id: ${definition.id}`); + const load = definition.load as () => Promise<{ default: ComponentType }>; + panels.set(definition.id, { ...definition, Component: lazy(load) }); + } + 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.browser.test.tsx b/apps/web/src/panels/preview/PreviewSidePanel.browser.test.tsx new file mode 100644 index 0000000000000..86ec1fee0deec --- /dev/null +++ b/apps/web/src/panels/preview/PreviewSidePanel.browser.test.tsx @@ -0,0 +1,307 @@ +import { act, render as renderScreen, type Screen } from "../../../test/render"; +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 { useEffect, type ReactNode } from "react"; +import { 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, + setAnnotationSendEnabled: vi.fn(async () => undefined), + }, +})); +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 Preview chrome ready; + }, +})); +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, + PreviewFileNotShown: () => null, +})); +vi.mock("~/components/preview/ZoomIndicator", () => ({ ZoomIndicator: () => null })); +vi.mock("~/components/preview/AgentBrowserCursor", () => ({ + AgentBrowserCursor: () => null, + AgentCursorMark: () => null, +})); +vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null })); +vi.mock("~/browser/browserSurfaceStore", () => ({ + useBrowserSurfaceStore: (select: (state: { byTabId: object }) => unknown) => + select({ byTabId: {} }), +})); +vi.mock("~/browser/browserDefaults", () => ({ + browserDefaultOpenProfileId: () => DEFAULT_BROWSER_PROFILE_ID, + browserDefaultOpenViewport: () => FILL_PREVIEW_VIEWPORT, + resolveBrowserDefaults: () => Promise.resolve(BROWSER_DEFAULTS), + 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", async () => { + const Schema = await import("effect/Schema"); + return { + DraftId: Schema.String.pipe(Schema.brand("DraftId")), + clearComposerDraftsEnvironment: vi.fn(), + useComposerDraftStore: (select: (store: object) => unknown) => + select({ addPreviewAnnotation: mocks.addPreviewAnnotation, addImage: vi.fn() }), + }; +}); +vi.mock("~/localApi", () => ({ ensureLocalApi: vi.fn() })); +vi.mock("~/previewStateStore", () => ({ + beginPreviewSessionClose: vi.fn(), + cancelPreviewSessionClose: vi.fn(), + applyPreviewServerSnapshot: vi.fn(), + readThreadPreviewState: () => ({ sessions: {}, activeTabId: null }), + setActivePreviewTab: vi.fn(), + whenPreviewTabKnown: vi.fn(), + 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", () => ({ + rightPanelHostRef: (ref: ScopedThreadRef) => ref, + selectSelectedRightPanelSurface: () => null, + useRightPanelStore: { getState: () => ({ close: vi.fn() }) }, +})); +vi.mock("~/state/environments", () => ({ + useEnvironments: () => ({ environments: [] }), + usePrimaryEnvironment: () => null, + 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 () => { + const { Atom, AsyncResult } = await import("effect/reactivity"); + const Option = await import("effect/Option"); + return { + environmentSession: { + initialConfigValueAtom: Atom.family(() => Atom.make(null)), + preparedConnectionValueAtom: Atom.family(() => Atom.make(Option.none())), + sessionStateAtom: Atom.family(() => Atom.make(AsyncResult.initial())), + }, + environmentSessionStatesAtom: () => Atom.make([]), + readPreparedConnection: () => null, + usePreparedConnection: () => null, + + 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", () => { + async function render(screen: Screen, host: PanelHost) { + await screen.rerender(panel(host)); + await expect.element(screen.getByText("Preview chrome ready", { exact: true })).toBeVisible(); + } + + 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 () => { + resetFixture(); + await import("./PreviewSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadA = thread("thread-a"); + const first = hostFor(threadA); + await render(screen, first); + const pick = await startPick(); + expect(mocks.pickActive).toBe(true); + + const second = hostFor({ ...threadA }); + await render(screen, 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 () => { + resetFixture(); + await import("./PreviewSidePanel"); + await using screen = await renderScreen(null, { router: false }); + const threadA = thread("thread-a"); + const threadB = thread("thread-b"); + const hostA = hostFor(threadA); + await render(screen, hostA); + const pick = await startPick(); + expect(mocks.pickElement).toHaveBeenCalledWith(previewRuntimeTabId(threadA, null, "tab-1")); + + const hostB = hostFor(threadB); + await render(screen, 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); + }); +}); + +function resetFixture() { + mocks.pickElement.mockReset(); + mocks.cancelPickElement.mockClear(); + mocks.addPreviewAnnotation.mockClear(); + mocks.togglePick = null; + mocks.pickActive = false; + mocks.mounts = 0; + mocks.disposals = 0; +} diff --git a/apps/web/src/components/preview/PreviewPanel.tsx b/apps/web/src/panels/preview/PreviewSidePanel.tsx similarity index 57% rename from apps/web/src/components/preview/PreviewPanel.tsx rename to apps/web/src/panels/preview/PreviewSidePanel.tsx index 9de48226672f5..3282869e7dff7 100644 --- a/apps/web/src/components/preview/PreviewPanel.tsx +++ b/apps/web/src/panels/preview/PreviewSidePanel.tsx @@ -1,41 +1,26 @@ -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 @@ -48,13 +33,13 @@ export function PreviewPanel({ } return ( - + ); diff --git a/apps/web/src/panels/pullRequest/PullRequestPanelPending.tsx b/apps/web/src/panels/pullRequest/PullRequestPanelPending.tsx new file mode 100644 index 0000000000000..2331e8aec07f3 --- /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.browser.test.tsx b/apps/web/src/panels/pullRequest/PullRequestSidePanel.browser.test.tsx new file mode 100644 index 0000000000000..2d8ca7114e79d --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestSidePanel.browser.test.tsx @@ -0,0 +1,372 @@ +import { act, render as renderScreen, type Screen } from "../../../test/render"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + EnvironmentId, + ProjectId, + SourceControlProviderKind, + ThreadId, + type PullRequestDetailView, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { use, Suspense, type ComponentProps, type ReactElement, type ReactNode } from "react"; +import { 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", async (importOriginal) => ({ + ...(await importOriginal()), + useAtomValue: () => [], +})); +vi.mock("~/state/server", async () => { + const { Atom } = await import("effect/reactivity"); + return { + environmentServerConfigsAtom: Atom.make(new Map()), + serverEnvironment: { configValueAtom: () => null }, + primaryServerKeybindingsAtom: {}, + }; +}); +vi.mock("~/state/entities", () => ({ + readProjects: () => [], + 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("~/commandPaletteBus", () => ({ isCommandPaletteOpen: () => false })); +vi.mock("~/hooks/useLiveRefresh", () => ({ useLiveRefresh: () => {} })); +vi.mock("~/hooks/useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); +vi.mock("~/state/sourceControlActions", () => ({ + usePreparePullRequestThreadAction: () => ({ run: vi.fn() }), +})); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("~/state/pullRequests", () => ({ + newestPullRequestSummary: (current: unknown, observed: unknown) => observed ?? current, + pullRequestListEntryToSummary: () => null, + pullRequestEnvironment: { + detail: () => "detail", + changeBreakdown: () => "change-breakdown", + activity: () => "activity", + reviewGuide: () => "review-guide", + generateReviewGuide: { permissionAtom: () => "generate-review-guide" }, + }, + 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() }, + stackedThreadToast: vi.fn(), +})); +vi.mock("~/components/ui/tooltip", () => ({ + TooltipProvider: Wrapper, + Tooltip: Wrapper, + TooltipTrigger: Trigger, + TooltipPopup: () => null, +})); +vi.mock("~/components/ui/menu", () => ({ + Menu: Wrapper, + MenuPopup: Wrapper, + MenuGroup: Wrapper, + MenuTrigger: Trigger, + MenuItem: "button", + MenuCheckboxItem: "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("~/components/pullRequest/PullRequestGuideTab", () => ({ + PullRequestGuideTab: () => null, +})); +vi.mock("./PullRequestSidePanel", async (importOriginal) => { + const { default: Body } = await importOriginal(); + return { + default: function DeferredBody(props: ComponentProps) { + use(bodyImport.promise); + return ; + }, + }; +}); + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +const detail: PullRequestDetailView = { + provider: SourceControlProviderKind.make("github"), + projectId: ProjectId.make("project"), + projectTitle: "Project", + workspaceRoot: "/workspace", + repository: "owner/repo", + number: 7, + title: "Test pull request", + body: "Original description", + url: "https://github.com/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 }; + +// 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(screen: Screen, threadRef: ScopedThreadRef) { + const host: PanelHost = { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, + }; + await screen.rerender( + + + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + /> + + , + ); +} + +const hasText = (screen: Screen, text: string) => + screen.container.textContent?.includes(text) ?? false; + +async function click(screen: Screen, label: string) { + const button = Array.from(screen.container.querySelectorAll("button")).find( + (node) => node.getAttribute("aria-label") === label || node.textContent?.includes(label), + ); + expect(button, label).toBeDefined(); + await act(async () => button!.click()); +} + +const isLoading = (screen: Screen) => + screen.container.querySelector('[role="status"][aria-label="Loading pull request"]') !== null; + +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 body is ready", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + await renderFor(screen, refOn("environment-new")); + expect(isLoading(screen)).toBe(true); + expect(hasText(screen, "Test pull request")).toBe(false); + + await act(loadBody); + expect(isLoading(screen)).toBe(false); + expect(hasText(screen, "Test pull request")).toBe(true); + await click(screen, "Ask a question"); + expect( + useComposerDraftStore.getState().getComposerDraft(refOn("environment-new"))?.reviewComments, + ).toHaveLength(1); + }); + + it("follows the host environment's pull request support", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + await loadBody(); + await renderFor(screen, refOn("environment-loading")); + expect(isLoading(screen)).toBe(true); + expect(hasText(screen, "Test pull request")).toBe(false); + expect(hasText(screen, "Pull requests unavailable")).toBe(false); + + await renderFor(screen, refOn("environment-old")); + expect(hasText(screen, "Pull requests unavailable")).toBe(true); + expect( + hasText(screen, "Update this environment's T3 Code server to browse pull requests."), + ).toBe(true); + expect(hasText(screen, "Test pull request")).toBe(false); + + await renderFor(screen, refOn("environment-new")); + expect(hasText(screen, "Test pull request")).toBe(true); + }); + + it("writes a question into the host thread's composer only", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + await loadBody(); + const threadRef = refOn("environment-new"); + useComposerDraftStore.getState().setPrompt(threadRef, "Keep my draft"); + await renderFor(screen, threadRef); + await click(screen, "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 () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + await loadBody(); + const threadRef = refOn("environment-new"); + await renderFor(screen, threadRef); + await click(screen, "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(); + }); +}); + +function resetFixture() { + 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: {}, + }); +} diff --git a/apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx b/apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx new file mode 100644 index 0000000000000..7858864e6c014 --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx @@ -0,0 +1,68 @@ +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 { type PullRequestDetailTab, useRightPanelStore } from "~/rightPanelStore"; +import { useEnvironment } from "~/state/environments"; + +import { usePanelHost } from "../panelHost"; + +interface PullRequestSidePanelProps { + reference: PullRequestRef; + initialTab?: PullRequestDetailTab; + onTabChange?: ((tab: PullRequestDetailTab) => void) | undefined; + 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, + initialTab = "summary", + onTabChange, +}: 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} + initialTab={initialTab} + onTabChange={onTabChange} + onBack={onBack} + /> + ); +} diff --git a/apps/web/src/panels/pullRequest/PullRequestsSidePanel.browser.test.tsx b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.browser.test.tsx new file mode 100644 index 0000000000000..208735eb5c93b --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.browser.test.tsx @@ -0,0 +1,218 @@ +import { act, render as renderScreen, type Screen } from "../../../test/render"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + EnvironmentId, + ThreadId, + type ScopedThreadRef, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { Fragment, Suspense, type ReactElement, type ReactNode } from "react"; +import { 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("~/hooks/useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); +vi.mock("~/state/pullRequests", async () => { + const { Atom } = await import("effect/reactivity"); + return { + useSharedPullRequestSummary: () => null, + pullRequestEnvironment: { + changeBreakdown: () => null, + runAction: { permissionAtom: () => Atom.make(false) }, + }, + }; +}); +// The panel's list behavior is exercised without the virtualizer's browser geometry. +vi.mock("@legendapp/list/react", () => ({ + LegendList: ({ + data, + renderItem, + keyExtractor, + }: { + data: ReadonlyArray; + renderItem: (input: { item: T; index: number }) => ReactNode; + keyExtractor: (item: T, index: number) => string; + }) => + data.map((item, index) => ( + {renderItem({ item, index })} + )), +})); +vi.mock("~/state/entities", () => ({ + readProject: () => null, + readProjects: () => [], + readThreadShell: () => null, + readThreadShells: () => [], + useQuietThreadShell: () => null, + // No projects, so rows offer no project-scoped fast actions. + useProjects: () => [], + // The other environment has no connected server configuration. + useServerConfigs: () => + new Map( + ["environment-new"].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, + MenuGroup: Wrapper, + MenuRadioGroup: Wrapper, + MenuRadioItem: "button", + MenuTrigger: Trigger, + MenuItem: "button", + MenuSeparator: () => null, +})); + +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, +}); + +const panelFor = (threadRef: ScopedThreadRef) => { + const host: PanelHost = { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, + }; + return ( + + + + + + ); +}; + +// One root across hosts, so a host change reaches the already mounted list. +async function renderFor(screen: Screen, threadRef: ScopedThreadRef) { + await screen.rerender(panelFor(threadRef)); + const expected = + threadRef.environmentId === "environment-new" + ? "owner/alpha" + : "Linked pull requests unavailable"; + if (threadRef.environmentId === "environment-new") { + const first = linksByThread.get(scopedThreadKey(threadRef))?.[0]; + await expect + .element(screen.getByText(first?.repository ?? expected, { exact: false }).first()) + .toBeVisible(); + } else { + await expect.element(screen.getByText(expected, { exact: true })).toBeVisible(); + } +} + +const hasText = (screen: Screen, text: string) => + screen.container.textContent?.includes(text) ?? false; + +describe("linked pull requests side panel", () => { + it("lists and unlinks the host thread's pull requests", async () => { + resetFixture(); + await import("./PullRequestsSidePanel"); + await using screen = await renderScreen(null, { router: false }); + await renderFor(screen, refOn("environment-new")); + expect(hasText(screen, "owner/alpha")).toBe(true); + expect(hasText(screen, "owner/beta")).toBe(false); + + const unlink = Array.from(screen.container.querySelectorAll("button")).find( + (node) => node.textContent?.includes("Unlink from thread"), + ); + expect(unlink).toBeDefined(); + await act(async () => unlink!.click()); + 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 () => { + resetFixture(); + await import("./PullRequestsSidePanel"); + await using screen = await renderScreen(null, { router: false }); + await renderFor(screen, refOn("environment-new")); + expect(hasText(screen, "owner/alpha")).toBe(true); + + await renderFor(screen, refOn("environment-old")); + expect(hasText(screen, "Linked pull requests unavailable")).toBe(true); + expect(hasText(screen, "owner/alpha")).toBe(false); + expect(hasText(screen, "owner/beta")).toBe(false); + + linksByThread.set(scopedThreadKey(refOn("environment-new")), [link("owner/gamma", 33)]); + await renderFor(screen, refOn("environment-new")); + expect(hasText(screen, "owner/gamma")).toBe(true); + }); +}); + +function resetFixture() { + 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)]); +} diff --git a/apps/web/src/panels/pullRequest/PullRequestsSidePanel.tsx b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.tsx new file mode 100644 index 0000000000000..b5e49f45f2fcd --- /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/components/PersistentThreadTerminals.tsx b/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx similarity index 95% rename from apps/web/src/components/PersistentThreadTerminals.tsx rename to apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx index a387349b4c038..1cd1c120b7fa9 100644 --- a/apps/web/src/components/PersistentThreadTerminals.tsx +++ b/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx @@ -9,23 +9,23 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { memo, useEffect } from "react"; -import { useComposerDraftStore } from "../composerDraftStore"; -import { useStableCallback } from "../hooks/useStableCallback"; -import type { TerminalContextSelection } from "../lib/terminalContext"; -import { focusActiveDrawerTerminal, focusTerminal } from "../lib/terminalFocus"; +import { useComposerDraftStore } from "~/composerDraftStore"; +import { useStableCallback } from "~/hooks/useStableCallback"; +import type { TerminalContextSelection } from "~/lib/terminalContext"; +import { focusActiveDrawerTerminal, focusTerminal } from "~/lib/terminalFocus"; import { cn } from "~/lib/utils"; import { selectThreadRightPanelState, useRightPanelStore, type RightPanelSurface, -} from "../rightPanelStore"; -import { useProject, useQuietThreadShell } from "../state/entities"; -import { useEnvironmentScope } from "../state/session"; -import { useKnownTerminalSessions } from "../state/terminalSessions"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useThreadTerminalActions } from "./terminal/useThreadTerminalActions"; -import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { resolveTerminalWorktreePath } from "./ChatView.logic"; +} from "~/rightPanelStore"; +import { useProject, useQuietThreadShell } from "~/state/entities"; +import { useEnvironmentScope } from "~/state/session"; +import { useKnownTerminalSessions } from "~/state/terminalSessions"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "~/terminalUiStateStore"; +import { useThreadTerminalActions } from "~/components/terminal/useThreadTerminalActions"; +import ThreadTerminalDrawer from "~/components/ThreadTerminalDrawer"; +import { resolveTerminalWorktreePath } from "~/components/ChatView.logic"; /** Where a drawer or panel opens new terminals when a script run overrides the thread's own. */ export type PersistentTerminalLaunchContext = { diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.attach.browser.test.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.attach.browser.test.tsx new file mode 100644 index 0000000000000..08f997624221f --- /dev/null +++ b/apps/web/src/panels/terminal/TerminalSidePanel.attach.browser.test.tsx @@ -0,0 +1,207 @@ +import { act, render as renderScreen, stubGlobal, type Screen } from "../../../test/render"; +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { Suspense } from "react"; +import { 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"); + const { Atom } = await import("effect/reactivity"); + const sessions = Atom.family((_key: string) => + Atom.make({ ...empty, status: "running" as const, version: 1 }), + ); + return { + attachedTerminalSessionAtom: (environmentId: string, terminal: unknown) => { + transport.attached.push({ environmentId, terminal }); + return sessions(JSON.stringify([environmentId, terminal])); + }, + useKnownTerminalSessions: () => [], + useAttachedTerminalSession: (input: unknown) => { + transport.attached.push(input); + return empty; + }, + }; +}); +vi.mock("~/components/preview/openTerminalLinkInPreview", () => ({ + openTerminalLinkInPreview: () => undefined, +})); +// This client holds every scope, so the terminal accepts typing. +vi.mock("~/state/session", async () => { + const { Atom, AsyncResult } = await import("effect/reactivity"); + const Option = await import("effect/Option"); + return { + environmentSession: { + initialConfigValueAtom: Atom.family(() => Atom.make(null)), + preparedConnectionValueAtom: Atom.family(() => Atom.make(Option.none())), + sessionStateAtom: Atom.family(() => Atom.make(AsyncResult.initial())), + }, + environmentSessionStatesAtom: () => Atom.make([]), + readPreparedConnection: () => null, + usePreparedConnection: () => null, + + 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", () => ({ + 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( + { input: document.createElement("textarea") }, + { + get: (target, key) => + key === "then" ? undefined : key in target ? Reflect.get(target, key) : () => undefined, + }, + ); + }, + }, +})); +vi.mock("~/state/entities", () => { + const project = { workspaceRoot: "/repo" }; + return { + readEnvironmentSupportsServerBrowser: () => false, + useEnvironmentSupportsServerBrowser: () => false, + useQuietThreadShell: (ref: ScopedThreadRef) => ({ + environmentId: ref.environmentId, + projectId: "project-a", + worktreePath: null, + }), + useProject: () => project, + readProject: () => project, + readProjects: () => [], + readThreadShell: () => null, + useProjects: () => [], + }; +}); + +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, +}); + +async function renderTerminal(screen: Screen, threadRef: ScopedThreadRef) { + await screen.rerender( + + + 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 () => { + resetFixture(); + using _resizeObserver = stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + await using screen = await renderScreen(null, { router: false }); + await renderTerminal(screen, 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(screen, 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" }, + }, + }, + ]); + }); +}); + +function resetFixture() { + transport.attached = []; + transport.commands = []; + transport.surfaces = []; +} diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.browser.test.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.browser.test.tsx new file mode 100644 index 0000000000000..b207481bd5c03 --- /dev/null +++ b/apps/web/src/panels/terminal/TerminalSidePanel.browser.test.tsx @@ -0,0 +1,137 @@ +import { render as renderScreen } from "../../../test/render"; +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +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; worktreePath: string | null }) => { + drawerRenders.push({ visible: props.visible, threadRef: props.threadRef }); + drawerWorktreePaths.push(props.worktreePath); + return null; + }, +})); +vi.mock("~/state/entities", () => { + const project = { workspaceRoot: "/repo" }; + return { useQuietThreadShell: () => thread, useProject: () => project }; +}); +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"; + +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, + onAddTerminalContext: () => undefined, + onSplitTerminal: () => undefined, + onSplitTerminalVertical: () => undefined, + onNewTerminal: () => undefined, + onActiveTerminalChange: () => undefined, + onCloseTerminal: () => undefined, +}; + +// A host rebuilt with the same inputs, as here, must not re-render the drawer. +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", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + await screen.rerender(panelIn(hostFor(true))); + expect(drawerRenders).toEqual([{ visible: true, threadRef }]); + + await screen.rerender(panelIn(hostFor(true))); + expect(drawerRenders).toHaveLength(1); + + await screen.rerender(panelIn(hostFor(false))); + expect(drawerRenders).toEqual([ + { visible: true, threadRef }, + { visible: false, threadRef }, + ]); + }); + + it("keeps a local-checkout launch on the checkout after the thread gains a worktree", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + thread.worktreePath = "/repo/.worktrees/feature"; + drawerWorktreePaths.length = 0; + const launchedLocally = ( + + + + ); + await screen.rerender(launchedLocally); + expect(drawerWorktreePaths).toEqual([null]); + + await screen.rerender(null); + drawerWorktreePaths.length = 0; + await screen.rerender(panelIn(hostFor(true))); + 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", async () => { + resetFixture(); + await using screen = await renderScreen(null, { router: false }); + thread.worktreePath = "/repo/.worktrees/feature"; + terminalSessions.push({ + target: { terminalId: "term-1" }, + state: { summary: { cwd: "/repo", worktreePath: null } }, + }); + await screen.rerender(null); + drawerWorktreePaths.length = 0; + await screen.rerender(panelIn(hostFor(true))); + expect(drawerWorktreePaths).toEqual([null]); + terminalSessions.length = 0; + thread.worktreePath = null; + }); +}); + +function resetFixture() { + drawerRenders.length = 0; + drawerWorktreePaths.length = 0; + thread.worktreePath = null; + terminalSessions.length = 0; +} diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.tsx new file mode 100644 index 0000000000000..a4d02af4d475f --- /dev/null +++ b/apps/web/src/panels/terminal/TerminalSidePanel.tsx @@ -0,0 +1,29 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { ThreadId } from "@t3tools/contracts"; +import { primaryServerKeybindingsAtom } from "~/state/server"; +import { usePanelHost } from "../panelHost"; +import { + PersistentThreadTerminalPanel, + type PersistentThreadTerminalPanelProps, +} from "./PersistentThreadTerminalDrawer"; + +export interface TerminalLaunchContext { + threadId: ThreadId; + cwd: string; + worktreePath: string | null; +} + +export default function TerminalSidePanel( + props: Omit, +) { + const { threadRef, visible } = usePanelHost(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + return ( + + ); +} diff --git a/apps/web/src/pluginActions.test.ts b/apps/web/src/pluginActions.test.ts new file mode 100644 index 0000000000000..1a30b3eb144d2 --- /dev/null +++ b/apps/web/src/pluginActions.test.ts @@ -0,0 +1,95 @@ +import { + AuthOrchestrationOperateScope, + EnvironmentId, + PluginActionId, + ThreadId, + type PluginAction, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + canOperate: true, + invoke: vi.fn(), + toast: vi.fn(), +})); + +// The session grant, the invoke RPC and the toast are the boundaries. +vi.mock("./state/session", () => ({ + readEnvironmentScope: (_environmentId: string, scope: string) => + scope === AuthOrchestrationOperateScope && state.canOperate, +})); +vi.mock("@t3tools/client-runtime/state/runtime", async (importOriginal) => ({ + ...(await importOriginal()), + runAtomCommand: state.invoke, +})); +vi.mock("./state/pluginActions", () => ({ + pluginActionEnvironment: { invoke: "invoke" }, + readPluginActions: () => [], +})); +vi.mock("./rpc/atomRegistry", () => ({ appAtomRegistry: {} })); +vi.mock("./components/ui/toast", () => ({ toastManager: { add: state.toast } })); + +import { buildPluginActionItems } from "./components/CommandPalette.logic"; +import { runPluginAction } from "./pluginActions"; + +const environmentId = EnvironmentId.make("environment-plugins"); +const threadId = ThreadId.make("thread-plugins"); +const deploy: PluginAction = { + id: PluginActionId.make("plugin-deploy:deploy"), + pluginId: "plugin-deploy", + pluginName: "Deploy", + name: "deploy", + title: "Deploy this thread", + target: "thread", + placements: ["command-palette"], +}; +const run = () => + runPluginAction({ environmentId, action: deploy, target: { _tag: "thread", threadId } }); + +beforeEach(() => { + state.canOperate = true; + state.invoke.mockReset().mockResolvedValue(AsyncResult.success({ message: null })); + state.toast.mockReset(); +}); + +describe("runPluginAction", () => { + it("reports that the plugin ran the action", async () => { + await expect(run()).resolves.toBe(true); + expect(state.invoke).toHaveBeenCalledOnce(); + expect(state.toast).toHaveBeenCalledWith(expect.objectContaining({ type: "success" })); + }); + + it("reports a refused action as not run", async () => { + state.invoke.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("Forbidden")))); + await expect(run()).resolves.toBe(false); + expect(state.toast).toHaveBeenCalledWith( + expect.objectContaining({ type: "error", description: "Forbidden" }), + ); + }); + + it("does not invoke once the connection has lost its operate grant", async () => { + state.canOperate = false; + await expect(run()).resolves.toBe(false); + expect(state.invoke).not.toHaveBeenCalled(); + expect(state.toast).toHaveBeenCalledWith(expect.objectContaining({ type: "error" })); + }); + + it("rechecks the grant when a palette entry offered earlier is chosen", async () => { + const [entry] = buildPluginActionItems({ + environmentId, + actions: [deploy], + canOperate: true, + threadId, + projectId: null, + icon: null, + runAction: runPluginAction, + }); + state.canOperate = false; + + await entry?.run(); + + expect(state.invoke).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/pluginActions.ts b/apps/web/src/pluginActions.ts new file mode 100644 index 0000000000000..99631602c74bd --- /dev/null +++ b/apps/web/src/pluginActions.ts @@ -0,0 +1,78 @@ +import { + isAtomCommandInterrupted, + runAtomCommand, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { pluginActionLabels, pluginActionsAt } from "@t3tools/client-runtime/state/pluginActions"; +import { + AuthOrchestrationOperateScope, + type EnvironmentId, + type PluginAction, + type PluginActionTarget, + type ProjectId, + type ScopedThreadRef, +} from "@t3tools/contracts"; + +import { toastManager } from "./components/ui/toast"; +import { appAtomRegistry } from "./rpc/atomRegistry"; +import { pluginActionEnvironment, readPluginActions } from "./state/pluginActions"; +import { readEnvironmentScope } from "./state/session"; + +export function threadMenuPluginActions(threadRef: ScopedThreadRef, projectId: ProjectId) { + const entries = pluginActionsAt(readPluginActions(threadRef.environmentId), "thread-menu", { + threadId: threadRef.threadId, + projectId, + }); + const labels = pluginActionLabels(entries.map((entry) => entry.action)); + return entries.map((entry, index) => ({ + ...entry, + environmentId: threadRef.environmentId, + id: `plugin-action:${entry.action.id}` as const, + label: labels[index] ?? entry.action.title, + })); +} + +/** Whether this connection may run plugin actions now, read from the live grant. */ +export function canRunPluginActionsNow(environmentId: EnvironmentId): boolean { + return readEnvironmentScope(environmentId, AuthOrchestrationOperateScope); +} + +// Execution fixes the picked environment and target, then checks the live grant before running. +// Only a completed action returns true; interruptions do not show an error toast. +export async function runPluginAction(input: { + readonly environmentId: EnvironmentId; + readonly action: PluginAction; + readonly target: PluginActionTarget; +}): Promise { + const { action } = input; + if (!canRunPluginActionsNow(input.environmentId)) { + toastManager.add({ + type: "error", + title: `${action.title} unavailable`, + description: "This connection cannot run plugin actions.", + }); + return false; + } + const result = await runAtomCommand( + appAtomRegistry, + pluginActionEnvironment.invoke, + { environmentId: input.environmentId, input: { actionId: action.id, target: input.target } }, + { reportFailure: false }, + ); + if (result._tag === "Success") { + toastManager.add({ + type: "success", + title: action.title, + ...(result.value.message === null ? {} : { description: result.value.message }), + }); + return true; + } + if (isAtomCommandInterrupted(result)) return false; + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: `${action.title} failed`, + description: error instanceof Error ? error.message : "The plugin action failed.", + }); + return false; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 1207b5017100d..a14afa0cce022 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -30,6 +30,7 @@ import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAp import { DesktopWebLinkCoordinator } from "../components/desktop/DesktopWebLinkCoordinator"; import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; import { ReopenClosedViewShortcut } from "../components/ReopenClosedViewShortcut"; +import { PluginActionSubscriptions } from "../components/PluginActionSubscriptions"; import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; import { ChatGptWelcomeCoordinator } from "../components/settings/ChatGptWelcomeCoordinator"; import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; @@ -207,6 +208,7 @@ function RootRouteBranch() { + diff --git a/apps/web/src/state/contributionStatus.ts b/apps/web/src/state/contributionStatus.ts new file mode 100644 index 0000000000000..20192a2f38f6f --- /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/apps/web/src/state/device.browser.test.tsx b/apps/web/src/state/device.browser.test.tsx index 1188119271c79..a148e4d77b90b 100644 --- a/apps/web/src/state/device.browser.test.tsx +++ b/apps/web/src/state/device.browser.test.tsx @@ -12,7 +12,8 @@ import { page } from "vite-plus/test/browser"; import { useEffect } from "react"; import { act, render } from "../../test/render"; -import { DevicePanel } from "../components/device/DevicePanel"; +import DeviceSidePanel from "../panels/device/DeviceSidePanel"; +import { PanelHostContext } from "../panels/panelHost"; import { startAgentOpenedPreviews, resetDeviceSessionBaselinesForTests, @@ -164,16 +165,26 @@ it("reports a failed first device load and retries it", async () => { using _subscription = vi.spyOn(deviceEnvironment, "state").mockReturnValue(stateAtom); await using screen = await render( - undefined, }} - surface={{ id: "device", kind: "device" }} - visible - onDismissSetup={() => undefined} - /> + > + undefined} + /> + , ); diff --git a/apps/web/src/state/pluginActions.ts b/apps/web/src/state/pluginActions.ts new file mode 100644 index 0000000000000..91d2b68472075 --- /dev/null +++ b/apps/web/src/state/pluginActions.ts @@ -0,0 +1,37 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createPluginActionEnvironmentAtoms } from "@t3tools/client-runtime/state/pluginActions"; +import type { EnvironmentId, PluginAction } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { useEnvironmentQuery } from "./query"; + +export const pluginActionEnvironment = createPluginActionEnvironmentAtoms(connectionAtomRuntime); + +const NO_ACTIONS: ReadonlyArray = []; + +const snapshotAtom = (environmentId: EnvironmentId) => + pluginActionEnvironment.snapshot({ environmentId, input: {} }); + +/** The environment's plugin actions, kept current while the caller is mounted. */ +export function usePluginActions(environmentId: EnvironmentId | null): ReadonlyArray { + return ( + useEnvironmentQuery(environmentId === null ? null : snapshotAtom(environmentId)).data + ?.actions ?? NO_ACTIONS + ); +} + +/** Keeps one environment's list subscribed so menus built on demand read it current. */ +export function useMountPluginActions(environmentId: EnvironmentId): void { + useAtomValue(snapshotAtom(environmentId)); +} + +/** The list as last received, for menus built when they open. */ +export function readPluginActions(environmentId: EnvironmentId): ReadonlyArray { + return Option.match(AsyncResult.value(appAtomRegistry.get(snapshotAtom(environmentId))), { + onNone: () => NO_ACTIONS, + onSome: (snapshot) => snapshot.actions, + }); +} diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index f88368b37021b..ef46e49839808 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -124,7 +124,7 @@ export const allEnvironmentProjectSnapshotsReadyAtom = createAllEnvironmentProjectSnapshotsReadyAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, shellStateValueAtom: environmentShell.stateValueAtom, - requiresPrimaryEnvironment: !isHostedStaticApp(), + requiresPrimaryEnvironment: expectsPrimaryEnvironment(), }); /** Hosted clients and desktops with the local environment disabled never discover a primary. */ diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index f6df5912ecdd1..dafb07f354437 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -187,6 +187,25 @@ describe("extractTerminalLinks", () => { ]); }); + it.each([ + "connected to redis://127.0.0.1:6379/0", + "connected to postgres://localhost:5432/app", + "uploaded s3://bucket/reports/today.csv", + " at main (file:///Users/jane/app/src/index.mjs:3:7)", + "+redis://localhost:6379/0", + "-redis://localhost:6379/0", + ])("does not turn part of a non-web URL into an editor link: %s", (line) => { + expect(extractTerminalLinks(line)).toEqual([]); + }); + + it("keeps file links beside a non-web URL", () => { + const line = "src/db.ts:4 redis://localhost:6379/0 C:/app/src/db.ts:9"; + expect(extractTerminalLinks(line)).toEqual([ + { kind: "path", text: "src/db.ts:4", start: 0, end: 11 }, + { kind: "path", text: "C:/app/src/db.ts:9", start: 37, end: 55 }, + ]); + }); + it("keeps a question mark inside a filesystem path", () => { const path = "/tmp/report?draft"; expect(extractTerminalLinks(`wrote ${path}`)).toEqual([ diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 1c078d86cd20f..68bd4c8d5a835 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -41,6 +41,9 @@ const TRAILING_PATH_PUNCTUATION_PATTERN = /[.,;:!?]+$/; // A question mark is legal in filenames; only exclude query-bearing request targets. const REQUEST_ROUTE_PATTERN = /(?:^|[\s"'`])(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|CONNECT|TRACE|proxying)\s+["'`]?(?:\/|[A-Za-z0-9_-]+\/)[^\s?]*\?\S*/g; +// The tail of `redis://host/0` or `file:///a.mjs` is not a path. One-letter schemes stay drives, +// a diff's `+` or `-` may lead, and the lookbehind keeps runs from being rescanned at every suffix. +const SCHEME_URL_PATTERN = /(?]+/gu; function trimClosingDelimiters(value: string, kind: TerminalLinkKind): string { let output = value.replace( @@ -110,10 +113,10 @@ export function extractTerminalLinks(line: string): TerminalLinkMatch[] { const promptMatches: TerminalLinkMatch[] = promptPath ? [{ kind: "path", text: promptPath, start: 3, end: 3 + promptPath.length }] : []; - const requestRoutes = Array.from(line.matchAll(REQUEST_ROUTE_PATTERN), (match) => ({ - start: match.index, - end: match.index + match[0].length, - })); + const nonPathRanges = Array.from( + [...line.matchAll(REQUEST_ROUTE_PATTERN), ...line.matchAll(SCHEME_URL_PATTERN)], + (match) => ({ start: match.index, end: match.index + match[0].length }), + ); const urlMatches = collectMatches(line, "url", URL_PATTERN, promptMatches); const quotedMatches = collectMatches(line, "path", QUOTED_FILE_PATH_PATTERN, [ ...promptMatches, @@ -126,7 +129,7 @@ export function extractTerminalLinks(line: string): TerminalLinkMatch[] { ]); return [...promptMatches, ...urlMatches, ...quotedMatches, ...pathMatches] .filter( - (match) => match.kind === "url" || !requestRoutes.some((route) => overlaps(match, route)), + (match) => match.kind === "url" || !nonPathRanges.some((range) => overlaps(match, range)), ) .toSorted((a, b) => a.start - b.start); } diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 9e5cf6c9d8a6c..55389188f3894 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -71,6 +71,14 @@ checkpoint or diff must not extend the recorded provider duration or keep the cl provider work as active. PR discovery after completion also checks that the checkout still matches the thread's non-default branch and that a newer run is not active. +Every newly finished run that was not rolled back records exactly one of `run.finalized` or +`run.finalization-failed`. A run that never enqueues a checkpoint capture records it in the commit +that writes its terminal status, through [EventSink](../../apps/server/src/orchestration-v2/EventSink.ts), +so new terminal paths need no extra work. A run that captures records it in a later write, after +the capture and the workspace refresh; a replayed capture first checks for a recorded outcome and +stops there, so a restart either redoes the unrecorded work or honours the recorded outcome. +Neither event is `thread.settled`, which is the sidebar's parking state. + [Checkpoints](../../apps/server/src/checkpointing/CheckpointStore.ts) use hidden Git refs to capture workspace state without adding commits to the user's branch. A revert must coordinate workspace state with the provider conversation. A provider that cannot roll back its conversation diff --git a/docs/internals/web-ui.md b/docs/internals/web-ui.md index 13b7d9df99fd0..c54a3802be941 100644 --- a/docs/internals/web-ui.md +++ b/docs/internals/web-ui.md @@ -31,8 +31,8 @@ The composer banners are the reference. renders `Button`. - [`ComposerBannerStack.tsx`](../../apps/web/src/components/chat/ComposerBannerStack.tsx) composes the slots and owns only stack behavior: priority, expand and collapse, dismiss transitions. -- Consumers are a few lines of slots, and the slots carry the styling: - [`ComposerActivityStatus.tsx`](../../apps/web/src/components/chat/ComposerActivityStatus.tsx). +- Consumers are a few lines of slots, and the slots carry the banner styling: + [`ComposerPlanFollowUpBanner.tsx`](../../apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx). Build new UI the same way. Compose or extend a component instead of copying its markup and classes. When a call site repeats a treatment another one already uses, make it a variant or a slot on the diff --git a/docs/user/mobile-notifications.md b/docs/user/mobile-notifications.md index 9c5e30ef76074..ce4b1fd85cf96 100644 --- a/docs/user/mobile-notifications.md +++ b/docs/user/mobile-notifications.md @@ -2,10 +2,10 @@ Sign in to T3 Connect, link your environments, and enable **Device Notifications** in **Settings → Notifications** to receive alerts when an agent finishes, fails, needs approval, or asks for input. Tap a notification to open its thread. Your environment must have agent activity publishing enabled. -Enable **Ongoing Agent Activity** on Android or **Live Activity Updates** on iOS to follow work without opening the app. Finished results remain visible for up to 15 minutes. You can dismiss an Android activity card without disabling alerts; turn off ongoing activity in Settings to stop future cards. +Enable **Live Activity Updates** on iOS, **Agent Live Updates** on Android 16 and newer, or **Ongoing Agent Activity** on older Android versions to follow work without opening the app. Finished results remain visible for up to 15 minutes. You can dismiss an Android activity card without disabling alerts; turn off the same switch to stop future cards. Ordinary alerts stay quiet while the mobile app is in the foreground. Ongoing activity continues to update. Viewing a thread on another device does not silence your phone's alerts. -Android notifications require Android 7.0 or newer and Google Play services. Android 16 and newer can promote ongoing activity to a Live Update, subject to system settings and device support. Other devices show a regular ongoing notification. Android 7's battery-saving modes can delay removal of expired cards. +Android notifications require Android 7.0 or newer and Google Play services. Android 16 and newer can promote ongoing activity to a Live Update, subject to system settings and device support. Other devices show a regular ongoing notification. If Live Updates don't appear on Android 16 or newer, check **Live Update Settings** in T3 Code's **Settings → Notifications**. Android 7's battery-saving modes can delay removal of expired cards. Notification permission and Android notification channels are controlled in system Settings. Background delivery requires T3 Connect; a direct or Tailscale connection alone does not enable push notifications. The mobile app does not need to maintain a connection to your environment. Force-stopping the Android app in system Settings prevents push delivery until you open it again. diff --git a/docs/user/plugin-actions.md b/docs/user/plugin-actions.md new file mode 100644 index 0000000000000..4b401aa7eb10d --- /dev/null +++ b/docs/user/plugin-actions.md @@ -0,0 +1,77 @@ +# Plugin actions + +A trusted local plugin can add actions to the command palette, the thread +menu and the composer's slash menu. Picking one runs the plugin's code right +away on the server's machine, as the OS account that runs the T3 Code server, +which is not necessarily the account on the device you picked it from. It does +not write a prompt or start an agent turn, so only enable plugins you trust. + +## Offering an action + +In the plugin's `t3-plugin.json`, add `"actions"` to `capabilities`, set +`"proposedApi": true`, and declare up to 16 actions in `actions`. Each has a +`name` (lowercase letters, digits and dashes; also its slash command), a +`title`, an optional `description`, a `target` and the `placements` where it +appears: + +```json +{ + "capabilities": ["actions"], + "proposedApi": true, + "actions": [ + { + "name": "deploy", + "title": "Deploy this branch", + "target": "thread", + "placements": ["command-palette", "thread-menu", "composer-slash"] + } + ] +} +``` + +The plugin answers by registering +`context.proposed.handle("action:", handler)`. The handler receives +`{ action, target }`, where `target` is one of: + +- `{ kind: "environment" }` +- `{ kind: "project", projectId, workspaceRoot }` +- `{ kind: "thread", threadId, projectId, cwd, branch }`, where `cwd` is the + thread's worktree or its project's folder + +Return `{ message }` to show the user a short result, or throw to report a +failure. An action that does not finish within 30 seconds fails. + +## Where actions appear + +Plugins are added, consented to and enabled from an administrative connection, +as described in [Plugin tools](./plugin-tools.md). Listing actions never +starts the plugin; the first action you pick does. + +An action appears only where its target is known: + +- **Command palette**: on web and desktop, environment actions, plus project + and thread actions for the project or thread you are in. On mobile with a + hardware keyboard, the palette shows actions for the open thread's + environment. +- **Thread menu**: thread actions for that thread, from the sidebar or the + chat header on web and desktop, and under **Plugin actions** in a thread's + long-press menu on mobile. +- **Slash menu**: type `/` at the start of a line in your message, on web, + desktop and mobile. In an open thread you see that thread's actions; in a + new, unsent thread only environment and project actions appear. Picking one + removes the typed command from your message and leaves the rest of the + message as it was. + +The result appears as a toast on web and desktop, or an alert on mobile. + +## When an action is refused + +Actions disappear as soon as their plugin is disabled, removed or no longer +able to run. If you picked an action from a list that changed in the meantime, +for example because the plugin was enabled again, it is refused instead of +running against the new version. Open the menu again and pick it from the +current list. + +If an environment's plugins declare more than 128 actions, the plugins that do +not fit are left out whole and their actions cannot run. Disable a plugin you +do not need to bring the others back. diff --git a/docs/user/plugin-settings.md b/docs/user/plugin-settings.md new file mode 100644 index 0000000000000..0e715220c0c3b --- /dev/null +++ b/docs/user/plugin-settings.md @@ -0,0 +1,58 @@ +# Plugin settings and storage + +A trusted local plugin can declare settings that you fill in, including +secrets such as API tokens, and keep a small amount of its own data between +runs. Forms for filling in settings arrive in the clients later; until then, +settings are saved from an administrative connection with the +`plugins.settings.update` request. + +## Declaring settings + +In the plugin's `t3-plugin.json`, add `"settings"` to `capabilities`, set +`"proposedApi": true`, and list up to 32 fields in `settings`. Each field has +a `type` (`text`, `secret`, `boolean`, `number` or `select`), a unique `key` +and a `label`, and may have a `description`. Every type except `secret` can +have a `default`; `number` can set `min`, `max` and `integer`, and `select` +lists its `options`: + +```json +{ + "capabilities": ["settings"], + "proposedApi": true, + "settings": [ + { "type": "text", "key": "apiUrl", "label": "API URL", "default": "https://api.example.com" }, + { "type": "secret", "key": "token", "label": "API token" } + ] +} +``` + +A manifest whose settings T3 Code cannot honor, such as a default outside its +own bounds, is refused when the plugin is added. + +## Reading settings + +`context.proposed.settings.get(key)` returns the saved value if it still fits +the field, else the field's default, else `undefined`. A secret returns its saved text. Asking for +a key the manifest does not declare rejects. + +Secrets are write-only for clients: a client learns only whether a secret is +saved, never its value. T3 Code stores each secret as a plain-text file, +readable only by the server's OS account, in the server's secrets directory. It is not +encrypted. + +## Plugin storage + +`context.proposed.storage` keeps JSON values under string keys with `get`, +`set`, `delete` and `keys`. Each installation has its own storage, limited to +keys of 1 to 128 characters, values up to 64 KiB of JSON, 256 keys and 1 MiB +in total. A write past a limit rejects. + +## How long values last + +Settings, secrets and storage belong to the installation. They survive +disabling and enabling it again, server restarts and updates to the plugin's +files. When a new version stops declaring a field, or switches it between +secret and non-secret, its saved value is deleted at the next save. A saved +value that no longer fits a changed field is kept, but reads fall back to the +default until it fits again. Removing the plugin deletes everything saved for +it. diff --git a/docs/user/plugin-tools.md b/docs/user/plugin-tools.md new file mode 100644 index 0000000000000..9622eaeac083f --- /dev/null +++ b/docs/user/plugin-tools.md @@ -0,0 +1,46 @@ +# Plugin tools + +A trusted local plugin can offer tools that agents call through T3 Code's own +MCP server. Plugins are code that runs as the T3 Code server's OS account, so +only add plugins you trust. + +## Offering a tool + +In the plugin's `t3-plugin.json`, add `"tools"` to `capabilities`, set +`"proposedApi": true`, and declare each tool in `tools` with a `name`, +`description`, `inputSchema` and `sideEffect` (`read`, `write` or +`destructive`). The plugin answers a call by registering +`context.proposed.handle("t3.tool.", handler)`; the handler receives +`{ input, context: { environmentId, threadId } }`. + +`inputSchema` must stay within the JSON Schema subset T3 Code enforces. A +manifest outside it is refused when the plugin is added, with the path of the +keyword it cannot enforce. Input that does not match the schema never reaches +the plugin. + +## Making tools available + +Plugins are managed from an administrative connection with the `plugins.add`, +`plugins.consent` and `plugins.enable` requests. Consent covers the plugin's +exact files, tool declarations included. Listing tools never starts the plugin; +the first call does. + +Agents see two tools in every session that uses T3 Code's MCP server: +`plugin_tools_list` and `plugin_tool_call`. + +## Which sessions can use a plugin + +Calls require a live thread in full-access/default mode. Restricted and planning +threads can list tools but cannot execute them, because a trusted plugin's +side-effect declaration does not constrain the code it runs. + +A session can use the tool plugins that were enabled when it started. A plugin +you enable later, or enable again after disabling it, shows up as not +available in this session. To use it, start a new thread. + +Disabling or removing a plugin refuses its tools at once in every session and +cancels calls in progress. T3 Code checks a plugin's files when you refresh, +consent to or enable it, when the server starts, and before a call starts the +plugin. If the files changed, the plugin is disabled until you consent to the +new version. A plugin that is already running is not checked again until one of +those points, so refresh it after editing its files. diff --git a/docs/user/providers-pi.md b/docs/user/providers-pi.md index 37026d50ab76c..dfc72fe8ccac7 100644 --- a/docs/user/providers-pi.md +++ b/docs/user/providers-pi.md @@ -49,8 +49,10 @@ loads for the current workspace; selecting one uses Pi's native skill expansion. Pi loads its normal user extensions, and project extensions once the folder is trusted in Pi. Blocking `select`, `confirm`, `input`, and `editor` dialogs work in T3 Code. Notifications appear in -the work log. Pi terminal decoration such as titles, status lines, and widgets does not have a T3 -Code equivalent. +the work log. On web and desktop, extension statuses (`setStatus`) appear beside the thread title; on mobile, +they appear under the thread header. +They can lag a session change until the extension updates them. Pi terminal titles and widgets +do not have a T3 Code equivalent. ## Permission Modes diff --git a/knip.jsonc b/knip.jsonc index c2093737fd942..0594af6072fd6 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -52,6 +52,7 @@ "scripts/update-test-shard-weights.ts", "scripts/verify-background-live.ts", "src/provider/testFixtures/*.mjs", + "src/plugins/testFixtures/**/*.mjs", // graphqlgen reads its config. "graphql.config.ts", ], diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d1b48e1d76f4f..2e43516298ba3 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -219,10 +219,18 @@ "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" }, + "./state/pluginActions": { + "types": "./src/state/pluginActions.ts", + "default": "./src/state/pluginActions.ts" + }, "./state/deviceHubAccess": { "types": "./src/state/deviceHubAccess.ts", "default": "./src/state/deviceHubAccess.ts" diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index ccc5bf6ef2807..bd5729656c841 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -12,6 +12,7 @@ import { createProviderWorkspaceSnapshotRefreshAtomFamily, getProviderSkillsForSlashMenu, hasCompleteProviderWorkspaceSnapshot, + isProviderWorkspaceSnapshotScanning, resolveProviderSkillsForCwd, resolveProviderSlashCommandsForCwd, } from "./providerSkills.ts"; @@ -298,6 +299,53 @@ describe("provider workspace snapshot refresh atom", () => { unmount(); }); + it("reports skills loading while a missing snapshot is scanned, until it arrives", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); + const scanned = withSnapshot("2026-01-01T00:00:00.000Z"); + const { registry, atom, providersAtom, unmount } = setup(noSnapshot, [() => scanned]); + expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(true); + registry.set(providersAtom, scanned); + await vi.advanceTimersByTimeAsync(0); + expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(false); + unmount(); + }); + + it("stops reporting skills loading when the scan fails", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); + const { registry, atom, unmount } = setup(noSnapshot); + expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(true); + await vi.advanceTimersByTimeAsync(0); + expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(false); + unmount(); + }); + + it("keeps reporting skills loading while the scan returns a pending snapshot", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); + const pending = withSnapshot("2026-01-01T00:00:00.000Z", true); + const { registry, atom, providersAtom, unmount } = setup(pending, [() => pending]); + await vi.advanceTimersByTimeAsync(0); + expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(true); + registry.set(providersAtom, withSnapshot("2026-01-01T00:00:00.000Z")); + expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(false); + unmount(); + }); + + it("stops reporting skills loading once a pending snapshot exhausts its retries", async () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); + const { registry, atom, run, unmount } = setup(withSnapshot("2026-01-01T00:00:00.000Z", true)); + await vi.advanceTimersByTimeAsync(60_000); + expect(run).toHaveBeenCalledTimes(3); + expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(false); + unmount(); + }); + + it("does not report skills loading for a current snapshot", () => { + vi.useFakeTimers({ now: Date.parse("2026-01-01T00:01:00.000Z") }); + const { registry, atom, unmount } = setup(withSnapshot("2026-01-01T00:00:00.000Z")); + expect(isProviderWorkspaceSnapshotScanning(registry.get(atom))).toBe(false); + unmount(); + }); + it("does not retry on a timer when the failed scan left no pending snapshot", async () => { vi.useFakeTimers({ now: Date.parse("2026-01-01T00:00:00.000Z") }); const { registry, atom, run, unmount } = setup(noSnapshot); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index 63ded7a47dc5c..2598c895c5053 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -136,7 +136,7 @@ const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; // A cwd that never scans, such as a deleted worktree, would otherwise retry forever. const WORKSPACE_SNAPSHOT_MAX_ATTEMPTS = 3; -interface ProviderWorkspaceSnapshotRefreshState { +export interface ProviderWorkspaceSnapshotRefreshState { /** * When the last scan was requested. A request inside the TTL is not * repeated, so a client clock ahead of the server's cannot loop rescans. @@ -147,6 +147,8 @@ interface ProviderWorkspaceSnapshotRefreshState { /** Failed scans within the current request TTL. */ readonly failedAttempts: number; readonly hadSnapshot: boolean; + /** The server published a partial snapshot and is still discovering. */ + readonly snapshotPending: boolean; } const initialProviderWorkspaceSnapshotRefreshState: ProviderWorkspaceSnapshotRefreshState = { @@ -154,6 +156,7 @@ const initialProviderWorkspaceSnapshotRefreshState: ProviderWorkspaceSnapshotRef retryNotBefore: null, failedAttempts: 0, hadSnapshot: false, + snapshotPending: false, }; /** @@ -172,6 +175,8 @@ function planProviderWorkspaceSnapshotRefresh( readonly recheckAt: number | null; } { const hasSnapshot = hasCompleteProviderWorkspaceSnapshot(provider, cwd); + const pending = + (provider && resolveProviderWorkspaceSnapshot(provider, cwd)?.slashCommandsPending) === true; // Losing a snapshot (the server dropped it) lifts both the TTL and the retry // cooldown so the scan happens at once. const lost = previous.hadSnapshot && !hasSnapshot; @@ -188,6 +193,7 @@ function planProviderWorkspaceSnapshotRefresh( ? 0 : previous.failedAttempts, hadSnapshot: hasSnapshot, + snapshotPending: pending, }; const refresh = (requestedAt === null || now - requestedAt >= PROVIDER_WORKSPACE_SNAPSHOT_TTL_MS) && @@ -196,8 +202,6 @@ function planProviderWorkspaceSnapshotRefresh( if (refresh) { return { state: { ...state, requestedAt: now }, refresh: true, recheckAt: null }; } - const pending = - (provider && resolveProviderWorkspaceSnapshot(provider, cwd)?.slashCommandsPending) === true; return { state, refresh: false, recheckAt: pending ? retryNotBefore : null }; } @@ -223,6 +227,23 @@ function failProviderWorkspaceSnapshotRefresh( }; } +/** + * A scan is in flight, or the server is still discovering, for a cwd without a + * complete snapshot, so its skills and commands may still arrive. A failed scan + * clears `requestedAt`, and exhausted attempts end it, so this cannot stay true + * for a scan that will not finish. + */ +export function isProviderWorkspaceSnapshotScanning( + state: ProviderWorkspaceSnapshotRefreshState | undefined, +): boolean { + return ( + state !== undefined && + (state.requestedAt !== null || state.snapshotPending) && + !state.hadSnapshot && + state.failedAttempts < WORKSPACE_SNAPSHOT_MAX_ATTEMPTS + ); +} + type WorkspaceSnapshotStatus = "absent" | "pending" | "complete"; function resolveWorkspaceSnapshotStatus( @@ -330,6 +351,8 @@ export function createProviderWorkspaceSnapshotRefreshAtomFamily(input: { ).pipe(Atom.withLabel(`provider-workspace-snapshot-refresh:${key}`)); return refreshAtom; }); - return (target: ProviderWorkspaceSnapshotRefreshTarget): Atom.Atom => + return ( + target: ProviderWorkspaceSnapshotRefreshTarget, + ): Atom.Atom => family({ environmentId: target.environmentId, instanceId: target.instanceId, cwd: target.cwd }); } diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 2832a4e231556..be39cd1ace807 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -5,6 +5,7 @@ import { type EnvironmentId, type ClientGuardedRpcTag, ORCHESTRATION_V2_WS_METHODS, + type ServerConfig, WS_METHODS, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -60,12 +61,14 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeServerLifecycle | typeof WS_METHODS.scheduledTasksSubscribe | typeof WS_METHODS.serverGetStorageCleanupReport + | typeof WS_METHODS.pluginActionsSubscribe | typeof WS_METHODS.serverGetDatabaseStorage | typeof WS_METHODS.subscribeTerminalMetadata | typeof WS_METHODS.subscribePreviewEvents | 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 @@ -186,16 +189,10 @@ const authorizeRequest = Effect.fn("EnvironmentRpc.authorize")(function* ( yield* guard.authorize(supervisor.target.environmentId, method, input); }); -export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* < +const requestOnSession = Effect.fn("EnvironmentRpc.requestOnSession")(function* < TTag extends EnvironmentUnaryRpcTag, ->(tag: TTag, input: EnvironmentRpcInput) { +>(session: RpcSession, tag: TTag, input: EnvironmentRpcInput) { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; - yield* Effect.annotateCurrentSpan({ - "environment.id": supervisor.target.environmentId, - "rpc.method": tag, - }); - const session = yield* currentSession(); - yield* authorizeRequest(tag, input); const observer = yield* EnvironmentRpcRequestObserver; const method = session.client[tag] as ( input: EnvironmentRpcInput, @@ -208,6 +205,50 @@ export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* < return yield* method(input).pipe(Effect.ensuring(completeObservation)); }); +export const requestGuarded = Effect.fn("EnvironmentRpc.request")(function* < + TTag extends EnvironmentUnaryRpcTag, +>(tag: TTag, input: EnvironmentRpcInput) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + yield* Effect.annotateCurrentSpan({ + "environment.id": supervisor.target.environmentId, + "rpc.method": tag, + }); + const session = yield* currentSession(); + yield* authorizeRequest(tag, input); + return yield* requestOnSession(session, tag, input); +}); + +/** + * Like `request`, but only to a server whose capabilities pass `supported`. + * The check and the request use the same session, so a reconnect to an older + * server in between cannot receive the call. Otherwise fails with + * `EnvironmentRpcUnavailableError` and sends nothing. + */ +export const requestIfSupported = Effect.fn("EnvironmentRpc.requestIfSupported")(function* < + TTag extends Exclude, +>( + tag: TTag, + input: EnvironmentRpcInput, + supported: (capabilities: ServerConfig["environment"]["capabilities"]) => boolean, +) { + const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; + yield* Effect.annotateCurrentSpan({ + "environment.id": supervisor.target.environmentId, + "rpc.method": tag, + }); + const session = yield* currentSession(); + const isSupported = yield* session.initialConfig.pipe( + Effect.map((config) => supported(config.environment.capabilities)), + Effect.orElseSucceed(() => false), + ); + if (!isSupported) + return yield* new EnvironmentRpcUnavailableError({ + environmentId: supervisor.target.environmentId, + message: `${supervisor.target.label} runs a server version without this feature.`, + }); + return yield* requestOnSession(session, tag, input); +}); + export function runStreamGuarded( tag: TTag, input: EnvironmentRpcInput, @@ -236,6 +277,8 @@ export function runStreamGuarded( } interface SubscriptionOptions { + /** Pins a subscription to a session already checked by its caller. */ + readonly session?: RpcSession; readonly onDefect?: ( cause: Cause.Cause>, ) => Effect.Effect; @@ -263,14 +306,21 @@ function subscribeDynamicMapped( Effect.gen(function* () { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; const observer = yield* EnvironmentRpcSubscriptionObserver; - const sessionChanges = SubscriptionRef.changes(supervisor.session); + const sessionChanges = + options?.session === undefined + ? SubscriptionRef.changes(supervisor.session) + : Stream.succeed(Option.some(options.session)); const sessions = options?.resubscribe === undefined ? sessionChanges : Stream.merge( sessionChanges, options.resubscribe.pipe( - Stream.mapEffect(() => SubscriptionRef.get(supervisor.session)), + Stream.mapEffect(() => + options.session === undefined + ? SubscriptionRef.get(supervisor.session) + : Effect.succeedSome(options.session), + ), ), ); return sessions.pipe( 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 0000000000000..9c04e8c562a9d --- /dev/null +++ b/packages/client-runtime/src/state/contributionStatus.test.ts @@ -0,0 +1,242 @@ +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, + inboundFrames: Effect.succeed(0), + 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 0000000000000..2170a1a94c738 --- /dev/null +++ b/packages/client-runtime/src/state/contributionStatus.ts @@ -0,0 +1,99 @@ +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 + ); + }) + ); + }) + ); +} + +// Subscribe on demand only to capable servers; reconnect snapshots retire old statuses. +// Keep source identity so a replacement provider session re-keys even identical status text. +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/client-runtime/src/state/filesystem.test.ts b/packages/client-runtime/src/state/filesystem.test.ts index 6d1c22bb856d3..6c21c5d926919 100644 --- a/packages/client-runtime/src/state/filesystem.test.ts +++ b/packages/client-runtime/src/state/filesystem.test.ts @@ -4,6 +4,7 @@ import { AuthFilesystemReadScope, AuthOrchestrationReadScope } from "@t3tools/co import { createBrowseNavigationCoordinator, getFilesystemBrowsePath, + getFilesystemBrowseTabCompletion, resolveFilesystemReadAccess, } from "./filesystem.ts"; @@ -79,6 +80,33 @@ describe("filesystem browse model", () => { expect(getFilesystemBrowsePath("C:\\Users\\test", "MacIntel").isBrowsing).toBe(false); expect(getFilesystemBrowsePath("~/projects/", "", false).isBrowsing).toBe(false); }); + + it("completes Tab like a shell", () => { + const entry = (name: string) => ({ name, fullPath: `/Users/me/${name}` }); + expect(getFilesystemBrowseTabCompletion([entry("Creations")], "Creation")).toEqual({ + name: "Creations", + isComplete: true, + }); + expect(getFilesystemBrowseTabCompletion([entry("OpenSourced"), entry("OpenAI")], "op")).toEqual( + { name: "Open", isComplete: false }, + ); + expect(getFilesystemBrowseTabCompletion([entry("Open"), entry("Opal")], "Op")).toBeNull(); + expect(getFilesystemBrowseTabCompletion([entry("openAI"), entry("OpenSource")], "o")).toEqual({ + name: "open", + isComplete: false, + }); + expect(getFilesystemBrowseTabCompletion([entry("😀one"), entry("😁two")], "")).toBeNull(); + expect(getFilesystemBrowseTabCompletion([entry("ΟΣα"), entry("οσβ")], "ο")).toBeNull(); + expect(getFilesystemBrowseTabCompletion([entry("İfoo"), entry("İfar")], "i\u0307")).toEqual({ + name: "İf", + isComplete: false, + }); + expect(getFilesystemBrowseTabCompletion([entry("😀one"), entry("😀other")], "😀")).toEqual({ + name: "😀o", + isComplete: false, + }); + expect(getFilesystemBrowseTabCompletion([], "x")).toBeNull(); + }); }); describe("browse navigation", () => { diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts index 90276fe21661e..4d423d0ed3609 100644 --- a/packages/client-runtime/src/state/filesystem.ts +++ b/packages/client-runtime/src/state/filesystem.ts @@ -22,6 +22,8 @@ import { } from "./projects.ts"; import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +const BROWSE_IDLE_TTL_MS = 5_000; + export function resolveFilesystemReadAccess(input: { readonly isCatalogReady: boolean; readonly connection: Pick | null; @@ -85,6 +87,33 @@ export function filterFilesystemBrowseEntries( return { visibleEntries, exactEntry }; } +/** Shell-style Tab: a single match enters the directory, several extend to their common prefix. */ +export function getFilesystemBrowseTabCompletion( + visibleEntries: ReadonlyArray, + filterQuery: string, +): { readonly name: string; readonly isComplete: boolean } | null { + const [first, ...rest] = visibleEntries; + if (first === undefined) return null; + if (rest.length === 0) return { name: first.name, isComplete: true }; + let prefix = Array.from(first.name); + for (const entry of rest) { + const characters = Array.from(entry.name); + let length = 0; + while ( + length < prefix.length && + prefix[length]?.toLowerCase() === characters[length]?.toLowerCase() + ) + length += 1; + prefix = prefix.slice(0, length); + } + const name = prefix.join(""); + const lowerName = name.toLowerCase(); + return lowerName.length > filterQuery.toLowerCase().length && + visibleEntries.every((entry) => entry.name.toLowerCase().startsWith(lowerName)) + ? { name, isComplete: false } + : null; +} + export function createBrowseNavigationCoordinator() { let generation = 0; @@ -121,6 +150,9 @@ export function createFilesystemEnvironmentAtoms( browse: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:filesystem:browse", tag: WS_METHODS.filesystemBrowse, + // A listing kept alive while unread is never revalidated, so reopening the browser + // would show folders as they were. Keep it only long enough for a prefetch to land. + idleTtlMs: BROWSE_IDLE_TTL_MS, }), }; } diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index fbba7d1bd9dd4..e3068ba46f8a7 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -302,6 +302,10 @@ export function applyOrchestrationV2ProjectionEvent( const next = { ...base, runs: upsertEntity(base.runs, event.payload) }; return { ...next, visibleTurnItems: activeVisibleTurnItems(next) }; } + // Milestones over state the run rows already hold. + case "run.finalized": + case "run.finalization-failed": + return projection; case "run.background-work-cancelled": return { ...base, diff --git a/packages/client-runtime/src/state/pluginActions.test.ts b/packages/client-runtime/src/state/pluginActions.test.ts new file mode 100644 index 0000000000000..9614e119a98f3 --- /dev/null +++ b/packages/client-runtime/src/state/pluginActions.test.ts @@ -0,0 +1,359 @@ +import { + EnvironmentId, + PluginActionId, + ProjectId, + type PluginAction, + type PluginActionsSnapshot, + type ServerConfig, + ThreadId, + WS_METHODS, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +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 { AsyncResult, 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 * as RpcSession from "../rpc/session.ts"; +import { + createPluginActionEnvironmentAtoms, + pluginActionLabels, + pluginActionsAt, + pluginActionsStream, +} from "./pluginActions.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const action = ( + name: string, + target: PluginAction["target"], + placements: PluginAction["placements"], +) => + ({ + id: PluginActionId.make(`installation-1:1:${name}`), + pluginId: "test.actions", + pluginName: "Actions", + name, + title: name, + target, + placements, + }) satisfies PluginAction; + +const ACTIONS = [ + action("everywhere", "environment", ["command-palette", "thread-menu"]), + action("on-project", "project", ["command-palette"]), + action("on-thread", "thread", ["command-palette", "composer-slash"]), +]; + +const ACTIONS_THEN_HOLD = Stream.succeed({ actions: ACTIONS }).pipe( + Stream.concat(Stream.never), +); + +/** A session to a server reporting `capabilities` and sending `frames`, recording each call. */ +const makeSession = ( + capabilities: ServerConfig["environment"]["capabilities"], + calls: Array, + frames: Stream.Stream = ACTIONS_THEN_HOLD, +): RpcSession.RpcSession => ({ + client: new Proxy( + {}, + { + get: (_target, method: string) => () => { + calls.push(method); + return method === WS_METHODS.pluginActionsSubscribe + ? frames + : Effect.succeed({ message: "done" }); + }, + }, + ) as WsRpcProtocolClient, + initialConfig: Effect.succeed({ environment: { capabilities } } as ServerConfig), + subscribeServerConfig: () => Stream.never, + inboundFrames: Effect.succeed(0), + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, +}); + +/** + * One environment whose session reaches a server reporting `capabilities` and + * sending `frames` to the subscription, recording each call. + */ +const makeEnvironment = Effect.fn("makeEnvironment")(function* ( + capabilities: ServerConfig["environment"]["capabilities"], + frames: Stream.Stream = ACTIONS_THEN_HOLD, +) { + const calls: Array = []; + const sessionRef = yield* SubscriptionRef.make( + Option.some(makeSession(capabilities, calls, frames)), + ); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: sessionRef, + prepared: yield* SubscriptionRef.make>(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + const run: EnvironmentRegistry.EnvironmentRegistry["Service"]["run"] = (_environmentId, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const atoms = createPluginActionEnvironmentAtoms( + Atom.runtime( + Layer.succeed( + EnvironmentRegistry.EnvironmentRegistry, + EnvironmentRegistry.EnvironmentRegistry.of({ + run, + followStream: (_environmentId, stream) => + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + } as EnvironmentRegistry.EnvironmentRegistry["Service"]), + ), + ), + ); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const firstList = pluginActionsStream.pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + const invoke = Effect.promise(() => + atoms.invoke.run(registry, { + environmentId: TARGET.environmentId, + input: { actionId: ACTIONS[0]!.id, target: { _tag: "environment" } }, + }), + ); + const snapshots = AtomRegistry.toStreamResult( + registry, + atoms.snapshot({ environmentId: TARGET.environmentId, input: {} }), + ); + return { calls, firstList, invoke, sessionRef, snapshots, supervisor }; +}); + +const failureTag = (result: AsyncResult.AsyncResult) => + AsyncResult.isFailure(result) + ? Option.getOrUndefined(Cause.findErrorOption(result.cause) as Option.Option<{ _tag: string }>) + ?._tag + : undefined; + +describe("plugin actions on an older server", () => { + it.effect("offer nothing and send nothing", () => + Effect.scoped( + Effect.gen(function* () { + for (const capabilities of [ + { repositoryIdentity: true }, + { repositoryIdentity: true, plugins: true, pluginActions: false }, + ]) { + const { calls, firstList, invoke } = yield* makeEnvironment(capabilities); + expect(yield* firstList).toEqual({ actions: [] }); + expect(failureTag(yield* invoke)).toBe("EnvironmentRpcUnavailableError"); + expect(calls).toEqual([]); + } + }), + ), + ); + + it.effect("are listed and run on a server that announces them", () => + Effect.scoped( + Effect.gen(function* () { + const { calls, firstList, invoke } = yield* makeEnvironment({ + repositoryIdentity: true, + pluginActions: true, + }); + expect(yield* firstList).toEqual({ actions: ACTIONS }); + const result = yield* invoke; + expect(AsyncResult.isSuccess(result) && result.value).toEqual({ message: "done" }); + expect(calls).toEqual([WS_METHODS.pluginActionsSubscribe, WS_METHODS.pluginActionsInvoke]); + }), + ), + ); +}); + +describe("plugin actions across a reconnect", () => { + it.effect("pins a subscription to the session whose capability it checked", () => + Effect.scoped( + Effect.gen(function* () { + const { sessionRef, supervisor } = yield* makeEnvironment({ + repositoryIdentity: true, + pluginActions: true, + }); + const oldCalls: Array = []; + const checkedCalls: Array = []; + const replacement = makeSession({ repositoryIdentity: true }, oldCalls); + const checked = makeSession( + { repositoryIdentity: true, pluginActions: true }, + checkedCalls, + Stream.succeed({ actions: ACTIONS }), + ); + yield* SubscriptionRef.set( + sessionRef, + Option.some({ + ...checked, + initialConfig: checked.initialConfig.pipe( + Effect.tap(() => SubscriptionRef.set(sessionRef, Option.some(replacement))), + ), + }), + ); + const snapshot = yield* pluginActionsStream.pipe( + Stream.take(1), + Stream.runCollect, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + expect(snapshot).toEqual([{ actions: [] }]); + expect(oldCalls).toEqual([]); + }), + ), + ); + + it.effect("follow the server the environment is connected to now", () => + Effect.scoped( + Effect.gen(function* () { + const { calls, invoke, sessionRef, supervisor } = yield* makeEnvironment({ + repositoryIdentity: true, + pluginActions: true, + }); + const lists = yield* Queue.unbounded(); + yield* pluginActionsStream.pipe( + Stream.runForEach((list) => Queue.offer(lists, list)), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkScoped, + ); + expect(yield* Queue.take(lists)).toEqual({ actions: ACTIONS }); + + // The environment reconnects to an older server: its actions disappear + // and a pick made from the old list is not sent there. + const oldCalls: Array = []; + yield* SubscriptionRef.set( + sessionRef, + Option.some(makeSession({ repositoryIdentity: true }, oldCalls)), + ); + expect(yield* Queue.take(lists)).toEqual({ actions: [] }); + expect(failureTag(yield* invoke)).toBe("EnvironmentRpcUnavailableError"); + expect(oldCalls).toEqual([]); + + // Back on a server with plugin actions, the list returns. + const newCalls: Array = []; + yield* SubscriptionRef.set( + sessionRef, + Option.some(makeSession({ repositoryIdentity: true, pluginActions: true }, newCalls)), + ); + expect(yield* Queue.take(lists)).toEqual({ actions: ACTIONS }); + expect(calls).toEqual([WS_METHODS.pluginActionsSubscribe]); + expect(newCalls).toEqual([WS_METHODS.pluginActionsSubscribe]); + }), + ), + ); +}); + +describe("the plugin actions snapshot", () => { + it.effect("keeps what the server's limit left out until room is freed", () => + Effect.scoped( + Effect.gen(function* () { + const frames = yield* Queue.unbounded(); + const { snapshots } = yield* makeEnvironment( + { repositoryIdentity: true, pluginActions: true }, + Stream.fromQueue(frames), + ); + const seen = yield* Queue.unbounded(); + yield* snapshots.pipe( + Stream.runForEach((snapshot) => Queue.offer(seen, snapshot)), + Effect.forkScoped, + ); + + yield* Queue.offer(frames, { actions: ACTIONS, omitted: { plugins: 1, actions: 16 } }); + expect(yield* Queue.take(seen)).toEqual({ + actions: ACTIONS, + omitted: { plugins: 1, actions: 16 }, + }); + + // Disabling a listed plugin admits the next whole; the server stops reporting omissions. + const promoted = [...ACTIONS, action("promoted", "environment", ["thread-menu"])]; + yield* Queue.offer(frames, { actions: promoted }); + const freed = yield* Queue.take(seen); + expect(freed).toEqual({ actions: promoted }); + expect(freed.omitted).toBeUndefined(); + }), + ), + ); +}); + +describe("pluginActionsAt", () => { + const names = (entries: ReturnType) => + entries.map(({ action, target }) => [action.name, target._tag]); + + it("offers an action only where it is placed and its target is known", () => { + const threadId = ThreadId.make("thread-1"); + const projectId = ProjectId.make("project-1"); + expect(names(pluginActionsAt(ACTIONS, "command-palette", { threadId, projectId }))).toEqual([ + ["everywhere", "environment"], + ["on-project", "project"], + ["on-thread", "thread"], + ]); + // A palette opened outside any thread or project. + expect( + names(pluginActionsAt(ACTIONS, "command-palette", { threadId: null, projectId: null })), + ).toEqual([["everywhere", "environment"]]); + expect(names(pluginActionsAt(ACTIONS, "thread-menu", { threadId, projectId }))).toEqual([ + ["everywhere", "environment"], + ]); + expect( + names(pluginActionsAt(ACTIONS, "composer-slash", { threadId: null, projectId })), + ).toEqual([]); + }); +}); + +describe("pluginActionLabels", () => { + it("distinguishes identical plugin names and colliding generated titles", () => { + const deploy = { ...action("deploy", "environment", ["thread-menu"]), title: "Deploy" }; + const other = { ...deploy, pluginId: "test.other", id: PluginActionId.make("other:1:deploy") }; + const labels = pluginActionLabels([ + deploy, + other, + { + ...deploy, + name: "generated", + title: "Deploy (Actions)", + id: PluginActionId.make("installation-1:1:generated"), + }, + { ...deploy, name: "another", id: PluginActionId.make("installation-1:1:another") }, + ACTIONS[0]!, + ]); + expect(new Set(labels).size).toBe(labels.length); + expect(labels).toEqual([ + "Deploy (Actions) [test.actions/deploy]", + "Deploy (Actions) [test.other/deploy]", + "Deploy (Actions) [test.actions/generated]", + "Deploy (Actions) [test.actions/another]", + "everywhere", + ]); + }); + + it("names the plugin only when two actions share a title", () => { + const deploy = { ...action("deploy", "environment", ["thread-menu"]), title: "Deploy" }; + expect( + pluginActionLabels([ + deploy, + { ...deploy, id: PluginActionId.make("installation-2:1:deploy"), pluginName: "Other" }, + ACTIONS[0]!, + ]), + ).toEqual(["Deploy (Actions)", "Deploy (Other)", "everywhere"]); + }); +}); diff --git a/packages/client-runtime/src/state/pluginActions.ts b/packages/client-runtime/src/state/pluginActions.ts new file mode 100644 index 0000000000000..02036bc50afa8 --- /dev/null +++ b/packages/client-runtime/src/state/pluginActions.ts @@ -0,0 +1,133 @@ +import { + type ExecutionEnvironmentCapabilities, + type PluginAction, + type PluginActionPlacement, + type PluginActionsSnapshot, + type PluginActionTarget, + type ProjectId, + type ThreadId, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import type { Atom } from "effect/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import { requestIfSupported, subscribe } from "../rpc/client.ts"; +import { createEnvironmentRpcCommand, createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; + +const supportsPluginActions = ( + capabilities: Pick | null | undefined, +) => capabilities?.pluginActions === true; + +const NO_ACTIONS: PluginActionsSnapshot = { actions: [] }; + +/** + * The environment's plugin actions snapshot, following its sessions. A server + * without the capability is never subscribed to and offers none. `omitted` + * is kept so a surface can explain actions the server's limit left out. + */ +export const pluginActionsStream = Stream.unwrap( + EnvironmentSupervisor.EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + SubscriptionRef.changes(supervisor.session).pipe( + Stream.switchMap( + Option.match({ + onNone: () => Stream.empty, + onSome: (session) => + Stream.unwrap( + session.initialConfig.pipe( + Effect.map((config) => + supportsPluginActions(config.environment.capabilities) + ? subscribe(WS_METHODS.pluginActionsSubscribe, {}, { session }) + : Stream.succeed(NO_ACTIONS), + ), + Effect.orElseSucceed(() => Stream.empty), + ), + ), + }), + ), + ), + ), + ), +); + +export function createPluginActionEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + /** + * Every action the environment offers now, plus `omitted` counts when its + * limit left plugins out; no actions on servers without plugin actions. + */ + snapshot: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:plugin-actions", + subscribe: (_input: Record) => pluginActionsStream, + }), + /** Runs on the session it checked, so an older server never receives it. */ + invoke: createEnvironmentRpcCommand(runtime, { + label: "environment-data:plugin-actions:invoke", + tag: WS_METHODS.pluginActionsInvoke, + execute: (input) => + requestIfSupported(WS_METHODS.pluginActionsInvoke, input, supportsPluginActions), + }), + }; +} + +/** What a surface knows about where the user is. */ +export interface PluginActionContext { + readonly threadId: ThreadId | null; + readonly projectId: ProjectId | null; +} + +/** The target `action` runs on from `context`, or null when the surface cannot supply it. */ +function pluginActionTarget( + action: Pick, + context: PluginActionContext, +): PluginActionTarget | null { + switch (action.target) { + case "environment": + return { _tag: "environment" }; + case "project": + return context.projectId === null ? null : { _tag: "project", projectId: context.projectId }; + case "thread": + return context.threadId === null ? null : { _tag: "thread", threadId: context.threadId }; + } +} + +/** The actions a surface offers at `placement`, each with the target it would run on. */ +export function pluginActionsAt( + actions: ReadonlyArray, + placement: PluginActionPlacement, + context: PluginActionContext, +): ReadonlyArray<{ readonly action: PluginAction; readonly target: PluginActionTarget }> { + return actions.flatMap((action) => { + if (!action.placements.includes(placement)) return []; + const target = pluginActionTarget(action, context); + return target === null ? [] : [{ action, target }]; + }); +} + +/** Menu labels that stay distinct when two plugins use the same title. */ +export function pluginActionLabels(actions: ReadonlyArray): ReadonlyArray { + const titleCounts = new Map(); + for (const action of actions) { + titleCounts.set(action.title, (titleCounts.get(action.title) ?? 0) + 1); + } + const labels = actions.map((action) => + (titleCounts.get(action.title) ?? 0) > 1 + ? `${action.title} (${action.pluginName})` + : action.title, + ); + const labelCounts = new Map(); + for (const label of labels) labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1); + return labels.map((label, index) => { + const action = actions[index]!; + return (labelCounts.get(label) ?? 0) > 1 + ? `${label} [${action.pluginId}/${action.name}]` + : label; + }); +} diff --git a/packages/contracts/src/contributionStatus.test.ts b/packages/contracts/src/contributionStatus.test.ts new file mode 100644 index 0000000000000..ba18aaed1e38b --- /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 0000000000000..ef7f2acad997c --- /dev/null +++ b/packages/contracts/src/contributionStatus.ts @@ -0,0 +1,98 @@ +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; + +// Frames replace the entire snapshot, including after reconnect; unknown entries are dropped. +// Order is thread id, provider sources first, then source key. +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 9d60bec43fe5c..ea1dab93abdd8 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -147,6 +147,18 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ commandOutputStreaming: Schema.optionalKey(Schema.Boolean), /** Hosts headless Chromium tabs streamed over /api/preview-stream for clients without a local runtime. */ 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), + /** Server exposes the trusted local plugin catalogue (`plugins.*`). Absent on + older servers, so clients must not call or subscribe to those methods. */ + plugins: Schema.optionalKey(Schema.Boolean), + /** Server stores plugin setting values and secrets (`plugins.settings.*`). Absent on + older servers, so clients must not call or subscribe to those methods. */ + pluginSettings: Schema.optionalKey(Schema.Boolean), + /** Server lists and runs plugin actions (`pluginActions.*`). Absent on older + servers, so clients must not call or subscribe to those methods. */ + pluginActions: Schema.optionalKey(Schema.Boolean), /** Server links to other environments (`PeerLink`) and its agents can work in them. Absent on servers from before linking, which clients must not offer linking for. */ diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f00976d243b37..09adcff7ce80f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -57,9 +57,17 @@ export * from "./mcpApps.ts"; export * from "./browserImport.ts"; export * from "./browserProfile.ts"; export * from "./device.ts"; +export * from "./plugin.ts"; +export * from "./pluginCatalog.ts"; +export * from "./pluginEvents.ts"; +export * from "./pluginTools.ts"; +export * from "./pluginSettingFields.ts"; +export * from "./pluginSettings.ts"; +export * from "./pluginActions.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./previewCapture.ts"; +export * from "./contributionStatus.ts"; export * from "./usage.ts"; export * from "./scheduledTask.ts"; export * from "./worktreeMcp.ts"; diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index 3782307171e87..9693d0371842f 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -224,6 +224,32 @@ describe("orchestration V2 contracts", () => { ).toThrow(); }); + it("decodes run finalization records as known thread events", () => { + const decodeWireItems = Schema.decodeUnknownSync( + Schema.toCodecJson(Schema.Array(OrchestrationV2RpcSchemas.subscribeThread.output)), + ); + const event = (sequence: number, type: string, payload: unknown) => ({ + kind: "event", + sequence, + event: { + id: `event:run-finalized:run-${sequence}`, + type, + threadId: "thread-1", + runId: `run-${sequence}`, + occurredAt: DateTime.formatIso(DateTime.makeUnsafe(0)), + payload, + }, + }); + const items = decodeWireItems([ + event(1, "run.finalized", { runId: "run-1", outcome: "interrupted", checkpointId: null }), + event(2, "run.finalization-failed", { runId: "run-2", operation: "refresh-workspace" }), + ]); + expect(items.map((item) => (item.kind === "event" ? item.event.payload : item.kind))).toEqual([ + { runId: "run-1", outcome: "interrupted", checkpointId: null }, + { runId: "run-2", operation: "refresh-workspace" }, + ]); + }); + it("decodes persisted capability snapshots that predate runtimePolicy", () => { const legacyCapabilities = { sessions: { diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 1c80128f8bbc5..bdf583989bf2c 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -668,6 +668,45 @@ export type OrchestrationV2ThreadLaunchWorkspaceStrategy = export const ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE = "workspace_preparation_failed"; +/** + * How a finalized run ended. A rolled-back run is discarded, not finalized. + */ +export const OrchestrationV2RunFinalizedOutcome = Schema.Literals([ + "completed", + "failed", + "interrupted", + "cancelled", +]); +export type OrchestrationV2RunFinalizedOutcome = typeof OrchestrationV2RunFinalizedOutcome.Type; + +// Finalization follows capture/workspace refresh, or the terminal write for runs without capture. +// Each run records either this or finalization-failed once; occurredAt is the finalization time. +export const OrchestrationV2RunFinalized = Schema.Struct({ + runId: RunId, + outcome: OrchestrationV2RunFinalizedOutcome, + checkpointId: Schema.NullOr(CheckpointId), +}); +export type OrchestrationV2RunFinalized = typeof OrchestrationV2RunFinalized.Type; + +/** The finalization step that failed. */ +export const OrchestrationV2RunFinalizationOperation = Schema.Literals([ + "capture-checkpoint", + "refresh-workspace", + "record-finalized", +]); +export type OrchestrationV2RunFinalizationOperation = + typeof OrchestrationV2RunFinalizationOperation.Type; + +/** + * A run's finalization gave up after its last attempt, so the run will not + * record `run.finalized`. The run row keeps whatever status it reached. + */ +export const OrchestrationV2RunFinalizationFailed = Schema.Struct({ + runId: RunId, + operation: OrchestrationV2RunFinalizationOperation, +}); +export type OrchestrationV2RunFinalizationFailed = typeof OrchestrationV2RunFinalizationFailed.Type; + export const OrchestrationV2Run = Schema.Struct({ id: RunId, threadId: ThreadId, @@ -1901,6 +1940,16 @@ export const OrchestrationV2DomainEvent = Schema.Union([ type: Schema.Literal("run.background-work-cancelled"), payload: OrchestrationV2RunBackgroundWorkCancelled, }), + Schema.Struct({ + ...OrchestrationV2EventBase.fields, + type: Schema.Literal("run.finalized"), + payload: OrchestrationV2RunFinalized, + }), + Schema.Struct({ + ...OrchestrationV2EventBase.fields, + type: Schema.Literal("run.finalization-failed"), + payload: OrchestrationV2RunFinalizationFailed, + }), Schema.Struct({ ...OrchestrationV2EventBase.fields, type: Schema.Literal("run-attempt.created"), @@ -2781,6 +2830,16 @@ export const OrchestrationV2DomainEventJson = Schema.Union([ type: Schema.Literal("run.background-work-cancelled"), payload: OrchestrationV2RunBackgroundWorkCancelled, }), + Schema.Struct({ + ...OrchestrationV2JsonEventBaseFields, + type: Schema.Literal("run.finalized"), + payload: OrchestrationV2RunFinalized, + }), + Schema.Struct({ + ...OrchestrationV2JsonEventBaseFields, + type: Schema.Literal("run.finalization-failed"), + payload: OrchestrationV2RunFinalizationFailed, + }), Schema.Struct({ ...OrchestrationV2JsonEventBaseFields, type: Schema.Literal("run-attempt.created"), diff --git a/packages/contracts/src/plugin.test.ts b/packages/contracts/src/plugin.test.ts new file mode 100644 index 0000000000000..df8a3cf1c145a --- /dev/null +++ b/packages/contracts/src/plugin.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +import { ForwardCompatibleOptional } from "./baseSchemas.ts"; +import { PluginHostState, PluginManifest } from "./plugin.ts"; + +const decode = Schema.decodeUnknownExit(PluginManifest); + +const minimal = { + id: "acme.notifier", + name: "Notifier", + version: "1.2.3", + apiVersion: 1, + entry: "dist/main.mjs", +}; + +describe("PluginManifest", () => { + it("defaults optional fields and ignores keys from newer manifests", () => { + const decoded = decode({ ...minimal, contributes: { views: [] } }); + expect(Exit.isSuccess(decoded) && decoded.value).toEqual({ + ...minimal, + capabilities: [], + proposedApi: false, + }); + }); + + it("keeps an unknown API version decodable so the loader can explain it", () => { + expect(Exit.isSuccess(decode({ ...minimal, apiVersion: 2 }))).toBe(true); + }); + + it.each([ + ["an unqualified id", { id: "notifier" }], + ["an uppercase id", { id: "Acme.Notifier" }], + ["an absolute entry", { entry: "/tmp/main.mjs" }], + ["a parent entry", { entry: "lib/../../main.mjs" }], + ["a TypeScript entry", { entry: "main.ts" }], + ["a Windows entry", { entry: "C:\\main.mjs" }], + ["a malformed capability", { capabilities: ["Tools!"] }], + ])("rejects %s", (_label, override) => { + expect(Exit.isFailure(decode({ ...minimal, ...override }))).toBe(true); + }); +}); + +describe("PluginHostState on the client wire", () => { + const decodeRow = Schema.decodeUnknownExit( + Schema.Struct({ id: Schema.String, state: ForwardCompatibleOptional(PluginHostState) }), + ); + + it("keeps a row whose state comes from a newer server, with the state unknown", () => { + const decoded = decodeRow({ id: "acme.notifier", state: { _tag: "hibernating", since: 1 } }); + expect(Exit.isSuccess(decoded) && decoded.value).toEqual({ id: "acme.notifier" }); + }); + + it("decodes the states this build knows", () => { + const state = { _tag: "incompatible", reason: "uses top-level await" }; + const decoded = decodeRow({ id: "acme.notifier", state }); + expect(Exit.isSuccess(decoded) && decoded.value).toEqual({ id: "acme.notifier", state }); + }); +}); diff --git a/packages/contracts/src/plugin.ts b/packages/contracts/src/plugin.ts new file mode 100644 index 0000000000000..f141312d71fd8 --- /dev/null +++ b/packages/contracts/src/plugin.ts @@ -0,0 +1,91 @@ +// Plugins run as trusted OS-user code; the supervised process boundary is not a sandbox. +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { IsoDateTime, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { PLUGIN_TOOL_LIMITS, PluginToolDeclaration } from "./pluginTools.ts"; +import { PluginSettingsDeclaration } from "./pluginSettingFields.ts"; +import { PLUGIN_ACTIONS_MAX_PER_PLUGIN, PluginActionDeclaration } from "./pluginActions.ts"; + +/** + * The one plugin API version this server implements. A manifest names the + * version it was written against; any other value is not loaded. Additive + * changes keep the version, removals and meaning changes bump it. New APIs + * ship under the manifest's `proposedApi` opt-in first. + */ +export const PLUGIN_API_VERSION = 1; + +export const PLUGIN_MANIFEST_FILE = "t3-plugin.json"; + +/** Owner-qualified id such as `acme.notifier`: two or more lowercase dot segments. */ +export const PluginId = TrimmedNonEmptyString.check( + Schema.isMaxLength(128), + Schema.isPattern(/^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/), +).pipe(Schema.brand("PluginId")); +export type PluginId = typeof PluginId.Type; + +/** + * A capability the plugin asks the host for. The host refuses to load a plugin + * that declares a capability it does not implement, so a plugin never runs + * with a silently missing feature. + */ +const PluginCapabilityName = Schema.String.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*$/), +); +export type PluginCapabilityName = typeof PluginCapabilityName.Type; + +/** Relative `.js` or `.mjs` path inside the plugin directory, without `..` segments. */ +const PluginEntryPath = Schema.String.check( + Schema.isMaxLength(256), + Schema.isPattern(/^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[^\\:\0]+\.m?js$/), +); + +export const PluginManifest = Schema.Struct({ + id: PluginId, + name: TrimmedNonEmptyString.check(Schema.isMaxLength(100)), + /** Display identity of the installed bytes; the host does not order versions. */ + version: TrimmedNonEmptyString.check(Schema.isMaxLength(64)), + description: Schema.optional(Schema.String.check(Schema.isMaxLength(500))), + apiVersion: Schema.Int, + entry: PluginEntryPath, + capabilities: Schema.Array(PluginCapabilityName) + .check(Schema.isMaxLength(32)) + .pipe(Schema.withDecodingDefault(Effect.succeed([]))), + /** Opts into APIs that may change or disappear without an API version bump. */ + proposedApi: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** Tools for agents; needs the `tools` capability (see PluginTools). */ + tools: Schema.optionalKey( + Schema.Array(PluginToolDeclaration).check( + Schema.isMaxLength(PLUGIN_TOOL_LIMITS.maxToolsPerPlugin), + ), + ), + /** Fields users can set for this plugin; needs the `settings` capability. */ + settings: Schema.optionalKey(PluginSettingsDeclaration), + /** Commands for clients to offer; needs the `actions` capability (see PluginActions). */ + actions: Schema.optionalKey( + Schema.Array(PluginActionDeclaration).check(Schema.isMaxLength(PLUGIN_ACTIONS_MAX_PER_PLUGIN)), + ), +}); +export type PluginManifest = typeof PluginManifest.Type; + +const PluginFailureReason = Schema.String.check(Schema.isMaxLength(1000)); + +// Decode through ForwardCompatibleOptional: an absent state is unknown, never idle or safe to enable. +// Incompatible code and quarantined crashes require explicit resume; ordinary backoff retries automatically. +export const PluginHostState = Schema.Union([ + Schema.TaggedStruct("idle", {}), + Schema.TaggedStruct("starting", {}), + Schema.TaggedStruct("running", {}), + Schema.TaggedStruct("backoff", { + failures: NonNegativeInt, + reason: PluginFailureReason, + retryAt: IsoDateTime, + }), + Schema.TaggedStruct("quarantined", { + failures: NonNegativeInt, + reason: PluginFailureReason, + }), + Schema.TaggedStruct("incompatible", { reason: PluginFailureReason }), +]); +export type PluginHostState = typeof PluginHostState.Type; diff --git a/packages/contracts/src/pluginActions.test.ts b/packages/contracts/src/pluginActions.test.ts new file mode 100644 index 0000000000000..0bc3a21147eeb --- /dev/null +++ b/packages/contracts/src/pluginActions.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +import { PluginManifest } from "./plugin.ts"; +import { PluginActionsSnapshot } from "./pluginActions.ts"; + +const action = { + id: "installation-1:1:open-dashboard", + pluginId: "acme.dashboard", + pluginName: "Dashboard", + name: "open-dashboard", + title: "Open dashboard", + target: "thread", + placements: ["command-palette", "thread-menu"], +}; +const decodeSnapshot = Schema.decodeUnknownExit(PluginActionsSnapshot); +const decodeManifest = Schema.decodeUnknownExit(PluginManifest); + +describe("PluginActionsSnapshot from a newer server", () => { + it("drops placements and actions this client cannot offer, keeping the rest", () => { + const decoded = decodeSnapshot({ + actions: [ + { ...action, placements: ["toolbar", "thread-menu"], icon: "rocket" }, + // A target kind this client cannot supply: the action is not offered at all. + { ...action, id: "installation-1:1:deploy", name: "deploy", target: "selection" }, + ], + }); + expect(Exit.isSuccess(decoded)).toBe(true); + if (!Exit.isSuccess(decoded)) return; + expect(decoded.value.actions).toEqual([{ ...action, placements: ["thread-menu"] }]); + }); + + it("carries what the environment bound left out, and older frames without it", () => { + const omitted = { plugins: 2, actions: 20 }; + const decoded = decodeSnapshot({ actions: [action], omitted }); + expect(Exit.isSuccess(decoded) && decoded.value.omitted).toEqual(omitted); + const older = decodeSnapshot({ actions: [action] }); + expect(Exit.isSuccess(older) && older.value).toEqual({ actions: [action] }); + }); +}); + +describe("PluginManifest actions", () => { + const manifest = { + id: "acme.dashboard", + name: "Dashboard", + version: "1.0.0", + apiVersion: 1, + entry: "main.mjs", + capabilities: ["actions"], + proposedApi: true, + }; + + it("refuses a malformed or unbounded declaration", () => { + const declaration = { + name: "open", + title: "Open", + target: "thread", + placements: ["thread-menu"], + }; + expect(Exit.isSuccess(decodeManifest({ ...manifest, actions: [declaration] }))).toBe(true); + for (const actions of [ + [{ ...declaration, name: "Open Dashboard" }], + [{ ...declaration, title: "x".repeat(61) }], + [{ ...declaration, placements: [] }], + [{ ...declaration, target: "selection" }], + Array.from({ length: 17 }, (_, index) => ({ ...declaration, name: `open-${index}` })), + ]) { + expect(Exit.isFailure(decodeManifest({ ...manifest, actions }))).toBe(true); + } + }); +}); diff --git a/packages/contracts/src/pluginActions.ts b/packages/contracts/src/pluginActions.ts new file mode 100644 index 0000000000000..0b86af632f7b3 --- /dev/null +++ b/packages/contracts/src/pluginActions.ts @@ -0,0 +1,127 @@ +// Opaque action ids pin registration generations; stale clicks never reach a newer registration. +// Declarations belong to consented manifests; listing them never starts a plugin. +import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; + +import { + ForwardCompatibleArray, + NonNegativeInt, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; + +export const PLUGIN_ACTIONS_MAX_PER_PLUGIN = 16; +/** The most actions one environment offers at once, across all its plugins. */ +export const PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT = 128; +/** The most bytes a snapshot's UTF-8 JSON encoding takes. */ +export const PLUGIN_ACTIONS_SNAPSHOT_MAX_BYTES = 128 * 1024; +const PLUGIN_ACTION_NAME_MAX_LENGTH = 48; +const PLUGIN_ACTION_TITLE_MAX_LENGTH = 60; +const PLUGIN_ACTION_DESCRIPTION_MAX_LENGTH = 240; +export const PLUGIN_ACTION_MESSAGE_MAX_LENGTH = 500; + +/** Unique within one plugin; also the slash command, `/`. */ +const PluginActionName = Schema.String.check( + Schema.isMaxLength(PLUGIN_ACTION_NAME_MAX_LENGTH), + Schema.isPattern(/^[a-z][a-z0-9-]*$/), +); + +/** What the action runs against. The client supplies the matching target when it invokes. */ +const PluginActionTargetKind = Schema.Literals(["environment", "project", "thread"]); + +/** Where clients offer the action. Clients ignore placements they do not know. */ +const PluginActionPlacement = Schema.Literals(["command-palette", "thread-menu", "composer-slash"]); +export type PluginActionPlacement = typeof PluginActionPlacement.Type; +const isPluginActionPlacement = Schema.is(PluginActionPlacement); + +/** + * Placements as sent to clients; a placement this client does not know is + * dropped by itself. `ForwardCompatibleArray` cannot do this here: nested in + * the actions array, dropping one placement would drop the whole action. + */ +const OfferedPluginActionPlacements = Schema.Array(Schema.String).pipe( + Schema.decodeTo( + Schema.Array(PluginActionPlacement), + SchemaTransformation.transform, ReadonlyArray>({ + decode: (placements) => placements.filter(isPluginActionPlacement), + encode: (placements) => placements, + }), + ), +); + +/** One entry of the manifest's `actions` array. */ +export const PluginActionDeclaration = Schema.Struct({ + name: PluginActionName, + title: TrimmedNonEmptyString.check(Schema.isMaxLength(PLUGIN_ACTION_TITLE_MAX_LENGTH)), + description: Schema.optionalKey( + Schema.String.check(Schema.isMaxLength(PLUGIN_ACTION_DESCRIPTION_MAX_LENGTH)), + ), + target: PluginActionTargetKind, + placements: Schema.Array(PluginActionPlacement).check( + Schema.isMinLength(1), + Schema.isMaxLength(PluginActionPlacement.literals.length), + ), +}); + +/** Opaque and server-issued. Clients pass it back unchanged and never parse it. */ +export const PluginActionId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)).pipe( + Schema.brand("PluginActionId"), +); +export type PluginActionId = typeof PluginActionId.Type; + +const PluginAction = Schema.Struct({ + id: PluginActionId, + /** Display only: the manifest's plugin id and name. */ + pluginId: Schema.String, + pluginName: Schema.String, + name: Schema.String, + title: Schema.String, + description: Schema.optionalKey(Schema.String), + target: PluginActionTargetKind, + placements: OfferedPluginActionPlacements, +}); +export type PluginAction = typeof PluginAction.Type; + +// Snapshots replace earlier frames and include whole plugins until the first limit is reached. +// Omitted plugins cannot run actions; later plugins are omitted with them. +export const PluginActionsSnapshot = Schema.Struct({ + actions: ForwardCompatibleArray(PluginAction), + /** Present only when plugins were left out. */ + omitted: Schema.optionalKey(Schema.Struct({ plugins: NonNegativeInt, actions: NonNegativeInt })), +}); +export type PluginActionsSnapshot = typeof PluginActionsSnapshot.Type; + +const PluginActionTarget = Schema.Union([ + Schema.TaggedStruct("environment", {}), + Schema.TaggedStruct("project", { projectId: ProjectId }), + Schema.TaggedStruct("thread", { threadId: ThreadId }), +]); +export type PluginActionTarget = typeof PluginActionTarget.Type; + +export const PluginActionInvokeInput = Schema.Struct({ + actionId: PluginActionId, + /** Must be the kind the action declares. */ + target: PluginActionTarget, +}); +export type PluginActionInvokeInput = typeof PluginActionInvokeInput.Type; + +export const PluginActionInvokeResult = Schema.Struct({ + /** What the plugin wants the user to read, one bounded string; null when it said nothing. */ + message: Schema.NullOr(Schema.String), +}); +export type PluginActionInvokeResult = typeof PluginActionInvokeResult.Type; + +/** + * `reason` is an open set so newer servers can add cases: `not-found` (no + * such action now), `stale` (the plugin was enabled again since the list was + * sent), `target-mismatch`, `target-not-found`, `unavailable`, `busy`, + * `timeout`, `stopped`, `failed` (the plugin threw; `message` is its own). + */ +export class PluginActionError extends Schema.TaggedError()( + "PluginActionError", + { + reason: Schema.String, + message: Schema.String, + }, +) {} diff --git a/packages/contracts/src/pluginCatalog.test.ts b/packages/contracts/src/pluginCatalog.test.ts new file mode 100644 index 0000000000000..9fc07ac14df8b --- /dev/null +++ b/packages/contracts/src/pluginCatalog.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +import { PluginCatalogSnapshot, pluginInstallationStatus } from "./pluginCatalog.ts"; + +const digest = `sha256:${"a".repeat(64)}`; +const installation = { + installationId: "installation-1", + generation: 1, + directory: "/srv/plugins/notifier", + manifest: { + id: "acme.notifier", + name: "Notifier", + version: "1.0.0", + capabilities: [], + proposedApi: false, + }, + source: { digest, files: 2, bytes: 120 }, + problem: null, + inspectedAt: "2026-10-04T00:00:00.000Z", + consent: { digest, capabilities: [], grantedAt: "2026-10-04T00:00:00.000Z" }, + enabled: true, + hostState: { _tag: "running" }, + addedAt: "2026-10-04T00:00:00.000Z", +}; +const decodeSnapshot = Schema.decodeUnknownExit(PluginCatalogSnapshot); + +describe("PluginCatalogSnapshot from a newer server", () => { + it("keeps an installation whose state or fields this client does not know", () => { + const decoded = decodeSnapshot({ + installations: [ + { ...installation, hostState: { _tag: "hibernating" }, publisher: "acme" }, + { + ...installation, + installationId: "installation-2", + // Relaxed id rules and new capability names on a newer server. + manifest: { ...installation.manifest, id: "notifier", capabilities: ["tools"] }, + }, + ], + }); + expect(Exit.isSuccess(decoded)).toBe(true); + if (!Exit.isSuccess(decoded)) return; + const [unknownState, relaxed] = decoded.value.installations; + expect(unknownState?.hostState).toBeUndefined(); + expect(unknownState && pluginInstallationStatus(unknownState)).toBe("enabled"); + expect(relaxed?.manifest?.capabilities).toEqual(["tools"]); + expect(relaxed?.hostState).toEqual({ _tag: "running" }); + }); +}); + +describe("PluginInstallation.eventDelivery", () => { + it("decodes known delivery states and keeps the row for unknown or missing ones", () => { + const quarantined = { _tag: "quarantined", failures: 5, reason: "onEvent failed" }; + const decoded = decodeSnapshot({ + installations: [ + { ...installation, eventDelivery: quarantined }, + { ...installation, installationId: "installation-2", eventDelivery: { _tag: "paused" } }, + // An older server sends no delivery state at all. + { ...installation, installationId: "installation-3" }, + ], + }); + expect(Exit.isSuccess(decoded)).toBe(true); + if (!Exit.isSuccess(decoded)) return; + const [known, unknown, older] = decoded.value.installations; + expect(known?.eventDelivery).toEqual(quarantined); + expect(unknown?.eventDelivery).toBeUndefined(); + expect(unknown?.hostState).toEqual({ _tag: "running" }); + expect(older?.eventDelivery).toBeUndefined(); + }); +}); + +describe("pluginInstallationStatus", () => { + it("asks for consent again when the bytes differ from what was approved", () => { + expect(pluginInstallationStatus(installation)).toBe("enabled"); + expect(pluginInstallationStatus({ ...installation, enabled: false })).toBe("disabled"); + expect(pluginInstallationStatus({ ...installation, consent: null, enabled: false })).toBe( + "needs-consent", + ); + const changed = { ...installation.source, digest: `sha256:${"b".repeat(64)}` }; + expect(pluginInstallationStatus({ ...installation, source: changed, enabled: false })).toBe( + "needs-consent", + ); + expect( + pluginInstallationStatus({ ...installation, source: null, problem: "gone", enabled: false }), + ).toBe("unavailable"); + }); +}); diff --git a/packages/contracts/src/pluginCatalog.ts b/packages/contracts/src/pluginCatalog.ts new file mode 100644 index 0000000000000..e2b373fdd9d2c --- /dev/null +++ b/packages/contracts/src/pluginCatalog.ts @@ -0,0 +1,155 @@ +// Installations are environment-local; consent pins bytes and generation distinguishes replacement registrations. +import * as Schema from "effect/Schema"; + +import { + ForwardCompatibleArray, + ForwardCompatibleOptional, + IsoDateTime, + NonNegativeInt, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; +import { PluginHostState } from "./plugin.ts"; +import { PluginEventDeliveryState } from "./pluginEvents.ts"; +import { PluginToolDeclaration } from "./pluginTools.ts"; +import { PluginSettingsFieldList } from "./pluginSettingFields.ts"; +import { PluginActionDeclaration } from "./pluginActions.ts"; + +export const PluginInstallationId = TrimmedNonEmptyString.check(Schema.isMaxLength(64)).pipe( + Schema.brand("PluginInstallationId"), +); +export type PluginInstallationId = typeof PluginInstallationId.Type; + +/** `sha256:` and the hex digest of every file in the plugin directory. */ +const PluginSourceDigest = Schema.String.check(Schema.isPattern(/^sha256:[0-9a-f]{64}$/)); + +/** + * What the manifest said when the server last read it. Ids and capability + * names are plain strings here so an older client keeps rows whose values a + * newer server accepts. + */ +const PluginInstallationManifest = Schema.Struct({ + id: TrimmedNonEmptyString.pipe(Schema.brand("PluginId")), + name: Schema.String, + /** Display metadata only; never proof of the installed bytes. */ + version: Schema.String, + description: Schema.optionalKey(Schema.String), + capabilities: Schema.Array(Schema.String), + proposedApi: Schema.Boolean, + /** The declared tools, when there are any. Unknown shapes from a newer server are dropped. */ + tools: Schema.optionalKey(ForwardCompatibleArray(PluginToolDeclaration)), + /** Declared settings fields (`settings` capability); absent when the plugin has none. */ + settings: Schema.optionalKey(PluginSettingsFieldList), + /** The declared actions, when there are any. Unknown shapes from a newer server are dropped. */ + actions: Schema.optionalKey(ForwardCompatibleArray(PluginActionDeclaration)), +}); +export type PluginInstallationManifest = typeof PluginInstallationManifest.Type; + +/** The exact bytes the server found at its last inspection. */ +const PluginSource = Schema.Struct({ + digest: PluginSourceDigest, + files: NonNegativeInt, + bytes: NonNegativeInt, +}); +export type PluginSource = typeof PluginSource.Type; + +/** Consent to run one exact source with the capabilities its manifest declared. */ +const PluginConsent = Schema.Struct({ + digest: PluginSourceDigest, + capabilities: Schema.Array(Schema.String), + grantedAt: IsoDateTime, +}); + +export const PluginInstallation = Schema.Struct({ + installationId: PluginInstallationId, + generation: NonNegativeInt, + /** Absolute path on the server's machine. */ + directory: Schema.String, + /** From the last inspection that could read the manifest. */ + manifest: Schema.NullOr(PluginInstallationManifest), + /** Null when the last inspection failed; `problem` says why. */ + source: Schema.NullOr(PluginSource), + problem: Schema.NullOr(Schema.String), + /** When the current manifest, source, and problem were found; a check that finds the same keeps it. */ + inspectedAt: IsoDateTime, + consent: Schema.NullOr(PluginConsent), + /** Registered to run. Only true while `consent.digest` matches `source.digest`. */ + enabled: Schema.Boolean, + /** + * The plugin process's state while enabled. Absent when disabled, and also + * when this client does not know the state a newer server sent: treat + * absence on an enabled installation as unknown, never as idle or stopped. + */ + hostState: ForwardCompatibleOptional(PluginHostState), + /** + * Event delivery while enabled, for a plugin that declares `events`. Absent + * otherwise, from a server without it, and when this client does not know + * the state a newer server sent: treat absence as unknown, never as healthy. + */ + eventDelivery: ForwardCompatibleOptional(PluginEventDeliveryState), + addedAt: IsoDateTime, +}); +export type PluginInstallation = typeof PluginInstallation.Type; + +type PluginInstallationStatus = "unavailable" | "needs-consent" | "enabled" | "disabled"; + +/** What the user can do next with an installation. */ +export const pluginInstallationStatus = ( + installation: Pick, +): PluginInstallationStatus => { + if (installation.problem !== null || installation.source === null) return "unavailable"; + if (installation.consent?.digest !== installation.source.digest) return "needs-consent"; + return installation.enabled ? "enabled" : "disabled"; +}; + +export const PluginCatalogSnapshot = Schema.Struct({ + installations: Schema.Array(PluginInstallation), +}); +export type PluginCatalogSnapshot = typeof PluginCatalogSnapshot.Type; + +export const PluginAddInput = Schema.Struct({ + /** Absolute path of the plugin directory on the server's machine. */ + directory: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), +}); +export type PluginAddInput = typeof PluginAddInput.Type; + +export const PluginInstallationInput = Schema.Struct({ + installationId: PluginInstallationId, +}); +export type PluginInstallationInput = typeof PluginInstallationInput.Type; + +export const PluginRefreshInput = Schema.Struct({ + /** Omit to inspect every installation. */ + installationId: Schema.optionalKey(PluginInstallationId), +}); +export type PluginRefreshInput = typeof PluginRefreshInput.Type; + +export const PluginConsentInput = Schema.Struct({ + installationId: PluginInstallationId, + /** The digest the user was shown. Consent fails if the bytes have changed since. */ + digest: PluginSourceDigest, +}); +export type PluginConsentInput = typeof PluginConsentInput.Type; + +export const PluginInstallationResult = Schema.Struct({ + installation: PluginInstallation, +}); +export type PluginInstallationResult = typeof PluginInstallationResult.Type; + +export const PluginRemoveResult = Schema.Struct({ + installationId: PluginInstallationId, +}); +export type PluginRemoveResult = typeof PluginRemoveResult.Type; + +/** + * `reason` is an open set so newer servers can add cases: `not-found`, + * `invalid-directory`, `already-added`, `source-changed`, `consent-required`, + * `plugin-id-conflict`, `unavailable`, `storage`, `generation-changed`. + */ +export class PluginCatalogError extends Schema.TaggedError()( + "PluginCatalogError", + { + reason: Schema.String, + message: Schema.String, + installationId: Schema.optionalKey(PluginInstallationId), + }, +) {} diff --git a/packages/contracts/src/pluginEvents.ts b/packages/contracts/src/pluginEvents.ts new file mode 100644 index 0000000000000..8b436fbebbcc2 --- /dev/null +++ b/packages/contracts/src/pluginEvents.ts @@ -0,0 +1,106 @@ +// At-least-once delivery requires deduplication by deliveryId; the projection omits message bodies. +// Handlers must ignore additive event types and optional fields. +import * as Schema from "effect/Schema"; + +import { + EnvironmentId, + EventId, + IsoDateTime, + NonNegativeInt, + ProjectId, + RunId, + ThreadId, +} from "./baseSchemas.ts"; +import { + OrchestrationV2RunFinalizationOperation, + OrchestrationV2RunFinalizedOutcome, +} from "./orchestrationV2.ts"; + +/** The manifest capability a plugin declares to receive events. */ +export const PLUGIN_EVENTS_CAPABILITY = "events"; + +/** Most events in one delivered page. */ +const PLUGIN_EVENT_PAGE_MAX_EVENTS = 256; + +/** Longest thread title in an event, in UTF-16 code units. Longer titles are cut. */ +export const PLUGIN_EVENT_THREAD_TITLE_MAX_LENGTH = 200; + +/** + * The thread an event belongs to, as it is when the event is delivered (not + * when it happened). `null` when the thread has been deleted since. + */ +const PluginEventThread = Schema.Struct({ + projectId: ProjectId, + title: Schema.String.check(Schema.isMaxLength(PLUGIN_EVENT_THREAD_TITLE_MAX_LENGTH)), +}); + +const PluginEventBase = Schema.Struct({ + /** + * Stable identity for deduplication: the environment-local id of the stored + * event. Redelivery repeats it. `run.finalized` and `run.finalization-failed` + * for one run share it, since a run records exactly one of them. Combine it + * with `environmentId` when events from several environments meet. + */ + deliveryId: EventId, + /** Position in the environment's event log; increases within and across pages. */ + sequence: NonNegativeInt, + occurredAt: IsoDateTime, + environmentId: EnvironmentId, + threadId: ThreadId, + runId: RunId, + thread: Schema.NullOr(PluginEventThread), +}); + +/** + * A turn finished: the run ended and its finalization (checkpoint capture and + * workspace refresh, when the run had them) completed. + */ +export const PluginRunFinalizedEvent = Schema.Struct({ + ...PluginEventBase.fields, + type: Schema.Literal("run.finalized"), + outcome: OrchestrationV2RunFinalizedOutcome, +}); +export type PluginRunFinalizedEvent = typeof PluginRunFinalizedEvent.Type; + +/** A run ended but its finalization was abandoned at `operation`. */ +export const PluginRunFinalizationFailedEvent = Schema.Struct({ + ...PluginEventBase.fields, + type: Schema.Literal("run.finalization-failed"), + operation: OrchestrationV2RunFinalizationOperation, +}); +export type PluginRunFinalizationFailedEvent = typeof PluginRunFinalizationFailedEvent.Type; + +export const PluginEvent = Schema.Union([ + PluginRunFinalizedEvent, + PluginRunFinalizationFailedEvent, +]); +export type PluginEvent = typeof PluginEvent.Type; + +export const PLUGIN_EVENT_TYPES = [ + "run.finalized", + "run.finalization-failed", +] as const satisfies ReadonlyArray; + +/** One delivery: events in log order, acknowledged together. */ +export const PluginEventPage = Schema.Struct({ + events: Schema.Array(PluginEvent).check(Schema.isMaxLength(PLUGIN_EVENT_PAGE_MAX_EVENTS)), +}); +export type PluginEventPage = typeof PluginEventPage.Type; + +const PluginEventDeliveryReason = Schema.String.check(Schema.isMaxLength(1000)); + +// Delivery quarantine is independent of process state; resume clears both. +// Unreadable events quarantine with zero failures; retrying retains the same page until acknowledgment. +export const PluginEventDeliveryState = Schema.Union([ + Schema.TaggedStruct("active", {}), + Schema.TaggedStruct("retrying", { + failures: NonNegativeInt, + reason: PluginEventDeliveryReason, + retryAt: IsoDateTime, + }), + Schema.TaggedStruct("quarantined", { + failures: NonNegativeInt, + reason: PluginEventDeliveryReason, + }), +]); +export type PluginEventDeliveryState = typeof PluginEventDeliveryState.Type; diff --git a/packages/contracts/src/pluginSettingFields.ts b/packages/contracts/src/pluginSettingFields.ts new file mode 100644 index 0000000000000..e5c09b9cd6782 --- /dev/null +++ b/packages/contracts/src/pluginSettingFields.ts @@ -0,0 +1,170 @@ +// Settings apply to the environment and persist with their installation through registration changes. +// Secrets are write-only for clients; only the plugin can read their values. +import * as Schema from "effect/Schema"; + +import { ForwardCompatibleArray, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** The manifest capability that grants settings, secrets and plugin storage. */ +export const PLUGIN_SETTINGS_CAPABILITY = "settings"; + +export const PLUGIN_SETTINGS_MAX_FIELDS = 32; +const PLUGIN_SETTING_MAX_OPTIONS = 32; +const PLUGIN_SETTING_TEXT_MAX_LENGTH = 2000; +const PLUGIN_SETTING_SECRET_MAX_LENGTH = 8192; + +/** A setting's identity within its installation, such as `apiUrl`. */ +export const PluginSettingKey = Schema.String.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[A-Za-z][A-Za-z0-9_.-]*$/), +); +export type PluginSettingKey = typeof PluginSettingKey.Type; + +const PluginSettingLabel = TrimmedNonEmptyString.check(Schema.isMaxLength(60)); +const PluginSettingDescription = Schema.String.check(Schema.isMaxLength(240)); +const PluginSettingText = Schema.String.check(Schema.isMaxLength(PLUGIN_SETTING_TEXT_MAX_LENGTH)); + +/** A select option. `value` is what is saved and must stay stable; `label` is display text. */ +const PluginSettingOption = Schema.Struct({ + value: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(64)), + label: PluginSettingLabel, +}); + +const fieldBase = { + key: PluginSettingKey, + label: PluginSettingLabel, + description: Schema.optionalKey(PluginSettingDescription), +}; + +const PluginTextSettingField = Schema.Struct({ + type: Schema.Literal("text"), + ...fieldBase, + default: Schema.optionalKey(PluginSettingText), +}); + +/** Never has a default, and its value never leaves the server. */ +const PluginSecretSettingField = Schema.Struct({ + type: Schema.Literal("secret"), + ...fieldBase, +}); + +const PluginBooleanSettingField = Schema.Struct({ + type: Schema.Literal("boolean"), + ...fieldBase, + default: Schema.optionalKey(Schema.Boolean), +}); + +const PluginNumberSettingField = Schema.Struct({ + type: Schema.Literal("number"), + ...fieldBase, + default: Schema.optionalKey(Schema.Finite), + min: Schema.optionalKey(Schema.Finite), + max: Schema.optionalKey(Schema.Finite), + integer: Schema.optionalKey(Schema.Boolean), +}); + +const PluginSelectSettingField = Schema.Struct({ + type: Schema.Literal("select"), + ...fieldBase, + options: Schema.Array(PluginSettingOption).check( + Schema.isMinLength(1), + Schema.isMaxLength(PLUGIN_SETTING_MAX_OPTIONS), + ), + default: Schema.optionalKey(Schema.String), +}); + +/** A saved setting value; which one a field takes depends on its `type`. */ +export const PluginSettingValue = Schema.Union([Schema.String, Schema.Finite, Schema.Boolean]); +export type PluginSettingValue = typeof PluginSettingValue.Type; + +const PluginSettingFieldShape = Schema.Union([ + PluginTextSettingField, + PluginSecretSettingField, + PluginBooleanSettingField, + PluginNumberSettingField, + PluginSelectSettingField, +]); +type PluginSettingFieldShape = typeof PluginSettingFieldShape.Type; + +/** + * Why `value` cannot be saved for `field`, or undefined when it can. The + * message never repeats the value, so it is safe for secrets. + */ +export const pluginSettingValueProblem = ( + field: PluginSettingFieldShape, + value: PluginSettingValue, +): string | undefined => { + switch (field.type) { + case "text": + if (typeof value !== "string") return `${field.label} must be text.`; + if (value.length > PLUGIN_SETTING_TEXT_MAX_LENGTH) + return `${field.label} must be at most ${PLUGIN_SETTING_TEXT_MAX_LENGTH} characters.`; + return undefined; + case "secret": + if (typeof value !== "string" || value.length === 0) + return `${field.label} must be non-empty text.`; + if (value.length > PLUGIN_SETTING_SECRET_MAX_LENGTH) + return `${field.label} must be at most ${PLUGIN_SETTING_SECRET_MAX_LENGTH} characters.`; + return undefined; + case "boolean": + return typeof value === "boolean" ? undefined : `${field.label} must be on or off.`; + case "number": + if (typeof value !== "number" || !Number.isFinite(value)) + return `${field.label} must be a number.`; + if (field.integer === true && !Number.isInteger(value)) + return `${field.label} must be a whole number.`; + if (field.min !== undefined && value < field.min) + return `${field.label} must be at least ${field.min}.`; + if (field.max !== undefined && value > field.max) + return `${field.label} must be at most ${field.max}.`; + return undefined; + case "select": + return typeof value === "string" && field.options.some((option) => option.value === value) + ? undefined + : `${field.label} must be one of its options.`; + } +}; + +const fieldDeclarationProblem = (field: PluginSettingFieldShape): string | undefined => { + if (field.type === "number" && field.min !== undefined && field.max !== undefined) + if (field.min > field.max) return `${field.key}: min is greater than max.`; + if (field.type === "select") { + const values = new Set(field.options.map((option) => option.value)); + if (values.size !== field.options.length) return `${field.key}: option values repeat.`; + } + if (field.type !== "secret" && field.default !== undefined) { + const problem = pluginSettingValueProblem(field, field.default); + if (problem !== undefined) return `${field.key}: the default is invalid. ${problem}`; + } + return undefined; +}; + +/** One declared setting. */ +export const PluginSettingField = PluginSettingFieldShape.check( + Schema.makeFilter((field) => fieldDeclarationProblem(field) ?? true), +); +export type PluginSettingField = typeof PluginSettingField.Type; + +/** The `settings` list of a manifest: unique keys, at most 32 fields. */ +export const PluginSettingsDeclaration = Schema.Array(PluginSettingField).check( + Schema.isMaxLength(PLUGIN_SETTINGS_MAX_FIELDS), + Schema.makeFilter((fields) => { + const keys = new Set(fields.map((field) => field.key)); + return keys.size === fields.length || "settings: keys repeat."; + }), +); + +/** + * The declared fields as clients read them from the catalogue. A field type a + * newer server knows and this client does not is dropped, not the whole row. + */ +export const PluginSettingsFieldList = ForwardCompatibleArray(PluginSettingField); + +/** The value a plugin reads for a non-secret field: what was saved if it still fits, else the default. */ +export const resolvePluginSettingValue = ( + field: PluginSettingField, + saved: PluginSettingValue | undefined, +): PluginSettingValue | undefined => { + if (field.type === "secret") return undefined; + if (saved !== undefined && pluginSettingValueProblem(field, saved) === undefined) return saved; + return field.default; +}; diff --git a/packages/contracts/src/pluginSettings.test.ts b/packages/contracts/src/pluginSettings.test.ts new file mode 100644 index 0000000000000..1867fd8eb9c8e --- /dev/null +++ b/packages/contracts/src/pluginSettings.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +import { PluginManifest } from "./plugin.ts"; +import { PluginCatalogSnapshot } from "./pluginCatalog.ts"; +import { + PluginSettingsDeclaration, + pluginSettingValueProblem, + resolvePluginSettingValue, + type PluginSettingField, +} from "./pluginSettingFields.ts"; +import { PluginSettingsValues } from "./pluginSettings.ts"; + +const decodeDeclaration = Schema.decodeUnknownExit(PluginSettingsDeclaration); +const decodeManifest = Schema.decodeUnknownSync(PluginManifest); +const decodeSnapshot = Schema.decodeUnknownSync(PluginCatalogSnapshot); +const decodeValues = Schema.decodeUnknownSync(PluginSettingsValues); +const select = { + type: "select", + key: "mode", + label: "Mode", + options: [ + { value: "safe", label: "Safe" }, + { value: "fast", label: "Fast" }, + ], + default: "safe", +} as const; + +describe("PluginSettingsDeclaration", () => { + it("accepts each field type and refuses inconsistent declarations", () => { + const declaration = [ + { type: "text", key: "apiUrl", label: "API URL", default: "https://example.com" }, + // A default on a secret is not part of its shape and is dropped. + { type: "secret", key: "token", label: "Token", default: "leaked" }, + { type: "boolean", key: "verbose", label: "Verbose" }, + { type: "number", key: "retries", label: "Retries", min: 0, max: 5, integer: true }, + select, + ]; + const decoded = decodeDeclaration(declaration); + expect(Exit.isSuccess(decoded)).toBe(true); + if (Exit.isSuccess(decoded)) expect(decoded.value[1]).not.toHaveProperty("default"); + + for (const invalid of [ + [select, select], + [{ ...select, default: "turbo" }], + [{ ...select, options: [select.options[0], select.options[0]] }], + [{ ...select, options: [] }], + [{ type: "number", key: "n", label: "N", min: 3, max: 1 }], + [{ type: "number", key: "n", label: "N", integer: true, default: 1.5 }], + [{ type: "text", key: "1st", label: "First" }], + [{ type: "color", key: "c", label: "C" }], + ]) + expect(Exit.isFailure(decodeDeclaration(invalid))).toBe(true); + }); + + it("is optional in the manifest", () => { + const manifest = decodeManifest({ + id: "acme.notifier", + name: "Notifier", + version: "1.0.0", + apiVersion: 1, + entry: "main.mjs", + }); + expect(manifest.settings).toBeUndefined(); + }); +}); + +describe("setting values", () => { + const retries: PluginSettingField = { + type: "number", + key: "retries", + label: "Retries", + min: 0, + max: 5, + integer: true, + default: 2, + }; + + it("checks a value against its field", () => { + expect(pluginSettingValueProblem(retries, 3)).toBeUndefined(); + expect(pluginSettingValueProblem(retries, 6)).toBe("Retries must be at most 5."); + expect(pluginSettingValueProblem(retries, "3")).toBe("Retries must be a number."); + expect(pluginSettingValueProblem(select, "fast")).toBeUndefined(); + expect(pluginSettingValueProblem(select, "Fast")).toBe("Mode must be one of its options."); + }); + + it("falls back to the default when a saved value no longer fits its field", () => { + expect(resolvePluginSettingValue(retries, 4)).toBe(4); + // For example after an update narrowed the range. + expect(resolvePluginSettingValue(retries, 9)).toBe(2); + expect(resolvePluginSettingValue(retries, undefined)).toBe(2); + expect( + resolvePluginSettingValue({ type: "secret", key: "token", label: "Token" }, "x"), + ).toBeUndefined(); + }); +}); + +describe("from a newer server", () => { + it("drops field types and values this client does not know, not the installation", () => { + const digest = `sha256:${"a".repeat(64)}`; + const snapshot = decodeSnapshot({ + installations: [ + { + installationId: "installation-1", + generation: 1, + directory: "/srv/plugins/notifier", + manifest: { + id: "acme.notifier", + name: "Notifier", + version: "1.0.0", + capabilities: ["settings"], + proposedApi: true, + settings: [{ type: "color", key: "accent", label: "Accent" }, select], + }, + source: { digest, files: 2, bytes: 120 }, + problem: null, + inspectedAt: "2026-10-04T00:00:00.000Z", + consent: null, + enabled: false, + addedAt: "2026-10-04T00:00:00.000Z", + }, + ], + }); + expect(snapshot.installations[0]?.manifest?.settings?.map((field) => field.key)).toEqual([ + "mode", + ]); + + const values = decodeValues({ + installationId: "installation-1", + values: [ + { key: "mode", value: "fast" }, + { key: "tags", value: ["a", "b"] }, + ], + secrets: ["token"], + }); + expect(values.values).toEqual([{ key: "mode", value: "fast" }]); + }); +}); diff --git a/packages/contracts/src/pluginSettings.ts b/packages/contracts/src/pluginSettings.ts new file mode 100644 index 0000000000000..49c2d4a80fcb7 --- /dev/null +++ b/packages/contracts/src/pluginSettings.ts @@ -0,0 +1,40 @@ +// Values persist with installations, while writes validate against the manifest current at arrival. +import * as Schema from "effect/Schema"; + +import { ForwardCompatibleArray } from "./baseSchemas.ts"; +import { PluginInstallationId } from "./pluginCatalog.ts"; +import { + PLUGIN_SETTINGS_MAX_FIELDS, + PluginSettingKey, + PluginSettingValue, +} from "./pluginSettingFields.ts"; + +/** What clients see of an installation's saved settings. */ +export const PluginSettingsValues = Schema.Struct({ + installationId: PluginInstallationId, + /** Saved non-secret values. A field without one uses its default. */ + values: ForwardCompatibleArray(Schema.Struct({ key: Schema.String, value: PluginSettingValue })), + /** Keys of secret fields that have a saved value. The values themselves are never sent. */ + secrets: Schema.Array(Schema.String), +}); +export type PluginSettingsValues = typeof PluginSettingsValues.Type; + +export const PluginSettingsInput = Schema.Struct({ + installationId: PluginInstallationId, +}); +export type PluginSettingsInput = typeof PluginSettingsInput.Type; + +const PluginSettingChange = Schema.Struct({ + key: PluginSettingKey, + /** `null` clears the saved value: a field returns to its default, a secret is deleted. */ + value: Schema.NullOr(PluginSettingValue), +}); + +export const PluginSettingsUpdateInput = Schema.Struct({ + installationId: PluginInstallationId, + changes: Schema.Array(PluginSettingChange).check( + Schema.isMinLength(1), + Schema.isMaxLength(PLUGIN_SETTINGS_MAX_FIELDS), + ), +}); +export type PluginSettingsUpdateInput = typeof PluginSettingsUpdateInput.Type; diff --git a/packages/contracts/src/pluginTools.ts b/packages/contracts/src/pluginTools.ts new file mode 100644 index 0000000000000..0253232c939b1 --- /dev/null +++ b/packages/contracts/src/pluginTools.ts @@ -0,0 +1,124 @@ +// Declared schemas are enforced; unsupported keywords reject the manifest instead of being dropped. +// Side-effect and open-world flags inform agents; trusted OS-user plugin code is not sandboxed. +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** The manifest capability a plugin declares to offer tools. */ +export const PLUGIN_TOOLS_CAPABILITY = "tools"; + +/** Handler names starting with this run declared tools; the prefix is reserved. */ +export const PLUGIN_TOOL_HANDLER_PREFIX = "t3.tool."; + +/** + * Handler that runs one tool. It receives `{ input, context: { environmentId, threadId } }`: + * `input` already matched the tool's schema, and `context` comes from the calling + * session's credential, never from the agent. + */ +export const pluginToolHandlerName = (name: PluginToolName) => + `${PLUGIN_TOOL_HANDLER_PREFIX}${name}`; + +export const PLUGIN_TOOL_LIMITS = { + maxToolsPerPlugin: 32, + /** Serialized `inputSchema` of one tool. */ + maxInputSchemaBytes: 16 * 1024, + /** Nesting of schemas inside one `inputSchema`. */ + maxInputSchemaDepth: 32, + /** One plugin's tools as `plugin_tools_list` shows them, so every plugin fits on a page. */ + maxPluginListingBytes: 48 * 1024, + /** A whole serialized `plugin_tools_list` result. */ + maxListBytes: 64 * 1024, + /** Serialized result of one tool call. */ + maxResultBytes: 64 * 1024, + defaultTimeoutSeconds: 60, + maxTimeoutSeconds: 600, +} as const; + +/** Unique within its plugin; agents address it as `/`. */ +export const PluginToolName = Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9_]{0,63}$/)); +export type PluginToolName = typeof PluginToolName.Type; + +/** + * What a tool may do: `read` only observes, `write` changes state, and + * `destructive` may lose data. Shown to the agent; not enforced. + */ +export const PluginToolSideEffect = Schema.Literals(["read", "write", "destructive"]); +export type PluginToolSideEffect = typeof PluginToolSideEffect.Type; + +const PluginToolTitle = Schema.String.check(Schema.isMaxLength(100)); +const PluginToolDescriptionText = TrimmedNonEmptyString.check(Schema.isMaxLength(2000)); + +/** A JSON Schema object in the subset above; the root describes an object. */ +const JsonSchemaObject = Schema.Record(Schema.String, Schema.Json); + +/** One entry of the manifest's `tools` array. */ +export const PluginToolDeclaration = Schema.Struct({ + name: PluginToolName, + title: Schema.optionalKey(PluginToolTitle), + description: PluginToolDescriptionText, + inputSchema: JsonSchemaObject, + sideEffect: PluginToolSideEffect, + /** True when the tool reaches outside this machine (network, external services). */ + openWorld: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** Deadline of the tool's handler once its plugin runs; default 60. */ + timeoutSeconds: Schema.optionalKey( + Schema.Int.check( + Schema.isBetween({ minimum: 1, maximum: PLUGIN_TOOL_LIMITS.maxTimeoutSeconds }), + ), + ), +}); +export type PluginToolDeclaration = typeof PluginToolDeclaration.Type; + +/** `/`, the name agents call a tool by. */ +export const qualifyPluginToolName = (pluginId: string, name: PluginToolName) => + `${pluginId}/${name}`; + +const decodeToolName = Schema.decodeUnknownOption(PluginToolName); + +/** Splits `/` at the first `/`; plugin ids never contain one. */ +export const parseQualifiedPluginToolName = (tool: string) => { + const slash = tool.indexOf("/"); + if (slash <= 0) return Option.none(); + return decodeToolName(tool.slice(slash + 1)).pipe( + Option.map((name) => ({ pluginId: tool.slice(0, slash), name })), + ); +}; + +const PluginToolPlugin = Schema.Struct({ id: Schema.String, name: Schema.String }); + +/** One tool as `plugin_tools_list` shows it. */ +export const PluginToolListing = Schema.Struct({ + tool: Schema.String, + plugin: PluginToolPlugin, + title: Schema.optionalKey(Schema.String), + description: Schema.String, + /** Exactly the declared schema; calls are checked against it. */ + inputSchema: JsonSchemaObject, + sideEffect: PluginToolSideEffect, + openWorld: Schema.Boolean, +}); +export type PluginToolListing = typeof PluginToolListing.Type; + +/** + * One page of tools, ordered by plugin id. A plugin's tools are never split + * across pages. Pass `nextCursor` back as `cursor` for the next page. + */ +export const PluginToolsListResult = Schema.Struct({ + tools: Schema.Array(PluginToolListing), + /** Tool plugins enabled after this session started; a new session can use them. */ + notInThisSession: Schema.Array(PluginToolPlugin), + nextCursor: Schema.optionalKey(Schema.String), +}); +export type PluginToolsListResult = typeof PluginToolsListResult.Type; + +/** + * Why a plugin tool call or listing failed. `reason` is an open string; in + * use: `not-granted`, `unavailable`, `unknown-tool`, `invalid-input`, + * `timeout`, `failed`, `result-too-large`. + */ +export class PluginToolError extends Schema.TaggedError()("PluginToolError", { + reason: Schema.String, + message: Schema.String, +}) {} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 37ea5866ae733..badc214e8f8f2 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -38,6 +38,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 { ProviderAuthCancelInput, ProviderAuthCompleteInput, @@ -376,6 +377,27 @@ import { ScheduledTaskMutationResult, } from "./scheduledTask.ts"; import { SecretRequestAnswerInput, SecretRequestError } from "./secretRequest.ts"; +import { + PluginAddInput, + PluginCatalogError, + PluginCatalogSnapshot, + PluginConsentInput, + PluginInstallationInput, + PluginInstallationResult, + PluginRefreshInput, + PluginRemoveResult, +} from "./pluginCatalog.ts"; +import { + PluginSettingsInput, + PluginSettingsUpdateInput, + PluginSettingsValues, +} from "./pluginSettings.ts"; +import { + PluginActionError, + PluginActionInvokeInput, + PluginActionInvokeResult, + PluginActionsSnapshot, +} from "./pluginActions.ts"; import { ProjectCloneActionInput, ProjectCloneActionResult, @@ -548,6 +570,25 @@ export const WS_METHODS = { scheduledTasksListWebhookDeliveries: "scheduledTasks.listWebhookDeliveries", scheduledTasksGetWebhookDelivery: "scheduledTasks.getWebhookDelivery", + // Trusted local plugins (gated on the `plugins` environment capability) + pluginsList: "plugins.list", + pluginsSubscribe: "plugins.subscribe", + pluginsAdd: "plugins.add", + pluginsRefresh: "plugins.refresh", + pluginsConsent: "plugins.consent", + pluginsEnable: "plugins.enable", + pluginsDisable: "plugins.disable", + pluginsRemove: "plugins.remove", + pluginsResume: "plugins.resume", + // Plugin setting values (gated on the `pluginSettings` environment capability) + pluginsSettingsSubscribe: "plugins.settings.subscribe", + pluginsSettingsUpdate: "plugins.settings.update", + + // Plugin actions (gated on the `pluginActions` environment capability) + pluginActionsSubscribe: "pluginActions.subscribe", + pluginActionsInvoke: "pluginActions.invoke", + + // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", @@ -610,6 +651,7 @@ export const WS_METHODS = { subscribeServerLifecycle: "subscribeServerLifecycle", subscribeAuthAccess: "subscribeAuthAccess", subscribeResourceTelemetry: "subscribeResourceTelemetry", + subscribeContributionStatus: "subscribeContributionStatus", } as const; const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { @@ -1949,6 +1991,102 @@ const WsScheduledTasksGetWebhookDeliveryRpc = Rpc.make( error: Schema.Union([ScheduledTaskError, EnvironmentAuthorizationError]), }, ); +const pluginRpcError = Schema.Union([PluginCatalogError, EnvironmentAuthorizationError]); + +const WsPluginsListRpc = Rpc.make(WS_METHODS.pluginsList, { + payload: Schema.Struct({}), + success: PluginCatalogSnapshot, + error: pluginRpcError, +}); + +/** One snapshot on subscribe, then a fresh one after every catalogue or plugin state change. */ +const WsPluginsSubscribeRpc = Rpc.make(WS_METHODS.pluginsSubscribe, { + payload: Schema.Struct({}), + success: PluginCatalogSnapshot, + error: pluginRpcError, + stream: true, +}); + +/** Reads the manifest and digests the directory; runs nothing. */ +const WsPluginsAddRpc = Rpc.make(WS_METHODS.pluginsAdd, { + payload: PluginAddInput, + success: PluginInstallationResult, + error: pluginRpcError, +}); + +/** Inspects the bytes again; an enabled installation whose bytes changed is stopped. */ +const WsPluginsRefreshRpc = Rpc.make(WS_METHODS.pluginsRefresh, { + payload: PluginRefreshInput, + success: PluginCatalogSnapshot, + error: pluginRpcError, +}); + +const WsPluginsConsentRpc = Rpc.make(WS_METHODS.pluginsConsent, { + payload: PluginConsentInput, + success: PluginInstallationResult, + error: pluginRpcError, +}); + +const WsPluginsEnableRpc = Rpc.make(WS_METHODS.pluginsEnable, { + payload: PluginInstallationInput, + success: PluginInstallationResult, + error: pluginRpcError, +}); + +const WsPluginsDisableRpc = Rpc.make(WS_METHODS.pluginsDisable, { + payload: PluginInstallationInput, + success: PluginInstallationResult, + error: pluginRpcError, +}); + +/** + * Disables and forgets the installation and deletes its saved settings and + * storage. The directory is left untouched. + */ +const WsPluginsRemoveRpc = Rpc.make(WS_METHODS.pluginsRemove, { + payload: PluginInstallationInput, + success: PluginRemoveResult, + error: pluginRpcError, +}); + +/** Clears backoff, quarantine, or incompatibility; the next use starts a fresh process. */ +const WsPluginsResumeRpc = Rpc.make(WS_METHODS.pluginsResume, { + payload: PluginInstallationInput, + success: PluginInstallationResult, + error: pluginRpcError, +}); + +/** The installation's saved values now, then after every change; fails `not-found` once it is removed. */ +const WsPluginsSettingsSubscribeRpc = Rpc.make(WS_METHODS.pluginsSettingsSubscribe, { + payload: PluginSettingsInput, + success: PluginSettingsValues, + error: pluginRpcError, + stream: true, +}); + +/** Saves or clears values; all changes are checked before any is saved. Never echoes a secret. */ +const WsPluginsSettingsUpdateRpc = Rpc.make(WS_METHODS.pluginsSettingsUpdate, { + payload: PluginSettingsUpdateInput, + success: PluginSettingsValues, + error: pluginRpcError, +}); + +const pluginActionRpcError = Schema.Union([PluginActionError, EnvironmentAuthorizationError]); + +/** The actions of every enabled plugin now, then a fresh list after every change. */ +const WsPluginActionsSubscribeRpc = Rpc.make(WS_METHODS.pluginActionsSubscribe, { + payload: Schema.Struct({}), + success: PluginActionsSnapshot, + error: pluginActionRpcError, + stream: true, +}); + +/** Runs one listed action against its target; starts the plugin if it is not running. */ +const WsPluginActionsInvokeRpc = Rpc.make(WS_METHODS.pluginActionsInvoke, { + payload: PluginActionInvokeInput, + success: PluginActionInvokeResult, + error: pluginActionRpcError, +}); const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, { payload: Schema.Struct({}), @@ -1964,12 +2102,20 @@ 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, +}); + export class RpcScopeAuthorization extends RpcMiddleware.Service()( "t3/contracts/RpcScopeAuthorization", { error: EnvironmentAuthorizationError }, ) {} -export const WsRpcGroup = RpcGroup.make( +export const WsCoreRpcGroup = RpcGroup.make( WsGuestShareCreateRpc, WsServerProbeRpc, WsServerGetConfigRpc, @@ -2159,6 +2305,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeServerLifecycleRpc, WsSubscribeAuthAccessRpc, WsSubscribeResourceTelemetryRpc, + WsSubscribeContributionStatusRpc, WsOrchestrationV2DispatchCommandRpc, WsOrchestrationV2GetWorkflowScriptRpc, WsOrchestrationV2StopWorkflowRpc, @@ -2179,3 +2326,26 @@ export const WsRpcGroup = RpcGroup.make( WsOrchestrationV2SubscribeThreadRpc, WsOrchestrationV2SubscribeCommandOutputRpc, ).middleware(RpcScopeAuthorization); + +/** + * The plugin RPCs. The server builds their handlers in a layer of their own: + * one handler object for every RPC exceeds the type checker's instantiation + * limit and silently types the WebSocket layer as `any`. + */ +export const WsPluginRpcGroup = RpcGroup.make( + WsPluginsListRpc, + WsPluginsSubscribeRpc, + WsPluginsAddRpc, + WsPluginsRefreshRpc, + WsPluginsConsentRpc, + WsPluginsEnableRpc, + WsPluginsDisableRpc, + WsPluginsRemoveRpc, + WsPluginsResumeRpc, + WsPluginsSettingsSubscribeRpc, + WsPluginsSettingsUpdateRpc, + WsPluginActionsSubscribeRpc, + WsPluginActionsInvokeRpc, +).middleware(RpcScopeAuthorization); + +export const WsRpcGroup = WsCoreRpcGroup.merge(WsPluginRpcGroup); diff --git a/packages/effect-codex-app-server/src/errors.ts b/packages/effect-codex-app-server/src/errors.ts index 33d8cf5c48839..9f16adcd66c87 100644 --- a/packages/effect-codex-app-server/src/errors.ts +++ b/packages/effect-codex-app-server/src/errors.ts @@ -109,6 +109,7 @@ export type CodexAppServerProtocolParseOperation = typeof CodexAppServerProtocol export const CodexAppServerTransportOperation = Schema.Literals([ "read-input-stream", + "write-output-stream", "read-process-exit-status", ]); export type CodexAppServerTransportOperation = typeof CodexAppServerTransportOperation.Type; diff --git a/packages/effect-codex-app-server/src/protocol.test.ts b/packages/effect-codex-app-server/src/protocol.test.ts index 965764f468b61..351f0d2b87731 100644 --- a/packages/effect-codex-app-server/src/protocol.test.ts +++ b/packages/effect-codex-app-server/src/protocol.test.ts @@ -1,8 +1,10 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; import * as Stdio from "effect/Stdio"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -740,6 +742,43 @@ it.layer(NodeServices.layer)("effect-codex-app-server protocol", (it) => { }), ); + it.effect("fails pending requests when a write fails while input stays open", () => + Effect.gen(function* () { + const { stdio: baseStdio } = yield* makeInMemoryStdio(); + const writeFailure = PlatformError.systemError({ + _tag: "Unknown", + module: "Stdio", + method: "stdout", + cause: new Error("EPIPE"), + }); + const terminated = yield* Deferred.make(); + const transport = yield* CodexProtocol.makeCodexAppServerPatchedProtocol({ + stdio: Stdio.make({ + args: baseStdio.args, + stdin: baseStdio.stdin, + stdout: () => Sink.forEach(() => Effect.fail(writeFailure)), + stderr: baseStdio.stderr, + }), + onTermination: (error) => Deferred.succeed(terminated, error).pipe(Effect.asVoid), + }); + const pending = yield* transport.request("thread/read", {}).pipe(Effect.forkScoped); + + const failure = yield* Deferred.await(terminated); + assert.instanceOf(failure, CodexError.CodexAppServerTransportError); + assert.equal(failure.operation, "write-output-stream"); + assert.strictEqual(failure.cause, writeFailure); + for (const request of [Fiber.join(pending), transport.request("thread/read", {})]) { + const requestFailure = yield* request.pipe( + Effect.match({ + onFailure: (error) => error, + onSuccess: () => assert.fail("Expected the broken transport to reject the request"), + }), + ); + assert.strictEqual(requestFailure, failure); + } + }), + ); + it.effect("reports a process exit that follows input end", () => Effect.gen(function* () { const { stdio, input } = yield* makeInMemoryStdio(); diff --git a/packages/effect-codex-app-server/src/protocol.ts b/packages/effect-codex-app-server/src/protocol.ts index c6a28992cad16..c628079d0b491 100644 --- a/packages/effect-codex-app-server/src/protocol.ts +++ b/packages/effect-codex-app-server/src/protocol.ts @@ -479,7 +479,20 @@ export const makeCodexAppServerPatchedProtocol = Effect.fn("makeCodexAppServerPa Effect.forkScoped, ); - yield* Stream.fromQueue(outgoing).pipe(Stream.run(options.stdio.stdout()), Effect.forkScoped); + yield* Stream.fromQueue(outgoing).pipe( + Stream.run(options.stdio.stdout()), + Effect.catch((cause) => + handleTermination(() => + Effect.succeed( + new CodexError.CodexAppServerTransportError({ + operation: "write-output-stream", + cause, + }), + ), + ), + ), + Effect.forkScoped, + ); const request = Effect.fn("CodexAppServerProtocol.request")(function* ( method: string, diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json index ab93310316ac4..f9c4e3a014373 100644 --- a/packages/provider-core/package.json +++ b/packages/provider-core/package.json @@ -27,6 +27,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 0000000000000..16ef272350bc9 --- /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 0000000000000..6c49908d5933a --- /dev/null +++ b/packages/provider-core/src/server/ContributionStatusStore.ts @@ -0,0 +1,339 @@ +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"; + +// Replacing a thread's source retires the previous handle until it binds again; +// closing its scope clears its statuses without touching the replacement's. +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. +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g; +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 */ +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"] = Effect.fn( + "ContributionStatusStore.openSource", + )(function* (source) { + 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-core/src/server/subagentProjection.ts b/packages/provider-core/src/server/subagentProjection.ts index e18687414f92e..7179398cf602d 100644 --- a/packages/provider-core/src/server/subagentProjection.ts +++ b/packages/provider-core/src/server/subagentProjection.ts @@ -60,6 +60,7 @@ export function makeSubagentChildThread(input: { creationSource: input.creationSource, id: input.childThreadId, title: input.title, + titleRegeneration: null, linkedPullRequest: null, pullRequests: [], historyOrigin: undefined, diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 5c94de3040209..c091f4fdd66de 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -13,6 +13,7 @@ import { RunId, ThreadId, type ChatAttachment, + type ContributionStatusSnapshot, type ModelSelection, type OrchestrationV2AppThread, type OrchestrationV2ProviderThread, @@ -27,9 +28,11 @@ import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Logger from "effect/Logger"; 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 ChildProcess from "effect/process/ChildProcess"; @@ -45,6 +48,7 @@ import { handoffBudget } from "@t3tools/provider-core/server/handoffBudget"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { AgentScope } from "@t3tools/shared/AgentScope"; import { toolOutputImages, compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; import { makePiAdapterV2, PiAdapterV2Driver, @@ -166,8 +170,12 @@ interface FakePi { readonly failNextState: () => void; readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork") => void; readonly deferNextModelSelection: () => void; + /** Send the held lifecycle request's normal response. */ + readonly resolveDeferredLifecycle: Effect.Effect; readonly queueModels: (models: ReadonlyArray) => void; readonly vetoNextNewSession: () => void; + readonly vetoNextFork: () => void; + readonly rejectNextFork: () => void; readonly allRequests: () => ReadonlyArray; readonly queueStats: (data: unknown) => void; /** Pi before 0.84.4 answers clear_queue as an unknown command. */ @@ -210,7 +218,10 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { let failState = false; 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; let sessionGeneration = 0; let models: ReadonlyArray = []; @@ -279,8 +290,15 @@ 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": { + if (rejectFork) { + rejectFork = false; + return { ...base, success: false, error: "fork refused" }; + } + const cancelled = vetoFork; + vetoFork = false; + return { ...base, data: { text: "Hello pi", cancelled } }; + } case "clear_queue": return clearQueue === "unknown_command" ? { ...base, success: false, error: "Unknown command: clear_queue" } @@ -309,6 +327,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { } if (record["type"] === deferredLifecycle) { deferredLifecycle = undefined; + deferredLifecycleRequest = record; continue; } const response = respondTo(record); @@ -383,12 +402,25 @@ 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; }, + rejectNextFork: () => { + rejectFork = true; + }, + vetoNextFork: () => { + vetoFork = true; + }, allRequests: () => allRequests, vetoNextSwitch: () => { vetoSwitch = true; @@ -473,6 +505,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 { @@ -1588,6 +1709,447 @@ 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 = "/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("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; + 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 a32a5759ba707..7b990c0702a86 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -6,6 +6,7 @@ import * as Predicate from "effect/Predicate"; import { ProviderDriverKind, type ChatAttachment, + type ContributionStatusItem, type ModelSelection, type OrchestrationV2ExecutionNode, type OrchestrationV2ProviderCapabilities, @@ -29,6 +30,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 Queue from "effect/Queue"; @@ -46,6 +48,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, @@ -417,6 +420,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) => @@ -524,6 +528,34 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( const sessionApprovals = new Set(); // Pi can settle immediately after dialog response; serialize so terminal cannot overtake request resolution. 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; + /** + * 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, + ) => + // 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; let registrationAttempted = false; let lastNativeThreadId: string | null = null; @@ -636,21 +668,36 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( return null; }; - const lifecycleRequest = (record: PiRpcRecord) => - request(record, PI_SESSION_TIMEOUT_MS).pipe( - // Timeout does not cancel Pi's lifecycle hook; retire the process before fallback can race it. - 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 = ( @@ -1298,6 +1345,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" && @@ -2064,6 +2121,32 @@ 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. Pi exposes no native-session boundary, so shutdown writes + // after the marker are attributed to the target session. + rollbackStatuses = + event["rollback"] === true + ? new Map((yield* statusSource.items).map((item) => [item.key, item])) + : null; + yield* statusSource.clearAll; + yield* statusSource.bindThread( + (event["threadId"] as OrchestrationV2ProviderThread["appThreadId"] | undefined) ?? + 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": { for (const extensionError of outOfTurnExtensionErrors.splice(0)) { yield* emitExtensionError(extensionError); @@ -2175,7 +2258,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( Effect.forkIn(scope), ); - const registerThread = Effect.fnUntraced(function* ( + const registerThreadUnguarded = Effect.fnUntraced(function* ( threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, publish = true, ) { @@ -2202,8 +2285,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; // Failed lifecycle operations can still change native sessions; invalidate bindings and defaults. threadState = null; @@ -2217,6 +2301,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"); @@ -2288,6 +2373,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); const baselineEntries = yield* request({ type: "get_entries" }).pipe( Effect.orElseSucceed(() => undefined), ); @@ -2302,6 +2389,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) { @@ -2919,19 +3016,44 @@ 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", + // 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"), + ), + }), + ), + ); 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( diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index e22545b9a23c7..7a33fc82e1996 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -18,6 +18,16 @@ describe("detectComposerTrigger", () => { }); }); + it.each(["/Model opus", "/MODEL opus"])( + "opens the model picker for capitalized %j the same as /model", + (text) => { + expect(detectComposerTrigger(text, text.length)).toMatchObject({ + kind: "slash-model", + query: "opus", + }); + }, + ); + it.each(["Use /", "Use /review"])("detects the inline slash token in %j", (text) => { expect(detectComposerTrigger(text, text.length)).toEqual({ kind: "slash-command", diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index 164eb935f8a17..77566a2b09608 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -100,7 +100,7 @@ export function detectComposerTrigger( }; } - const modelMatch = /^\/model(?:\s+(.*))?$/.exec(linePrefix); + const modelMatch = /^\/model(?:\s+(.*))?$/i.exec(linePrefix); if (modelMatch) { return { kind: "slash-model", diff --git a/scripts/check-changed.test.ts b/scripts/check-changed.test.ts index b01c3556766c5..b0e0fe01e851b 100644 --- a/scripts/check-changed.test.ts +++ b/scripts/check-changed.test.ts @@ -17,10 +17,12 @@ import * as TestClock from "effect/testing/TestClock"; import { comparisonBase, defaultConcurrency, + docReaderTests, gitLines, runSteps, type Step, } from "./check-changed.ts"; +import { decide, readWorkspace } from "../.github/scripts/ci-changes.cjs"; const GiB = 1024 ** 3; @@ -33,6 +35,17 @@ describe("check:changed", () => { assert.strictEqual(defaultConcurrency(1, 64 * GiB), 1); }); + it("runs the server tests for the one guide they read and leaves other docs inert", () => { + const packages = readWorkspace(); + assert.isTrue( + decide(["docs/user/t3-connect-troubleshooting.md"], packages, false).outputs.test_server, + ); + assert.deepStrictEqual(docReaderTests(["docs/user/t3-connect-troubleshooting.md"]), [ + "apps/server/src/cloud/ConnectReachability.test.ts", + ]); + assert.deepStrictEqual(decide(["docs/user/install.md"], packages, false).affected, []); + }); + it.effect( "keeps weighted steps within the budget, in order, and serial steps one at a time", () => diff --git a/scripts/check-changed.ts b/scripts/check-changed.ts index b23289211b90e..583b00b5361ca 100644 --- a/scripts/check-changed.ts +++ b/scripts/check-changed.ts @@ -190,6 +190,12 @@ const override = Effect.fn("checkChanged.override")(function* ( return value; }); +// Tests that read a doc; a changed doc has no test beside it to select them. +export const docReaderTests = (files: ReadonlyArray) => + files.includes("docs/user/t3-connect-troubleshooting.md") + ? ["apps/server/src/cloud/ConnectReachability.test.ts"] + : []; + const checkChanged = Effect.fn("checkChanged")(function* (input: { readonly ref: Option.Option; readonly concurrency: Option.Option; @@ -309,6 +315,7 @@ const checkChanged = Effect.fn("checkChanged")(function* (input: { if (entry.startsWith(prefix) && TEST.test(entry)) tests.add(path.join(dir, entry)); } } + for (const test of docReaderTests(files)) tests.add(test); // It reads files other than the ones beside it, so no changed file selects it. if (affected.includes("t3")) tests.add("apps/server/src/provider/AgentContextBudget.test.ts"); diff --git a/scripts/lib/reference-repos.ts b/scripts/lib/reference-repos.ts index 005c619ff24b6..23250f8116d16 100644 --- a/scripts/lib/reference-repos.ts +++ b/scripts/lib/reference-repos.ts @@ -23,8 +23,8 @@ export const referenceRepos: ReadonlyArray = [ prefix: ".repos/alchemy-effect", repository: "https://github.com/alchemy-run/alchemy-effect.git", latestRef: "main", - versionSourcePath: "infra/relay/package.json", - packageVersionPath: ["dependencies", "alchemy"], + versionSourcePath: "pnpm-workspace.yaml", + packageVersionPath: ["catalog", "alchemy"], versionTagPrefix: "v", }, ]; diff --git a/scripts/render-metrics.test.ts b/scripts/render-metrics.test.ts index 919ba77e5c366..9ce66d27a9bde 100644 --- a/scripts/render-metrics.test.ts +++ b/scripts/render-metrics.test.ts @@ -1,6 +1,45 @@ -import { describe, expect, it } from "vite-plus/test"; +// @effect-diagnostics nodeBuiltinImport:off - runOnce is a synchronous script, tested on a real temp dir. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; -import { nondeterministic, renderComparison } from "./render-metrics.ts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { nondeterministic, renderComparison, runOnce } from "./render-metrics.ts"; + +const mountReport = { commits: 2, renders: 10, components: { Row: 10 } }; + +vi.mock("node:child_process", () => ({ + spawnSync: ( + _command: string, + _args: string[], + options: { env: { T3CODE_RENDER_REPORT_DIR: string } }, + ) => { + NodeFS.writeFileSync( + NodePath.join(options.env.T3CODE_RENDER_REPORT_DIR, "mount.json"), + JSON.stringify(mountReport), + ); + return { status: 0 }; + }, +})); + +describe("runOnce", () => { + it("collects the scenario reports when HOME has no .cache, then removes its report dir", () => { + const home = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-render-metrics-")); + vi.stubEnv("HOME", home); + vi.stubEnv("TMPDIR", home); + vi.stubEnv("USERPROFILE", home); + vi.stubEnv("TEMP", home); + vi.stubEnv("TMP", home); + try { + expect(runOnce()).toEqual({ mount: mountReport }); + expect(NodeFS.readdirSync(home)).toEqual([]); + } finally { + vi.unstubAllEnvs(); + NodeFS.rmSync(home, { recursive: true, force: true }); + } + }); +}); describe("nondeterministic", () => { it("names only the scenarios whose counts differ between runs", () => { diff --git a/scripts/render-metrics.ts b/scripts/render-metrics.ts index a49154ff5c788..4c9a81c2d8994 100644 --- a/scripts/render-metrics.ts +++ b/scripts/render-metrics.ts @@ -37,8 +37,8 @@ export type RenderMetrics = Readonly>; const root = NodePath.resolve(import.meta.dirname, ".."); -function runOnce(): RenderMetrics { - const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.homedir(), ".cache", "t3-renders-")); +export function runOnce(): RenderMetrics { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-renders-")); try { const result = NodeChildProcess.spawnSync("vp", ["test", "run", "--project", "renders"], { cwd: NodePath.join(root, "apps/web"), diff --git a/scripts/sync-reference-repos.test.ts b/scripts/sync-reference-repos.test.ts index 9972d180e373f..022d7eb5a38dd 100644 --- a/scripts/sync-reference-repos.test.ts +++ b/scripts/sync-reference-repos.test.ts @@ -8,9 +8,10 @@ import { referenceRepos } from "./lib/reference-repos.ts"; import { planReferenceRepoSync, resolveReferenceRepoRef } from "./sync-reference-repos.ts"; const effectSmol = referenceRepos[0]!; +const alchemyEffect = referenceRepos[1]!; it.layer(NodeServices.layer)("sync-reference-repos", (it) => { - it.effect("resolves the effect-smol tag from the root catalog", () => + it.effect("resolves reference tags from the root catalog", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -19,13 +20,14 @@ it.layer(NodeServices.layer)("sync-reference-repos", (it) => { }); yield* fs.writeFileString( path.join(rootDir, "pnpm-workspace.yaml"), - "catalog:\n effect: 4.0.0-beta.73\n", + "catalog:\n alchemy: 2.0.0-beta.84\n effect: 4.0.0-beta.73\n", ); assert.equal( yield* resolveReferenceRepoRef(effectSmol, rootDir, false), "effect@4.0.0-beta.73", ); + assert.equal(yield* resolveReferenceRepoRef(alchemyEffect, rootDir, false), "v2.0.0-beta.84"); }), );