diff --git a/apps/mobile/src/features/threads/ThreadContributionStatusStrip.tsx b/apps/mobile/src/features/threads/ThreadContributionStatusStrip.tsx new file mode 100644 index 000000000000..89fc0a246a16 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadContributionStatusStrip.tsx @@ -0,0 +1,120 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { + ContributionStatusTone, + EnvironmentId, + ProviderDriverKind, + ThreadId, +} from "@t3tools/contracts"; +import { useMemo } from "react"; +import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { cn } from "../../lib/cn"; +import { contributionStatusEnvironment } from "../../state/contribution-status"; +import { + reservesContributionStatusBand, + threadContributionStatusChips, + threadHasContributionStatus, +} from "./thread-contribution-status-presentation"; + +/** The strip's height: one row of chip touch targets (44pt iOS, 48dp Android). */ +const STRIP_HEIGHT = Platform.OS === "android" ? 48 : 44; + +// Neutral, the default, has no dot: Pi status text often brings its own glyph. +const TONE_DOT_CLASS = { + neutral: null, + info: "bg-adaptive-sky-600-400", + success: "bg-adaptive-emerald-600-400", + warning: "bg-adaptive-amber-700-400", + error: "bg-adaptive-rose-600-400", +} as const satisfies Record; + +/** + * The band the feed reserves above its first row for the status strip, passed + * to ThreadFeed's `topOverlayInset`. See `reservesContributionStatusBand`. + */ +export function useThreadContributionStatusStripInset(input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly supported: boolean; + readonly driver: ProviderDriverKind | undefined; +}): number { + const hasStatus = useAtomValue( + contributionStatusEnvironment.threadStatus(input.environmentId, input.threadId), + threadHasContributionStatus, + ); + return reservesContributionStatusBand({ ...input, hasStatus }) ? STRIP_HEIGHT : 0; +} + +/** + * Advisory statuses the thread's provider set, such as Pi extension + * `setStatus` text, as one row of chips floating just under the navigation + * header. It overlays the feed like the header does, so a status changing + * never resizes the composer; the feed reserves its band through + * `useThreadContributionStatusStripInset`. Renders nothing when the server + * lacks the capability or the thread has no statuses. Pressing a chip shows + * its tooltip and where it came from. + */ +export function ThreadContributionStatusStrip(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + /** Distance from the screen's top edge to the bottom of the navigation header. */ + readonly top: number; + readonly contentMaxWidth: number | undefined; +}) { + const entries = useAtomValue( + contributionStatusEnvironment.threadStatus(props.environmentId, props.threadId), + ); + const chips = useMemo(() => threadContributionStatusChips(entries), [entries]); + if (chips.length === 0) return null; + + return ( + + + {/* Sized to its chips, so the feed under the empty rest of the row + still takes touches. */} + + {chips.map((chip) => { + const dotClass = TONE_DOT_CLASS[chip.tone]; + // The compact pill sits inside a full-size touch target (44pt + // iOS, 48dp Android); neighbours abut without overlapping. + return ( + Alert.alert(chip.details.title, chip.details.message)} + > + + {chip.leadsSource ? : null} + {dotClass === null ? null : ( + + )} + + {chip.text} + + + + ); + })} + + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e4f489115f3a..9ed9b6786da1 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -123,6 +123,10 @@ import { ComposerFeedback } from "./ComposerFeedback"; import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; import { ProviderSubagentBar } from "./ProviderSubagentBar"; +import { + ThreadContributionStatusStrip, + useThreadContributionStatusStripInset, +} from "./ThreadContributionStatusStrip"; import { ThreadCreationFailedCard } from "./ThreadCreationFailedCard"; import { FLOATING_WORKING_CONTROL_COVERAGE, @@ -855,6 +859,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread providerSubagentProvider.models, ) : null; + const statusStripInset = useThreadContributionStatusStripInset({ + environmentId: props.environmentId, + threadId: props.selectedThread.id, + supported: props.serverConfig?.environment.capabilities.contributionStatus === true, + driver: providerSubagentProvider?.driver, + }); const providerSubagentCatalogModel = providerSubagentProvider?.models.find( (model) => model.slug === providerSubagentModelSlug, ); @@ -1178,6 +1188,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread submittedMessageId={submittedMessageId} contentInsetEndAdjustment={combinedContentInsetEndAdjustment} contentTopInset={0} + topOverlayInset={statusStripInset} contentBottomInset={ estimatedOverlayHeight + (showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0) @@ -1210,6 +1221,21 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread /> ) : null} + {showContent ? ( + + ) : null} + {/* Floating composer — sticks to keyboard via KeyboardStickyView */} {showContent ? ( ; readonly contentTopInset?: number; + /** Height of UI floating over the feed's top edge below the header (the status strip). */ + readonly topOverlayInset?: number; readonly contentBottomInset?: number; readonly historyControls?: ThreadFeedHistoryControls; readonly contentMaxWidth?: number; @@ -2327,6 +2330,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const anchorTopInset = usesNativeAutomaticInsets ? (navigationHeaderHeight ?? insets.top + IOS_NAV_BAR_HEIGHT) : topContentInset; + const feedTop = deriveThreadFeedTopGeometry({ + usesNativeAutomaticInsets, + headerInset: anchorTopInset, + topOverlayInset: props.topOverlayInset ?? 0, + }); const theme = useUniwindTheme(); const iconSubtleColor = theme["--color-icon-subtle"]; @@ -2774,9 +2782,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { presentedFeed, props.anchorMessageId, (entry) => (entry.type === "message" ? entry.id : null), - { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, + { anchorOffset: feedTop.anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, ), - [presentedFeed, props.anchorMessageId, anchorTopInset], + [presentedFeed, props.anchorMessageId, feedTop.anchorTopInset], ); const failedRunIds = useMemo( () => failedFeedRunIds(props.feed, props.latestRun), @@ -3279,7 +3287,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { scrollEventThrottle={16} ListHeaderComponent={ <> - {usesNativeAutomaticInsets ? null : } + {feedTop.spacerHeight > 0 ? ( + + ) : null} {setupAnchorIndex < 0 && props.worktreeSetup ? ( ) : null} diff --git a/apps/mobile/src/features/threads/thread-contribution-status-presentation.test.ts b/apps/mobile/src/features/threads/thread-contribution-status-presentation.test.ts new file mode 100644 index 000000000000..3f09c12308fb --- /dev/null +++ b/apps/mobile/src/features/threads/thread-contribution-status-presentation.test.ts @@ -0,0 +1,121 @@ +import { + type ContributionStatusEntry, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ThreadId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + reservesContributionStatusBand, + threadContributionStatusChips, + threadHasContributionStatus, +} from "./thread-contribution-status-presentation"; + +const entry = ( + session: string, + items: ContributionStatusEntry["items"], + driver = "pi", +): ContributionStatusEntry => ({ + threadId: ThreadId.make("thread-1"), + source: { + kind: "provider-session", + providerSessionId: ProviderSessionId.make(session), + providerInstanceId: ProviderInstanceId.make(driver), + driver: ProviderDriverKind.make(driver), + }, + items, +}); + +describe("threadContributionStatusChips", () => { + it("shows nothing for a thread without statuses", () => { + expect(threadContributionStatusChips([])).toEqual([]); + }); + + it("keys chips by source and item key, in the server's order", () => { + const chips = threadContributionStatusChips([ + entry("session-a", [ + { key: "mode", text: "● plan" }, + { key: "tokens", text: "12k", tone: "warning", tooltip: "Context is filling up" }, + ]), + entry("session-b", [{ key: "mode", text: "● plan" }], "codex"), + ]); + + expect(chips.map((chip) => [chip.text, chip.leadsSource, chip.tone, chip.tooltip])).toEqual([ + ["● plan", true, "neutral", null], + ["12k", false, "warning", "Context is filling up"], + ["● plan", true, "neutral", null], + ]); + // The same item key under two sources must stay two distinct rows. + expect(new Set(chips.map((chip) => chip.id)).size).toBe(3); + }); + + it("re-keys a chip when another provider session takes the thread over", () => { + const [before] = threadContributionStatusChips([entry("old", [{ key: "mode", text: "x" }])]); + const [after] = threadContributionStatusChips([entry("new", [{ key: "mode", text: "x" }])]); + + expect(after?.id).not.toBe(before?.id); + }); + + it("attributes the status to its producer without claiming freshness", () => { + const [pi] = threadContributionStatusChips([entry("s", [{ key: "mode", text: "● startup" }])]); + const [codex] = threadContributionStatusChips([ + entry("s", [{ key: "mode", text: "busy" }], "codex"), + ]); + + expect(pi?.accessibilityLabel).toBe("Pi status: ● startup"); + expect(pi?.help).toBe("Set by a Pi extension. It can lag a session change."); + expect(codex?.help).toBe("Set by Codex. It can lag a session change."); + }); + + it("puts the full status text in the details body, where it is never truncated", () => { + const text = "● build — indexing 1,284 files in packages/client-runtime before the next turn"; + const [plain, withTooltip] = threadContributionStatusChips([ + entry("s", [ + { key: "mode", text }, + { key: "tokens", text: "12k", tooltip: "Context is filling up" }, + ]), + ]); + + expect(plain?.details).toEqual({ + title: "Pi status", + message: `${text}\n\nSet by a Pi extension. It can lag a session change.`, + }); + expect(withTooltip?.details.message).toBe( + "12k\n\nContext is filling up\n\nSet by a Pi extension. It can lag a session change.", + ); + }); +}); + +describe("status strip band", () => { + it("counts a thread as having statuses only when an entry has items", () => { + expect(threadHasContributionStatus([])).toBe(false); + expect(threadHasContributionStatus([entry("s", [])])).toBe(false); + expect(threadHasContributionStatus([entry("s", [{ key: "mode", text: "x" }])])).toBe(true); + }); + + it("keeps a Pi thread's band through set, replace and clear", () => { + const pi = ProviderDriverKind.make("pi"); + const band = [false, true, true, false].map((hasStatus) => + reservesContributionStatusBand({ supported: true, driver: pi, hasStatus }), + ); + + expect(band).toEqual([true, true, true, true]); + }); + + it("reserves nothing for other providers without statuses or on servers without the stream", () => { + const codex = ProviderDriverKind.make("codex"); + const pi = ProviderDriverKind.make("pi"); + + expect( + reservesContributionStatusBand({ supported: true, driver: codex, hasStatus: false }), + ).toBe(false); + expect( + reservesContributionStatusBand({ supported: true, driver: codex, hasStatus: true }), + ).toBe(true); + expect(reservesContributionStatusBand({ supported: false, driver: pi, hasStatus: false })).toBe( + false, + ); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-contribution-status-presentation.ts b/apps/mobile/src/features/threads/thread-contribution-status-presentation.ts new file mode 100644 index 000000000000..647edaff8371 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-contribution-status-presentation.ts @@ -0,0 +1,85 @@ +import { + type ContributionStatusEntry, + type ContributionStatusTone, + contributionStatusSourceKey, + PROVIDER_DISPLAY_NAMES, + type ProviderDriverKind, +} from "@t3tools/contracts"; + +export interface ThreadContributionStatusChip { + /** Source key plus item key, so a provider session taking over re-keys the chip. */ + readonly id: string; + readonly driver: ProviderDriverKind; + /** The first chip of each source carries that source's provider icon. */ + readonly leadsSource: boolean; + readonly text: string; + readonly tone: ContributionStatusTone; + readonly tooltip: string | null; + readonly accessibilityLabel: string; + /** Says where the status came from and that it can lag; never "current session". */ + readonly help: string; + /** The details alert. The full text goes in the body: native alert titles truncate. */ + readonly details: { readonly title: string; readonly message: string }; +} + +function providerLabel(driver: ProviderDriverKind): string { + return PROVIDER_DISPLAY_NAMES[driver] ?? driver; +} + +function statusHelp(driver: ProviderDriverKind): string { + const origin = driver === "pi" ? "a Pi extension" : providerLabel(driver); + return `Set by ${origin}. It can lag a session change.`; +} + +/** Flattens a thread's status entries into chips, in the server's order. */ +export function threadContributionStatusChips( + entries: ReadonlyArray, +): ReadonlyArray { + return entries.flatMap((entry) => { + const sourceKey = contributionStatusSourceKey(entry.source); + const { driver } = entry.source; + return entry.items.map((item, index) => { + const help = statusHelp(driver); + return { + id: JSON.stringify([sourceKey, item.key]), + driver, + leadsSource: index === 0, + text: item.text, + tone: item.tone ?? "neutral", + tooltip: item.tooltip ?? null, + accessibilityLabel: `${providerLabel(driver)} status: ${item.text}`, + help, + details: { + title: `${providerLabel(driver)} status`, + message: [item.text, item.tooltip, help].filter(Boolean).join("\n\n"), + }, + }; + }); + }); +} + +export function threadHasContributionStatus( + entries: ReadonlyArray, +): boolean { + return entries.some((entry) => entry.items.length > 0); +} + +/** Drivers whose sessions publish statuses (Pi's `setStatus`). */ +const STATUS_PUBLISHING_DRIVERS: ReadonlySet = new Set(["pi"]); + +/** + * Whether the feed reserves the status strip's band above its first row. + * Threads of a publishing provider keep it from the start, so a status + * appearing, changing or clearing never shifts the feed; other threads only + * get it while they have a status. + */ +export function reservesContributionStatusBand(input: { + readonly supported: boolean; + readonly driver: ProviderDriverKind | undefined; + readonly hasStatus: boolean; +}): boolean { + if (!input.supported) return false; + return ( + input.hasStatus || (input.driver !== undefined && STATUS_PUBLISHING_DRIVERS.has(input.driver)) + ); +} diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 7d288b1352a5..d655781a8f42 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -6,6 +6,7 @@ import { deriveFileInspectorPaneLayout, deriveLayout, deriveThreadFeedInitialContentInset, + deriveThreadFeedTopGeometry, deriveThreadWorkLogSizing, deriveWorkspacePaneLayout, SPLIT_LAYOUT_MIN_HEIGHT, @@ -72,6 +73,45 @@ describe("deriveThreadFeedInitialContentInset", () => { }); }); +describe("deriveThreadFeedTopGeometry", () => { + it("keeps the first row and an anchored message below a strip under an opaque header", () => { + expect( + deriveThreadFeedTopGeometry({ + usesNativeAutomaticInsets: false, + headerInset: 0, + topOverlayInset: 48, + }), + ).toEqual({ spacerHeight: 48, anchorTopInset: 48 }); + }); + + it("adds only the strip to content under a glass header, whose inset UIKit applies", () => { + expect( + deriveThreadFeedTopGeometry({ + usesNativeAutomaticInsets: true, + headerInset: 106, + topOverlayInset: 44, + }), + ).toEqual({ spacerHeight: 44, anchorTopInset: 150 }); + }); + + it("leaves the feed where it was when nothing floats over it", () => { + expect( + deriveThreadFeedTopGeometry({ + usesNativeAutomaticInsets: true, + headerInset: 106, + topOverlayInset: 0, + }), + ).toEqual({ spacerHeight: 0, anchorTopInset: 106 }); + expect( + deriveThreadFeedTopGeometry({ + usesNativeAutomaticInsets: false, + headerInset: 0, + topOverlayInset: 0, + }), + ).toEqual({ spacerHeight: 0, anchorTopInset: 0 }); + }); +}); + describe("resizable pane constraints", () => { it("preserves a useful main pane while constraining a trailing pane", () => { expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 1_100 })).toBe(440); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index ac4cde7789b0..53afe9e51471 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -87,6 +87,24 @@ export function deriveThreadFeedInitialContentInset(input: { return { bottom: Math.max(0, input.bottomContentInset) }; } +/** + * Where the thread feed's first row and a just-sent message rest. The header + * inset is UIKit's under native automatic insets and a content spacer + * otherwise. UI floating over the feed's top edge below the header, such as + * the status strip, adds to both, so it never covers the oldest loaded row, + * the load-earlier control or an anchored message. + */ +export function deriveThreadFeedTopGeometry(input: { + readonly usesNativeAutomaticInsets: boolean; + readonly headerInset: number; + readonly topOverlayInset: number; +}): { readonly spacerHeight: number; readonly anchorTopInset: number } { + return { + spacerHeight: (input.usesNativeAutomaticInsets ? 0 : input.headerInset) + input.topOverlayInset, + anchorTopInset: input.headerInset + input.topOverlayInset, + }; +} + export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector"; export function deriveLayout(input: { diff --git a/apps/mobile/src/state/contribution-status.ts b/apps/mobile/src/state/contribution-status.ts new file mode 100644 index 000000000000..8a83addc96ab --- /dev/null +++ b/apps/mobile/src/state/contribution-status.ts @@ -0,0 +1,9 @@ +import { createContributionStatusEnvironmentAtoms } from "@t3tools/client-runtime/state/contribution-status"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { serverEnvironment } from "./server"; + +export const contributionStatusEnvironment = createContributionStatusEnvironmentAtoms( + connectionAtomRuntime, + { configValueAtom: serverEnvironment.configValueAtom }, +); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5b8d483dc102..fc1eaf2f89f0 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -173,6 +173,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeResourceTelemetry]: AuthDiagnosticsReadScope, + [WS_METHODS.subscribeContributionStatus]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope, [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, diff --git a/apps/server/src/contributions/ContributionStatusRpc.test.ts b/apps/server/src/contributions/ContributionStatusRpc.test.ts new file mode 100644 index 000000000000..34d999adf280 --- /dev/null +++ b/apps/server/src/contributions/ContributionStatusRpc.test.ts @@ -0,0 +1,206 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + AuthOrchestrationReadScope, + AuthRelayReadScope, + type AuthEnvironmentScope, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ThreadId, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as TestClock from "effect/testing/TestClock"; +import { RpcMessage, RpcSerialization, RpcServer } from "effect/rpc"; + +import * as RpcAuthorization from "../auth/RpcAuthorization.ts"; +import { RPC_REQUIRED_SCOPES } from "../auth/RpcAuthorization.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import * as ProviderOrchestrationAdapterInfrastructure from "../provider/ProviderOrchestrationAdapterInfrastructure.ts"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; + +const TAG = WS_METHODS.subscribeContributionStatus; +const THREAD = ThreadId.make("thread-1"); +const SOURCE = { + kind: "provider-session", + providerSessionId: ProviderSessionId.make("session-1"), + providerInstanceId: ProviderInstanceId.make("pi"), + driver: ProviderDriverKind.make("pi"), +} as const; +const encodedEntry = (texts: ReadonlyArray) => ({ + threadId: THREAD, + source: SOURCE, + items: texts.map((text, index) => ({ key: `k${index}`, text })), +}); + +// The server group narrowed to this RPC; it keeps the group's scope middleware. +const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => tag !== TAG, + ), +); + +/** + * Serves `subscribeContributionStatus` as ws.ts does (the store's subscription + * stream behind the connection's scope middleware) over an in-memory RPC protocol. + */ +const serveStatus = Effect.fn("serveStatus")(function* ( + store: ContributionStatusStore.ContributionStatusStoreShape, + scopes: ReadonlyArray, +) { + const responses = yield* Queue.unbounded(); + const receive = yield* Deferred.make[0]>(); + const protocol = yield* RpcServer.Protocol.make((write) => + Effect.gen(function* () { + yield* Deferred.succeed(receive, write); + const serialization = yield* RpcSerialization.RpcSerialization; + return { + disconnects: yield* Queue.unbounded(), + send: (_clientId, response) => Queue.offer(responses, response), + end: () => Effect.void, + clientIds: Effect.succeed(new Set([0])), + initialMessage: Effect.succeedNone, + supportsAck: false, + supportsTransferables: false, + supportsSpanPropagation: false, + supportsNotifications: true, + codecFor: serialization.codecFor, + }; + }), + ); + yield* RpcServer.make(group).pipe( + Effect.provide( + Layer.merge( + group.toLayerHandler(TAG, () => ContributionStatusStore.subscriptionStream(store)), + RpcAuthorization.layer(scopes), + ), + ), + Effect.provideService(RpcServer.Protocol, protocol), + Effect.forkScoped, + ); + const write = yield* Deferred.await(receive); + return { + subscribe: (id: string) => + write(0, { _tag: "Request", id, tag: TAG, payload: {}, headers: [] }), + interrupt: (id: string) => write(0, { _tag: "Interrupt", requestId: id }), + /** The next frame the client receives; the store publishes one per visible change. */ + next: Queue.take(responses), + /** Lets server fibers run so a frame that would be sent is in the queue. */ + pending: TestClock.adjust(0).pipe(Effect.andThen(Queue.size(responses))), + }; +}); + +const chunk = ( + requestId: string, + entries: ReadonlyArray>, +): RpcMessage.FromServerEncoded => ({ _tag: "Chunk", requestId, values: [{ entries }] }); + +describe("subscribeContributionStatus", () => { + it.effect("streams the current snapshot, replacements, and a fresh snapshot on resubscribe", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const handle = yield* store.openSource(SOURCE); + yield* handle.bindThread(THREAD); + yield* handle.set({ key: "k0", text: "plan" }); + const client = yield* serveStatus(store, [AuthOrchestrationReadScope]); + + yield* client.subscribe("1"); + assert.deepStrictEqual(yield* client.next, chunk("1", [encodedEntry(["plan"])])); + + yield* handle.set({ key: "k0", text: "build" }); + assert.deepStrictEqual(yield* client.next, chunk("1", [encodedEntry(["build"])])); + yield* handle.clear("k0"); + assert.deepStrictEqual(yield* client.next, chunk("1", [])); + + // A reconnect is a new subscription whose first frame is the current state. + yield* handle.set({ key: "k0", text: "review" }); + assert.deepStrictEqual(yield* client.next, chunk("1", [encodedEntry(["review"])])); + yield* client.subscribe("2"); + assert.deepStrictEqual(yield* client.next, chunk("2", [encodedEntry(["review"])])); + + // An interrupted subscription stops receiving frames; the other keeps going. + yield* client.interrupt("1"); + assert.strictEqual((yield* client.next)._tag, "Exit"); + yield* handle.set({ key: "k0", text: "done" }); + assert.deepStrictEqual(yield* client.next, chunk("2", [encodedEntry(["done"])])); + assert.strictEqual(yield* client.pending, 0); + }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), + ); + + it.effect("refuses a client without the orchestration read scope", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const handle = yield* store.openSource(SOURCE); + yield* handle.bindThread(THREAD); + yield* handle.set({ key: "k0", text: "plan" }); + const client = yield* serveStatus(store, [AuthRelayReadScope]); + + yield* client.subscribe("1"); + assert.deepStrictEqual(yield* client.next, { + _tag: "Exit", + requestId: "1", + exit: { + _tag: "Failure", + cause: [ + { + _tag: "Fail", + error: { + _tag: "EnvironmentAuthorizationError", + message: `The authenticated token is missing required scope: ${AuthOrchestrationReadScope}.`, + requiredPermission: AuthOrchestrationReadScope, + requiredScope: AuthOrchestrationReadScope, + }, + }, + ], + }, + }); + }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), + ); + + it.effect("shares one store between provider adapters and the WebSocket stream", () => + Effect.gen(function* () { + // Mirrors server.ts: adapters get the store through the provider + // infrastructure inside an unwrapped instance-registry layer, while the + // WebSocket layer reads it from the runtime's own reference. + const producer = Layer.unwrap( + Effect.succeed( + Layer.effectDiscard( + Effect.gen(function* () { + const store = yield* ContributionStatusStore.ContributionStatusStore; + const handle = yield* store.openSource(SOURCE); + yield* handle.bindThread(THREAD); + yield* handle.set({ key: "k0", text: "from adapter" }); + }), + ).pipe(Layer.provide(ProviderOrchestrationAdapterInfrastructure.layer)), + ), + ); + // As in server.ts, the registry layer sits below the store reference, so + // the producer only sees the store its own infrastructure provides. + const runtime = ContributionStatusStore.layer.pipe( + Layer.provideMerge(producer), + Layer.provide( + Layer.merge( + NodeServices.layer, + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ), + ); + + const snapshot = yield* Effect.gen(function* () { + const store = yield* ContributionStatusStore.ContributionStatusStore; + return yield* store.snapshot; + }).pipe(Effect.provide(runtime)); + assert.deepStrictEqual(snapshot.entries, [ + { threadId: THREAD, source: SOURCE, items: [{ key: "k0", text: "from adapter" }] }, + ]); + }), + ); +}); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index a55b1098d5f0..2fa19b4d42bf 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -253,6 +253,7 @@ export const make = Effect.gen(function* () { serverResolvedCommandContext: true, environmentIcon: true, projectCloneTracking: true, + contributionStatus: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverInstallation === null ? {} : { serverInstallation }), // V2 restart recovery uses the environment-owned opt-in. The old diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 167ce8d11fff..3f725fff4a71 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -160,6 +160,7 @@ const RPC_AGGREGATES = { [WS_METHODS.subscribeWorktreeSetup]: "vcs", [WS_METHODS.worktreeSetupCancel]: "vcs", [WS_METHODS.subscribeResourceTelemetry]: "server", + [WS_METHODS.subscribeContributionStatus]: "server", [WS_METHODS.vcsRefreshStatus]: "vcs", [WS_METHODS.vcsPull]: "git", [WS_METHODS.gitRunStackedAction]: "vcs", diff --git a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts index f312b3d58320..12b71f401d2e 100644 --- a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts +++ b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts @@ -1,5 +1,6 @@ import * as Layer from "effect/Layer"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk"; @@ -20,7 +21,8 @@ export type ProviderOrchestrationAdapterInfrastructure = * Infrastructure shared by the V2 adapters materialized inside provider * instances. `providerContinuationRequestsLayer` must be the same layer * reference the orchestration runtime provides to its continuation worker so - * Effect layer memoization yields one shared queue. + * Effect layer memoization yields one shared queue. The contribution status + * store follows the same rule with the WebSocket server that streams it. */ export const layer = Layer.mergeAll( ClaudeAdapterV2.layerQueryRunner, @@ -29,4 +31,5 @@ export const layer = Layer.mergeAll( CursorKeychain.layer, IdAllocator.layer, ProviderContinuationRequests.layer, + ContributionStatusStore.layer, ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3c1539a70985..807b9395c3a3 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -26,6 +26,7 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as HostPowerMonitor from "./background/HostPowerMonitor.ts"; import * as ServerConfig from "./config.ts"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; import { withUntracedRequests } from "./http.ts"; import * as ServerHttp from "./http.ts"; import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; @@ -595,6 +596,9 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(layerGit), Layer.provideMerge(layerVcs), Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), + // The same layer reference provider adapters write through, so memoization + // gives producers and the WebSocket stream one store. + Layer.provideMerge(ContributionStatusStore.layer), Layer.provideMerge(layerPersistence), // Both read a user-owned file out of the state directory and stream changes // to clients; neither depends on the other. diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 375c2ac78aa1..88b7e452addc 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -89,7 +89,7 @@ import { ChatAttachmentId, PersistChatAttachmentsError, RpcClientId, - EnvironmentAuthorizationError, + type EnvironmentAuthorizationError, type ProjectId, type ProviderDriverKind, type ProviderInstanceId, @@ -212,6 +212,7 @@ import * as DirectEndpoints from "./environment/DirectEndpoints.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as DefectReporter from "./observability/DefectReporter.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { requiredScopeForDeviceList, rpcAuthorizationError } from "./auth/RpcAuthorization.ts"; import * as RpcAuthorization from "./auth/RpcAuthorization.ts"; @@ -1327,6 +1328,7 @@ const layerWsRpc = ( const hostResources = yield* HostResources.HostResources; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; + const contributionStatus = yield* ContributionStatusStore.ContributionStatusStore; const relayClient = yield* RelayClient.RelayClient; // A webhook URL starts agent runs, so only sessions that may operate // see it; read-only sessions still see the task itself. @@ -3133,6 +3135,8 @@ const layerWsRpc = ( Stream.concat(Stream.make(latest), changes), ), ), + [WS_METHODS.subscribeContributionStatus]: (_input) => + ContributionStatusStore.subscriptionStream(contributionStatus), }); return handlers; }), diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index c582e5a5b6c8..63ef0c3d0778 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -54,6 +54,16 @@ export type OpenPreviewMutation = (input: { readonly input: PreviewOpenInput; }) => Promise>; +/** + * False once the caller has left the thread it started in. Work that has not + * asked the server for a browser by then ends as an interruption. A browser the + * server already opened is still applied to the thread it was opened for, so + * its session never lingers unseen. + */ +type ScopeCheck = (() => boolean) | undefined; + +const leftScope = (isScopeCurrent: ScopeCheck) => isScopeCurrent?.() === false; + export async function openUrlInPreview(input: { readonly threadRef: ScopedThreadRef; readonly url: string; @@ -62,10 +72,12 @@ export async function openUrlInPreview(input: { readonly profileId?: PreviewOpenInput["profileId"]; /** Open the tab without switching the thread to it. */ readonly background?: boolean; + readonly isScopeCurrent?: ScopeCheck; }): Promise> { const defaults = await resolveBrowserDefaults().catch( (cause: unknown) => new BrowserSettingsReadError({ cause }), ); + if (leftScope(input.isScopeCurrent)) return AsyncResult.failure(Cause.interrupt()); if (defaults instanceof BrowserSettingsReadError) { return AsyncResult.failure(Cause.fail(defaults)); } @@ -124,6 +136,7 @@ export async function openFileInPreview(input: { readonly input: { readonly resource: AssetResource }; }) => Promise>; readonly openPreview: OpenPreviewMutation; + readonly isScopeCurrent?: ScopeCheck; }): Promise< AtomCommandResult< void, @@ -151,6 +164,7 @@ export async function openFileInPreview(input: { }, }, }); + if (leftScope(input.isScopeCurrent)) return AsyncResult.failure(Cause.interrupt()); if (assetResult._tag === "Failure") { return AsyncResult.failure(assetResult.cause); } @@ -164,5 +178,6 @@ export async function openFileInPreview(input: { threadRef: input.threadRef, url: assetUrl, openPreview: input.openPreview, + isScopeCurrent: input.isScopeCurrent, }); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b760810a3876..6003215f4f8e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -70,7 +70,6 @@ import { type PreviewAnnotationPayload, ProviderInstanceId, type ServerProvider, - type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, type ThreadLinkedPullRequest, @@ -151,10 +150,7 @@ import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/reactivity"; import { Fragment, - lazy, - memo, type SetStateAction, - Suspense, useCallback, useEffect, useEffectEvent, @@ -167,6 +163,15 @@ import { flushSync } from "react-dom"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; import { isMacPlatform } from "../lib/utils"; +import { RegisteredSidePanel } from "~/panels/bundledPanels"; +import { + PanelHostContext, + threadBoundAnnotationSender, + type PanelHost, + type ThreadAnnotationSender, +} from "~/panels/panelHost"; +import { PersistentThreadTerminalDrawer } from "~/panels/terminal/PersistentThreadTerminalDrawer"; +import type { TerminalLaunchContext } from "~/panels/terminal/TerminalSidePanel"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; import { @@ -283,12 +288,8 @@ import { pullRequestPanelContext, threadPullRequestPanelTarget, } from "./pullRequest/pullRequestDetail.logic"; -import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; -import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; -import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; import { RightPanelTabs } from "./RightPanelTabs"; import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; -import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; import { useDeviceState } from "~/state/device"; import { DeviceSetup } from "./device/DeviceSetup"; import { Dialog } from "./ui/dialog"; @@ -298,7 +299,6 @@ import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { isEditableFocused } from "../lib/editableFocus"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { resolveChatShortcutCommand, shortcutLabelForCommand } from "../keybindings"; -import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { AlarmClockIcon, CheckCircle2Icon, @@ -701,16 +701,8 @@ function useDraftHeroLayoutTransition( } as const; } -const PreviewPanel = lazy(() => - import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), -); -const DiffPanel = lazy(() => import("./DiffPanel")); const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPreview: boolean }) => settings.browserAutoShowFloatingPreview; -const DevicePanel = lazy(() => - import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })), -); -const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ "input", @@ -847,14 +839,6 @@ type ChatViewProps = draftId: DraftId; }; -interface TerminalLaunchContext { - threadId: ThreadId; - cwd: string; - worktreePath: string | null; -} - -type PersistentTerminalLaunchContext = Pick; - function useLocalDispatchState(input: { activeThread: Thread | undefined; activeLatestRun: Thread["latestRun"] | null; @@ -925,619 +909,6 @@ function useLocalDispatchState(input: { }; } -/** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */ -function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean { - if (left.length !== right.length) { - return false; - } - if (left.length === 0) { - return true; - } - const sortedLeft = left.toSorted((a, b) => a.localeCompare(b)); - const sortedRight = right.toSorted((a, b) => a.localeCompare(b)); - for (let index = 0; index < sortedLeft.length; index += 1) { - if (sortedLeft[index] !== sortedRight[index]) { - return false; - } - } - return true; -} - -/** - * Server knows about fewer sessions than the client, but every server id still exists locally. - * Typical right after `terminal.open`: known-session list lags; reconciling would drop the new id - * and later re-add it as a separate group (no split layout). - */ -function serverTerminalIdsStrictSubsetOfClient( - serverIds: readonly string[], - clientIds: readonly string[], -): boolean { - if (serverIds.length >= clientIds.length || clientIds.length === 0) { - return false; - } - const clientSet = new Set(clientIds); - for (const id of serverIds) { - if (!clientSet.has(id)) { - return false; - } - } - return true; -} - -interface PersistentThreadTerminalDrawerProps { - threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; - threadId: ThreadId; - active: boolean; - launchContext: PersistentTerminalLaunchContext | null; - focusRequestId: number; - splitShortcutLabel: string | undefined; - splitVerticalShortcutLabel: string | undefined; - newShortcutLabel: string | undefined; - closeShortcutLabel: string | undefined; - keybindings: ResolvedKeybindingsConfig; - onAddTerminalContext: (selection: TerminalContextSelection) => void; -} - -const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ - threadRef, - threadId, - active, - launchContext, - focusRequestId, - splitShortcutLabel, - splitVerticalShortcutLabel, - newShortcutLabel, - closeShortcutLabel, - keybindings, - onAddTerminalContext, -}: PersistentThreadTerminalDrawerProps) { - const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); - const hasTerminalWriteAccess = useCallback( - () => readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope), - [threadRef.environmentId], - ); - const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); - const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); - const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); - const serverThread = useThreadShell(threadRef); - const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const projectRef = serverThread - ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) - : draftThread - ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) - : null; - const project = useProject(projectRef); - const terminalUiState = useTerminalUiStateStore((state) => - selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), - ); - const visible = active && terminalUiState.terminalOpen; - const knownTerminalSessions = useKnownTerminalSessions({ - environmentId: threadRef.environmentId, - threadId, - }); - const panelSurfaces = useRightPanelStore( - (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, - ); - const panelTerminalIds = useMemo( - () => - new Set( - panelSurfaces.flatMap((surface) => - surface.kind === "terminal" ? surface.terminalIds : [], - ), - ), - [panelSurfaces], - ); - const drawerTerminalSessions = useMemo( - () => - knownTerminalSessions?.filter( - (session) => !panelTerminalIds.has(session.target.terminalId), - ) ?? [], - [knownTerminalSessions, panelTerminalIds], - ); - const terminalLabelsById = useMemo(() => { - const next = new Map(); - for (const session of drawerTerminalSessions) { - next.set( - session.target.terminalId, - resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), - ); - } - return next; - }, [drawerTerminalSessions]); - const terminalLaunchLocationsById = useMemo(() => { - const next = new Map< - string, - { - readonly cwd: string; - readonly worktreePath: string | null; - readonly runtimeEnv: Record; - } - >(); - if (!project) { - return next; - } - - for (const session of drawerTerminalSessions) { - const summary = session.state.summary; - if (!summary) { - continue; - } - const worktreePathForLaunch = - launchContext !== null ? launchContext.worktreePath : summary.worktreePath; - next.set(session.target.terminalId, { - cwd: launchContext?.cwd ?? summary.cwd, - worktreePath: worktreePathForLaunch, - runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: worktreePathForLaunch, - }), - }); - } - - return next; - }, [drawerTerminalSessions, launchContext, project]); - const serverOrderedTerminalIds = useMemo( - () => drawerTerminalSessions.map((session) => session.target.terminalId), - [drawerTerminalSessions], - ); - // Every client-side id source participates in allocation: the server list - // lags fresh opens, and panel terminals are filtered out of the drawer's - // sessions — an id collision attaches two viewports to one PTY session. - const allocatableTerminalIds = useMemo( - () => [ - ...new Set([ - ...serverOrderedTerminalIds, - ...terminalUiState.terminalIds, - ...panelTerminalIds, - ]), - ], - [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds], - ); - const allocateTerminalId = useCallback( - () => - nextTerminalId( - allocatableTerminalIds, - knownTerminalSessions === null || - !readEnvironmentScope(threadRef.environmentId, AuthTerminalReadScope) - ? randomUUID() - : undefined, - ), - [allocatableTerminalIds, knownTerminalSessions, threadRef.environmentId], - ); - const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); - const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); - const storeSplitTerminalVertical = useTerminalUiStateStore( - (state) => state.splitTerminalVertical, - ); - const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); - const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); - const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); - const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); - - useEffect(() => { - if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) { - return; - } - if ( - serverTerminalIdsStrictSubsetOfClient(serverOrderedTerminalIds, terminalUiState.terminalIds) - ) { - return; - } - reconcileTerminalIds(threadRef, serverOrderedTerminalIds); - }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]); - const [localFocusRequestId, setLocalFocusRequestId] = useState(0); - const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const effectiveWorktreePath = useMemo(() => { - if (launchContext !== null) { - return launchContext.worktreePath; - } - return worktreePath; - }, [launchContext, worktreePath]); - const cwd = useMemo( - () => - launchContext?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.workspaceRoot }, - worktreePath: effectiveWorktreePath, - }) - : null), - [effectiveWorktreePath, launchContext?.cwd, project], - ); - const runtimeEnv = useMemo( - () => - project - ? projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: effectiveWorktreePath, - }) - : {}, - [effectiveWorktreePath, project], - ); - - const bumpFocusRequestId = useCallback(() => { - if (!visible) { - return; - } - setLocalFocusRequestId((value) => value + 1); - }, [visible]); - - const setTerminalHeight = useCallback( - (height: number) => { - storeSetTerminalHeight(threadRef, height); - }, - [storeSetTerminalHeight, threadRef], - ); - - const splitTerminal = useCallback(() => { - if (!hasTerminalWriteAccess() || !cwd) { - return; - } - const terminalId = allocateTerminalId(); - storeSplitTerminal(threadRef, terminalId); - bumpFocusRequestId(); - void openTerminal({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }, - }); - }, [ - allocateTerminalId, - bumpFocusRequestId, - cwd, - effectiveWorktreePath, - runtimeEnv, - storeSplitTerminal, - threadId, - threadRef, - openTerminal, - hasTerminalWriteAccess, - ]); - const splitTerminalVertical = useCallback(() => { - if (!hasTerminalWriteAccess() || !cwd) { - return; - } - const terminalId = allocateTerminalId(); - storeSplitTerminalVertical(threadRef, terminalId); - bumpFocusRequestId(); - void openTerminal({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }, - }); - }, [ - allocateTerminalId, - bumpFocusRequestId, - cwd, - effectiveWorktreePath, - openTerminal, - hasTerminalWriteAccess, - runtimeEnv, - storeSplitTerminalVertical, - threadId, - threadRef, - ]); - - const createNewTerminal = useCallback(() => { - if (!hasTerminalWriteAccess() || !cwd) { - return; - } - const terminalId = allocateTerminalId(); - storeNewTerminal(threadRef, terminalId); - bumpFocusRequestId(); - void openTerminal({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, - }, - }); - }, [ - bumpFocusRequestId, - cwd, - effectiveWorktreePath, - allocateTerminalId, - runtimeEnv, - storeNewTerminal, - threadId, - threadRef, - openTerminal, - hasTerminalWriteAccess, - ]); - - const activateTerminal = useCallback( - (terminalId: string) => { - storeSetActiveTerminal(threadRef, terminalId); - bumpFocusRequestId(); - }, - [bumpFocusRequestId, storeSetActiveTerminal, threadRef], - ); - - const closeTerminal = useCallback( - (terminalId: string) => { - if (!hasTerminalWriteAccess()) return; - const fallbackExitWrite = () => - writeTerminal({ - environmentId: threadRef.environmentId, - input: { threadId, terminalId, data: "exit\n" }, - }); - - void (async () => { - const closeResult = await closeTerminalMutation({ - environmentId: threadRef.environmentId, - input: { - threadId, - terminalId, - deleteHistory: true, - }, - }); - if ( - closeResult._tag === "Failure" && - !isAtomCommandInterrupted(closeResult) && - hasTerminalWriteAccess() - ) { - await fallbackExitWrite(); - } - })(); - - storeCloseTerminal(threadRef, terminalId); - bumpFocusRequestId(); - }, - [ - bumpFocusRequestId, - storeCloseTerminal, - threadId, - threadRef, - closeTerminalMutation, - hasTerminalWriteAccess, - writeTerminal, - ], - ); - - const handleAddTerminalContext = useCallback( - (selection: TerminalContextSelection) => { - if (!visible) { - return; - } - onAddTerminalContext(selection); - }, - [onAddTerminalContext, visible], - ); - - if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) { - return null; - } - - return ( -
-
- -
-
- ); -}); - -interface PersistentThreadTerminalPanelProps { - visible: boolean; - threadRef: ScopedThreadRef; - surface: Extract; - launchContext: PersistentTerminalLaunchContext | null; - focusRequestId: number; - keybindings: ResolvedKeybindingsConfig; - onAddTerminalContext: (selection: TerminalContextSelection) => void; - onSplitTerminal: () => void; - onSplitTerminalVertical: () => void; - onNewTerminal: () => void; - onActiveTerminalChange: (terminalId: string) => void; - onCloseTerminal: (terminalId: string) => void; - splitShortcutLabel?: string | undefined; - splitVerticalShortcutLabel?: string | undefined; - newShortcutLabel?: string | undefined; - closeShortcutLabel?: string | undefined; -} - -const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ - visible, - threadRef, - surface, - launchContext, - focusRequestId, - keybindings, - onAddTerminalContext, - onSplitTerminal, - onSplitTerminalVertical, - onNewTerminal, - onActiveTerminalChange, - onCloseTerminal, - splitShortcutLabel, - splitVerticalShortcutLabel, - newShortcutLabel, - closeShortcutLabel, -}: PersistentThreadTerminalPanelProps) { - const serverThread = useThreadShell(threadRef); - const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const projectRef = serverThread - ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) - : draftThread - ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) - : null; - const project = useProject(projectRef); - const knownTerminalSessions = useKnownTerminalSessions({ - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - }); - const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const activeSummary = - knownTerminalSessions?.find((session) => session.target.terminalId === surface.activeTerminalId) - ?.state.summary ?? null; - const worktreePath = - launchContext?.worktreePath ?? activeSummary?.worktreePath ?? threadWorktreePath; - const cwd = useMemo( - () => - launchContext?.cwd ?? - activeSummary?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.workspaceRoot }, - worktreePath, - }) - : null), - [activeSummary?.cwd, launchContext?.cwd, project, worktreePath], - ); - const runtimeEnv = useMemo( - () => - project - ? projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath, - }) - : {}, - [project, worktreePath], - ); - const terminalLabelsById = useMemo(() => { - const labels = new Map(); - for (const terminalId of surface.terminalIds) { - const summary = - knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state - .summary ?? null; - labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); - } - return labels; - }, [knownTerminalSessions, surface.terminalIds]); - const terminalLaunchLocationsById = useMemo(() => { - const locations = new Map< - string, - { - readonly cwd: string; - readonly worktreePath: string | null; - readonly runtimeEnv: Record; - } - >(); - for (const terminalId of surface.terminalIds) { - const summary = - knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state - .summary ?? null; - const terminalWorktreePath = - launchContext?.worktreePath ?? summary?.worktreePath ?? threadWorktreePath; - const terminalCwd = - launchContext?.cwd ?? - summary?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.workspaceRoot }, - worktreePath: terminalWorktreePath, - }) - : null); - if (!terminalCwd || !project) continue; - locations.set(terminalId, { - cwd: terminalCwd, - worktreePath: terminalWorktreePath, - runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: terminalWorktreePath, - }), - }); - } - return locations; - }, [ - knownTerminalSessions, - launchContext?.cwd, - launchContext?.worktreePath, - project, - surface.terminalIds, - threadWorktreePath, - ]); - - if (!project || !cwd) return null; - - return ( - undefined} - onAddTerminalContext={onAddTerminalContext} - terminalLabelsById={terminalLabelsById} - terminalLaunchLocationsById={terminalLaunchLocationsById} - keybindings={keybindings} - /> - ); -}); - // Errors surface through two maps (draft-keyed and thread-keyed) whose entries // can race around promotion, so each write carries its time to let the latest // one win when they collide. @@ -5596,13 +4967,6 @@ export default function ChatView(props: ChatViewProps) { ); if (!sessionStillExists) usePreviewMiniPlayerStore.getState().close(activeThreadRef); }, [activePreviewMiniPlayer, activeThreadRef, deviceState.sessions, deviceStateLoaded]); - const openFileSurface = useCallback( - (relativePath: string) => { - if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().openFile(activeThreadRef, relativePath); - }, - [activeProject, activeThreadRef], - ); // The thread's own change request, placed against the project it belongs to. Without a // project there is nothing to resolve it against, so the caller falls back to the browser. const persistedLinkedThreadPullRequest = isServerThread @@ -11073,33 +10437,62 @@ export default function ChatView(props: ChatViewProps) { pendingSidebarFileDrops, ]); + // Plain server threads share one ChatView, so the sender carries its thread + // and a host only forwards to a sender for the thread it was built for. + // Updated after commit so a discarded render cannot lend its `onSend`. + const annotationSenderRef = useRef(null); + useLayoutEffect(() => { + annotationSenderRef.current = activeThreadKey + ? { + threadKey: activeThreadKey, + send: (annotation, image) => { + void onSend(undefined, "auto", "foreground", { annotation, image }); + }, + } + : null; + }); + // Memoized so mounted panels re-render only when a host field changes. + const panelHost = useMemo( + () => + activeThreadRef + ? { + threadRef: activeThreadRef, + visible: rightPanelOpen, + composerDraftTarget, + workspaceMutationId, + sendAnnotation: threadBoundAnnotationSender( + () => annotationSenderRef.current, + scopedThreadKey(activeThreadRef), + ), + } + : null, + [ + activeThreadRef, + annotationSenderRef, + composerDraftTarget, + rightPanelOpen, + workspaceMutationId, + ], + ); + // Empty state: no active thread if (!activeThread) { return ; } - const rightPanelContent = activeThreadRef ? ( + const rightPanelSurfaceContent = activeThreadRef ? ( renderedRightPanelSurface?.kind === "preview" ? ( - - { - void onSend(undefined, "auto", "foreground", { annotation, image }); - }} - /> - + ) : renderedRightPanelSurface?.kind === "terminal" ? ( - ) : renderedRightPanelSurface?.kind === "diff" ? ( - - - - ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( - - ) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( - + ) : renderedRightPanelSurface?.kind === "pull-request" ? ( - // No onClose: the surface tab's own X owns closing here, and a second X in the header - // would be the same action twice. The thread context also drops the checkout button, so it - // is only right for the thread's own pull request, whose branch is already under the - // reader's feet. A link the agent wrote can open any other one here, and that one has to be - // checkable out like it is anywhere else. - { - if (activeThreadRef) - useRightPanelStore.getState().openPullRequest(activeThreadRef, { - projectId: reference.projectId, - repository: reference.repository, - number: reference.number, - ...(reference.host ? { host: reference.host } : {}), - }); - }} - threadRef={activeThreadRef} reference={{ projectId: renderedRightPanelSurface.projectId as ProjectId, ...(renderedRightPanelSurface.host ? { host: renderedRightPanelSurface.host } : {}), @@ -11166,76 +10533,75 @@ export default function ChatView(props: ChatViewProps) { }, renderedRightPanelSurface, )} - composerDraftTarget={composerDraftTarget} onBack={ activeThreadRef !== null && pullRequestsSurfaceAvailable && visiblePullRequestCount > 1 ? addPullRequestsSurface : undefined } /> - ) : renderedRightPanelSurface?.kind === "pull-requests" && activeThreadRef ? ( - + ) : renderedRightPanelSurface?.kind === "pull-requests" ? ( + ) : renderedRightPanelSurface?.kind === "device" ? ( - - { - closeRightPanelSurface(renderedRightPanelSurface); - useRightPanelStore.getState().show(activeThreadRef); - }} - /> - + { + closeRightPanelSurface(renderedRightPanelSurface); + useRightPanelStore.getState().show(activeThreadRef); + }} + /> ) : (renderedRightPanelSurface?.kind === "files" || renderedRightPanelSurface?.kind === "file") && ((activeProject && activeWorkspaceRoot) || (renderedRightPanelSurface.kind === "file" && renderedRightPanelSurface.attachment)) ? ( - - - + ) : null ) : null; + const rightPanelContent = ( + {rightPanelSurfaceContent} + ); + const sidePanelLaunchers = { + preview: { available: canOperatePreview && browserAvailable, onOpen: createBrowserSurface }, + diff: { available: isServerThread && isGitRepo, onOpen: addDiffSurface }, + terminal: { + available: activeProject !== null && canOperateTerminal, + onOpen: addTerminalSurface, + }, + files: { available: activeProject !== null, onOpen: addFilesSurface }, + device: { available: activeThreadRef !== null, onOpen: addDeviceSurface }, + "pull-request": { available: pullRequestSurfaceAvailable, onOpen: addPullRequestSurface }, + "pull-requests": { available: pullRequestsSurfaceAvailable, onOpen: addPullRequestsSurface }, + }; const threadDetailsPanelProps: ThreadDetailsPanelProps = { anchor: threadPanelPopoverAnchorRef, handle: threadPanelPopoverHandle, @@ -12056,21 +11422,8 @@ export default function ChatView(props: ChatViewProps) { onCloseAllSurfaces={closeAllRightPanelSurfaces} onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={() => createBrowserSurface()} + panels={sidePanelLaunchers} onAddBrowserInProfile={createBrowserSurface} - onAddTerminal={addTerminalSurface} - onAddDiff={addDiffSurface} - onAddFiles={addFilesSurface} - onAddPullRequest={addPullRequestSurface} - onAddPullRequests={addPullRequestsSurface} - onAddDevice={addDeviceSurface} - browserAvailable={canOperatePreview && browserAvailable} - terminalAvailable={activeProject !== null && canOperateTerminal} - diffAvailable={isServerThread && isGitRepo} - filesAvailable={activeProject !== null} - pullRequestAvailable={pullRequestSurfaceAvailable} - pullRequestsAvailable={pullRequestsSurfaceAvailable} - deviceAvailable={activeThreadRef !== null} > {rightPanelContent} @@ -12115,21 +11468,8 @@ export default function ChatView(props: ChatViewProps) { onCloseAllSurfaces={closeAllRightPanelSurfaces} onMoveSurface={moveRightPanelSurface} onCopyFilePath={copyRightPanelFilePath} - onAddBrowser={() => createBrowserSurface()} + panels={sidePanelLaunchers} onAddBrowserInProfile={createBrowserSurface} - onAddTerminal={addTerminalSurface} - onAddDiff={addDiffSurface} - onAddFiles={addFilesSurface} - onAddPullRequest={addPullRequestSurface} - onAddPullRequests={addPullRequestsSurface} - onAddDevice={addDeviceSurface} - browserAvailable={canOperatePreview && browserAvailable} - terminalAvailable={activeProject !== null && canOperateTerminal} - diffAvailable={isServerThread && isGitRepo} - filesAvailable={activeProject !== null} - pullRequestAvailable={pullRequestSurfaceAvailable} - pullRequestsAvailable={pullRequestsSurfaceAvailable} - deviceAvailable={activeThreadRef !== null} > {rightPanelContent} diff --git a/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx b/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx new file mode 100644 index 000000000000..96ae673b849d --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.browserProfile.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment jsdom + +import { + BUILT_IN_BROWSER_PROFILES, + DEFAULT_BROWSER_PROFILE_ID, + INCOGNITO_BROWSER_PROFILE_ID, +} from "@t3tools/contracts"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +// The profile list normally comes from client settings; the built-ins are enough to choose from. +vi.mock("~/browser/browserDefaults", async (importOriginal) => ({ + ...(await importOriginal()), + useBrowserDefaults: () => ({ profiles: BUILT_IN_BROWSER_PROFILES }), +})); + +import { RightPanelTabs } from "./RightPanelTabs"; + +let root: Root; +let container: HTMLDivElement; +let opened: string[]; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + // Browser is desktop-only; the launcher offers it once the preview bridge exists. + vi.stubGlobal("desktopBridge", { preview: {} }); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + // jsdom lacks the Web Animations API that the tab bar's scroll area waits on. + Element.prototype.getAnimations ??= () => []; + opened = []; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +// Records Browser opens the way ChatView's createBrowserSurface does: one +// handler for the default open and the profile chooser, where an omitted +// profile means the default one. +function Harness() { + const openBrowser = (profileId?: string) => { + opened.push(profileId ?? DEFAULT_BROWSER_PROFILE_ID); + }; + return ( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + surfaces={[]} + environmentId={null} + activeSurfaceId={null} + pendingSurfaceIds={new Set()} + previewSessions={{}} + desktopByTabId={{}} + terminalLabelsById={new Map()} + onActivate={() => undefined} + onCloseSurface={() => undefined} + onCloseOtherSurfaces={() => undefined} + onCloseSurfacesToRight={() => undefined} + onCloseAllSurfaces={() => undefined} + onCopyFilePath={() => undefined} + panels={{ + preview: { available: true, onOpen: openBrowser }, + diff: { available: false, onOpen: () => undefined }, + 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 }, + }} + onAddBrowserInProfile={openBrowser} + > + {null} + + ); +} + +function launcherRow(label: string): HTMLButtonElement { + const launcher = container.querySelector('[aria-label="Open a surface"]'); + const row = [...(launcher?.querySelectorAll("button") ?? [])].find((button) => + button.textContent?.startsWith(label), + ); + if (!row) throw new Error(`No launcher row ${label}`); + return row; +} + +function menuItem(label: string): HTMLElement { + const item = [...document.querySelectorAll('[role="menuitem"]')].find( + (element) => element.textContent === label, + ); + if (!item) throw new Error(`No menu item ${label}`); + return item; +} + +async function click(element: Element) { + await act(async () => { + element.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +describe("opening Browser from the launcher", () => { + it("opens the default profile from the row and another profile from the chooser", async () => { + await act(async () => root.render()); + + await click(launcherRow("Browser")); + expect(opened).toEqual([DEFAULT_BROWSER_PROFILE_ID]); + + await click(container.querySelector('[aria-label="Open browser in a profile"]')!); + await click(menuItem("Incognito")); + expect(opened).toEqual([DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID]); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx index e7a952ba43c5..d7b38b0e5a75 100644 --- a/apps/web/src/components/RightPanelTabs.keyboard.test.tsx +++ b/apps/web/src/components/RightPanelTabs.keyboard.test.tsx @@ -76,21 +76,16 @@ async function renderPanel(overrides: Partial content diff --git a/apps/web/src/components/RightPanelTabs.terminal.test.tsx b/apps/web/src/components/RightPanelTabs.terminal.test.tsx new file mode 100644 index 000000000000..df3ab0a2f327 --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.terminal.test.tsx @@ -0,0 +1,137 @@ +// @vitest-environment jsdom + +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; + +import { RightPanelTabs } from "./RightPanelTabs"; + +const threadRef: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), +}; + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + // jsdom lacks the Web Animations API that the tab bar's scroll area waits on. + Element.prototype.getAnimations ??= () => []; + useRightPanelStore.setState({ byThreadKey: {} }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); +}); + +// Shows the thread's surfaces from the real right-panel store and opens the +// terminal into it the way ChatView's addTerminalSurface does. +function Harness({ terminalAvailable }: { terminalAvailable: boolean }) { + const surfaces = useRightPanelStore( + (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, + ); + return ( + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + surfaces={surfaces} + environmentId={threadRef.environmentId} + activeSurfaceId={surfaces[0]?.id ?? null} + pendingSurfaceIds={new Set()} + previewSessions={{}} + desktopByTabId={{}} + terminalLabelsById={new Map()} + onActivate={() => undefined} + onCloseSurface={() => undefined} + onCloseOtherSurfaces={() => undefined} + onCloseSurfacesToRight={() => undefined} + onCloseAllSurfaces={() => undefined} + onCopyFilePath={() => undefined} + panels={{ + preview: { available: false, onOpen: () => undefined }, + diff: { available: false, onOpen: () => undefined }, + terminal: { + available: terminalAvailable, + onOpen: () => useRightPanelStore.getState().openTerminal(threadRef, "term-1"), + }, + device: { available: false, onOpen: () => undefined }, + "pull-request": { available: false, onOpen: () => undefined }, + "pull-requests": { available: false, onOpen: () => undefined }, + files: { available: false, onOpen: () => undefined }, + }} + onAddBrowserInProfile={() => undefined} + > + {null} + + ); +} + +function launcherRow(label: string): HTMLElement { + const launcher = container.querySelector('[aria-label="Open a surface"]'); + // Unavailable rows render as aria-disabled elements rather than buttons. + const row = [ + ...(launcher?.querySelectorAll('button, [aria-disabled="true"]') ?? []), + ].find((element) => element.textContent?.startsWith(label)); + if (!row) throw new Error(`No launcher row ${label}`); + return row; +} + +async function click(element: Element) { + await act(async () => { + element.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("pointerup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +const threadSurfaces = () => + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef).surfaces; + +describe("opening Terminal from the launcher", () => { + it("opens a terminal surface from the row when a project allows it", async () => { + await act(async () => root.render()); + + await click(launcherRow("Terminal")); + expect(threadSurfaces()).toMatchObject([{ kind: "terminal", activeTerminalId: "term-1" }]); + expect(container.querySelector('[aria-label="Open a surface"]')).toBeNull(); + expect(container.textContent).toContain("Terminal 1"); + }); + + it("keeps the row disabled and opens nothing without a project", async () => { + await act(async () => root.render()); + + const row = launcherRow("Terminal"); + expect(row.getAttribute("aria-disabled")).toBe("true"); + await click(row); + expect(threadSurfaces()).toEqual([]); + expect(container.querySelector('[aria-label="Open a surface"]')).not.toBeNull(); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 823028eddff1..f45ca4e0011d 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -2,11 +2,12 @@ import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts"; import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { RightPanelTabs, resolvePullRequestTabLink, + rightPanelSurfaceActions, shouldOpenDefaultBrowserProfileFromMenuClick, surfaceShortcutActionForKey, surfaceShortcutTargetsTypingContext, @@ -21,6 +22,41 @@ describe("browser profile submenu", () => { }); }); +describe("right panel surface actions", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const inputs = () => ({ + panels: { + preview: { available: true, onOpen: () => undefined }, + diff: { available: true, onOpen: () => undefined }, + terminal: { available: true, onOpen: () => undefined }, + device: { available: true, onOpen: () => undefined }, + "pull-request": { available: true, onOpen: () => undefined }, + "pull-requests": { available: true, onOpen: () => undefined }, + files: { available: true, onOpen: () => undefined }, + }, + }); + + it("keeps launcher order, letters and copy for registered and local surfaces", () => { + const actions = rightPanelSurfaceActions(inputs()); + expect(actions.map((action) => [action.shortcut, action.label])).toEqual([ + ["B", "Browser"], + ["T", "Terminal"], + ["F", "Files"], + ["D", "Diff"], + ["P", "Pull request"], + ["L", "Linked pull requests"], + ["M", "Device"], + ]); + expect(actions.find((action) => action.id === "diff")).toMatchObject({ + unavailableHint: "Available for Git repositories.", + unavailableReason: "Diff is only available for server threads in Git repositories.", + }); + }); +}); + function shortcutEvent( key: string, overrides: Partial[1]> = {}, @@ -125,21 +161,16 @@ function renderTabs( onCloseSurfacesToRight={() => undefined} onCloseAllSurfaces={() => undefined} onCopyFilePath={() => undefined} - onAddBrowser={() => undefined} + panels={{ + preview: { available: true, 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 }, + }} onAddBrowserInProfile={() => undefined} - onAddTerminal={() => undefined} - onAddPullRequest={() => undefined} - onAddPullRequests={() => undefined} - onAddDiff={() => undefined} - onAddFiles={() => undefined} - onAddDevice={() => undefined} - browserAvailable - terminalAvailable={false} - diffAvailable={false} - filesAvailable={false} - pullRequestAvailable={false} - pullRequestsAvailable={false} - deviceAvailable={false} >
content
, diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 5c8323a7ae34..4cf3653e42ef 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -25,20 +25,11 @@ import { import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers"; import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { - Smartphone, - ChevronDown, - ChevronLeft, - ChevronRight, - FileDiff, - Files, - Globe2, - Plus, - TerminalSquare, -} from "lucide-react"; +import { ChevronDown, ChevronLeft, ChevronRight, Plus } from "lucide-react"; import { Volume2, VolumeOff } from "lucide"; import { type ComponentProps, + type ComponentType, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, @@ -52,6 +43,7 @@ import { } from "react"; import { isElectron } from "~/env"; +import { getSidePanelMetadata, type SidePanelId } from "~/panels/bundledPanels"; import type { DesktopPreviewOverlay } from "~/previewStateStore"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; @@ -130,26 +122,9 @@ interface RightPanelTabsProps { /** Tabs are draggable only when the owner can persist the new order. */ onMoveSurface?: (surfaceId: string, toIndex: number) => void; onCopyFilePath: (relativePath: string) => void; - onAddBrowser: () => void; - /** - * Separate from `onAddBrowser` on purpose: that one is passed directly as a - * DOM click handler, and a `(profileId?: string)` signature would silently - * accept the MouseEvent as a profile id. - */ + /** Whether each registered panel can open here, and how; titles and icons come from its definition. */ + panels: Readonly>; onAddBrowserInProfile: (profileId: string) => void; - onAddTerminal: () => void; - onAddDiff: () => void; - onAddFiles: () => void; - onAddPullRequest: () => void; - onAddPullRequests: () => void; - onAddDevice: () => void; - browserAvailable: boolean; - terminalAvailable: boolean; - diffAvailable: boolean; - filesAvailable: boolean; - pullRequestAvailable: boolean; - pullRequestsAvailable: boolean; - deviceAvailable: boolean; pullRequestStatusSeeds?: Readonly>; children: ReactNode; } @@ -170,16 +145,6 @@ export function shouldOpenDefaultBrowserProfileFromMenuClick( return pointerType !== "touch"; } -const SURFACE_DISABLED_REASONS = { - browser: "Browser previews are only available in the T3 Code desktop app.", - terminal: "Terminal surfaces are only available from a project thread.", - files: "Files are only available when a project is open.", - diff: "Diff is only available for server threads in Git repositories.", - pullRequest: "This thread's branch has no pull request yet.", - pullRequests: "No linked pull requests are available for this thread.", - device: "Devices are only available from a thread.", -} as const; - /** Overlays that must win over the launcher's letter shortcuts. */ const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ '[data-slot="dialog-popup"]', @@ -192,16 +157,57 @@ const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ '[data-slot="autocomplete-popup"]', ].join(","); -/** One-line unavailability hints for the empty-state rows. */ -const SURFACE_UNAVAILABLE_HINTS = { - browser: "Only available in the desktop app.", - terminal: "Available when a project is open.", - files: "Available when a project is open.", - diff: "Available for Git repositories.", - pullRequest: "No pull request on this branch yet.", - pullRequests: "No linked pull requests available.", - device: "Available from a thread.", -} as const; +interface SidePanelLauncher { + available: boolean; + onOpen: () => void; +} + +interface SurfaceAction { + id: string; + label: string; + icon: ComponentType<{ className?: string }>; + shortcut: string; + available: boolean; + /** One-line reason for the empty launcher rows. */ + unavailableHint: string; + /** Full reason for the add menu tooltip. */ + unavailableReason: string; + onClick: () => void; +} + +type SurfaceActionInputs = Pick; + +/** + * The surfaces the empty launcher and the add menu offer, in launcher order. + * Registered panels describe themselves; the rest are listed here until they + * move onto the panel registry. + */ +export function rightPanelSurfaceActions(props: SurfaceActionInputs): SurfaceAction[] { + const registered = (id: SidePanelId): SurfaceAction => { + const panel = getSidePanelMetadata(id); + const launcher = props.panels[id]; + return { + id, + label: panel.title, + icon: panel.icon, + shortcut: panel.launcherKey, + available: (panel.isSupported?.() ?? true) && launcher.available, + unavailableHint: panel.unavailableHint, + unavailableReason: panel.unavailableReason, + // Never forward the click event as an argument. + onClick: () => launcher.onOpen(), + }; + }; + return [ + registered("preview"), + registered("terminal"), + registered("files"), + registered("diff"), + registered("pull-request"), + registered("pull-requests"), + registered("device"), + ]; +} type TabContextMenuAction = | "rename" @@ -332,87 +338,13 @@ function SurfaceMenuItem(props: { * surfaces stay visible with a one-line reason. */ function RightPanelEmptyState(props: { - onAddBrowser: () => void; + actions: readonly SurfaceAction[]; onAddBrowserInProfile: (profileId: string) => void; browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>; - onAddTerminal: () => void; - onAddDiff: () => void; - onAddFiles: () => void; - onAddPullRequest: () => void; - onAddPullRequests: () => void; - onAddDevice: () => void; - browserAvailable: boolean; - terminalAvailable: boolean; - diffAvailable: boolean; - filesAvailable: boolean; - pullRequestAvailable: boolean; - pullRequestsAvailable: boolean; - deviceAvailable: boolean; }) { // -1 means no highlight: it only appears on hover or arrow use. const [highlight, setHighlight] = useState(-1); - - const actions = [ - { - label: "Browser", - icon: Globe2, - shortcut: "B", - available: props.browserAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.browser, - onClick: props.onAddBrowser, - }, - { - label: "Terminal", - icon: TerminalSquare, - shortcut: "T", - available: props.terminalAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal, - onClick: props.onAddTerminal, - }, - { - label: "Files", - icon: Files, - shortcut: "F", - available: props.filesAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.files, - onClick: props.onAddFiles, - }, - { - label: "Diff", - icon: FileDiff, - shortcut: "D", - available: props.diffAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.diff, - onClick: props.onAddDiff, - }, - { - label: "Pull request", - icon: PullRequestGlyph.pullRequest, - shortcut: "P", - available: props.pullRequestAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest, - onClick: props.onAddPullRequest, - }, - { - label: "Linked pull requests", - icon: PullRequestGlyph.link, - shortcut: "L", - available: props.pullRequestsAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, - onClick: props.onAddPullRequests, - }, - { - label: "Device", - description: "Watch an iOS Simulator or Android Emulator.", - icon: Smartphone, - shortcut: "M", - available: props.deviceAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.device, - onClick: props.onAddDevice, - }, - ] as const; - - type SurfaceAction = (typeof actions)[number]; + const actions = props.actions; const availableActions = actions.filter((action) => action.available); const highlightIndex = @@ -511,7 +443,7 @@ function RightPanelEmptyState(props: { // wrapper: the chooser overlays the row, and a pointer moving // onto it must not read as leaving the row.
setHighlight(availableActions.indexOf(action))} onMouseLeave={() => @@ -532,7 +464,7 @@ function RightPanelEmptyState(props: { 1 && "pr-7", + action.id === "preview" && props.browserProfiles.length > 1 && "pr-7", )} > {action.label} @@ -544,7 +476,7 @@ function RightPanelEmptyState(props: { default profile, the chevron picks another. Only worth showing once there is something to choose between. */} - {action.label === "Browser" && props.browserProfiles.length > 1 ? ( + {action.id === "preview" && props.browserProfiles.length > 1 ? ( ) : ( 0) return snapshot.navStatus.title; try { - return new URL(snapshot.navStatus.url).host || "Browser"; + return new URL(snapshot.navStatus.url).host || fallback; } catch { - return "Browser"; + return fallback; } } } @@ -635,10 +568,11 @@ function surfaceTitle( function PreviewFavicon({ capturedUrl, url }: { capturedUrl: string | null; url: string | null }) { const publicProviderUrl = faviconUrlForOrigin(url, 32); + const Icon = getSidePanelMetadata("preview").icon; return ( } + fallback={} className="size-3 shrink-0 rounded-sm object-contain" /> ); @@ -676,10 +610,14 @@ function SurfaceIcon({ favicon && url && sameOrigin(favicon.pageUrl, url) ? favicon.dataUrl : null; 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 "device": + case "pull-requests": { + const Icon = getSidePanelMetadata("pull-requests").icon; + return ; + } + case "device": { + const DeviceIcon = getSidePanelMetadata("device").icon; return surface.target?.platform === "ios" ? ( ) : surface.target?.platform === "android" ? ( ) : ( - + ); + } } } @@ -934,64 +878,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }); }, []); - const addSurfaceActions = [ - { - label: "Browser", - icon: Globe2, - shortcut: "B", - available: props.browserAvailable, - disabledReason: SURFACE_DISABLED_REASONS.browser, - onClick: props.onAddBrowser, - }, - { - label: "Terminal", - icon: TerminalSquare, - shortcut: "T", - available: props.terminalAvailable, - disabledReason: SURFACE_DISABLED_REASONS.terminal, - onClick: props.onAddTerminal, - }, - { - label: "Files", - icon: Files, - shortcut: "F", - available: props.filesAvailable, - disabledReason: SURFACE_DISABLED_REASONS.files, - onClick: props.onAddFiles, - }, - { - label: "Diff", - icon: FileDiff, - shortcut: "D", - available: props.diffAvailable, - disabledReason: SURFACE_DISABLED_REASONS.diff, - onClick: props.onAddDiff, - }, - { - label: "Pull request", - icon: PullRequestGlyph.pullRequest, - shortcut: "P", - available: props.pullRequestAvailable, - disabledReason: SURFACE_DISABLED_REASONS.pullRequest, - onClick: props.onAddPullRequest, - }, - { - label: "Linked pull requests", - icon: PullRequestGlyph.link, - shortcut: "L", - available: props.pullRequestsAvailable, - disabledReason: SURFACE_DISABLED_REASONS.pullRequests, - onClick: props.onAddPullRequests, - }, - { - label: "Device", - icon: Smartphone, - shortcut: "M", - available: props.deviceAvailable, - disabledReason: SURFACE_DISABLED_REASONS.device, - onClick: props.onAddDevice, - }, - ] as const; + const addSurfaceActions = rightPanelSurfaceActions(props); const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { const action = surfaceShortcutActionForKey(addSurfaceActions, event.nativeEvent); @@ -1353,9 +1240,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) { // while hover or arrow reveals the profiles. The choice // lives at open time because a tab's profile is fixed then — // Electron only honours a partition before attach. - if (action.label === "Browser" && action.available) { + if (action.id === "preview" && action.available) { return ( - + @@ -1474,22 +1361,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
{props.activeSurfaceId === null ? ( ) : ( props.children diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index c2b1a805011b..d0dcd23b50d9 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -39,6 +39,7 @@ import { import { observeResize } from "~/lib/observeResize"; import { cn } from "~/lib/utils"; import { useClientSettings } from "../../hooks/useSettings"; +import { ThreadContributionStatus } from "./ThreadContributionStatus"; interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; @@ -436,6 +437,12 @@ export const ChatHeader = memo(function ChatHeader({ )} + {isServerThread ? ( + + ) : null}
); }); diff --git a/apps/web/src/components/chat/ThreadContributionStatus.logic.test.ts b/apps/web/src/components/chat/ThreadContributionStatus.logic.test.ts new file mode 100644 index 000000000000..d7a1cb968ab3 --- /dev/null +++ b/apps/web/src/components/chat/ThreadContributionStatus.logic.test.ts @@ -0,0 +1,212 @@ +import { assert, describe, it } from "@effect/vitest"; +import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; +import { createContributionStatusEnvironmentAtoms } from "@t3tools/client-runtime/state/contribution-status"; +import { + type ContributionStatusEntry, + type ContributionStatusSnapshot, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + type ServerConfig, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import { Atom, AtomRegistry } from "effect/reactivity"; + +import { contributionStatusChips } from "./ThreadContributionStatus.logic"; + +const THREAD = ThreadId.make("thread-1"); +const OTHER_THREAD = ThreadId.make("thread-2"); + +const entry = ( + items: ContributionStatusEntry["items"], + options: { session?: string; instance?: string; threadId?: ThreadId } = {}, +): ContributionStatusEntry => ({ + threadId: options.threadId ?? THREAD, + source: { + kind: "provider-session", + providerSessionId: ProviderSessionId.make(options.session ?? "session-1"), + providerInstanceId: ProviderInstanceId.make(options.instance ?? "pi"), + driver: ProviderDriverKind.make("pi"), + }, + items, +}); + +describe("contributionStatusChips", () => { + it("keeps the server's order and keys each chip by source plus item key", () => { + const chips = contributionStatusChips([ + entry([ + { key: "mode", text: "● plan" }, + { key: "tokens", text: "12k" }, + ]), + entry([{ key: "mode", text: "● plan" }], { session: "session-2", instance: "pi_work" }), + ]); + assert.deepStrictEqual( + chips.map((chip) => chip.text), + ["● plan", "12k", "● plan"], + ); + // The same item key from another source is a different chip. + assert.strictEqual(new Set(chips.map((chip) => chip.id)).size, 3); + + // A new provider session taking over with identical text still re-keys the chip. + const [before] = contributionStatusChips([entry([{ key: "mode", text: "● plan" }])]); + const [after] = contributionStatusChips([ + entry([{ key: "mode", text: "● plan" }], { session: "session-3" }), + ]); + assert.notStrictEqual(before!.id, after!.id); + }); + + it("defaults the tone, ignores empty tooltips, and names the producing instance", () => { + const [plain, styled] = contributionStatusChips([ + entry([ + { key: "a", text: "idle", tooltip: "" }, + { key: "b", text: "failing", tone: "error", tooltip: "2 checks failed" }, + ]), + ]); + assert.deepStrictEqual([plain!.tone, plain!.tooltip], ["neutral", null]); + assert.deepStrictEqual([styled!.tone, styled!.tooltip], ["error", "2 checks failed"]); + assert.strictEqual(plain!.origin, "From a Pi extension. It can lag a session change."); + + const [custom] = contributionStatusChips([ + entry([{ key: "a", text: "x" }], { instance: "pi_work" }), + ]); + assert.strictEqual(custom!.origin, "From a Pi Work extension. It can lag a session change."); + }); + + it("renders no chips for no entries", () => { + assert.deepStrictEqual(contributionStatusChips([]), []); + }); +}); + +const config = (contributionStatus: boolean) => + ({ + environment: { + serverVersion: "0.0.1", + capabilities: contributionStatus ? { contributionStatus: true } : {}, + }, + }) as ServerConfig; + +/** Environments whose servers push status frames, read through the real client selector. */ +const makeHarness = Effect.fn("makeHarness")(function* () { + const makeServer = Effect.fn("makeServer")(function* (id: string, supported: boolean) { + const frames = yield* Queue.unbounded(); + let subscriptions = 0; + return { + environmentId: EnvironmentId.make(id), + config: Atom.make(config(supported)), + subscriptions: () => subscriptions, + push: (frame: ContributionStatusSnapshot) => Queue.offer(frames, frame), + frames: Stream.suspend(() => { + subscriptions += 1; + return Stream.fromQueue(frames); + }), + }; + }); + const servers = [ + yield* makeServer("env-a", true), + yield* makeServer("env-b", true), + yield* makeServer("env-old", false), + ] as const; + const byId = new Map(servers.map((server) => [server.environmentId, server])); + // Each environment's subscription reads its own server's frames. + const registryService = { + followStream: (environmentId: EnvironmentId) => byId.get(environmentId)!.frames, + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]; + const runtime = Atom.runtime( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, registryService), + ); + const atoms = createContributionStatusEnvironmentAtoms(runtime, { + configValueAtom: (environmentId) => byId.get(environmentId)!.config, + }); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const chipTexts = (environmentId: EnvironmentId, threadId = THREAD) => + contributionStatusChips(registry.get(atoms.threadStatus(environmentId, threadId))).map( + (chip) => chip.text, + ); + /** Waits until the header's chips match: the client-side receipt for a frame. */ + const waitForChips = (environmentId: EnvironmentId, expected: ReadonlyArray) => + AtomRegistry.toStream(registry, atoms.threadStatus(environmentId, THREAD)).pipe( + Stream.map((entries) => contributionStatusChips(entries).map((chip) => chip.text)), + Stream.filter((actual) => actual.join("\n") === expected.join("\n")), + Stream.runHead, + ); + const mount = (environmentId: EnvironmentId, threadId = THREAD) => + Effect.acquireRelease( + Effect.sync(() => registry.mount(atoms.threadStatus(environmentId, threadId))), + (unmount) => Effect.sync(unmount), + ); + return { servers, chipTexts, waitForChips, mount }; +}); + +describe("thread status chips from the client selector", () => { + it.effect("shows each environment's own statuses for the same thread id", () => + Effect.scoped( + Effect.gen(function* () { + const { servers, chipTexts, waitForChips, mount } = yield* makeHarness(); + const [a, b] = servers; + yield* mount(a.environmentId); + yield* mount(b.environmentId); + yield* mount(a.environmentId, OTHER_THREAD); + + yield* a.push({ + entries: [ + entry([{ key: "mode", text: "● plan" }]), + entry([{ key: "mode", text: "● other" }], { threadId: OTHER_THREAD }), + ], + }); + yield* b.push({ entries: [entry([{ key: "mode", text: "● build" }])] }); + yield* waitForChips(a.environmentId, ["● plan"]); + yield* waitForChips(b.environmentId, ["● build"]); + assert.deepStrictEqual(chipTexts(a.environmentId, OTHER_THREAD), ["● other"]); + }), + ), + ); + + it.effect("replaces and clears chips with each frame", () => + Effect.scoped( + Effect.gen(function* () { + const { servers, waitForChips, mount } = yield* makeHarness(); + const [a] = servers; + yield* mount(a.environmentId); + + yield* a.push({ entries: [entry([{ key: "mode", text: "● startup" }])] }); + yield* waitForChips(a.environmentId, ["● startup"]); + yield* a.push({ + entries: [ + entry([ + { key: "mode", text: "● resume" }, + { key: "tokens", text: "12k" }, + ]), + ], + }); + yield* waitForChips(a.environmentId, ["● resume", "12k"]); + yield* a.push({ entries: [] }); + yield* waitForChips(a.environmentId, []); + }), + ), + ); + + it.effect("shows nothing and never subscribes when the server lacks the capability", () => + Effect.scoped( + Effect.gen(function* () { + const { servers, chipTexts, waitForChips, mount } = yield* makeHarness(); + const [current, , old] = servers; + yield* mount(old.environmentId); + yield* mount(current.environmentId); + + yield* old.push({ entries: [entry([{ key: "mode", text: "● stale" }])] }); + // Once a supported environment has delivered, any subscription would have started. + yield* current.push({ entries: [entry([{ key: "mode", text: "● plan" }])] }); + yield* waitForChips(current.environmentId, ["● plan"]); + assert.strictEqual(old.subscriptions(), 0); + assert.deepStrictEqual(chipTexts(old.environmentId), []); + }), + ), + ); +}); diff --git a/apps/web/src/components/chat/ThreadContributionStatus.logic.ts b/apps/web/src/components/chat/ThreadContributionStatus.logic.ts new file mode 100644 index 000000000000..0393585f2345 --- /dev/null +++ b/apps/web/src/components/chat/ThreadContributionStatus.logic.ts @@ -0,0 +1,47 @@ +import { resolveProviderInstanceDisplayName } from "@t3tools/client-runtime/state/provider-instance-display"; +import { + type ContributionStatusEntry, + type ContributionStatusSource, + type ContributionStatusTone, + contributionStatusSourceKey, + ProviderDriverKind, +} from "@t3tools/contracts"; + +const PI_DRIVER = ProviderDriverKind.make("pi"); + +export interface ContributionStatusChip { + /** Source key plus item key, so a provider-session takeover re-keys the chip. */ + readonly id: string; + readonly text: string; + readonly tone: ContributionStatusTone; + readonly tooltip: string | null; + /** Static help naming where the status came from. Never claims it is current. */ + readonly origin: string; +} + +function contributionStatusOrigin(source: ContributionStatusSource): string { + const name = resolveProviderInstanceDisplayName({ + instanceId: source.providerInstanceId, + driver: source.driver, + }); + return source.driver === PI_DRIVER + ? `From a ${name} extension. It can lag a session change.` + : `From ${name}. It can lag a session change.`; +} + +/** One chip per status item, in the server's order. */ +export function contributionStatusChips( + entries: ReadonlyArray, +): ReadonlyArray { + return entries.flatMap((entry) => { + const sourceKey = contributionStatusSourceKey(entry.source); + const origin = contributionStatusOrigin(entry.source); + return entry.items.map((item) => ({ + id: JSON.stringify([sourceKey, item.key]), + text: item.text, + tone: item.tone ?? "neutral", + tooltip: item.tooltip || null, + origin, + })); + }); +} diff --git a/apps/web/src/components/chat/ThreadContributionStatus.test.tsx b/apps/web/src/components/chat/ThreadContributionStatus.test.tsx new file mode 100644 index 000000000000..e62607d06bd1 --- /dev/null +++ b/apps/web/src/components/chat/ThreadContributionStatus.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom + +import { + type ContributionStatusEntry, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ThreadId, +} from "@t3tools/contracts"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ + entries: [] as ReadonlyArray, +})); + +vi.mock("../../state/contributionStatus", () => ({ + useThreadContributionStatus: () => testState.entries, +})); + +import { ThreadContributionStatus } from "./ThreadContributionStatus"; + +const THREAD = ThreadId.make("thread-1"); +const ENVIRONMENT = EnvironmentId.make("environment-1"); +const PI_ORIGIN = "From a Pi extension. It can lag a session change."; + +const entry = (items: ContributionStatusEntry["items"]): ContributionStatusEntry => ({ + threadId: THREAD, + source: { + kind: "provider-session", + providerSessionId: ProviderSessionId.make("session-1"), + providerInstanceId: ProviderInstanceId.make("pi"), + driver: ProviderDriverKind.make("pi"), + }, + items, +}); + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + testState.entries = []; + vi.unstubAllGlobals(); +}); + +async function renderStatus(entries: ReadonlyArray) { + testState.entries = entries; + await act(async () => { + root.render(); + }); +} + +/** The trigger's accessible name: its explicit label, else its text. */ +function accessibleName(element: Element) { + return element.getAttribute("aria-label") ?? element.textContent ?? ""; +} + +function statusButton() { + const button = [...container.querySelectorAll("button")].find((candidate) => + accessibleName(candidate).startsWith("Provider status"), + ); + if (!button) throw new Error("Status button was not rendered"); + return button; +} + +function popupText() { + return document.querySelector('[data-slot="popover-popup"]')?.textContent ?? null; +} + +describe("ThreadContributionStatus", () => { + it("counts statuses past the inline ones and lists every one when tapped", async () => { + await renderStatus([ + entry([ + { key: "a", text: "● plan" }, + { key: "b", text: "build 3/9" }, + { key: "c", text: "lint clean", tone: "success" }, + { key: "d", text: "disk almost full", tone: "warning" }, + ]), + ]); + + const button = statusButton(); + expect(button.textContent).toBe("● planbuild 3/9+2"); + expect(popupText()).toBeNull(); + + await act(async () => { + button.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" }), + ); + button.click(); + }); + + const text = popupText(); + for (const status of ["● plan", "build 3/9", "lint clean", "disk almost full", PI_ORIGIN]) { + expect(text).toContain(status); + } + }); + + it("is a focusable native button, so keyboard users reach the same list", async () => { + await renderStatus([entry([{ key: "mode", text: "● plan" }])]); + + const button = statusButton(); + button.focus(); + expect(document.activeElement).toBe(button); + expect(button.tagName).toBe("BUTTON"); + }); + + it("names the control with every status text, even when an item has a tooltip", async () => { + const longText = `● ${"x".repeat(78)}`; + await renderStatus([ + entry([ + { key: "a", text: "Plan", tooltip: "12 files to change" }, + { key: "b", text: longText }, + { key: "c", text: "disk almost full", tone: "warning" }, + ]), + ]); + + expect(accessibleName(statusButton())).toBe( + `Provider status: Plan, ${longText}, disk almost full`, + ); + + await act(async () => statusButton().click()); + const text = popupText(); + expect(text).toContain("Plan"); + expect(text).toContain("12 files to change"); + expect(text).toContain(longText); + }); + + it("renders nothing without statuses", async () => { + await renderStatus([]); + expect(container.childElementCount).toBe(0); + }); +}); diff --git a/apps/web/src/components/chat/ThreadContributionStatus.tsx b/apps/web/src/components/chat/ThreadContributionStatus.tsx new file mode 100644 index 000000000000..404dcbe83252 --- /dev/null +++ b/apps/web/src/components/chat/ThreadContributionStatus.tsx @@ -0,0 +1,106 @@ +import type { ContributionStatusTone, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { memo, useMemo } from "react"; + +import { useThreadContributionStatus } from "../../state/contributionStatus"; +import { Badge } from "../ui/badge"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { + type ContributionStatusChip, + contributionStatusChips, +} from "./ThreadContributionStatus.logic"; + +/** Chips shown inline; the rest are counted in a "+N" badge and listed in the popover. */ +const VISIBLE_CHIPS = 2; + +const TONE_VARIANT = { + neutral: "outline", + info: "info", + success: "success", + warning: "warning", + error: "error", +} as const satisfies Record; + +const TONE_TEXT = { + neutral: "", + info: "text-info-foreground", + success: "text-success-foreground", + warning: "text-warning-foreground", + error: "text-destructive-foreground", +} as const satisfies Record; + +/** + * One trigger for every status: the first chips inline, then a "+N" count. + * Click, tap, Enter or hover opens a list with each status's full text, + * tooltip and origin, so a truncated or hidden status is always reachable. + */ +function ContributionStatusChips(props: { readonly chips: ReadonlyArray }) { + const { chips } = props; + const visible = chips.slice(0, VISIBLE_CHIPS); + const hiddenCount = chips.length - visible.length; + const origins = [...new Set(chips.map((chip) => chip.origin))]; + return ( + + chip.text).join(", ")}`} + data-thread-contribution-status + className="relative isolate flex min-w-0 max-w-[45%] shrink cursor-pointer items-center gap-1 rounded-sm outline-none before:pointer-events-none before:absolute before:inset-0 before:z-10 before:rounded-sm focus-visible:before:ring-2 focus-visible:before:ring-inset focus-visible:before:ring-ring pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11" + /> + } + > + {visible.map((chip) => ( + + {chip.text} + + ))} + {hiddenCount > 0 ? ( + + +{hiddenCount} + + ) : null} + + +
    + {chips.map((chip) => ( +
  • + + {chip.text} + + {chip.tooltip ? ( + {chip.tooltip} + ) : null} +
  • + ))} +
+ {origins.map((origin) => ( +

+ {origin} +

+ ))} +
+
+ ); +} + +/** + * Advisory statuses a provider (today, Pi extensions) set on the open thread. + * Renders nothing when there are none or the server predates the channel. + */ +export const ThreadContributionStatus = memo(function ThreadContributionStatus(props: { + environmentId: EnvironmentId; + threadId: ThreadId; +}) { + const entries = useThreadContributionStatus(props.environmentId, props.threadId); + const chips = useMemo(() => contributionStatusChips(entries), [entries]); + if (chips.length === 0) return null; + return ; +}); diff --git a/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx b/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx index 6bf4302fbcc1..b903e57087a0 100644 --- a/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx +++ b/apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from "react"; -import { DiffFileHeaderSkeleton } from "../DiffPanelShell"; +import { DiffFileHeaderSkeleton } from "./DiffLoadingState"; /** Load the next batch before the reader reaches the end of the current files. */ export function DiffFileLoadingBoundary({ load, count }: { load: () => void; count: number }) { diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/diffs/DiffLoadingState.tsx similarity index 55% rename from apps/web/src/components/DiffPanelShell.tsx rename to apps/web/src/components/diffs/DiffLoadingState.tsx index 456f70516b23..34b1e4960587 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/diffs/DiffLoadingState.tsx @@ -1,50 +1,4 @@ -import type { ReactNode } from "react"; - -import { isElectron } from "~/env"; -import { cn } from "~/lib/utils"; - -import { Skeleton } from "./ui/skeleton"; - -export type DiffPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; - -function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { - const shouldUseDragRegion = isElectron && mode !== "sheet" && mode !== "embedded"; - return cn( - "flex items-center justify-between gap-2", - mode === "embedded" ? "px-2" : "px-4", - shouldUseDragRegion - ? "drag-region h-[var(--workspace-topbar-height)] border-b border-border wco:pr-(--workspace-native-controls-inset)" - : "flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-transparent", - ); -} - -export function DiffPanelShell(props: { - mode: DiffPanelMode; - header: ReactNode; - children: ReactNode; -}) { - const shouldUseDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; - - return ( -
- {shouldUseDragRegion ? ( -
{props.header}
- ) : ( -
- {props.header} -
- )} - {props.children} -
- ); -} +import { Skeleton } from "../ui/skeleton"; export function DiffFileHeaderSkeleton({ titleWidth, diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index d9722303ab72..548042d140bb 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -7,14 +7,13 @@ import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { ChevronsDownUp, ChevronsUpDown } from "lucide"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Button } from "~/components/ui/button"; import { InputGroup, InputGroupInput } from "~/components/ui/input-group"; import { MorphIcon } from "~/components/MorphIcon"; import { toastManager } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; -import { useComposerHandleContext } from "~/composerHandleContext"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { useTheme } from "~/hooks/useTheme"; import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; @@ -40,8 +39,13 @@ interface FileBrowserPanelProps { onOpenFile: (relativePath: string) => void; onRefreshSelectedFile?: () => void; workspaceMutationId: string | null; + /** Inserts a mention into the chat composer of the thread this panel belongs to. */ + addToChat: (text: string) => AddToChatResult; } +/** "dropped" means the panel left the thread before the action settled. */ +export type AddToChatResult = "inserted" | "no-composer" | "not-ready" | "dropped"; + function treePath(entry: ProjectEntry): string { return entry.kind === "directory" ? `${entry.path}/` : entry.path; } @@ -104,9 +108,9 @@ export default function FileBrowserPanel({ onOpenFile, onRefreshSelectedFile, workspaceMutationId, + addToChat, }: FileBrowserPanelProps) { const { resolvedTheme } = useTheme(); - const composerRef = useComposerHandleContext(); const fileContextMenu = useFileContextMenu(environmentId); const { entries: directoryEntries, @@ -213,8 +217,8 @@ export default function FileBrowserPanel({ return; } if (clicked === "add-to-chat") { - const composer = composerRef?.current; - if (!composer) { + const result = addToChat(`${mention} `); + if (result === "no-composer") { toastManager.add({ type: "error", title: "Unable to add to chat", @@ -222,8 +226,7 @@ export default function FileBrowserPanel({ }); return; } - const inserted = composer.insertTextAtEnd(`${mention} `, { ensureLeadingBoundary: true }); - if (!inserted) { + if (result === "not-ready") { toastManager.add({ type: "error", title: "Unable to add to chat", @@ -239,6 +242,14 @@ export default function FileBrowserPanel({ useEffect(() => { showEntryContextMenuRef.current = showEntryContextMenu; }); + // The tree keeps its first selection callback, so it reads the current + // opener through a ref; otherwise a click after a thread switch opens the + // file in the thread the panel first showed. Updated at commit, so a click + // that lands before passive effects run already opens in the new thread. + const onOpenFileRef = useRef(onOpenFile); + useLayoutEffect(() => { + onOpenFileRef.current = onOpenFile; + }, [onOpenFile]); // The tree reads decorations at render time; a folder still loading its // children shows a spinner in its row instead of a banner that shifts the tree. @@ -283,7 +294,7 @@ export default function FileBrowserPanel({ const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { treeSelectionPathRef.current = selectedPath; - onOpenFile(selectedPath); + onOpenFileRef.current(selectedPath); } }, paths: [], diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 25a8c10ad6b2..d0c473b39768 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -58,7 +58,7 @@ import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; -import { DiffPanelLoadingState } from "../DiffPanelShell"; +import { DiffPanelLoadingState } from "../diffs/DiffLoadingState"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { DiffFileTree } from "../diffs/DiffFileTree"; import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal"; diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index d68983937f19..09231cda02ad 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -125,7 +125,7 @@ import { import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; import { PullRequestCopyableCode } from "./PullRequestCopyableCode"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; -import { DiffPanelLoadingState } from "../DiffPanelShell"; +import { DiffPanelLoadingState } from "../diffs/DiffLoadingState"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; import type { PullRequestAgentSelectionInput } from "./PullRequestCodeTab"; import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; diff --git a/apps/web/src/panels/bundledPanels.test.tsx b/apps/web/src/panels/bundledPanels.test.tsx new file mode 100644 index 000000000000..287bb346698c --- /dev/null +++ b/apps/web/src/panels/bundledPanels.test.tsx @@ -0,0 +1,225 @@ +import { EnvironmentId, ProjectId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { act, Suspense } from "react"; +import { create } from "react-test-renderer"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const loaded = vi.hoisted(() => ({ + diff: 0, + preview: 0, + terminal: 0, + device: 0, + pullRequest: 0, + pullRequests: 0, + files: 0, + previewRenders: [] as unknown[], +})); +vi.mock("./diff/DiffSidePanel", () => { + loaded.diff += 1; + return { default: () => null }; +}); +vi.mock("./terminal/TerminalSidePanel", () => { + loaded.terminal += 1; + return { default: () => null }; +}); +vi.mock("./device/DeviceSidePanel", () => { + loaded.device += 1; + return { default: () => null }; +}); +vi.mock("./files/FilesSidePanel", () => { + loaded.files += 1; + return { default: () => null }; +}); +vi.mock("./preview/PreviewSidePanel", () => { + loaded.preview += 1; + return { + default: function PreviewSidePanel(props: unknown) { + loaded.previewRenders.push({ props, host: usePanelHost() }); + return null; + }, + }; +}); + +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 () => { + expect(loaded).toMatchObject({ + diff: 0, + preview: 0, + terminal: 0, + device: 0, + pullRequest: 0, + pullRequests: 0, + files: 0, + }); + await act(async () => { + create( + + + + + , + ); + }); + expect(loaded).toMatchObject({ + diff: 0, + preview: 1, + terminal: 0, + device: 0, + pullRequest: 0, + pullRequests: 0, + files: 0, + }); + expect(loaded.previewRenders).toEqual([{ props: { tabId: "tab-1" }, host }]); + }); + + it("loads only the terminal body when the terminal is selected", async () => { + const surface: Extract = { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", + }; + await act(async () => { + create( + + + undefined} + onSplitTerminal={() => undefined} + onSplitTerminalVertical={() => undefined} + onNewTerminal={() => undefined} + onActiveTerminalChange={() => undefined} + onCloseTerminal={() => undefined} + /> + + , + ); + }); + expect(loaded).toMatchObject({ diff: 0, terminal: 1 }); + }); +}); + +const filesProps = { + cwd: "/repo", + projectName: "repo", + relativePath: null, + availableEditors: [], + revealLine: null, + revealRequestId: 0, + onPendingChange: () => undefined, + selectedFilePending: false, +}; + +// Never called. The project typecheck compiles these pairings, and each +// expect-error directive fails it if a wrong pairing starts to compile. +export function typeFixtures( + widenedId: "diff" | "preview", + terminalSurface: Extract, + deviceSurface: Extract, + dismiss: () => void, +) { + const terminalProps = { + surface: terminalSurface, + launchContext: null, + focusRequestId: 0, + 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 The host owns the composer draft target. */} + + {/* @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 000000000000..b8211ca1b505 --- /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.test.tsx b/apps/web/src/panels/device/DeviceSidePanel.test.tsx new file mode 100644 index 000000000000..828d50d6c904 --- /dev/null +++ b/apps/web/src/panels/device/DeviceSidePanel.test.tsx @@ -0,0 +1,246 @@ +import { + EnvironmentId, + ThreadId, + type DeviceServiceState, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { act, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { RightPanelSurface } from "~/rightPanelStore"; + +import { 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", () => ({ + deviceEnvironment: { list: "list", open: "open", close: "close" }, + useDeviceState: () => ({ state: deviceState, loaded: true }), +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: string) => + command === "open" + ? mocks.open + : command === "close" + ? mocks.close + : () => Promise.resolve({ _tag: "Success", value: {} }), +})); +vi.mock("~/state/query", () => ({ formatEnvironmentQueryError: () => "Device failed" })); +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { + getState: () => ({ openDevice: mocks.openDevice, closeSurface: mocks.closeSurface }), + }, +})); +vi.mock("~/components/device/DeviceHostUpdates", () => ({ DeviceHostUpdates: () => null })); +vi.mock("~/components/device/DeviceLoadingView", () => ({ DeviceLoadingView: () => null })); +vi.mock("~/components/device/DeviceWorkspace", () => ({ DeviceWorkspace: () => null })); +vi.mock("~/components/preview/PreviewPanelShell", () => ({ + PreviewPanelShell: ({ children }: { children: ReactNode }) => children, +})); + +// Same thread id in another environment: the surface id collides, so the panel +// stays mounted across the switch, as it does under ChatView's surface key. +const picking = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-1"), +}; +const next = { environmentId: EnvironmentId.make("environment-b"), threadId: picking.threadId }; +const picker: Extract = { id: "device", kind: "device" }; +const streaming: Extract = { + id: "device:phone", + kind: "device", + target: { hostId: "local", deviceId: "phone", platform: "ios", name: "Phone" }, +}; + +// 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 (threadRef: ScopedThreadRef, surface = picker) => { + const renderer = await act(async () => create(render(threadRef, surface))); + // Wait for the registered lazy body to load and replace the Suspense fallback. + await act(async () => { + await import("./DeviceSidePanel"); + }); + return renderer; +}; +const deferred = () => { + let settle!: (result: CommandResult) => void; + const promise = new Promise((resolve) => (settle = resolve)); + return { promise, settle }; +}; +const startPhone = (renderer: ReactTestRenderer) => + act(async () => { + renderer.root.findByProps({ "aria-label": "Start Phone" }).props.onClick(); + }); +const errors = (renderer: ReactTestRenderer) => renderer.root.findAllByProps({ role: "alert" }); +const opened = { _tag: "Success", value: { hostId: "local", deviceId: "phone" } } as const; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("registered Device panel", () => { + it("opens a picked device when the pick settles in the same thread", async () => { + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + const renderer = await mount(picking); + + await startPhone(renderer); + expect(mocks.open).toHaveBeenCalledWith({ + environmentId: picking.environmentId, + input: { threadId: picking.threadId, hostId: "local", deviceId: "phone", platform: "ios" }, + }); + await act(async () => pick.settle(opened)); + + expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(picking, { + hostId: "local", + deviceId: "phone", + platform: "ios", + name: "Phone", + }); + }); + + it("keeps the next thread's picker usable while an earlier pick is pending", async () => { + mocks.open.mockReturnValue(deferred().promise); + const renderer = await mount(picking); + + await startPhone(renderer); + await act(async () => renderer.update(render(next))); + + expect(renderer.root.findByProps({ "aria-label": "Start Phone" }).props.disabled).toBe(false); + }); + + it("opens a pick that settles after a switch in the thread it started in", async () => { + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + const renderer = await mount(picking); + + await startPhone(renderer); + await act(async () => renderer.update(render(next))); + await act(async () => pick.settle(opened)); + + expect(mocks.openDevice).toHaveBeenCalledExactlyOnceWith(picking, { + hostId: "local", + deviceId: "phone", + platform: "ios", + name: "Phone", + }); + expect(renderer.root.findByProps({ "aria-label": "Start Phone" }).props.disabled).toBe(false); + }); + + it("hides a failed pick that settles after the panel moved, even back again", async () => { + const pick = deferred(); + mocks.open.mockReturnValue(pick.promise); + const renderer = await mount(picking); + + await startPhone(renderer); + await act(async () => renderer.update(render(next))); + await act(async () => renderer.update(render(picking))); + await act(async () => pick.settle({ _tag: "Failure", cause: new Error("boom") })); + + expect(errors(renderer)).toHaveLength(0); + expect(mocks.openDevice).not.toHaveBeenCalled(); + }); + + it("does not carry an operation error into another thread", async () => { + mocks.open.mockResolvedValue({ _tag: "Failure", cause: new Error("boom") }); + const renderer = await mount(picking); + + await startPhone(renderer); + expect(errors(renderer)).toHaveLength(1); + await act(async () => renderer.update(render(next))); + + expect(errors(renderer)).toHaveLength(0); + }); + + it("closes a powered-off surface in its own thread after the panel moved", async () => { + const powerOff = deferred(); + mocks.close.mockReturnValue(powerOff.promise); + const renderer = await mount(picking, streaming); + + await act(async () => { + renderer.root.findByProps({ hostLabel: "This Mac" }).props.onPowerOff(); + }); + expect(mocks.close).toHaveBeenCalledWith({ + environmentId: picking.environmentId, + input: { threadId: picking.threadId, hostId: "local", deviceId: "phone", shutdown: true }, + }); + await act(async () => renderer.update(render(next, streaming))); + await act(async () => powerOff.settle(opened)); + + expect(mocks.closeSurface).toHaveBeenCalledExactlyOnceWith(picking, streaming.id); + }); + + it("closes the surface when a power-off settles in the same thread", async () => { + mocks.close.mockResolvedValue(opened); + const renderer = await mount(picking, streaming); + + await act(async () => { + renderer.root.findByProps({ hostLabel: "This Mac" }).props.onPowerOff(); + }); + + expect(mocks.closeSurface).toHaveBeenCalledExactlyOnceWith(picking, streaming.id); + }); +}); diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/panels/device/DeviceSidePanel.tsx similarity index 78% rename from apps/web/src/components/device/DevicePanel.tsx rename to apps/web/src/panels/device/DeviceSidePanel.tsx index 7e6804043f44..c3dd34d8ff77 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/panels/device/DeviceSidePanel.tsx @@ -1,12 +1,7 @@ -import { DeviceHostUpdates } from "./DeviceHostUpdates"; -import type { - DevicePlatform, - DeviceServiceState, - DeviceSummary, - ScopedThreadRef, -} from "@t3tools/contracts"; +import { DeviceHostUpdates } from "~/components/device/DeviceHostUpdates"; +import type { DevicePlatform, DeviceServiceState, DeviceSummary } from "@t3tools/contracts"; import { Smartphone, X } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { useRightPanelStore, type RightPanelSurface } from "~/rightPanelStore"; @@ -19,10 +14,12 @@ import { cn } from "~/lib/utils"; import { deviceEnvironment, useDeviceState } from "~/state/device"; import { formatEnvironmentQueryError } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; -import { DeviceLoadingView } from "./DeviceLoadingView"; -import { DeviceSetup } from "./DeviceSetup"; -import { DeviceWorkspace } from "./DeviceWorkspace"; -import { PreviewPanelShell, type PreviewPanelMode } from "../preview/PreviewPanelShell"; +import { DeviceLoadingView } from "~/components/device/DeviceLoadingView"; +import { DeviceSetup } from "~/components/device/DeviceSetup"; +import { DeviceWorkspace } from "~/components/device/DeviceWorkspace"; +import { PreviewPanelShell } from "~/components/preview/PreviewPanelShell"; + +import { usePanelHost } from "../panelHost"; const platformLabel = (platform: DevicePlatform) => platform === "ios" ? "iOS Simulators" : "Android Emulators"; @@ -30,30 +27,40 @@ const platformLabel = (platform: DevicePlatform) => const deviceKey = (device: Pick) => `${device.hostId}\u0000${device.id}`; -/** Each surface owns one host/device; only the visible surface streams. */ -export function DevicePanel(props: { - readonly mode: PreviewPanelMode; - readonly threadRef: ScopedThreadRef; +/** + * Each surface owns one host/device; only the visible surface streams. + * RightPanelTabs owns placement, so the side panel is always embedded. + */ +export default function DeviceSidePanel(props: { readonly surface: Extract; - readonly visible: boolean; readonly onDismissSetup: () => void; }) { - const { environmentId, threadId } = props.threadRef; + const { threadRef, visible } = usePanelHost(); + const { environmentId, threadId } = threadRef; const { state, loaded } = useDeviceState(environmentId); const list = useAtomCommand(deviceEnvironment.list, { reportFailure: false }); const open = useAtomCommand(deviceEnvironment.open); const close = useAtomCommand(deviceEnvironment.close); const [operationError, setOperationError] = useState(null); const [pendingDevice, setPendingDevice] = useState(null); + // Operation state belongs to one scoped thread; drop it when the panel moves on. + const scopeKey = `${environmentId}\u0000${threadId}`; + const [operationScopeKey, setOperationScopeKey] = useState(scopeKey); + if (operationScopeKey !== scopeKey) { + setOperationScopeKey(scopeKey); + setOperationError(null); + setPendingDevice(null); + } + const isCurrentScope = useScopeGuard(scopeKey); const pendingDeviceKey = pendingDevice ? deviceKey(pendingDevice) : null; const hostDisabled = state.hostStatus === "disabled"; // Opening setup never grants permission to install or start helpers. useEffect(() => { - if (!props.visible || !loaded || hostDisabled) return; + if (!visible || !loaded || hostDisabled) return; void list({ environmentId, input: {} }); - }, [environmentId, list, loaded, props.visible, hostDisabled]); + }, [environmentId, list, loaded, visible, hostDisabled]); const sessions = useMemo( () => state.sessions.filter((session) => session.threadId === threadId), @@ -79,6 +86,7 @@ export function DevicePanel(props: { if (!device) return; setOperationError(null); setPendingDevice(device); + const stillCurrent = isCurrentScope(); try { const result = await open({ environmentId, @@ -89,39 +97,44 @@ export function DevicePanel(props: { platform: device.platform, }, }); - if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); - else - useRightPanelStore.getState().openDevice(props.threadRef, { + // The server opened the device for the starting thread, so its tab opens + // there even after a switch; only this panel's own state is scope-guarded. + if (result._tag === "Failure") { + if (stillCurrent()) setOperationError(formatEnvironmentQueryError(result.cause)); + } else { + useRightPanelStore.getState().openDevice(threadRef, { hostId: result.value.hostId, deviceId: result.value.deviceId, platform: device.platform, name: device.name, }); + } } finally { - setPendingDevice(null); + if (stillCurrent()) setPendingDevice(null); } }; // Floating the device closes the panel, like the browser's floating preview. const floatActive = () => { if (!activeDevice) return; - usePreviewMiniPlayerStore.getState().open(props.threadRef, { + usePreviewMiniPlayerStore.getState().open(threadRef, { kind: "device", hostId: activeDevice.hostId, deviceId: activeDevice.id, platform: activeDevice.platform, name: activeDevice.name, }); - useRightPanelStore.getState().close(props.threadRef); + useRightPanelStore.getState().close(threadRef); }; const closeActive = (powerOff: boolean) => { if (!powerOff) { - useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); + useRightPanelStore.getState().closeSurface(threadRef, props.surface.id); return; } if (!activeSession) return; setOperationError(null); + const stillCurrent = isCurrentScope(); void close({ environmentId, input: { @@ -131,8 +144,11 @@ export function DevicePanel(props: { shutdown: powerOff, }, }).then((result) => { - if (result._tag === "Failure") setOperationError(formatEnvironmentQueryError(result.cause)); - else useRightPanelStore.getState().closeSurface(props.threadRef, props.surface.id); + if (result._tag === "Failure") { + if (stillCurrent()) setOperationError(formatEnvironmentQueryError(result.cause)); + } else { + useRightPanelStore.getState().closeSurface(threadRef, props.surface.id); + } }); }; @@ -153,7 +169,7 @@ export function DevicePanel(props: { if (loaded && (!state.onboardingCompleted || hostDisabled)) { return ( { if (!isOpen) props.onDismissSetup(); }} @@ -166,7 +182,7 @@ export function DevicePanel(props: { } return ( - + {hostReady && !activeDevice && state.hostStatusDetail ? (
host.id === activeDevice.hostId)?.label ?? "Device host" } hostDiagnostics={state.hostStatusDetail} - visible={props.visible} + visible={visible} onFloat={floatActive} onClose={() => closeActive(false)} onPowerOff={() => closeActive(true)} @@ -313,6 +329,27 @@ export function DevicePanel(props: { ); } +/** + * Binds async work to the committed scope. Call the returned function when the + * work starts; the check it returns is false once the panel moved to another + * thread (even back again) or unmounted, so late results are dropped. + */ +function useScopeGuard(scopeKey: string) { + const scopeRef = useRef<{ readonly key: string } | null>(null); + useLayoutEffect(() => { + // A fresh token per commit of a scope, so returning to a thread is a new scope. + const scope = { key: scopeKey }; + scopeRef.current = scope; + return () => { + scopeRef.current = null; + }; + }, [scopeKey]); + return () => { + const started = scopeRef.current; + return () => started !== null && scopeRef.current === started; + }; +} + function groupDevices(state: DeviceServiceState) { const groups: Array<{ platform: DevicePlatform; devices: DeviceSummary[] }> = []; for (const platform of ["ios", "android"] as const) { diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/panels/diff/DiffSidePanel.tsx similarity index 93% rename from apps/web/src/components/DiffPanel.tsx rename to apps/web/src/panels/diff/DiffSidePanel.tsx index 0c7a4a28988d..b66b1953db38 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/panels/diff/DiffSidePanel.tsx @@ -1,4 +1,3 @@ -import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import type { FileDiffContentsLoader, FileDiffMetadata } from "@pierre/diffs"; import { useParams } from "@tanstack/react-router"; @@ -7,7 +6,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; -import type { ScopedThreadRef, RunId } from "@t3tools/contracts"; +import type { RunId } from "@t3tools/contracts"; import { ArrowRightIcon, CheckIcon, @@ -21,45 +20,31 @@ import { import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide"; import * as Schema from "effect/Schema"; import * as DateTime from "effect/DateTime"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { useCodeViewFileReveal } from "~/components/diffs/useCodeViewFileReveal"; import { useFilesystemReadAccess } from "~/state/filesystem"; -import { useOpenInPreferredEditor } from "../editorPreferences"; -import { useFileContextMenuHandler } from "../fileContextMenu"; -import { type DraftId } from "../composerDraftStore"; -import { openDiffFilePrimaryAction } from "../diffFileActions"; -import { useCheckpointDiff } from "~/lib/checkpointDiffState"; -import { cn } from "~/lib/utils"; -import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore"; -import { useLocalStorage } from "../hooks/useLocalStorage"; -import { useTheme } from "../hooks/useTheme"; +import { DiffFilePathCopyButton } from "~/components/DiffFilePathCopyButton"; +import { DiffStatLabel } from "~/components/chat/DiffStatLabel"; import { - buildFileDiffContentVersion, - buildFileDiffIdentityKey, - getDiffCollapseIconClassName, - getDiffLineStat, - getRenderablePatch, - resolveDiffThemeName, - resolveFileDiffPath, -} from "../lib/diffRendering"; -import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; -import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; -import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh"; -import { useProject, useThreadProjection, useThreadShell } from "../state/entities"; -import { resolveThreadRouteRef } from "../threadRoutes"; -import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; -import { formatShortTimestamp } from "../timestampFormat"; -import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; -import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { DiffStatLabel } from "./chat/DiffStatLabel"; -import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; -import { DiffFileTree } from "./diffs/DiffFileTree"; -import { diffFileTreeEntries } from "./diffs/diffFileTree.logic"; -import { Button } from "./ui/button"; + AnnotatableCodeView, + type AnnotatableCodeViewHandle, +} from "~/components/diffs/AnnotatableCodeView"; +import { DiffFileTree } from "~/components/diffs/DiffFileTree"; +import { diffFileTreeEntries } from "~/components/diffs/diffFileTree.logic"; +import { Button } from "~/components/ui/button"; import { MorphIcon } from "~/components/MorphIcon"; -import { ToggleGroup, Toggle } from "./ui/toggle-group"; -import { Switch } from "./ui/switch"; +import { ToggleGroup, Toggle } from "~/components/ui/toggle-group"; +import { Switch } from "~/components/ui/switch"; import { Combobox, ComboboxEmpty, @@ -68,7 +53,7 @@ import { ComboboxList, ComboboxPopup, ComboboxTrigger, -} from "./ui/combobox"; +} from "~/components/ui/combobox"; import { DropdownMenu, DropdownMenuContent, @@ -78,19 +63,48 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, -} from "./ui/menu"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { useEnvironmentQuery } from "../state/query"; -import { useAtomCommand } from "../state/use-atom-command"; -import { serverEnvironment } from "../state/server"; -import { reviewEnvironment } from "../state/review"; -import { vcsEnvironment } from "../state/vcs"; -import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices"; -import { createGitDiffFileContentsLoader } from "../lib/diffFileContents"; +} from "~/components/ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { useReviewFilePatches } from "~/components/diffs/useReviewFilePatches"; +import { DiffFileLoadingBoundary } from "~/components/diffs/DiffFileLoadingBoundary"; +import { DiffFileStatus } from "~/components/diffs/DiffFileStatus"; +import { DiffPanelLoadingState } from "~/components/diffs/DiffLoadingState"; -import { useReviewFilePatches } from "./diffs/useReviewFilePatches"; -import { DiffFileLoadingBoundary } from "./diffs/DiffFileLoadingBoundary"; -import { DiffFileStatus } from "./diffs/DiffFileStatus"; +import { usePanelHost } from "../panelHost"; + +import { useOpenInPreferredEditor } from "~/editorPreferences"; +import { useFileContextMenuHandler } from "~/fileContextMenu"; +import { openDiffFilePrimaryAction } from "~/diffFileActions"; +import { useCheckpointDiff } from "~/lib/checkpointDiffState"; +import { cn } from "~/lib/utils"; +import { selectThreadDiffPanelSelection, useDiffPanelStore } from "~/diffPanelStore"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { useTheme } from "~/hooks/useTheme"; +import { + buildFileDiffContentVersion, + buildFileDiffIdentityKey, + getDiffCollapseIconClassName, + getDiffLineStat, + getRenderablePatch, + resolveDiffThemeName, + resolveFileDiffPath, +} from "~/lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; +import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "~/lib/diffCollapse"; +import { useTurnDiffSummaries } from "~/hooks/useTurnDiffSummaries"; +import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh"; +import { useProject, useThreadProjection, useThreadShell } from "~/state/entities"; +import { resolveThreadRouteRef } from "~/threadRoutes"; +import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; +import { formatShortTimestamp } from "~/timestampFormat"; + +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { serverEnvironment } from "~/state/server"; +import { reviewEnvironment } from "~/state/review"; +import { vcsEnvironment } from "~/state/vcs"; +import { buildBaseRefChoices, filterBaseRefChoices } from "~/lib/baseRefChoices"; +import { createGitDiffFileContentsLoader } from "~/lib/diffFileContents"; type DiffThemeType = "light" | "dark"; const AUTOMATIC_BASE_REF = "__automatic_base_ref__"; @@ -187,17 +201,23 @@ function DiffFileHeaderSuffix({ ); } -interface DiffPanelProps { - mode?: DiffPanelMode; - composerDraftTarget: ScopedThreadRef | DraftId; - workspaceMutationId: string | null; +// RightPanelTabs owns placement and desktop chrome; Diff keeps its embedded toolbar. +function DiffSidePanelFrame({ header, children }: { header: ReactNode; children: ReactNode }) { + return ( +
+
+ {header} +
+ {children} +
+ ); } -export default function DiffPanel({ - mode = "inline", - composerDraftTarget, - workspaceMutationId, -}: DiffPanelProps) { +export default function DiffSidePanel() { + const { composerDraftTarget, workspaceMutationId } = usePanelHost(); const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const diffLayout = settings.diffLayout; @@ -1041,7 +1061,7 @@ export default function DiffPanel({ ); return ( - + {!activeThread ? (
Select a thread to inspect turn diffs. @@ -1249,6 +1269,6 @@ export default function DiffPanel({
)} -
+ ); } diff --git a/apps/web/src/panels/files/FilesSidePanel.test.tsx b/apps/web/src/panels/files/FilesSidePanel.test.tsx new file mode 100644 index 000000000000..a1a6f3329d12 --- /dev/null +++ b/apps/web/src/panels/files/FilesSidePanel.test.tsx @@ -0,0 +1,615 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/reactivity"; +import { + act, + cloneElement, + Suspense, + use, + useLayoutEffect, + type ReactElement, + type ReactNode, +} from "react"; +import { create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +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", () => ({ useAtomValue: () => [] })); +vi.mock("~/state/server", () => ({ + primaryServerKeybindingsAtom: {}, + serverEnvironment: { configValueAtom: () => null }, +})); +// This client holds every scope, so files can be read, written and previewed. +vi.mock("~/state/filesystem", () => ({ + useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }), +})); +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +// The desktop app hosts these browsers itself, so no environment's server browser is consulted. +vi.mock("~/browser/previewRuntime", async (importOriginal) => ({ + ...(await importOriginal()), + previewRuntimeFor: () => undefined, + isPreviewAvailableFor: () => true, + usePreviewAvailable: () => true, +})); +vi.mock("~/state/environments", () => ({ + useEnvironmentHttpBaseUrl: () => "http://localhost:3773/", + usePrimaryEnvironmentId: () => null, +})); +vi.mock("~/remoteOpen", () => ({ useRemoteOpenState: () => ({ mode: "local-exec" }) })); +vi.mock("~/hooks/useSettings", () => ({ + useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + select(DEFAULT_CLIENT_SETTINGS), + useUpdateClientSettings: () => vi.fn(), +})); +vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }) })); +// The panel's only command and query runner: open a preview session, create an asset URL. +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: () => () => { + late.previewRequests += 1; + return late.session.promise; + }, +})); +vi.mock("~/state/use-atom-query-runner", () => ({ + useAtomQueryRunner: () => () => late.assetUrl.promise, +})); +vi.mock("~/browser/browserDefaults", async (importOriginal) => ({ + ...(await importOriginal()), + resolveBrowserDefaults: () => + late.browserDefaults ?? Promise.resolve({ viewport: { _tag: "fill" }, profileId: "default" }), +})); +vi.mock("~/components/files/projectFilesQueryState", async (importOriginal) => ({ + ...(await importOriginal()), + useProjectFileQuery: () => ({ + 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: () => null, + useFileTree: (options: { + composition: { contextMenu: { onOpen: (item: unknown, context: unknown) => void } }; + onSelectionChange: (paths: ReadonlyArray) => void; + }) => { + late.openTreeMenu = options.composition.contextMenu.onOpen; + late.selectTreeRows ??= options.onSelectionChange; + return { + model: { + isSearchOpen: () => false, + 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/PierreEntryIcon", () => ({ PierreEntryIcon: () => null })); +vi.mock("~/components/ui/tooltip", () => ({ + Tooltip: Wrapper, + TooltipTrigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => + render ? cloneElement(render, {}, children) : children, + TooltipPopup: () => null, +})); +// Menus render through a portal; these stand-ins keep their open and pick semantics. +vi.mock("~/components/ui/menu", () => ({ + Menu: ({ + children, + onOpenChange, + }: { + children?: ReactNode; + onOpenChange: (open: boolean) => void; + }) => {children}, + MenuTrigger: function MenuTrigger({ render }: { render: ReactElement }) { + const onOpenChange = use(MenuContext); + return cloneElement(render as ReactElement<{ onClick: () => void }>, { + onClick: () => onOpenChange(true), + }); + }, + MenuPopup: Wrapper, + MenuGroup: Wrapper, + MenuItem: Wrapper, + MenuSeparator: () => null, + MenuRadioGroup: ({ + children, + onValueChange, + }: { + children?: ReactNode; + onValueChange: (value: string) => void; + }) => {children}, + MenuRadioItem: function MenuRadioItem({ + children, + value, + }: { + children?: ReactNode; + value: string; + }) { + const onValueChange = use(RadioContext); + return ( + + ); + }, +})); + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +// The same thread id on two environments is two threads. +const refOn = (environmentId: string, thread = "thread-a"): ScopedThreadRef => ({ + environmentId: EnvironmentId.make(environmentId), + threadId: ThreadId.make(thread), +}); +let renderer: ReactTestRenderer | undefined; + +// 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( + 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} + selectedFilePending={false} + /> + + + + + ); + await act(async () => { + composerRef.current = { + insertTextAtEnd: composerOf(threadRef), + } as unknown as ChatComposerHandle; + if (renderer) renderer.update(element); + else renderer = create(element); + }); +} + +async function press(node: ReactTestInstance) { + await act(async () => node.props.onClick()); +} + +async function openSrcFolder() { + const browse = renderer!.root.findAll( + (node) => node.type === "button" && node.props["aria-label"] === "Browse src", + ); + expect(browse).toHaveLength(1); + await press(browse[0]!); +} + +const siblingValues = () => + renderer!.root + .findAll((node) => node.type === "button" && node.props["data-value"] !== undefined) + .map((node) => node.props["data-value"] as string); + +const surfacesOf = (threadRef: ScopedThreadRef) => + useRightPanelStore.getState().byThreadKey[scopedThreadKey(threadRef)]?.surfaces; + +// Right-clicks a tree row and returns a way to pick from the native menu later. +function rightClickTreeRow() { + late.menuChoice = late.deferred(); + const closed = late.deferred(); + late.openTreeMenu!( + { path: "src/a.ts" }, + { + anchorElement: { getBoundingClientRect: () => ({ left: 0, bottom: 0 }) }, + close: closed.resolve, + }, + ); + return (choice: string) => + act(async () => { + late.menuChoice.resolve(choice); + await closed.promise; + }); +} + +const insertedAnywhere = () => [...composers.values()].some((insert) => insert.mock.calls.length); + +const browsersOf = (threadRef: ScopedThreadRef) => + (surfacesOf(threadRef) ?? []).filter((surface) => surface.kind === "preview"); + +// Presses "Open file in preview browser" with the asset URL and preview session still pending. +async function openInBrowser() { + late.assetUrl = late.deferred(); + late.session = late.deferred(); + await press( + renderer!.root.find( + (node) => + node.type === "button" && node.props["aria-label"] === "Open file in preview browser", + ), + ); +} + +const settleAsset = () => + act(async () => { + late.assetUrl.resolve(AsyncResult.success({ relativeUrl: "/a/index.html", expiresAt: 0 })); + }); +const settleSession = () => + act(async () => { + late.session.resolve( + AsyncResult.success({ + threadId: ThreadId.make("thread-a"), + tabId: "tab-1", + navStatus: { _tag: "Loading", url: "http://localhost:3773/a/index.html", title: "" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-10-04T00:00:00.000Z", + }), + ); + }); + +const failAsset = () => + act(async () => { + late.assetUrl.resolve(AsyncResult.failure(Cause.fail(new Error("asset unavailable")))); + }); +const failSession = () => + act(async () => { + late.session.resolve(AsyncResult.failure(Cause.fail(new Error("preview unavailable")))); + }); +const browserErrorToasts = () => + vi + .mocked(toastManager.add) + .mock.calls.filter(([toast]) => toast.title === "Unable to open file in browser"); + +// Transform the lazy body once up front, so mounting it settles inside one act(). +beforeAll(() => import("./FilesSidePanel"), 30_000); +beforeEach(() => { + late.selectTreeRows = null; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const storage = new Map(); + vi.stubGlobal("document", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); + vi.stubGlobal("window", { + desktopBridge: { preview: {} }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }, + }); + siblingsByEnvironment.clear(); + composers.clear(); + late.previewRequests = 0; + late.browserDefaults = null; + vi.spyOn(toastManager, "add"); + resetPreviewStateForTests(); + siblingsByEnvironment.set("environment-a", ["src/a.ts", "src/b.ts"]); + siblingsByEnvironment.set("environment-b", ["src/a.ts", "src/c.ts"]); + useRightPanelStore.setState({ + byThreadKey: {}, + threadPanelVisibilityByThreadKey: {}, + userActionRevisionByThreadKey: {}, + }); +}); +afterEach(() => { + act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("files side panel", () => { + it("opens a sibling file as a tab in the host's own thread", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef); + await openSrcFolder(); + expect(siblingValues()).toEqual(["src/a.ts", "src/b.ts"]); + + await press( + renderer!.root.find( + (node) => node.type === "button" && node.props["data-value"] === "src/b.ts", + ), + ); + expect(surfacesOf(threadRef)).toMatchObject([{ kind: "file", relativePath: "src/b.ts" }]); + expect(surfacesOf(refOn("environment-b"))).toBeUndefined(); + }); + + it("follows the host to another environment's thread with the same id", async () => { + await renderFileFor(refOn("environment-a")); + const threadRef = refOn("environment-b"); + await renderFileFor(threadRef); + await openSrcFolder(); + expect(siblingValues()).toEqual(["src/a.ts", "src/c.ts"]); + + await press( + renderer!.root.find( + (node) => node.type === "button" && node.props["data-value"] === "src/c.ts", + ), + ); + expect(surfacesOf(threadRef)).toMatchObject([{ kind: "file", relativePath: "src/c.ts" }]); + expect(surfacesOf(refOn("environment-a"))).toBeUndefined(); + }); + + it("opens a tree click in the thread showing now, not the one the tree first showed", async () => { + // Threads in the same project share one tree, so it outlives the switch. + await renderFileFor(refOn("environment-a")); + const threadRef = refOn("environment-a", "thread-b"); + await renderFileFor(threadRef); + + await act(async () => late.selectTreeRows!(["README.md"])); + expect(surfacesOf(threadRef)).toMatchObject([{ kind: "file", relativePath: "README.md" }]); + expect(surfacesOf(refOn("environment-a"))).toBeUndefined(); + }); + + it("opens a tree click that lands before the switch's passive effects in the new thread", async () => { + await renderFileFor(refOn("environment-a")); + const threadRef = refOn("environment-a", "thread-b"); + await renderFileFor(threadRef, "src/a.ts", threadRef, () => + late.selectTreeRows!(["README.md"]), + ); + + expect(surfacesOf(threadRef)).toMatchObject([{ kind: "file", relativePath: "README.md" }]); + expect(surfacesOf(refOn("environment-a"))).toBeUndefined(); + }); +}); + +describe("files actions that settle late", () => { + it("adds a tree entry to the chat of the thread it was picked in", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, null); + const pick = rightClickTreeRow(); + await pick("add-to-chat"); + expect(composerOf(threadRef)).toHaveBeenCalledExactlyOnceWith("[a.ts](src/a.ts) ", { + ensureLeadingBoundary: true, + }); + }); + + it("drops a late Add to chat after moving to the same thread id in another environment", async () => { + await renderFileFor(refOn("environment-a"), null); + const pick = rightClickTreeRow(); + await renderFileFor(refOn("environment-b"), null); + await pick("add-to-chat"); + expect(insertedAnywhere()).toBe(false); + }); + + it("drops a late Add to chat after moving to another thread in the project", async () => { + await renderFileFor(refOn("environment-a"), null); + const pick = rightClickTreeRow(); + await renderFileFor(refOn("environment-a", "thread-other"), null); + await pick("add-to-chat"); + expect(insertedAnywhere()).toBe(false); + + // The new thread's own menu still reaches its composer. + await rightClickTreeRow()("add-to-chat"); + expect(composerOf(refOn("environment-a", "thread-other"))).toHaveBeenCalledOnce(); + }); + + it("keeps a late Add to chat dropped after leaving and returning to the thread", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, null); + const pick = rightClickTreeRow(); + await renderFileFor(refOn("environment-a", "thread-other"), null); + await renderFileFor(threadRef, null); + await pick("add-to-chat"); + expect(insertedAnywhere()).toBe(false); + }); + + it("opens a Browser tab in the thread that is still showing", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, "index.html"); + await openInBrowser(); + await settleAsset(); + await settleSession(); + expect(browsersOf(threadRef).map((surface) => surface.id)).toEqual(["browser:tab-1"]); + }); + + it("still opens a browser after the composer switches drafts in the same thread", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, "index.html"); + await openInBrowser(); + // Editing a queued message moves the composer to a draft; the thread stays. + await renderFileFor(threadRef, "index.html", "draft-1" as PanelHost["composerDraftTarget"]); + await settleAsset(); + await settleSession(); + expect(browsersOf(threadRef).map((surface) => surface.id)).toEqual(["browser:tab-1"]); + }); + + it("never asks for a browser when the thread was left while the file was prepared", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, "index.html"); + await openInBrowser(); + await renderFileFor(refOn("environment-b"), "index.html"); + await settleAsset(); + expect(late.previewRequests).toBe(0); + expect(browsersOf(threadRef)).toEqual([]); + expect(browsersOf(refOn("environment-b"))).toEqual([]); + }); + + it("keeps a browser the server opened after the thread was left in that thread", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, "index.html"); + await openInBrowser(); + await settleAsset(); + expect(late.previewRequests).toBe(1); + await renderFileFor(refOn("environment-a", "thread-other"), "index.html"); + await settleSession(); + expect(readThreadPreviewState(threadRef).snapshot?.tabId).toBe("tab-1"); + expect(browsersOf(threadRef).map((surface) => surface.id)).toEqual(["browser:tab-1"]); + expect(browsersOf(refOn("environment-a", "thread-other"))).toEqual([]); + }); + + it("keeps a late open dropped after leaving and returning to the thread", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, "index.html"); + await openInBrowser(); + await renderFileFor(refOn("environment-a", "thread-other"), "index.html"); + await renderFileFor(threadRef, "index.html"); + await settleAsset(); + expect(late.previewRequests).toBe(0); + expect(browsersOf(threadRef)).toEqual([]); + }); + + it("reports a browser failure in the thread that is still showing", async () => { + await renderFileFor(refOn("environment-a"), "index.html"); + await openInBrowser(); + await failAsset(); + expect(browserErrorToasts()).toHaveLength(1); + }); + + it("stays silent when the file fails to prepare after the thread was left", async () => { + await renderFileFor(refOn("environment-a"), "index.html"); + await openInBrowser(); + await renderFileFor(refOn("environment-b"), "index.html"); + await failAsset(); + expect(browserErrorToasts()).toEqual([]); + }); + + it("stays silent when browser settings fail to load after the panel closed", async () => { + await renderFileFor(refOn("environment-a"), "index.html"); + let rejectSettings!: (error: Error) => void; + late.browserDefaults = new Promise((_, reject) => (rejectSettings = reject)); + await openInBrowser(); + await settleAsset(); + act(() => renderer!.unmount()); + renderer = undefined; + await act(async () => rejectSettings(new Error("settings unreadable"))); + expect(late.previewRequests).toBe(0); + expect(browserErrorToasts()).toEqual([]); + }); + + it("stays silent when a browser fails to open after leaving and returning", async () => { + const threadRef = refOn("environment-a"); + await renderFileFor(threadRef, "index.html"); + await openInBrowser(); + await settleAsset(); + await renderFileFor(refOn("environment-a", "thread-other"), "index.html"); + await renderFileFor(threadRef, "index.html"); + await failSession(); + expect(browserErrorToasts()).toEqual([]); + }); +}); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/panels/files/FilesSidePanel.tsx similarity index 94% rename from apps/web/src/components/files/FilePreviewPanel.tsx rename to apps/web/src/panels/files/FilesSidePanel.tsx index efd7a7fbc90f..c3b97c4c5909 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/panels/files/FilesSidePanel.tsx @@ -3,9 +3,9 @@ import { AuthPreviewOperateScope, type EditorId, type EnvironmentId, - type ResolvedKeybindingsConfig, type ScopedThreadRef, } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview"; import { AuthFilesystemWriteScope } from "@t3tools/contracts"; import { @@ -30,12 +30,13 @@ 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, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { FolderTree, Globe2, WrapTextIcon } from "lucide-react"; import { Code2, Eye, Table2 } from "lucide"; @@ -63,21 +64,23 @@ import { ScrollArea } from "~/components/ui/scroll-area"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { buildFileReviewComment } from "~/reviewCommentContext"; +import { useRightPanelStore } from "~/rightPanelStore"; import { assetEnvironment } from "~/state/assets"; import { usePreviewAvailable } from "~/browser/previewRuntime"; import { useEnvironmentHttpBaseUrl, usePrimaryEnvironmentId } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { useEnvironmentScope } from "~/state/session"; +import { primaryServerKeybindingsAtom } from "~/state/server"; import { useAtomCommand } from "~/state/use-atom-command"; import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; -import { AttachmentFilePreview } from "./AttachmentFilePreview"; -import { AudioPreview } from "./AudioPreview"; -import { BrowserDocumentFrame, isPdfPreviewFile } from "./BrowserDocumentFrame"; -import { DelimitedTablePreview } from "./DelimitedTablePreview"; -import FileBrowserPanel from "./FileBrowserPanel"; -import { FileBreadcrumbs } from "./FileBreadcrumbs"; -import { FileMarkdownPreview } from "./FileMarkdownPreview"; +import { AttachmentFilePreview } from "~/components/files/AttachmentFilePreview"; +import { AudioPreview } from "~/components/files/AudioPreview"; +import { BrowserDocumentFrame, isPdfPreviewFile } from "~/components/files/BrowserDocumentFrame"; +import { DelimitedTablePreview } from "~/components/files/DelimitedTablePreview"; +import FileBrowserPanel from "~/components/files/FileBrowserPanel"; +import { FileBreadcrumbs } from "~/components/files/FileBreadcrumbs"; +import { FileMarkdownPreview } from "~/components/files/FileMarkdownPreview"; import { type FileCommentAnnotationEntry, type FileCommentAnnotationGroup, @@ -86,8 +89,8 @@ import { nextFileCommentId, normalizeFileCommentRange, remapFileCommentAnnotations, -} from "./fileCommentAnnotations"; -import { installFileEditorDismissal } from "./fileEditorDismissal"; +} from "~/components/files/fileCommentAnnotations"; +import { installFileEditorDismissal } from "~/components/files/fileEditorDismissal"; import { FILE_LINK_REVEAL_ATTRIBUTE, FILE_LINK_REVEAL_UNSAFE_CSS, @@ -95,11 +98,11 @@ import { FileSurfaceAction, FileSurfaceFailure, FileSurfaceLoading, -} from "./fileSurfaceChrome"; -import SourceFilePreview from "./ReadOnlySourcePreview"; -import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; -import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; -import { projectFileCacheKey } from "./fileContentRevision"; +} from "~/components/files/fileSurfaceChrome"; +import SourceFilePreview from "~/components/files/ReadOnlySourcePreview"; +import { resolveCenteredFileLineScrollTop } from "~/components/files/fileLineReveal"; +import { DiffCommentAnnotation } from "~/components/diffs/DiffCommentAnnotation"; +import { projectFileCacheKey } from "~/components/files/fileContentRevision"; import { filePreviewReadErrorMessage, isMarkdownPreviewFile, @@ -107,31 +110,28 @@ import { setMarkdownTaskChecked, shouldShowFileExplorer, workspaceAssetResource, -} from "./filePreviewMode"; -import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; +} from "~/components/files/filePreviewMode"; +import { useFileSaveCoordinator } from "~/components/files/useFileSaveCoordinator"; import { getOptimisticProjectFileQueryData, getProjectFileContents, setProjectFileQueryData, useProjectFileQuery, -} from "./projectFilesQueryState"; +} from "~/components/files/projectFilesQueryState"; -interface FilePreviewPanelProps { - environmentId: EnvironmentId; +import { usePanelHost } from "../panelHost"; +import { useScopedComposerInsert, useScopeLifetime } from "./fileScope"; + +interface FilesSidePanelProps { cwd: string; projectName: string; relativePath: string | null; attachment?: ChatFileAttachment; - threadRef: ScopedThreadRef; - composerDraftTarget: ScopedThreadRef | DraftId; - keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; revealLine: number | null; revealRequestId: number; - onOpenFile: (relativePath: string) => void; onPendingChange: (relativePath: string, pending: boolean) => void; selectedFilePending: boolean; - workspaceMutationId: string | null; } const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; @@ -1025,23 +1025,38 @@ function initialExplorerOpen(): boolean { } } -export default function FilePreviewPanel({ - environmentId, +// Renders both the Files explorer surface and single file surfaces. +export default function FilesSidePanel({ cwd, projectName, relativePath: requestedPath, attachment, - threadRef, - composerDraftTarget, - keybindings, availableEditors, revealLine, revealRequestId, - onOpenFile, onPendingChange, selectedFilePending, - workspaceMutationId, -}: FilePreviewPanelProps) { +}: FilesSidePanelProps) { + const { threadRef, composerDraftTarget, workspaceMutationId } = usePanelHost(); + const environmentId = threadRef.environmentId; + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const onOpenFile = useCallback( + (path: string) => useRightPanelStore.getState().openFile(threadRef, path), + [threadRef], + ); + // Menu actions settle late; they are dropped once the host moves to another + // thread or draft, never applied to the newer one. + const isScopeCurrent = useScopeLifetime( + `${scopedThreadKey(threadRef)}|${ + typeof composerDraftTarget === "string" + ? composerDraftTarget + : scopedThreadKey(composerDraftTarget) + }`, + ); + const addToChat = useScopedComposerInsert(isScopeCurrent); + // Opening a browser belongs to the thread, not the draft: entering or + // leaving queued-message editing keeps the thread on screen. + const isThreadVisitCurrent = useScopeLifetime(scopedThreadKey(threadRef)); const relativePath = attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath; // A draft's composer target is its draft id; a thread the server knows is a ref. @@ -1203,13 +1218,19 @@ 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)) { + // Errors are reported only to the visit that started the open. + if ( + result._tag === "Success" || + isAtomCommandInterrupted(result) || + !isThreadVisitCurrent() + ) { return; } const error = squashAtomCommandFailure(result); @@ -1228,6 +1249,7 @@ export default function FilePreviewPanel({ createAssetUrl, cwd, environmentHttpBaseUrl, + isThreadVisitCurrent, openPreview, threadRef, ]); @@ -1495,6 +1517,7 @@ export default function FilePreviewPanel({ selectedPathRevealId={revealRequestId} onOpenFile={onOpenFile} workspaceMutationId={workspaceMutationId} + addToChat={addToChat} {...(previewPath && !isMedia && !isPdf ? { onRefreshSelectedFile: file.refresh } : {})} diff --git a/apps/web/src/panels/files/fileScope.ts b/apps/web/src/panels/files/fileScope.ts new file mode 100644 index 000000000000..7ba1fbc93385 --- /dev/null +++ b/apps/web/src/panels/files/fileScope.ts @@ -0,0 +1,46 @@ +import { useCallback, useLayoutEffect, useRef, useState } from "react"; + +import type { AddToChatResult } from "~/components/files/FileBrowserPanel"; +import { useComposerHandleContext } from "~/composerHandleContext"; + +/** + * Tells async work started in one visit to a scope whether the panel is still + * on that visit. The returned check is stable for the visit, so work keeps the + * check from the visit it started in. It turns false once the panel moves to + * another scope (another thread, or the same thread id in another environment) + * or unmounts, and stays false if the panel later returns to the same scope. + */ +export function useScopeLifetime(scopeKey: string): () => boolean { + // A fresh visit object each time the scope changes, so A -> B -> A is a new visit. + const [visit, setVisit] = useState(() => ({ scopeKey })); + if (visit.scopeKey !== scopeKey) setVisit({ scopeKey }); + const liveRef = useRef(null); + // Set on commit, so a discarded render never retires live work. + useLayoutEffect(() => { + liveRef.current = visit; + return () => { + liveRef.current = null; + }; + }, [visit]); + return useCallback(() => liveRef.current === visit, [visit]); +} + +/** + * Inserts text at the end of the layout's chat composer while `isScopeCurrent` + * holds. The composer ref is shared by the whole chat layout, so an action that + * settles after navigation is dropped instead of reaching the newer thread. + */ +export function useScopedComposerInsert(isScopeCurrent: () => boolean) { + const composerRef = useComposerHandleContext(); + return useCallback( + (text: string): AddToChatResult => { + if (!isScopeCurrent()) return "dropped"; + const composer = composerRef?.current; + if (!composer) return "no-composer"; + return composer.insertTextAtEnd(text, { ensureLeadingBoundary: true }) + ? "inserted" + : "not-ready"; + }, + [composerRef, isScopeCurrent], + ); +} diff --git a/apps/web/src/panels/panelHost.test.ts b/apps/web/src/panels/panelHost.test.ts new file mode 100644 index 000000000000..5a2423868c5b --- /dev/null +++ b/apps/web/src/panels/panelHost.test.ts @@ -0,0 +1,77 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId, type PreviewAnnotationPayload } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { threadBoundAnnotationSender, type ThreadAnnotationSender } from "./panelHost"; + +const THREAD_A = scopedThreadKey( + scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-a")), +); +const THREAD_B = scopedThreadKey( + scopeThreadRef(EnvironmentId.make("environment-1"), ThreadId.make("thread-b")), +); +const THREAD_A_ELSEWHERE = scopedThreadKey( + scopeThreadRef(EnvironmentId.make("environment-2"), ThreadId.make("thread-a")), +); + +const annotation: PreviewAnnotationPayload = { + id: "annotation-1", + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + comment: "Tighten this spacing", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-07-27T00:00:00.000Z", +}; + +describe("threadBoundAnnotationSender", () => { + it("drops a pick that settles after the chat view moved to another thread", async () => { + const sendA = vi.fn(); + const sendB = vi.fn(); + const senderRef: { current: ThreadAnnotationSender | null } = { + current: { threadKey: THREAD_A, send: sendA }, + }; + const hostSendForA = threadBoundAnnotationSender(() => senderRef.current, THREAD_A); + + let resolvePick!: (value: PreviewAnnotationPayload) => void; + const pick = new Promise((resolve) => { + resolvePick = resolve; + }); + const settled = pick.then((picked) => hostSendForA(picked, null)); + + // The shared ChatView renders thread B before A's pick is cancelled. + senderRef.current = { threadKey: THREAD_B, send: sendB }; + resolvePick(annotation); + await settled; + + expect(sendB).not.toHaveBeenCalled(); + expect(sendA).not.toHaveBeenCalled(); + }); + + it("treats the same thread id in another environment as a different thread", () => { + const sendElsewhere = vi.fn(); + const senderRef = { current: { threadKey: THREAD_A_ELSEWHERE, send: sendElsewhere } }; + + threadBoundAnnotationSender(() => senderRef.current, THREAD_A)(annotation, null); + + expect(sendElsewhere).not.toHaveBeenCalled(); + }); + + it("uses the newest sender after a same-thread rerender", () => { + const firstRender = vi.fn(); + const latestRender = vi.fn(); + const senderRef: { current: ThreadAnnotationSender | null } = { + current: { threadKey: THREAD_A, send: firstRender }, + }; + const hostSend = threadBoundAnnotationSender(() => senderRef.current, THREAD_A); + + senderRef.current = { threadKey: THREAD_A, send: latestRender }; + hostSend(annotation, null); + + expect(firstRender).not.toHaveBeenCalled(); + expect(latestRender).toHaveBeenCalledWith(annotation, null); + }); +}); diff --git a/apps/web/src/panels/panelHost.ts b/apps/web/src/panels/panelHost.ts new file mode 100644 index 000000000000..09805d1f3227 --- /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.test.tsx b/apps/web/src/panels/panelRegistry.test.tsx new file mode 100644 index 000000000000..b2c81ab122a3 --- /dev/null +++ b/apps/web/src/panels/panelRegistry.test.tsx @@ -0,0 +1,144 @@ +import { act, Suspense, useEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createPanelRegistry } from "./panelRegistry"; + +const metadata = { + icon: () => null, + launcherKey: "X", + unavailableHint: "Unavailable.", + unavailableReason: "Unavailable here.", +} as const; + +// These exercise lazy evaluation and actual mount/disposal, without inspecting markup. +describe("panel registry", () => { + it("rejects duplicate ids without loading either panel", () => { + const load = vi.fn(); + const definition = { ...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 () => { + 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; + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create( + + + , + ); + }); + expect(load).toHaveBeenCalledTimes(1); + expect(mounted).not.toHaveBeenCalled(); + await act(async () => { + finish({ default: Panel }); + await loaded; + }); + expect(mounted).toHaveBeenCalledTimes(1); + // A parent re-render looks the panel up again; it must keep the same mount and state. + const Again = registry.get("diff").Component; + expect(Again).toBe(Component); + await act(async () => { + renderer.update( + + + , + ); + }); + expect(load).toHaveBeenCalledTimes(1); + expect(mounted).toHaveBeenCalledTimes(1); + expect(disposed).not.toHaveBeenCalled(); + await act(async () => { + renderer.unmount(); + }); + expect(disposed).toHaveBeenCalledTimes(1); + }); + + it("keeps heterogeneous panels independent and their lazy identities stable", async () => { + const events: string[] = []; + function Notes({ text }: { text: string }) { + useEffect(() => { + events.push(`mount notes:${text}`); + return () => { + events.push("dispose notes"); + }; + }, [text]); + return null; + } + function Counter({ count }: { count: number }) { + useEffect(() => { + events.push(`mount counter:${count}`); + }, [count]); + return null; + } + const loadNotes = vi.fn(async () => ({ default: Notes })); + const loadCounter = vi.fn(async () => ({ default: Counter })); + const registry = createPanelRegistry([ + { ...metadata, id: "notes", title: "Notes", load: loadNotes }, + { ...metadata, id: "counter", title: "Counter", load: loadCounter }, + ]); + const NotesPanel = registry.get("notes").Component; + // Compile-only: the project typecheck rejects these pairings. + const compileOnly = () => [ + // @ts-expect-error Missing required text. + , + // @ts-expect-error Counter's props on Notes. + , + // @ts-expect-error Unknown id. + registry.get("missing"), + ]; + expect(compileOnly).toBeTypeOf("function"); + expect(registry.get("notes").Component).toBe(NotesPanel); + expect(loadNotes).not.toHaveBeenCalled(); + expect(loadCounter).not.toHaveBeenCalled(); + + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create( + + + , + ); + }); + await act(async () => { + renderer.update( + + + , + ); + }); + expect(loadNotes).toHaveBeenCalledTimes(1); + expect(loadCounter).not.toHaveBeenCalled(); + expect(events).toEqual(["mount notes:a"]); + + const CounterPanel = registry.get("counter").Component; + await act(async () => { + renderer.update( + + + , + ); + }); + expect(loadCounter).toHaveBeenCalledTimes(1); + expect(events).toEqual(["mount notes:a", "dispose notes", "mount counter:2"]); + expect(registry.get("counter").Component).toBe(CounterPanel); + }); +}); diff --git a/apps/web/src/panels/panelRegistry.ts b/apps/web/src/panels/panelRegistry.ts new file mode 100644 index 000000000000..b3b925413a50 --- /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.test.tsx b/apps/web/src/panels/preview/PreviewSidePanel.test.tsx new file mode 100644 index 000000000000..f71ed8e97828 --- /dev/null +++ b/apps/web/src/panels/preview/PreviewSidePanel.test.tsx @@ -0,0 +1,275 @@ +import { + BUILT_IN_BROWSER_PROFILES, + DEFAULT_BROWSER_PROFILE_ID, + DEFAULT_PREVIEW_APPEARANCE, + DEFAULT_PREVIEW_ZOOM_FACTOR, + EnvironmentId, + FILL_PREVIEW_VIEWPORT, + ThreadId, + type PreviewAnnotationPayload, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { act, useEffect, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +// Drives the registered Preview through the real RegisteredSidePanel -> +// PreviewSidePanel -> PreviewView path. Only the desktop bridge, stores and +// leaf chrome are stubbed; the chrome row stub exposes the pick button. +const mocks = vi.hoisted(() => ({ + pickElement: vi.fn(), + cancelPickElement: vi.fn(async (_runtimeTabId: string) => undefined), + addPreviewAnnotation: vi.fn(), + togglePick: null as (() => void) | null, + pickActive: false, + mounts: 0, + disposals: 0, +})); + +const BROWSER_DEFAULTS = { + viewport: FILL_PREVIEW_VIEWPORT, + zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, + appearance: DEFAULT_PREVIEW_APPEARANCE, + autoShowFloatingPreview: true, + profiles: BUILT_IN_BROWSER_PROFILES, + profileId: DEFAULT_BROWSER_PROFILE_ID, +}; + +vi.mock("~/components/preview/previewBridge", () => ({ + previewBridge: { pickElement: mocks.pickElement, cancelPickElement: mocks.cancelPickElement }, +})); +vi.mock("~/components/preview/usePreviewSession", () => ({ + // Lives exactly as long as the PreviewView instance, so it counts remounts. + usePreviewSession: () => { + useEffect(() => { + mocks.mounts += 1; + return () => { + mocks.disposals += 1; + }; + }, []); + }, +})); +vi.mock("~/components/preview/PreviewChromeRow", () => ({ + PreviewChromeRow: (props: { onPickElement?: () => void; pickActive?: boolean }) => { + mocks.togglePick = props.onPickElement ?? null; + mocks.pickActive = props.pickActive ?? false; + return null; + }, +})); +vi.mock("~/components/preview/PreviewPanelShell", () => ({ + PreviewPanelShell: (props: { children: ReactNode }) => props.children, +})); +vi.mock("~/components/preview/PreviewEmptyState", () => ({ PreviewEmptyState: () => null })); +vi.mock("~/components/preview/PreviewMoreMenu", () => ({ PreviewMoreMenu: () => null })); +vi.mock("~/components/preview/PreviewUnreachable", () => ({ PreviewUnreachable: () => null })); +vi.mock("~/components/preview/ZoomIndicator", () => ({ ZoomIndicator: () => null })); +vi.mock("~/components/preview/AgentBrowserCursor", () => ({ AgentBrowserCursor: () => null })); +vi.mock("~/browser/BrowserSurfaceSlot", () => ({ BrowserSurfaceSlot: () => null })); +vi.mock("~/browser/browserSurfaceStore", () => ({ + useBrowserSurfaceStore: (select: (state: { byTabId: object }) => unknown) => + select({ byTabId: {} }), +})); +vi.mock("~/browser/browserDefaults", () => ({ + useBrowserDefaults: () => BROWSER_DEFAULTS, + getBrowserDefaults: () => BROWSER_DEFAULTS, + browserResponsiveViewportForToggle: () => FILL_PREVIEW_VIEWPORT, +})); +vi.mock("~/browser/browserRecording", () => ({ + findActiveBrowserRecordingRuntimeTabId: () => null, + isBrowserRecordingStartCancelledError: () => false, + startBrowserRecording: vi.fn(), + stopBrowserRecording: vi.fn(), + useActiveBrowserRecordingTabIds: () => new Set(), +})); +vi.mock("~/browserHistoryStore", () => ({ + BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT: 50, + recordVisitForThread: vi.fn(), + removeUrlForThread: vi.fn(), + setTitleForThreadUrl: vi.fn(), + useThreadRecentHistory: () => [], +})); +vi.mock("~/composerDraftStore", () => ({ + useComposerDraftStore: (select: (store: object) => unknown) => + select({ addPreviewAnnotation: mocks.addPreviewAnnotation, addImage: vi.fn() }), +})); +vi.mock("~/localApi", () => ({ ensureLocalApi: vi.fn() })); +vi.mock("~/previewStateStore", () => ({ + isPreviewSupportedInRuntime: () => true, + rememberPreviewUrl: vi.fn(), + updatePreviewServerSnapshot: vi.fn(), + useThreadPreviewState: (threadRef: ScopedThreadRef) => ({ + activeTabId: "tab-1", + serverEpoch: null, + desktopByTabId: {}, + recentlySeenUrls: [], + sessions: { + "tab-1": { + threadId: threadRef.threadId, + tabId: "tab-1", + navStatus: { _tag: "Success", url: "http://localhost:3000/", title: "App" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-10-04T00:00:00.000Z", + }, + }, + }), +})); +vi.mock("~/previewMiniPlayerStore", () => ({ + browserMiniPlayerSource: (tabId: string) => ({ kind: "browser", tabId }), + selectThreadPreviewMiniPlayerTabId: () => null, + usePreviewMiniPlayerStore: Object.assign( + (select: (state: { byThreadKey: object }) => unknown) => select({ byThreadKey: {} }), + { getState: () => ({ open: vi.fn(), close: vi.fn() }) }, + ), +})); +vi.mock("~/rightPanelStore", () => ({ + useRightPanelStore: { getState: () => ({ close: vi.fn() }) }, +})); +vi.mock("~/state/environments", () => ({ + useEnvironment: () => ({ label: "Local" }), + useEnvironmentHttpBaseUrl: () => "http://localhost:3773", + usePrimaryEnvironmentId: () => null, +})); +vi.mock("~/state/preview", () => ({ previewEnvironment: { open: {}, resize: {} } })); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +// This client holds every scope, so preview and annotation sends stay enabled. +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +vi.mock("~/components/ui/toast", () => ({ + stackedThreadToast: vi.fn(), + toastManager: { add: vi.fn() }, +})); + +import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +function thread(id: string): ScopedThreadRef { + return { environmentId: EnvironmentId.make("environment-1"), threadId: ThreadId.make(id) }; +} + +function hostFor(threadRef: ScopedThreadRef): PanelHost { + // A fresh closure per render, as ChatView lends it. + return { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: vi.fn(), + }; +} + +function panel(host: PanelHost) { + return ( + + + + ); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + +const annotation: PreviewAnnotationPayload = { + id: "annotation-1", + pageUrl: "http://localhost:3000/", + pageTitle: "App", + comment: "Tighten this spacing", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-10-04T00:00:00.000Z", +}; + +describe("registered Preview side panel", () => { + let renderer: ReactTestRenderer | null = null; + + // The registry loads the body lazily; warm the module so a render settles in one act. + beforeAll(() => import("./PreviewSidePanel")); + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + mocks.pickElement.mockReset(); + mocks.cancelPickElement.mockClear(); + mocks.addPreviewAnnotation.mockClear(); + mocks.togglePick = null; + mocks.pickActive = false; + mocks.mounts = 0; + mocks.disposals = 0; + }); + + afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = null; + vi.unstubAllGlobals(); + }); + + async function render(host: PanelHost) { + await act(async () => { + if (renderer) renderer.update(panel(host)); + else renderer = create(panel(host)); + }); + } + + async function startPick() { + const pick = deferred<{ annotation: PreviewAnnotationPayload; submission: "send" } | null>(); + mocks.pickElement.mockReturnValueOnce(pick.promise); + await act(async () => mocks.togglePick?.()); + return pick; + } + + it("keeps an in-flight pick across a same-thread host re-render", async () => { + const threadA = thread("thread-a"); + const first = hostFor(threadA); + await render(first); + const pick = await startPick(); + expect(mocks.pickActive).toBe(true); + + const second = hostFor({ ...threadA }); + await render(second); + expect(mocks).toMatchObject({ mounts: 1, disposals: 0, pickActive: true }); + expect(mocks.cancelPickElement).not.toHaveBeenCalled(); + + await act(async () => pick.resolve({ annotation, submission: "send" })); + expect(mocks.pickActive).toBe(false); + // The pick reports to the render that started it, as before the registry. + expect(first.sendAnnotation).toHaveBeenCalledWith(annotation, null); + expect(second.sendAnnotation).not.toHaveBeenCalled(); + expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(threadA, annotation); + }); + + it("stays mounted across a thread switch and drops a pick that settles after it", async () => { + const threadA = thread("thread-a"); + const threadB = thread("thread-b"); + const hostA = hostFor(threadA); + await render(hostA); + const pick = await startPick(); + expect(mocks.pickElement).toHaveBeenCalledWith(previewRuntimeTabId(threadA, null, "tab-1")); + + const hostB = hostFor(threadB); + await render(hostB); + expect(mocks).toMatchObject({ mounts: 1, disposals: 0, pickActive: false }); + // The old thread's picker is told to stop; the panel itself is not torn down. + expect(mocks.cancelPickElement).toHaveBeenCalledWith( + previewRuntimeTabId(threadA, null, "tab-1"), + ); + + // The switch cancelled the pick, so its late result reaches neither thread. + await act(async () => pick.resolve({ annotation, submission: "send" })); + expect(hostA.sendAnnotation).not.toHaveBeenCalled(); + expect(hostB.sendAnnotation).not.toHaveBeenCalled(); + expect(mocks.addPreviewAnnotation).not.toHaveBeenCalled(); + expect(mocks.disposals).toBe(0); + }); +}); diff --git a/apps/web/src/components/preview/PreviewPanel.tsx b/apps/web/src/panels/preview/PreviewSidePanel.tsx similarity index 58% rename from apps/web/src/components/preview/PreviewPanel.tsx rename to apps/web/src/panels/preview/PreviewSidePanel.tsx index 81501ae7d727..8a2cdc571b86 100644 --- a/apps/web/src/components/preview/PreviewPanel.tsx +++ b/apps/web/src/panels/preview/PreviewSidePanel.tsx @@ -1,43 +1,28 @@ "use client"; -import { - AuthPreviewOperateScope, - type PreviewAnnotationPayload, - type ScopedThreadRef, -} from "@t3tools/contracts"; +import { AuthPreviewOperateScope } from "@t3tools/contracts"; -import type { ComposerImageAttachment } from "~/composerDraftStore"; +import { PreviewPanelShell } from "~/components/preview/PreviewPanelShell"; +import { PreviewView } from "~/components/preview/PreviewView"; import { usePreviewAvailable } from "~/browser/previewRuntime"; import { useEnvironmentScope } from "~/state/session"; -import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell"; -import { PreviewView } from "./PreviewView"; +import { usePanelHost } from "../panelHost"; -interface Props { - mode: PreviewPanelMode; - threadRef: ScopedThreadRef; +interface PreviewSidePanelProps { tabId?: string | null; configuredUrls?: ReadonlyArray | undefined; - visible: boolean; - onSendAnnotation?: ( - annotation: PreviewAnnotationPayload, - image: ComposerImageAttachment | null, - ) => void; } -export function PreviewPanel({ - mode, - threadRef, - tabId, - configuredUrls, - visible, - onSendAnnotation, -}: Props) { +// RightPanelTabs owns placement, so the side panel is always embedded. +export default function PreviewSidePanel({ tabId, configuredUrls }: PreviewSidePanelProps) { + const { threadRef, visible, sendAnnotation } = usePanelHost(); + // The desktop app hosts browsers itself; other clients need an environment that runs them. const available = usePreviewAvailable(threadRef.environmentId); const canOperatePreview = useEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope); if (!canOperatePreview || !available) { return ( - +

{canOperatePreview @@ -50,13 +35,13 @@ export function PreviewPanel({ } return ( - + ); diff --git a/apps/web/src/panels/pullRequest/PullRequestPanelPending.tsx b/apps/web/src/panels/pullRequest/PullRequestPanelPending.tsx new file mode 100644 index 000000000000..2331e8aec07f --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestPanelPending.tsx @@ -0,0 +1,15 @@ +const BAR_WIDTHS = ["h-4 w-2/5", "h-3 w-3/5", "h-3 w-1/2"]; + +/** + * Shown while a pull request panel's code loads. It stays this small because the registry + * imports it eagerly; the panel's own ghosts take over once its code has arrived. + */ +export function PullRequestPanelPending({ label }: { label: string }) { + return ( +

+ {BAR_WIDTHS.map((width) => ( +
+ ))} +
+ ); +} diff --git a/apps/web/src/panels/pullRequest/PullRequestSidePanel.test.tsx b/apps/web/src/panels/pullRequest/PullRequestSidePanel.test.tsx new file mode 100644 index 000000000000..5fab05ddc83d --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestSidePanel.test.tsx @@ -0,0 +1,359 @@ +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 { act, Suspense, type ComponentProps, type ReactElement, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useComposerDraftStore } from "~/composerDraftStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +const { bodyImport, capabilities, Wrapper, Trigger } = vi.hoisted(() => ({ + // Holds the body's code until a test lets it arrive, like a slow first chunk load. + bodyImport: (() => { + let resolve = () => {}; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve: () => resolve() }; + })(), + // Pull request capability per environment; null means its server config has not arrived. + capabilities: new Map(), + Wrapper: ({ children }: { children?: ReactNode }) => children, + Trigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => ( + <> + {render} + {children} + + ), +})); +const configFor = (environmentId: string) => { + const pullRequests = capabilities.get(environmentId); + return pullRequests == null + ? null + : { environment: { capabilities: { pullRequests, threadPullRequests: pullRequests } } }; +}; +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => [] })); +vi.mock("~/state/server", () => ({ primaryServerKeybindingsAtom: {} })); +vi.mock("~/state/entities", () => ({ + useProjects: () => [], + useServerConfigs: () => + new Map( + [...capabilities.keys()].map((environmentId) => [environmentId, configFor(environmentId)]), + ), +})); +vi.mock("~/state/environments", () => ({ + useEnvironment: (environmentId: string) => { + const serverConfig = configFor(environmentId); + return serverConfig ? { serverConfig } : null; + }, + useEnvironments: () => ({ environments: [] }), + usePrimaryEnvironmentId: () => EnvironmentId.make("environment-new"), +})); +vi.mock("~/hooks/useSettings", () => ({ + useClientSettings: (select: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + select(DEFAULT_CLIENT_SETTINGS), + useEnvironmentSettings: () => undefined, +})); +vi.mock("~/hooks/useLiveRefresh", () => ({ useLiveRefresh: () => {} })); +vi.mock("~/hooks/useHandleNewThread", () => ({ useNewThreadHandler: () => vi.fn() })); +vi.mock("~/lib/sourceControlActions", () => ({ + usePreparePullRequestThreadAction: () => ({ run: vi.fn() }), +})); +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("~/state/pullRequests", async (importOriginal) => ({ + ...(await importOriginal()), + pullRequestEnvironment: { + detail: () => "detail", + activity: () => "activity", + 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() } })); +vi.mock("~/components/ui/tooltip", () => ({ + TooltipProvider: Wrapper, + Tooltip: Wrapper, + TooltipTrigger: Trigger, + TooltipPopup: () => null, +})); +vi.mock("~/components/ui/menu", () => ({ + Menu: Wrapper, + MenuPopup: Wrapper, + MenuTrigger: Trigger, + MenuItem: "button", + MenuRadioGroup: Wrapper, + MenuRadioItem: "button", + MenuSeparator: () => null, + MenuShortcut: () => null, +})); +vi.mock("~/components/ui/alert-dialog", () => ({ + AlertDialog: () => null, + AlertDialogPopup: Wrapper, + AlertDialogHeader: Wrapper, + AlertDialogTitle: Wrapper, + AlertDialogDescription: Wrapper, + AlertDialogFooter: Wrapper, + AlertDialogClose: Wrapper, +})); +vi.mock("~/components/pullRequest/PullRequestMarkdown", () => ({ + PullRequestMarkdownContext: Wrapper, + PullRequestMarkdown: () => null, +})); +vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => vi.fn() })); +vi.mock("~/components/pullRequest/PullRequestThreadLinks", () => ({ + PullRequestThreadLinks: () => null, +})); +vi.mock("~/components/pullRequest/PullRequestSummaryTab", () => ({ + PullRequestSummaryTab: () => null, +})); +vi.mock("~/components/pullRequest/PullRequestCodeTab", () => ({ default: () => null })); +vi.mock("~/components/pullRequest/PullRequestGuideTab", () => ({ + PullRequestGuideTab: () => null, +})); +vi.mock("./PullRequestSidePanel", async (importOriginal) => { + await bodyImport.promise; + return importOriginal(); +}); + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +const detail: PullRequestDetailView = { + provider: 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 }; +let renderer: ReactTestRenderer; + +// Lets the body's code arrive and settles it before rendering, so mounts finish inside one act(). +async function loadBody() { + bodyImport.resolve(); + await import("./PullRequestSidePanel"); +} + +async function renderFor(threadRef: ScopedThreadRef) { + const host: PanelHost = { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, + }; + await act(async () => { + renderer = create( + + + ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: false, + isWeb: true, + isDesktop: false, + })} + /> + + , + ); + }); +} + +const hasText = (text: string) => + renderer.root.findAll((node) => node.children.includes(text)).length > 0; + +async function click(label: string) { + const button = renderer.root + .findAllByType("button") + .find( + (node) => + node.props["aria-label"] === label || + node.findAll((child) => child.children.includes(label)).length > 0, + ); + expect(button, label).toBeDefined(); + await act(async () => + button!.props.onClick({ + nativeEvent: new Event("click"), + preventDefault() {}, + stopPropagation() {}, + }), + ); +} + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); + capabilities.clear(); + capabilities.set("environment-old", false); + capabilities.set("environment-new", true); + capabilities.set("environment-loading", null); + useComposerDraftStore.setState({ draftsByThreadKey: {} }); + useRightPanelStore.setState({ + byThreadKey: {}, + threadPanelVisibilityByThreadKey: {}, + userActionRevisionByThreadKey: {}, + }); +}); +afterEach(() => { + act(() => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +const isLoading = () => + renderer.root.findAll( + (node) => node.props.role === "status" && node.props["aria-label"] === "Loading pull request", + ).length > 0; + +describe("pull request side panel", () => { + // Runs first, while the body's code is still held back. + it("shows the pull request loading state until its code arrives", async () => { + await renderFor(refOn("environment-new")); + expect(isLoading()).toBe(true); + expect(hasText("Test pull request")).toBe(false); + + await act(loadBody); + expect(isLoading()).toBe(false); + expect(hasText("Test pull request")).toBe(true); + await click("Ask a question"); + expect( + useComposerDraftStore.getState().getComposerDraft(refOn("environment-new"))?.reviewComments, + ).toHaveLength(1); + }); + + it("follows the host environment's pull request support", async () => { + await loadBody(); + await renderFor(refOn("environment-loading")); + expect(isLoading()).toBe(true); + expect(hasText("Test pull request")).toBe(false); + expect(hasText("Pull requests unavailable")).toBe(false); + + await renderFor(refOn("environment-old")); + expect(hasText("Pull requests unavailable")).toBe(true); + expect(hasText("Update this environment's T3 Code server to browse pull requests.")).toBe(true); + expect(hasText("Test pull request")).toBe(false); + + await renderFor(refOn("environment-new")); + expect(hasText("Test pull request")).toBe(true); + }); + + it("writes a question into the host thread's composer only", async () => { + await loadBody(); + const threadRef = refOn("environment-new"); + useComposerDraftStore.getState().setPrompt(threadRef, "Keep my draft"); + await renderFor(threadRef); + await click("Ask a question"); + const draft = useComposerDraftStore.getState().getComposerDraft(threadRef); + expect(draft?.prompt).toContain("Keep my draft"); + expect(draft?.reviewComments?.length).toBeGreaterThan(0); + expect(useComposerDraftStore.getState().getComposerDraft(refOn("environment-old"))).toBeNull(); + }); + + it("opens a stack layer as a tab in the host's own thread", async () => { + await loadBody(); + const threadRef = refOn("environment-new"); + await renderFor(threadRef); + await click("Open #8"); + const { byThreadKey } = useRightPanelStore.getState(); + expect(byThreadKey[scopedThreadKey(threadRef)]?.surfaces).toMatchObject([ + { kind: "pull-request", repository: "owner/repo", number: 8 }, + ]); + expect(byThreadKey[scopedThreadKey(refOn("environment-old"))]).toBeUndefined(); + }); +}); diff --git a/apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx b/apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx new file mode 100644 index 000000000000..d5376e5a7cd6 --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestSidePanel.tsx @@ -0,0 +1,62 @@ +import type { PullRequestRef } from "@t3tools/contracts"; + +import { PullRequestDetailPanel } from "~/components/pullRequest/PullRequestDetailPanel"; +import { PullRequestDetailGhost } from "~/components/pullRequest/PullRequestGhosts"; +import { PullRequestsUnavailableState } from "~/components/pullRequest/PullRequestsUnavailableState"; +import type { ShortcutMatchContext } from "~/keybindings"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { useEnvironment } from "~/state/environments"; + +import { usePanelHost } from "../panelHost"; + +interface PullRequestSidePanelProps { + reference: PullRequestRef; + context: "page" | "thread"; + /** True only while this surface is the visible, active tab. */ + shortcutsEnabled: boolean; + getShortcutContext: () => ShortcutMatchContext; + /** Back to the thread's linked pull requests; only offered when there is more than one. */ + onBack?: (() => void) | undefined; +} + +// No onClose: the surface tab's own X owns closing here, and a second X in the header would be +// the same action twice. +export default function PullRequestSidePanel({ + reference, + context, + shortcutsEnabled, + getShortcutContext, + onBack, +}: PullRequestSidePanelProps) { + const { threadRef, composerDraftTarget } = usePanelHost(); + const serverConfig = useEnvironment(threadRef.environmentId)?.serverConfig ?? null; + if (serverConfig === null) return ; + if (serverConfig.environment.capabilities.pullRequests !== true) { + return ( + + ); + } + return ( + { + useRightPanelStore.getState().openPullRequest(threadRef, { + projectId: selected.projectId, + repository: selected.repository, + number: selected.number, + ...(selected.host ? { host: selected.host } : {}), + }); + }} + threadRef={threadRef} + reference={reference} + context={context} + composerDraftTarget={composerDraftTarget} + onBack={onBack} + /> + ); +} diff --git a/apps/web/src/panels/pullRequest/PullRequestsSidePanel.test.tsx b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.test.tsx new file mode 100644 index 000000000000..294aa7470d15 --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.test.tsx @@ -0,0 +1,178 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + EnvironmentId, + ThreadId, + type ScopedThreadRef, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; +import { act, Suspense, type ReactElement, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { commands, linksByThread, Wrapper, Trigger } = vi.hoisted(() => ({ + commands: [] as Array<{ command: string; request: unknown }>, + // Linked pull requests per scoped thread key. + linksByThread: new Map(), + Wrapper: ({ children }: { children?: ReactNode }) => children, + Trigger: ({ children, render }: { children?: ReactNode; render?: ReactElement }) => ( + <> + {render} + {children} + + ), +})); +vi.mock("~/state/entities", () => ({ + // No projects, so rows offer no project-scoped fast actions. + useProjects: () => [], + // Only environment-new supports linked pull requests. + useServerConfigs: () => + new Map( + ["environment-new", "environment-old"].map((environmentId) => [ + environmentId, + { + environment: { + capabilities: { threadPullRequests: environmentId === "environment-new" }, + }, + }, + ]), + ), + useThreadShell: (threadRef: ScopedThreadRef) => ({ + pullRequests: linksByThread.get(scopedThreadKey(threadRef)) ?? [], + }), +})); +vi.mock("~/state/threads", () => ({ + threadEnvironment: { unlinkPullRequest: "unlink", watchPullRequest: "watch" }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: string) => (request: unknown) => { + commands.push({ command, request }); + }, +})); +vi.mock("~/lib/openPullRequestLink", () => ({ + findProjectForChangeRequest: () => null, + useOpenPrLink: () => vi.fn(), +})); +vi.mock("~/shortcutModifierState", () => ({ + useShortcutModifierState: () => ({ + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }), +})); +vi.mock("~/components/pullRequest/LinkPullRequestDialog", () => ({ + openLinkPullRequestDialog: vi.fn(), +})); +vi.mock("~/components/ui/tooltip", () => ({ + Tooltip: Wrapper, + TooltipTrigger: Trigger, + TooltipPopup: () => null, +})); +vi.mock("~/components/ui/menu", () => ({ + Menu: Wrapper, + MenuPopup: Wrapper, + MenuTrigger: Trigger, + MenuItem: "button", +})); + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +// The same thread id on two environments is two threads. +const threadId = ThreadId.make("thread-a"); +const refOn = (environmentId: string): ScopedThreadRef => ({ + environmentId: EnvironmentId.make(environmentId), + threadId, +}); +const link = (repository: string, number: number): ThreadPullRequestLink => ({ + host: "github.com", + repository, + number, + url: `https://github.com/${repository}/pull/${number}`, + source: "manual", + linkedAt: "2026-09-01T00:00:00.000Z", + snapshot: null, + stack: null, +}); +let renderer: ReactTestRenderer | undefined; + +const panelFor = (threadRef: ScopedThreadRef) => { + const host: PanelHost = { + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, + }; + return ( + + + + + + ); +}; + +// One renderer across hosts, so a host change reaches the already mounted list. +async function renderFor(threadRef: ScopedThreadRef) { + await act(async () => { + if (renderer) renderer.update(panelFor(threadRef)); + else renderer = create(panelFor(threadRef)); + }); +} + +const hasText = (text: string) => + renderer!.root.findAll((node) => node.children.includes(text)).length > 0; + +// Transform the lazy body once up front, so mounting it settles inside one act(). +beforeAll(() => import("./PullRequestsSidePanel")); +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + commands.length = 0; + linksByThread.clear(); + linksByThread.set(scopedThreadKey(refOn("environment-new")), [link("owner/alpha", 11)]); + linksByThread.set(scopedThreadKey(refOn("environment-old")), [link("owner/beta", 22)]); +}); +afterEach(() => { + act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +describe("linked pull requests side panel", () => { + it("lists and unlinks the host thread's pull requests", async () => { + await renderFor(refOn("environment-new")); + expect(hasText("owner/alpha")).toBe(true); + expect(hasText("owner/beta")).toBe(false); + + const unlink = renderer!.root + .findAllByType("button") + .find( + (node) => node.findAll((child) => child.children.includes("Unlink from thread")).length, + ); + await act(async () => unlink!.props.onClick()); + expect(commands).toEqual([ + { + command: "unlink", + request: { + environmentId: "environment-new", + input: { threadId, host: "github.com", repository: "owner/alpha", number: 11 }, + }, + }, + ]); + }); + + it("follows the host to a thread whose environment cannot link pull requests", async () => { + await renderFor(refOn("environment-new")); + expect(hasText("owner/alpha")).toBe(true); + + await renderFor(refOn("environment-old")); + expect(hasText("Linked pull requests unavailable")).toBe(true); + expect(hasText("owner/alpha")).toBe(false); + expect(hasText("owner/beta")).toBe(false); + + linksByThread.set(scopedThreadKey(refOn("environment-new")), [link("owner/gamma", 33)]); + await renderFor(refOn("environment-new")); + expect(hasText("owner/gamma")).toBe(true); + }); +}); diff --git a/apps/web/src/panels/pullRequest/PullRequestsSidePanel.tsx b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.tsx new file mode 100644 index 000000000000..b5e49f45f2fc --- /dev/null +++ b/apps/web/src/panels/pullRequest/PullRequestsSidePanel.tsx @@ -0,0 +1,7 @@ +import { ThreadPullRequestsPanel } from "~/components/pullRequest/ThreadPullRequestsPanel"; + +import { usePanelHost } from "../panelHost"; + +export default function PullRequestsSidePanel() { + return ; +} diff --git a/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx b/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx new file mode 100644 index 000000000000..45256e185872 --- /dev/null +++ b/apps/web/src/panels/terminal/PersistentThreadTerminalDrawer.tsx @@ -0,0 +1,467 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +import { + AuthTerminalOperateScope, + AuthTerminalReadScope, + type EnvironmentId, + type ResolvedKeybindingsConfig, + type ThreadId, +} from "@t3tools/contracts"; +import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; + +import ThreadTerminalDrawer from "~/components/ThreadTerminalDrawer"; +import { useComposerDraftStore } from "~/composerDraftStore"; +import type { TerminalContextSelection } from "~/lib/terminalContext"; +import { cn, randomUUID } from "~/lib/utils"; +import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; +import { useProject, useThreadShell } from "~/state/entities"; +import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; +import { terminalEnvironment } from "~/state/terminal"; +import { useKnownTerminalSessions } from "~/state/terminalSessions"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "~/terminalUiStateStore"; + +import type { PersistentTerminalLaunchContext } from "./TerminalSidePanel"; + +/** Same terminal ids (order ignored) — avoids reconcile when only server session ordering differs. */ +function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean { + if (left.length !== right.length) { + return false; + } + if (left.length === 0) { + return true; + } + const sortedLeft = left.toSorted((a, b) => a.localeCompare(b)); + const sortedRight = right.toSorted((a, b) => a.localeCompare(b)); + for (let index = 0; index < sortedLeft.length; index += 1) { + if (sortedLeft[index] !== sortedRight[index]) { + return false; + } + } + return true; +} + +/** + * Server knows about fewer sessions than the client, but every server id still exists locally. + * Typical right after `terminal.open`: known-session list lags; reconciling would drop the new id + * and later re-add it as a separate group (no split layout). + */ +function serverTerminalIdsStrictSubsetOfClient( + serverIds: readonly string[], + clientIds: readonly string[], +): boolean { + if (serverIds.length >= clientIds.length || clientIds.length === 0) { + return false; + } + const clientSet = new Set(clientIds); + for (const id of serverIds) { + if (!clientSet.has(id)) { + return false; + } + } + return true; +} + +interface PersistentThreadTerminalDrawerProps { + threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; + threadId: ThreadId; + active: boolean; + launchContext: PersistentTerminalLaunchContext | null; + focusRequestId: number; + splitShortcutLabel: string | undefined; + splitVerticalShortcutLabel: string | undefined; + newShortcutLabel: string | undefined; + closeShortcutLabel: string | undefined; + keybindings: ResolvedKeybindingsConfig; + onAddTerminalContext: (selection: TerminalContextSelection) => void; +} + +export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ + threadRef, + threadId, + active, + launchContext, + focusRequestId, + splitShortcutLabel, + splitVerticalShortcutLabel, + newShortcutLabel, + closeShortcutLabel, + keybindings, + onAddTerminalContext, +}: PersistentThreadTerminalDrawerProps) { + const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); + const hasTerminalWriteAccess = useCallback( + () => readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope), + [threadRef.environmentId], + ); + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); + const serverThread = useThreadShell(threadRef); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const projectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const project = useProject(projectRef); + const terminalUiState = useTerminalUiStateStore((state) => + selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), + ); + const visible = active && terminalUiState.terminalOpen; + const knownTerminalSessions = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId, + }); + const panelSurfaces = useRightPanelStore( + (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, + ); + const panelTerminalIds = useMemo( + () => + new Set( + panelSurfaces.flatMap((surface) => + surface.kind === "terminal" ? surface.terminalIds : [], + ), + ), + [panelSurfaces], + ); + const drawerTerminalSessions = useMemo( + () => + knownTerminalSessions?.filter( + (session) => !panelTerminalIds.has(session.target.terminalId), + ) ?? [], + [knownTerminalSessions, panelTerminalIds], + ); + const terminalLabelsById = useMemo(() => { + const next = new Map(); + for (const session of drawerTerminalSessions) { + next.set( + session.target.terminalId, + resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + ); + } + return next; + }, [drawerTerminalSessions]); + const terminalLaunchLocationsById = useMemo(() => { + const next = new Map< + string, + { + readonly cwd: string; + readonly worktreePath: string | null; + readonly runtimeEnv: Record; + } + >(); + if (!project) { + return next; + } + + for (const session of drawerTerminalSessions) { + const summary = session.state.summary; + if (!summary) { + continue; + } + const worktreePathForLaunch = + launchContext !== null ? launchContext.worktreePath : summary.worktreePath; + next.set(session.target.terminalId, { + cwd: launchContext?.cwd ?? summary.cwd, + worktreePath: worktreePathForLaunch, + runtimeEnv: projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: worktreePathForLaunch, + }), + }); + } + + return next; + }, [drawerTerminalSessions, launchContext, project]); + const serverOrderedTerminalIds = useMemo( + () => drawerTerminalSessions.map((session) => session.target.terminalId), + [drawerTerminalSessions], + ); + // Every client-side id source participates in allocation: the server list + // lags fresh opens, and panel terminals are filtered out of the drawer's + // sessions — an id collision attaches two viewports to one PTY session. + const allocatableTerminalIds = useMemo( + () => [ + ...new Set([ + ...serverOrderedTerminalIds, + ...terminalUiState.terminalIds, + ...panelTerminalIds, + ]), + ], + [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds], + ); + const allocateTerminalId = useCallback( + () => + nextTerminalId( + allocatableTerminalIds, + knownTerminalSessions === null || + !readEnvironmentScope(threadRef.environmentId, AuthTerminalReadScope) + ? randomUUID() + : undefined, + ), + [allocatableTerminalIds, knownTerminalSessions, threadRef.environmentId], + ); + const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); + const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); + const storeSplitTerminalVertical = useTerminalUiStateStore( + (state) => state.splitTerminalVertical, + ); + const storeNewTerminal = useTerminalUiStateStore((state) => state.newTerminal); + const storeSetActiveTerminal = useTerminalUiStateStore((state) => state.setActiveTerminal); + const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); + const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); + + useEffect(() => { + if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) { + return; + } + if ( + serverTerminalIdsStrictSubsetOfClient(serverOrderedTerminalIds, terminalUiState.terminalIds) + ) { + return; + } + reconcileTerminalIds(threadRef, serverOrderedTerminalIds); + }, [reconcileTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds, threadRef]); + const [localFocusRequestId, setLocalFocusRequestId] = useState(0); + const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const effectiveWorktreePath = useMemo(() => { + if (launchContext !== null) { + return launchContext.worktreePath; + } + return worktreePath; + }, [launchContext, worktreePath]); + const cwd = useMemo( + () => + launchContext?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.workspaceRoot }, + worktreePath: effectiveWorktreePath, + }) + : null), + [effectiveWorktreePath, launchContext?.cwd, project], + ); + const runtimeEnv = useMemo( + () => + project + ? projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: effectiveWorktreePath, + }) + : {}, + [effectiveWorktreePath, project], + ); + + const bumpFocusRequestId = useCallback(() => { + if (!visible) { + return; + } + setLocalFocusRequestId((value) => value + 1); + }, [visible]); + + const setTerminalHeight = useCallback( + (height: number) => { + storeSetTerminalHeight(threadRef, height); + }, + [storeSetTerminalHeight, threadRef], + ); + + const splitTerminal = useCallback(() => { + if (!hasTerminalWriteAccess() || !cwd) { + return; + } + const terminalId = allocateTerminalId(); + storeSplitTerminal(threadRef, terminalId); + bumpFocusRequestId(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); + }, [ + allocateTerminalId, + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + runtimeEnv, + storeSplitTerminal, + threadId, + threadRef, + openTerminal, + hasTerminalWriteAccess, + ]); + const splitTerminalVertical = useCallback(() => { + if (!hasTerminalWriteAccess() || !cwd) { + return; + } + const terminalId = allocateTerminalId(); + storeSplitTerminalVertical(threadRef, terminalId); + bumpFocusRequestId(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); + }, [ + allocateTerminalId, + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + openTerminal, + hasTerminalWriteAccess, + runtimeEnv, + storeSplitTerminalVertical, + threadId, + threadRef, + ]); + + const createNewTerminal = useCallback(() => { + if (!hasTerminalWriteAccess() || !cwd) { + return; + } + const terminalId = allocateTerminalId(); + storeNewTerminal(threadRef, terminalId); + bumpFocusRequestId(); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); + }, [ + bumpFocusRequestId, + cwd, + effectiveWorktreePath, + allocateTerminalId, + runtimeEnv, + storeNewTerminal, + threadId, + threadRef, + openTerminal, + hasTerminalWriteAccess, + ]); + + const activateTerminal = useCallback( + (terminalId: string) => { + storeSetActiveTerminal(threadRef, terminalId); + bumpFocusRequestId(); + }, + [bumpFocusRequestId, storeSetActiveTerminal, threadRef], + ); + + const closeTerminal = useCallback( + (terminalId: string) => { + if (!hasTerminalWriteAccess()) return; + const fallbackExitWrite = () => + writeTerminal({ + environmentId: threadRef.environmentId, + input: { threadId, terminalId, data: "exit\n" }, + }); + + void (async () => { + const closeResult = await closeTerminalMutation({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + deleteHistory: true, + }, + }); + if ( + closeResult._tag === "Failure" && + !isAtomCommandInterrupted(closeResult) && + hasTerminalWriteAccess() + ) { + await fallbackExitWrite(); + } + })(); + + storeCloseTerminal(threadRef, terminalId); + bumpFocusRequestId(); + }, + [ + bumpFocusRequestId, + storeCloseTerminal, + threadId, + threadRef, + closeTerminalMutation, + hasTerminalWriteAccess, + writeTerminal, + ], + ); + + const handleAddTerminalContext = useCallback( + (selection: TerminalContextSelection) => { + if (!visible) { + return; + } + onAddTerminalContext(selection); + }, + [onAddTerminalContext, visible], + ); + + if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) { + return null; + } + + return ( +
+
+ +
+
+ ); +}); diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.attach.test.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.attach.test.tsx new file mode 100644 index 000000000000..e0d995a5f048 --- /dev/null +++ b/apps/web/src/panels/terminal/TerminalSidePanel.attach.test.tsx @@ -0,0 +1,192 @@ +// @vitest-environment jsdom + +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { act, Suspense } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +// Drives the registered terminal through the real RegisteredSidePanel -> +// TerminalSidePanel -> ThreadTerminalDrawer -> TerminalViewport path. Only the +// terminal transport (attach, write, resize), the WASM surface and the thread +// read model are stubbed. +const transport = vi.hoisted(() => ({ + attached: [] as unknown[], + commands: [] as Array<{ command: string; value: unknown }>, + surfaces: [] as Array<{ onData: (data: string) => void }>, +})); + +vi.mock("~/state/terminalSessions", async () => { + const { EMPTY_TERMINAL_SESSION_STATE: empty } = + await import("@t3tools/client-runtime/state/terminal"); + return { + useKnownTerminalSessions: () => [], + useAttachedTerminalSession: (input: unknown) => { + transport.attached.push(input); + return empty; + }, + }; +}); +// This client holds every scope, so the terminal accepts typing. +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironmentScope: () => true, + readEnvironmentScope: () => true, +})); +vi.mock("~/state/terminal", () => ({ + terminalEnvironment: { write: "write", resize: "resize", open: "open" }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => async (value: unknown) => { + if (typeof command === "string") transport.commands.push({ command, value }); + return { _tag: "Success" }; + }, +})); +vi.mock("~/terminal/ghostty/surface", async (importOriginal) => ({ + ...(await importOriginal()), + GhosttyTerminalSurface: { + create: async (_mount: HTMLElement, options: { onData: (data: string) => void }) => { + transport.surfaces.push(options); + // Every surface method is a no-op; the test only feeds keystrokes through onData. + return new Proxy({}, { get: () => () => undefined }); + }, + }, +})); +vi.mock("~/state/entities", () => { + const project = { workspaceRoot: "/repo" }; + return { + useThreadShell: (ref: ScopedThreadRef) => ({ + environmentId: ref.environmentId, + projectId: "project-a", + worktreePath: null, + }), + useProject: () => project, + }; +}); + +import type { RightPanelSurface } from "~/rightPanelStore"; + +import { RegisteredSidePanel } from "../bundledPanels"; +import { PanelHostContext, type PanelHost } from "../panelHost"; + +const threadA: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), +}; +const threadB: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-b"), + threadId: ThreadId.make("thread-b"), +}; +const surface: Extract = { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", +}; +const hostFor = (threadRef: ScopedThreadRef): PanelHost => ({ + threadRef, + visible: true, + composerDraftTarget: threadRef, + workspaceMutationId: null, + sendAnnotation: () => undefined, +}); + +let root: Root; +let container: HTMLDivElement; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + // jsdom has no canvas; the terminal theme reader falls back without one. + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); + transport.attached = []; + transport.commands = []; + transport.surfaces = []; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +async function renderTerminal(threadRef: ScopedThreadRef) { + await act(async () => + root.render( + + + undefined} + onSplitTerminal={() => undefined} + onSplitTerminalVertical={() => undefined} + onNewTerminal={() => undefined} + onActiveTerminalChange={() => undefined} + onCloseTerminal={() => undefined} + /> + + , + ), + ); +} + +// The registry loads the body lazily; let that import settle before asserting. +async function settle() { + await act(async () => { + await import("./TerminalSidePanel"); + }); +} + +async function type(data: string) { + await act(async () => transport.surfaces.at(-1)!.onData(data)); +} + +describe("registered terminal panel", () => { + it("attaches the host thread's terminal and sends typing to it, then follows a thread switch", async () => { + await renderTerminal(threadA); + await settle(); + expect(transport.attached.at(-1)).toMatchObject({ + environmentId: threadA.environmentId, + terminal: { threadId: threadA.threadId, terminalId: "term-1", cwd: "/repo" }, + }); + await type("ls\r"); + expect(transport.commands).toContainEqual({ + command: "write", + value: { + environmentId: threadA.environmentId, + input: { threadId: threadA.threadId, terminalId: "term-1", data: "ls\r" }, + }, + }); + + await renderTerminal(threadB); + await settle(); + expect(transport.attached.at(-1)).toMatchObject({ + environmentId: threadB.environmentId, + terminal: { threadId: threadB.threadId, terminalId: "term-1", cwd: "/repo" }, + }); + transport.commands = []; + await type("pwd\r"); + expect(transport.commands.filter((entry) => entry.command === "write")).toEqual([ + { + command: "write", + value: { + environmentId: threadB.environmentId, + input: { threadId: threadB.threadId, terminalId: "term-1", data: "pwd\r" }, + }, + }, + ]); + }); +}); diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx new file mode 100644 index 000000000000..e3e30694eb9a --- /dev/null +++ b/apps/web/src/panels/terminal/TerminalSidePanel.test.tsx @@ -0,0 +1,133 @@ +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const drawerRenders = vi.hoisted(() => [] as Array<{ visible: boolean; threadRef: unknown }>); +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 { useThreadShell: () => 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, + focusRequestId: 0, + 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", () => { + let renderer: ReactTestRenderer | undefined; + act(() => { + renderer = create(panelIn(hostFor(true))); + }); + expect(drawerRenders).toEqual([{ visible: true, threadRef }]); + + act(() => renderer!.update(panelIn(hostFor(true)))); + expect(drawerRenders).toHaveLength(1); + + act(() => renderer!.update(panelIn(hostFor(false)))); + expect(drawerRenders).toEqual([ + { visible: true, threadRef }, + { visible: false, threadRef }, + ]); + }); + + it("keeps a local-checkout launch on the checkout after the thread gains a worktree", () => { + thread.worktreePath = "/repo/.worktrees/feature"; + drawerWorktreePaths.length = 0; + const launchedLocally = ( + + + + ); + act(() => { + create(launchedLocally); + }); + expect(drawerWorktreePaths).toEqual([null]); + + drawerWorktreePaths.length = 0; + act(() => { + create(panelIn(hostFor(true))); + }); + expect(drawerWorktreePaths).toEqual(["/repo/.worktrees/feature"]); + thread.worktreePath = null; + }); + + it("keeps a terminal the server opened on the checkout off the thread's later worktree", () => { + thread.worktreePath = "/repo/.worktrees/feature"; + terminalSessions.push({ + target: { terminalId: "term-1" }, + state: { summary: { cwd: "/repo", worktreePath: null } }, + }); + drawerWorktreePaths.length = 0; + act(() => { + create(panelIn(hostFor(true))); + }); + expect(drawerWorktreePaths).toEqual([null]); + terminalSessions.length = 0; + thread.worktreePath = null; + }); +}); diff --git a/apps/web/src/panels/terminal/TerminalSidePanel.tsx b/apps/web/src/panels/terminal/TerminalSidePanel.tsx new file mode 100644 index 000000000000..e94284b7f5e4 --- /dev/null +++ b/apps/web/src/panels/terminal/TerminalSidePanel.tsx @@ -0,0 +1,225 @@ +import { useAtomValue } from "@effect/atom-react"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { ResolvedKeybindingsConfig, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; +import { memo, useMemo } from "react"; + +import ThreadTerminalDrawer from "~/components/ThreadTerminalDrawer"; +import { useComposerDraftStore } from "~/composerDraftStore"; +import type { TerminalContextSelection } from "~/lib/terminalContext"; +import type { RightPanelSurface } from "~/rightPanelStore"; +import { useProject, useThreadShell } from "~/state/entities"; +import { primaryServerKeybindingsAtom } from "~/state/server"; +import { useKnownTerminalSessions } from "~/state/terminalSessions"; + +import { usePanelHost } from "../panelHost"; + +export interface TerminalLaunchContext { + threadId: ThreadId; + cwd: string; + worktreePath: string | null; +} + +export type PersistentTerminalLaunchContext = Pick; + +/** + * A launch context's or summary's null worktree means the local checkout, not + * "unknown", so only fall back to the thread's worktree when neither exists. + */ +function terminalWorktreePath( + launchContext: PersistentTerminalLaunchContext | null, + summary: { readonly worktreePath: string | null } | null, + threadWorktreePath: string | null, +): string | null { + if (launchContext !== null) return launchContext.worktreePath; + if (summary !== null) return summary.worktreePath; + return threadWorktreePath; +} + +interface PersistentThreadTerminalPanelProps { + visible: boolean; + threadRef: ScopedThreadRef; + surface: Extract; + launchContext: PersistentTerminalLaunchContext | null; + focusRequestId: number; + keybindings: ResolvedKeybindingsConfig; + onAddTerminalContext: (selection: TerminalContextSelection) => void; + onSplitTerminal: () => void; + onSplitTerminalVertical: () => void; + onNewTerminal: () => void; + onActiveTerminalChange: (terminalId: string) => void; + onCloseTerminal: (terminalId: string) => void; + splitShortcutLabel?: string | undefined; + splitVerticalShortcutLabel?: string | undefined; + newShortcutLabel?: string | undefined; + closeShortcutLabel?: string | undefined; +} + +const PersistentThreadTerminalPanel = memo(function PersistentThreadTerminalPanel({ + visible, + threadRef, + surface, + launchContext, + focusRequestId, + keybindings, + onAddTerminalContext, + onSplitTerminal, + onSplitTerminalVertical, + onNewTerminal, + onActiveTerminalChange, + onCloseTerminal, + splitShortcutLabel, + splitVerticalShortcutLabel, + newShortcutLabel, + closeShortcutLabel, +}: PersistentThreadTerminalPanelProps) { + const serverThread = useThreadShell(threadRef); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const projectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const project = useProject(projectRef); + const knownTerminalSessions = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }); + const threadWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const activeSummary = + knownTerminalSessions?.find((session) => session.target.terminalId === surface.activeTerminalId) + ?.state.summary ?? null; + const worktreePath = terminalWorktreePath(launchContext, activeSummary, threadWorktreePath); + const cwd = useMemo( + () => + launchContext?.cwd ?? + activeSummary?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.workspaceRoot }, + worktreePath, + }) + : null), + [activeSummary?.cwd, launchContext?.cwd, project, worktreePath], + ); + const runtimeEnv = useMemo( + () => + project + ? projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath, + }) + : {}, + [project, worktreePath], + ); + const terminalLabelsById = useMemo(() => { + const labels = new Map(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; + labels.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); + } + return labels; + }, [knownTerminalSessions, surface.terminalIds]); + const terminalLaunchLocationsById = useMemo(() => { + const locations = new Map< + string, + { + readonly cwd: string; + readonly worktreePath: string | null; + readonly runtimeEnv: Record; + } + >(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; + const worktreePathForTerminal = terminalWorktreePath( + launchContext, + summary, + threadWorktreePath, + ); + const terminalCwd = + launchContext?.cwd ?? + summary?.cwd ?? + (project + ? projectScriptCwd({ + project: { cwd: project.workspaceRoot }, + worktreePath: worktreePathForTerminal, + }) + : null); + if (!terminalCwd || !project) continue; + locations.set(terminalId, { + cwd: terminalCwd, + worktreePath: worktreePathForTerminal, + runtimeEnv: projectScriptRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: worktreePathForTerminal, + }), + }); + } + return locations; + }, [knownTerminalSessions, launchContext, project, surface.terminalIds, threadWorktreePath]); + + if (!project || !cwd) return null; + + return ( + undefined} + onAddTerminalContext={onAddTerminalContext} + terminalLabelsById={terminalLabelsById} + terminalLaunchLocationsById={terminalLaunchLocationsById} + keybindings={keybindings} + /> + ); +}); + +/** + * Registered right-panel body. ChatView rebuilds the host on every render, so + * the host is read here, outside the memo: renders that leave the thread, + * visibility and terminal props unchanged still skip the terminal. + */ +export default function TerminalSidePanel( + props: Omit, +) { + const { threadRef, visible } = usePanelHost(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + return ( + + ); +} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index b3d1b140aece..53f9b11a2ccb 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -276,6 +276,15 @@ 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_PENDING_SURFACES = new Set(); const MAX_SEARCH_LABEL_CANDIDATES = 100; @@ -2284,21 +2293,8 @@ function PullRequestsRouteView() { useRightPanelStore.getState().moveSurface(rightPanelRef, surfaceId, toIndex); }} onCopyFilePath={() => undefined} - onAddBrowser={() => undefined} + panels={UNAVAILABLE_SIDE_PANELS} onAddBrowserInProfile={() => undefined} - onAddTerminal={() => undefined} - onAddDiff={() => undefined} - onAddFiles={() => undefined} - onAddPullRequest={() => undefined} - onAddPullRequests={() => undefined} - onAddDevice={() => undefined} - browserAvailable={false} - terminalAvailable={false} - diffAvailable={false} - filesAvailable={false} - pullRequestAvailable={false} - pullRequestsAvailable={false} - deviceAvailable={false} pullRequestStatusSeeds={listedPullRequestTabStatuses} > { + return useAtomValue(contributionStatusEnvironment.threadStatus(environmentId, threadId)); +} diff --git a/docs/user/providers-pi.md b/docs/user/providers-pi.md index 123258f6ccb4..27a9efba57d0 100644 --- a/docs/user/providers-pi.md +++ b/docs/user/providers-pi.md @@ -48,8 +48,11 @@ Pi skills appear in the composer's `$` menu. This includes user skills and proje loads for the current workspace; selecting one uses Pi's native skill expansion. Pi loads its normal user and project extensions. Blocking `select`, `confirm`, `input`, and `editor` -dialogs work in T3 Code. Notifications appear in the work log. Pi terminal decoration such as -titles, status lines, and widgets does not have a T3 Code equivalent. +dialogs work in T3 Code. Notifications appear in the work log. In the web and desktop apps, +extension statuses (`setStatus`) appear beside the thread title; on mobile they appear in a row +under the header, where you tap one to see its details. They are hints from the running extensions +and can lag a session change, such as a resume or fork, until the extension updates them. Pi +terminal decoration such as titles and widgets does not have a T3 Code equivalent. ## Permission Modes diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 672f19927026..f774b3bd36c1 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -175,6 +175,10 @@ "types": "./src/state/presentation.ts", "default": "./src/state/presentation.ts" }, + "./state/contribution-status": { + "types": "./src/state/contributionStatus.ts", + "default": "./src/state/contributionStatus.ts" + }, "./state/device": { "types": "./src/state/device.ts", "default": "./src/state/device.ts" diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index c11a49897b88..d5c2fe2cfd51 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -65,6 +65,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeDiscoveredLocalServers | typeof WS_METHODS.subscribeDeviceState | typeof WS_METHODS.subscribeResourceTelemetry + | typeof WS_METHODS.subscribeContributionStatus | typeof WS_METHODS.pullRequestsSubscribeRefreshes | typeof WS_METHODS.subscribeVcsStatus | typeof WS_METHODS.subscribeWorktreeSetup diff --git a/packages/client-runtime/src/state/contributionStatus.test.ts b/packages/client-runtime/src/state/contributionStatus.test.ts new file mode 100644 index 000000000000..1dc9df8ebc6f --- /dev/null +++ b/packages/client-runtime/src/state/contributionStatus.test.ts @@ -0,0 +1,241 @@ +import { + type ContributionStatusSnapshot, + contributionStatusSourceKey, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + type ServerConfig, + ThreadId, + WS_METHODS, +} from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { Atom, AtomRegistry } from "effect/reactivity"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { createContributionStatusEnvironmentAtoms } from "./contributionStatus.ts"; + +const THREAD = ThreadId.make("thread-1"); + +const OTHER_THREAD = ThreadId.make("thread-2"); + +const entry = (text: string, session = `session-${text}`, threadId = THREAD) => ({ + threadId, + source: { + kind: "provider-session" as const, + providerSessionId: ProviderSessionId.make(session), + providerInstanceId: ProviderInstanceId.make("pi"), + driver: ProviderDriverKind.make("pi"), + }, + items: [{ key: "mode", text }], +}); + +const snapshot = (text: string): ContributionStatusSnapshot => ({ entries: [entry(text)] }); + +const config = (contributionStatus: boolean) => + ({ + environment: { + serverVersion: "0.0.1", + capabilities: contributionStatus ? { contributionStatus: true } : {}, + }, + }) as ServerConfig; + +/** One environment whose server pushes status snapshots from a queue. */ +const makeEnvironment = Effect.fn("makeEnvironment")(function* (id: string, supported: boolean) { + const environmentId = EnvironmentId.make(id); + let subscriptions = 0; + const makeSession = Effect.fn("makeSession")(function* () { + const frames = yield* Queue.unbounded(); + const client = { + [WS_METHODS.subscribeContributionStatus]: () => { + subscriptions += 1; + return Stream.fromQueue(frames); + }, + } as unknown as WsRpcProtocolClient; + const session: RpcSession = { + client, + initialConfig: Effect.succeed(config(supported)), + subscribeServerConfig: () => Stream.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + return { session, push: (frame: ContributionStatusSnapshot) => Queue.offer(frames, frame) }; + }); + const first = yield* makeSession(); + const session = yield* SubscriptionRef.make(Option.some(first.session)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: new PrimaryConnectionTarget({ + environmentId, + label: id, + httpBaseUrl: `https://${id}.example.test`, + wsBaseUrl: `wss://${id}.example.test`, + }), + state: yield* SubscriptionRef.make({ + ...AVAILABLE_CONNECTION_STATE, + phase: "connected", + }), + session, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + return { + environmentId, + supervisor, + config: Atom.make(config(supported)), + subscriptions: () => subscriptions, + push: first.push, + /** Swaps in a new connection, as a reconnect does, and returns its push. */ + reconnect: Effect.gen(function* () { + const next = yield* makeSession(); + yield* SubscriptionRef.set(session, Option.some(next.session)); + return next.push; + }), + }; +}); + +const makeHarness = Effect.fn("makeHarness")(function* () { + const environments = [ + yield* makeEnvironment("env-a", true), + yield* makeEnvironment("env-b", true), + yield* makeEnvironment("env-old", false), + ] as const; + const byId = new Map(environments.map((environment) => [environment.environmentId, environment])); + const supervisorFor = (environmentId: EnvironmentId) => byId.get(environmentId)!.supervisor; + const registryService = EnvironmentRegistry.EnvironmentRegistry.of({ + run: (environmentId, effect) => + Effect.provideService( + effect, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisorFor(environmentId), + ), + followStream: (environmentId, stream) => + Stream.provideService( + stream, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisorFor(environmentId), + ), + } as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const runtime = Atom.runtime( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, registryService), + ); + const atoms = createContributionStatusEnvironmentAtoms(runtime, { + configValueAtom: (environmentId) => byId.get(environmentId)!.config, + }); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const texts = (environmentId: EnvironmentId) => + registry + .get(atoms.threadStatus(environmentId, THREAD)) + .flatMap((entry) => entry.items.map((item) => item.text)); + /** Waits until a thread's rendered texts match, the client-side receipt for a frame. */ + const waitForTexts = (environmentId: EnvironmentId, expected: ReadonlyArray) => + AtomRegistry.toStream(registry, atoms.threadStatus(environmentId, THREAD)).pipe( + Stream.map((entries) => entries.flatMap((entry) => entry.items.map((item) => item.text))), + Stream.filter((actual) => actual.join("\n") === expected.join("\n")), + Stream.runHead, + ); + return { environments, atoms, registry, texts, waitForTexts }; +}); + +it.effect("keeps each environment's statuses apart and replaces them on reconnect", () => + Effect.scoped( + Effect.gen(function* () { + const { environments, atoms, registry, texts, waitForTexts } = yield* makeHarness(); + const [a, b] = environments; + const unmountA = registry.mount(atoms.threadStatus(a.environmentId, THREAD)); + const unmountB = registry.mount(atoms.threadStatus(b.environmentId, THREAD)); + + yield* a.push(snapshot("plan")); + yield* b.push(snapshot("build")); + yield* waitForTexts(a.environmentId, ["plan"]); + yield* waitForTexts(b.environmentId, ["build"]); + + // The status ended while disconnected; the new connection's first frame drops it. + const pushAfterReconnect = yield* a.reconnect; + yield* pushAfterReconnect({ entries: [] }); + yield* waitForTexts(a.environmentId, []); + assert.strictEqual(a.subscriptions(), 2); + assert.deepStrictEqual(texts(b.environmentId), ["build"]); + unmountA(); + unmountB(); + }), + ), +); + +it.effect("never subscribes to a server without the capability", () => + Effect.scoped( + Effect.gen(function* () { + const { environments, atoms, registry, waitForTexts } = yield* makeHarness(); + const [current, , old] = environments; + const unmountOld = registry.mount(atoms.threadStatus(old.environmentId, THREAD)); + const unmountCurrent = registry.mount(atoms.threadStatus(current.environmentId, THREAD)); + + // Once a supported environment has delivered, any subscription would have started. + yield* current.push(snapshot("plan")); + yield* waitForTexts(current.environmentId, ["plan"]); + assert.strictEqual(old.subscriptions(), 0); + assert.deepStrictEqual(registry.get(atoms.threadStatus(old.environmentId, THREAD)), []); + unmountOld(); + unmountCurrent(); + }), + ), +); + +it.effect("keeps each entry's source so a takeover with the same text re-keys the row", () => + Effect.scoped( + Effect.gen(function* () { + const { environments, atoms, registry } = yield* makeHarness(); + const [a] = environments; + const status = atoms.threadStatus(a.environmentId, THREAD); + const unmount = registry.mount(status); + const waitForSession = (session: string) => + AtomRegistry.toStream(registry, status).pipe( + Stream.filter((entries) => entries[0]?.source.providerSessionId === session), + Stream.runHead, + ); + + yield* a.push({ entries: [entry("plan", "session-old")] }); + yield* waitForSession("session-old"); + const before = registry.get(status); + + // Another thread changing keeps this thread's array, so its row does not re-render. + yield* a.push({ + entries: [entry("plan", "session-old"), entry("x", "session-x", OTHER_THREAD)], + }); + yield* AtomRegistry.toStream(registry, atoms.snapshot(a.environmentId)).pipe( + Stream.filter((snapshot) => snapshot.entries.length === 2), + Stream.runHead, + ); + assert.strictEqual(registry.get(status), before); + + yield* a.push({ entries: [entry("plan", "session-new")] }); + yield* waitForSession("session-new"); + const after = registry.get(status); + assert.notStrictEqual(after, before); + assert.notStrictEqual( + contributionStatusSourceKey(after[0]!.source), + contributionStatusSourceKey(before[0]!.source), + ); + unmount(); + }), + ), +); diff --git a/packages/client-runtime/src/state/contributionStatus.ts b/packages/client-runtime/src/state/contributionStatus.ts new file mode 100644 index 000000000000..e35f1d52b64e --- /dev/null +++ b/packages/client-runtime/src/state/contributionStatus.ts @@ -0,0 +1,108 @@ +import { + type ContributionStatusEntry, + type ContributionStatusSnapshot, + contributionStatusSourceKey, + type EnvironmentId, + type ServerConfig, + type ThreadId, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createEnvironmentRpcSubscriptionAtomFamily } from "./runtime.ts"; + +const EMPTY_SNAPSHOT: ContributionStatusSnapshot = { entries: [] }; +const NO_ENTRIES: ReadonlyArray = []; + +/** Older servers lack the stream; clients show nothing for them and never subscribe. */ +function supportsContributionStatus(config: ServerConfig | null): boolean { + return config?.environment.capabilities.contributionStatus === true; +} + +/** A thread's entries, one per source, in the server's order. */ +function threadContributionStatusEntries( + snapshot: ContributionStatusSnapshot, + threadId: ThreadId, +): ReadonlyArray { + const entries = snapshot.entries.filter((entry) => entry.threadId === threadId); + return entries.length === 0 ? NO_ENTRIES : entries; +} + +/** Equal when every entry has the same source identity and the same items. */ +function sameEntries( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every((entry, index) => { + const other = right[index]; + return ( + other !== undefined && + contributionStatusSourceKey(entry.source) === contributionStatusSourceKey(other.source) && + entry.items.length === other.items.length && + entry.items.every((item, itemIndex) => { + const otherItem = other.items[itemIndex]; + return ( + otherItem !== undefined && + item.key === otherItem.key && + item.text === otherItem.text && + item.tone === otherItem.tone && + item.tooltip === otherItem.tooltip + ); + }) + ); + }) + ); +} + +/** + * Live thread statuses per environment for the web and mobile renderers. + * Nothing subscribes until a renderer reads an atom, and only against servers + * that advertise the capability. Each server frame is a full replacement, so a + * reconnect's first frame drops statuses that ended while disconnected. + * + * `threadStatus` keeps each entry's source: renderers key an entry by + * `contributionStatusSourceKey(entry.source)` and an item by that plus its + * key, so a provider session taking over a thread re-keys its rows even when + * the text is unchanged. + */ +export function createContributionStatusEnvironmentAtoms( + runtime: Atom.AtomRuntime, + options: { + readonly configValueAtom: (environmentId: EnvironmentId) => Atom.Atom; + }, +) { + const subscription = createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:contribution-status", + tag: WS_METHODS.subscribeContributionStatus, + }); + const snapshotAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): ContributionStatusSnapshot => { + if (!supportsContributionStatus(get(options.configValueAtom(environmentId)))) { + return EMPTY_SNAPSHOT; + } + return Option.getOrElse( + AsyncResult.value(get(subscription({ environmentId, input: {} }))), + () => EMPTY_SNAPSHOT, + ); + }).pipe(Atom.withLabel(`environment-data:contribution-status:snapshot:${environmentId}`)), + ); + const threadStatusFamily = Atom.family((key: string) => { + const [environmentId, threadId] = JSON.parse(key) as [EnvironmentId, ThreadId]; + // A change on another thread keeps this thread's array, so its row does not re-render. + let previous = NO_ENTRIES; + return Atom.make((get) => { + const next = threadContributionStatusEntries(get(snapshotAtom(environmentId)), threadId); + if (!sameEntries(previous, next)) previous = next; + return previous; + }).pipe(Atom.withLabel(`environment-data:contribution-status:thread:${key}`)); + }); + return { + snapshot: snapshotAtom, + threadStatus: (environmentId: EnvironmentId, threadId: ThreadId) => + threadStatusFamily(JSON.stringify([environmentId, threadId])), + }; +} diff --git a/packages/contracts/src/contributionStatus.test.ts b/packages/contracts/src/contributionStatus.test.ts new file mode 100644 index 000000000000..ba18aaed1e38 --- /dev/null +++ b/packages/contracts/src/contributionStatus.test.ts @@ -0,0 +1,47 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { ContributionStatusSnapshot } from "./contributionStatus.ts"; + +const decodeSnapshot = Schema.decodeUnknownSync(ContributionStatusSnapshot); + +const piEntry = (items: ReadonlyArray) => ({ + threadId: "thread-1", + source: { + kind: "provider-session", + providerSessionId: "session-1", + providerInstanceId: "pi", + driver: "pi", + }, + items, +}); + +describe("ContributionStatusSnapshot", () => { + it("decodes a tone from a newer server as neutral instead of dropping the item", () => { + const snapshot = decodeSnapshot({ + entries: [piEntry([{ key: "build", text: "Building", tone: "celebrate" }])], + }); + expect(snapshot.entries[0]?.items).toEqual([ + { key: "build", text: "Building", tone: "neutral" }, + ]); + }); + + it("drops entries an older client cannot decode and keeps the rest", () => { + const snapshot = decodeSnapshot({ + entries: [ + { + ...piEntry([{ key: "a", text: "From a plugin" }]), + source: { kind: "plugin", pluginId: "x" }, + }, + piEntry([{ key: "a", text: "x".repeat(81) }]), + piEntry([{ key: "mode", text: "plan" }]), + ], + }); + expect(snapshot.entries.map((entry) => entry.items)).toEqual([[{ key: "mode", text: "plan" }]]); + }); + + it("rejects an entry with more items than one source may set", () => { + const items = Array.from({ length: 9 }, (_, index) => ({ key: `k${index}`, text: "on" })); + expect(decodeSnapshot({ entries: [piEntry(items)] }).entries).toEqual([]); + }); +}); diff --git a/packages/contracts/src/contributionStatus.ts b/packages/contracts/src/contributionStatus.ts new file mode 100644 index 000000000000..7f0ef1b73a4f --- /dev/null +++ b/packages/contracts/src/contributionStatus.ts @@ -0,0 +1,104 @@ +import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; + +import { ForwardCompatibleArray, ProviderSessionId, ThreadId } from "./baseSchemas.ts"; +import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; + +/** + * Short status text a producer attaches to a thread, such as a Pi + * extension's `ctx.ui.setStatus(key, text)`. The server owns these in memory + * only: they are never persisted and disappear with their producer. + */ +export const CONTRIBUTION_STATUS_KEY_MAX_LENGTH = 64; +export const CONTRIBUTION_STATUS_TEXT_MAX_LENGTH = 80; +export const CONTRIBUTION_STATUS_TOOLTIP_MAX_LENGTH = 240; +export const CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE = 8; +/** + * Server-owned limits on a whole snapshot. They bound counts, not bytes: frame + * size also depends on identifier lengths and on how JSON encodes the text. + * Clients never enforce them; raising one needs a new capability. + */ +export const CONTRIBUTION_STATUS_MAX_SOURCES_PER_THREAD = 4; +export const CONTRIBUTION_STATUS_MAX_THREADS = 64; +export const CONTRIBUTION_STATUS_MAX_ITEMS = 128; + +const CONTRIBUTION_STATUS_TONES = ["neutral", "info", "success", "warning", "error"] as const; +const ContributionStatusToneLiteral = Schema.Literals(CONTRIBUTION_STATUS_TONES); +const isContributionStatusTone = Schema.is(ContributionStatusToneLiteral); + +/** Tones a newer server adds decode as `neutral` instead of dropping the item. */ +export const ContributionStatusTone = Schema.String.pipe( + Schema.decodeTo( + ContributionStatusToneLiteral, + SchemaTransformation.transform({ + decode: (tone) => (isContributionStatusTone(tone) ? tone : "neutral"), + encode: (tone) => tone, + }), + ), +); +export type ContributionStatusTone = typeof ContributionStatusTone.Type; + +export const ContributionStatusItem = Schema.Struct({ + /** Stable per source; setting an existing key replaces its item. */ + key: Schema.String.check( + Schema.isNonEmpty(), + Schema.isMaxLength(CONTRIBUTION_STATUS_KEY_MAX_LENGTH), + ), + /** Single line of plain text. Producers strip control characters and ANSI styling. */ + text: Schema.String.check( + Schema.isNonEmpty(), + Schema.isMaxLength(CONTRIBUTION_STATUS_TEXT_MAX_LENGTH), + ), + /** Absent means neutral. */ + tone: Schema.optionalKey(ContributionStatusTone), + tooltip: Schema.optionalKey( + Schema.String.check(Schema.isMaxLength(CONTRIBUTION_STATUS_TOOLTIP_MAX_LENGTH)), + ), +}); +export type ContributionStatusItem = typeof ContributionStatusItem.Type; + +/** + * Who set an entry's items. A thread has at most one provider-session source: + * a new provider session on the thread takes over the previous one's entry. + * Plugin sources are reserved for a later `kind`; each plugin will own its own + * entry beside the provider's. + */ +export const ContributionStatusSource = Schema.Struct({ + kind: Schema.Literal("provider-session"), + providerSessionId: ProviderSessionId, + providerInstanceId: ProviderInstanceId, + driver: ProviderDriverKind, +}); +export type ContributionStatusSource = typeof ContributionStatusSource.Type; + +/** + * Identity of a source, stable for its lifetime and distinct across a takeover. + * Renderers key an entry by it and an item by it plus the item key. + */ +export const contributionStatusSourceKey = (source: ContributionStatusSource): string => + JSON.stringify([source.kind, source.providerInstanceId, source.providerSessionId]); + +/** One source's items on one thread. Entry identity is the thread plus the source. */ +export const ContributionStatusEntry = Schema.Struct({ + threadId: ThreadId, + source: ContributionStatusSource, + /** Sorted by key, at most CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE, never empty. */ + items: Schema.Array(ContributionStatusItem).check( + Schema.isMinLength(1), + Schema.isMaxLength(CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE), + ), +}); +export type ContributionStatusEntry = typeof ContributionStatusEntry.Type; + +/** + * Every live status in one environment, ordered by thread id, then provider + * sources before any other kind, then source key. `subscribeContributionStatus` + * sends one on subscribe and a full replacement after each change, so a + * client replaces its copy and never merges. Entries an older client cannot + * decode, such as a future source kind, are dropped rather than failing the + * stream. + */ +export const ContributionStatusSnapshot = Schema.Struct({ + entries: ForwardCompatibleArray(ContributionStatusEntry), +}); +export type ContributionStatusSnapshot = typeof ContributionStatusSnapshot.Type; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index a4763076639b..60ebc71014d2 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -220,6 +220,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ "server"`) and streams them over `/api/preview-stream`. Clients without a local browser runtime open server tabs here. */ serverBrowser: Schema.optionalKey(Schema.Boolean), + /** Server serves subscribeContributionStatus. Absent on older servers, which + lack the method, so clients must not subscribe and show no statuses. */ + contributionStatus: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 600d503ade39..b9feb676a04c 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -57,6 +57,7 @@ export * from "./device.ts"; export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; +export * from "./contributionStatus.ts"; export * from "./usage.ts"; export * from "./scheduledTask.ts"; export * from "./worktreeMcp.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 61304c52f860..7549874ab865 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -26,6 +26,7 @@ import * as Rpc from "effect/rpc/Rpc"; import * as RpcGroup from "effect/rpc/RpcGroup"; import * as RpcMiddleware from "effect/rpc/RpcMiddleware"; import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ContributionStatusSnapshot } from "./contributionStatus.ts"; import { CodexAuthCallbackInput, CodexAuthCallbackState, @@ -584,6 +585,7 @@ export const WS_METHODS = { subscribeAuthAccess: "subscribeAuthAccess", subscribeBackgroundPolicy: "subscribeBackgroundPolicy", subscribeResourceTelemetry: "subscribeResourceTelemetry", + subscribeContributionStatus: "subscribeContributionStatus", } as const; const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybinding, { @@ -1865,6 +1867,14 @@ const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeResourceTel stream: true, }); +/** Streams every live thread status in the environment: one snapshot on subscribe, then a full replacement after each change. Gated by the `contributionStatus` capability. */ +const WsSubscribeContributionStatusRpc = Rpc.make(WS_METHODS.subscribeContributionStatus, { + payload: Schema.Struct({}), + success: ContributionStatusSnapshot, + error: EnvironmentAuthorizationError, + stream: true, +}); + /** * Checks the connection's scopes against the scope each RPC declares, before * the handler runs. Every RPC in `WsRpcGroup` carries it, so a handler cannot @@ -2054,6 +2064,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeAuthAccessRpc, WsSubscribeBackgroundPolicyRpc, WsSubscribeResourceTelemetryRpc, + WsSubscribeContributionStatusRpc, WsOrchestrationV2DispatchCommandRpc, WsOrchestrationV2GetWorkflowScriptRpc, WsOrchestrationV2StopWorkflowRpc, diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json index c45de9a9b149..5586621a8613 100644 --- a/packages/provider-core/package.json +++ b/packages/provider-core/package.json @@ -23,6 +23,10 @@ "types": "./src/server/collectStreamText.ts", "import": "./src/server/collectStreamText.ts" }, + "./server/ContributionStatusStore": { + "types": "./src/server/ContributionStatusStore.ts", + "import": "./src/server/ContributionStatusStore.ts" + }, "./server/driver": { "types": "./src/server/driver.ts", "import": "./src/server/driver.ts" diff --git a/packages/provider-core/src/server/ContributionStatusStore.test.ts b/packages/provider-core/src/server/ContributionStatusStore.test.ts new file mode 100644 index 000000000000..16ef272350bc --- /dev/null +++ b/packages/provider-core/src/server/ContributionStatusStore.test.ts @@ -0,0 +1,280 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + CONTRIBUTION_STATUS_MAX_ITEMS, + CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE, + CONTRIBUTION_STATUS_MAX_THREADS, + type ContributionStatusSource, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as ContributionStatusStore from "./ContributionStatusStore.ts"; + +const THREAD_A = ThreadId.make("thread-a"); +const THREAD_B = ThreadId.make("thread-b"); + +const source = (session: string): ContributionStatusSource => ({ + kind: "provider-session", + providerSessionId: ProviderSessionId.make(session), + providerInstanceId: ProviderInstanceId.make("pi"), + driver: ProviderDriverKind.make("pi"), +}); + +/** Opens a handle in its own scope so a test can end that producer on demand. */ +const openHandle = Effect.fn("openHandle")(function* ( + store: ContributionStatusStore.ContributionStatusStoreShape, + session: string, + threadId: ThreadId | null, +) { + const scope = yield* Scope.make(); + const handle = yield* store.openSource(source(session)).pipe(Scope.provide(scope)); + yield* handle.bindThread(threadId); + return { handle, close: Scope.close(scope, Exit.void) }; +}); + +const itemsByThread = (store: ContributionStatusStore.ContributionStatusStoreShape) => + Effect.map(store.snapshot, (snapshot) => + Object.fromEntries( + snapshot.entries.map((entry) => [ + entry.threadId, + entry.items.map((item) => `${item.key}=${item.text}`), + ]), + ), + ); + +describe("ContributionStatusStore", () => { + it.effect("sets, replaces, and clears statuses as plain single-line text", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const { handle } = yield* openHandle(store, "session-1", THREAD_A); + + yield* handle.set({ key: "mode", text: "\u001b[32m● plan\u001b[39m\nmode" }); + yield* handle.set({ key: "branch", text: "main" }); + assert.deepStrictEqual(yield* itemsByThread(store), { + [THREAD_A]: ["branch=main", "mode=● plan mode"], + }); + + yield* handle.set({ key: "mode", text: "build" }); + yield* handle.clear("branch"); + assert.deepStrictEqual(yield* itemsByThread(store), { [THREAD_A]: ["mode=build"] }); + + // A lone surrogate would encode as a six-byte JSON escape; it becomes U+FFFD. + yield* handle.set({ key: "lone", text: "a\uD800b", tooltip: "\uDC00" }); + const lone = (yield* store.snapshot).entries[0]?.items.find((item) => item.key === "lone"); + assert.deepStrictEqual(lone, { key: "lone", text: "a\uFFFDb", tooltip: "\uFFFD" }); + + yield* handle.set({ key: "mode", text: " \t " }); + yield* handle.clear("lone"); + assert.deepStrictEqual(yield* itemsByThread(store), {}); + }), + ); + + it.effect("bounds keys, text, and items per source", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const { handle } = yield* openHandle(store, "session-1", THREAD_A); + + for (let index = 0; index < 9; index += 1) { + yield* handle.set({ key: `k${index}`, text: "on" }); + } + yield* handle.set({ key: "k0", text: `${"a".repeat(78)}😀tail` }); + + const [entry] = (yield* store.snapshot).entries; + assert.strictEqual(entry?.items.length, 8); + assert.isFalse(entry?.items.some((item) => item.key === "k8")); + // The emoji would straddle the limit, so it is dropped rather than split. + assert.strictEqual(entry?.items[0]?.text, `${"a".repeat(78)}…`); + }), + ); + + it.effect("treats keys as identities that never collide", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const { handle } = yield* openHandle(store, "session-1", THREAD_A); + const prefix = "x".repeat(63); + + yield* handle.set({ key: `${prefix}A`, text: "first" }); + yield* handle.set({ key: `${prefix}B`, text: "second" }); + yield* handle.set({ key: "a b", text: "spaced" }); + yield* handle.set({ key: "a b", text: "double spaced" }); + // Overlong or control-character keys are rejected, never rewritten into another key. + yield* handle.set({ key: `${prefix}AB`, text: "overlong" }); + yield* handle.set({ key: "a\nb", text: "control" }); + yield* handle.set({ key: "a\uD800", text: "lone surrogate" }); + assert.deepStrictEqual(yield* itemsByThread(store), { + [THREAD_A]: ["a b=double spaced", "a b=spaced", `${prefix}A=first`, `${prefix}B=second`], + }); + + // Clearing applies the same rule, so it cannot remove a neighbouring key. + yield* handle.clear(`${prefix}AB`); + yield* handle.clear("a\tb"); + yield* handle.clear("a\uD800"); + yield* handle.clear(`${prefix}A`); + yield* handle.clear("a b"); + assert.deepStrictEqual(yield* itemsByThread(store), { + [THREAD_A]: ["a b=double spaced", `${prefix}B=second`], + }); + }), + ); + + it.effect("clears a producer's statuses when its scope closes and ignores it afterwards", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const { handle, close } = yield* openHandle(store, "session-1", THREAD_A); + yield* handle.set({ key: "mode", text: "plan" }); + + yield* close; + yield* handle.set({ key: "mode", text: "late" }); + yield* handle.bindThread(THREAD_B); + + assert.deepStrictEqual(yield* itemsByThread(store), {}); + }), + ); + + it.effect("moves a producer between threads and keeps items when rebinding its own thread", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const { handle } = yield* openHandle(store, "session-1", THREAD_A); + yield* handle.set({ key: "mode", text: "plan" }); + + yield* handle.bindThread(THREAD_A); + assert.deepStrictEqual(yield* itemsByThread(store), { [THREAD_A]: ["mode=plan"] }); + + yield* handle.bindThread(THREAD_B); + yield* handle.set({ key: "mode", text: "fork" }); + assert.deepStrictEqual(yield* itemsByThread(store), { [THREAD_B]: ["mode=fork"] }); + }), + ); + + it.effect("rejects a replaced producer so late updates cannot touch the replacement", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const old = yield* openHandle(store, "session-old", THREAD_A); + yield* old.handle.set({ key: "mode", text: "old" }); + + const replacement = yield* openHandle(store, "session-new", THREAD_A); + assert.deepStrictEqual(yield* itemsByThread(store), {}); + yield* replacement.handle.set({ key: "mode", text: "new" }); + + yield* old.handle.set({ key: "mode", text: "stale" }); + yield* old.handle.clear("mode"); + yield* old.handle.clearAll; + yield* old.close; + + const snapshot = yield* store.snapshot; + assert.deepStrictEqual(yield* itemsByThread(store), { [THREAD_A]: ["mode=new"] }); + assert.strictEqual( + snapshot.entries[0]?.source.providerSessionId, + ProviderSessionId.make("session-new"), + ); + }), + ); + + it.effect("gives each subscriber the current state, then only the latest change", () => + Effect.scoped( + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const { handle } = yield* openHandle(store, "session-1", THREAD_A); + yield* handle.set({ key: "mode", text: "plan" }); + + const subscription = yield* store.subscribe; + assert.deepStrictEqual( + subscription.latest.entries.map((entry) => entry.threadId), + [THREAD_A], + ); + + yield* handle.set({ key: "mode", text: "build" }); + yield* handle.set({ key: "mode", text: "review" }); + yield* handle.set({ key: "turn", text: "3" }); + const next = yield* Stream.runHead(subscription.changes); + assert.deepStrictEqual( + next._tag === "Some" ? next.value.entries[0]?.items.map((item) => item.text) : [], + ["review", "3"], + ); + + // A reconnect is a fresh subscription; its first frame is the current state. + const reconnect = yield* store.subscribe; + assert.deepStrictEqual(reconnect.latest, yield* store.snapshot); + }), + ), + ); + + it.effect("caps threads showing a status without counting silent producers", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const max = CONTRIBUTION_STATUS_MAX_THREADS; + // More bound producers than the cap that never set anything take no capacity. + for (let index = 0; index <= max; index += 1) { + yield* openHandle(store, `silent-${index}`, ThreadId.make(`silent-${index}`)); + } + const visible = []; + for (let index = 0; index < max; index += 1) { + const opened = yield* openHandle(store, `visible-${index}`, ThreadId.make(`t-${index}`)); + yield* opened.handle.set({ key: "mode", text: "on" }); + visible.push(opened.handle); + } + assert.strictEqual((yield* store.snapshot).entries.length, max); + + const late = yield* openHandle(store, "late", ThreadId.make("late")); + yield* late.handle.set({ key: "mode", text: "rejected" }); + assert.isUndefined( + (yield* store.snapshot).entries.find((entry) => entry.threadId === "late"), + ); + + // Clearing a thread's last item returns its capacity, and the rejected producer's next set lands. + yield* visible[0]!.clear("mode"); + yield* late.handle.set({ key: "mode", text: "admitted" }); + const snapshot = yield* store.snapshot; + assert.strictEqual(snapshot.entries.length, max); + assert.deepStrictEqual(snapshot.entries.find((entry) => entry.threadId === "late")?.items, [ + { key: "mode", text: "admitted" }, + ]); + // A thread already showing a status can still replace it at the cap. + yield* visible[1]!.set({ key: "mode", text: "replaced" }); + assert.strictEqual( + (yield* store.snapshot).entries.find((entry) => entry.threadId === "t-1")?.items[0]?.text, + "replaced", + ); + }), + ); + + it.effect("orders entries by thread and caps the items in one snapshot", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const fullThreads = CONTRIBUTION_STATUS_MAX_ITEMS / CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE; + const handles = []; + // Bind in reverse so the snapshot order cannot come from insertion order. + for (let index = fullThreads; index >= 0; index -= 1) { + const opened = yield* openHandle(store, `s-${index}`, ThreadId.make(`t-${index}`)); + handles[index] = opened.handle; + } + for (let index = 0; index <= fullThreads; index += 1) { + for (let item = 0; item < CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE; item += 1) { + yield* handles[index]!.set({ key: `k${item}`, text: "on" }); + } + } + const snapshot = yield* store.snapshot; + const threadIds = snapshot.entries.map((entry) => entry.threadId); + assert.deepStrictEqual(threadIds, threadIds.toSorted()); + assert.strictEqual(threadIds.length, fullThreads); + assert.notInclude(threadIds, ThreadId.make(`t-${fullThreads}`)); + assert.strictEqual( + snapshot.entries.reduce((total, entry) => total + entry.items.length, 0), + CONTRIBUTION_STATUS_MAX_ITEMS, + ); + + yield* handles[0]!.clear("k0"); + yield* handles[fullThreads]!.set({ key: "k0", text: "admitted" }); + assert.include( + (yield* store.snapshot).entries.map((entry) => entry.threadId), + ThreadId.make(`t-${fullThreads}`), + ); + }), + ); +}); diff --git a/packages/provider-core/src/server/ContributionStatusStore.ts b/packages/provider-core/src/server/ContributionStatusStore.ts new file mode 100644 index 000000000000..8730f2f6b388 --- /dev/null +++ b/packages/provider-core/src/server/ContributionStatusStore.ts @@ -0,0 +1,347 @@ +import * as NodeUtil from "node:util"; + +import { + CONTRIBUTION_STATUS_KEY_MAX_LENGTH, + CONTRIBUTION_STATUS_MAX_ITEMS, + CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE, + CONTRIBUTION_STATUS_MAX_SOURCES_PER_THREAD, + CONTRIBUTION_STATUS_MAX_THREADS, + CONTRIBUTION_STATUS_TEXT_MAX_LENGTH, + CONTRIBUTION_STATUS_TOOLTIP_MAX_LENGTH, + type ContributionStatusItem, + type ContributionStatusSnapshot, + type ContributionStatusSource, + type ContributionStatusTone, + contributionStatusSourceKey, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import type * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +/** + * One producer's statuses. A handle writes to at most one thread at a time and + * is retired when the scope that opened it closes, clearing what it set. After + * another handle with the same kind of source binds the same thread, this one + * is stale: its writes are ignored until it binds again, so a late update from + * a replaced provider session never reaches the replacement's statuses. + */ +export interface ContributionStatusSourceHandle { + /** + * Moves this handle to `threadId`, clearing what it set on its previous + * thread. Rebinding the thread it still owns keeps its items; binding a + * thread where another provider session owns the provider entry takes that + * entry over and clears its items. + */ + readonly bindThread: (threadId: ThreadId | null) => Effect.Effect; + /** Sets or replaces `key`. Text that is empty after normalization clears the key. */ + readonly set: (input: { + readonly key: string; + readonly text: string; + readonly tone?: ContributionStatusTone; + readonly tooltip?: string; + }) => Effect.Effect; + readonly clear: (key: string) => Effect.Effect; + readonly clearAll: Effect.Effect; + /** What this handle currently shows, as stored: admitted, normalized, and capped. */ + readonly items: Effect.Effect>; +} + +export interface ContributionStatusStoreShape { + readonly openSource: ( + source: ContributionStatusSource, + ) => Effect.Effect; + readonly snapshot: Effect.Effect; + /** Latest snapshot plus later full replacements; a slow subscriber only skips intermediate ones. */ + readonly subscribe: Effect.Effect< + { + readonly latest: ContributionStatusSnapshot; + readonly changes: Stream.Stream; + }, + never, + Scope.Scope + >; +} + +const emptySnapshot: ContributionStatusSnapshot = { entries: [] }; + +const noopHandle: ContributionStatusSourceHandle = { + bindThread: () => Effect.void, + set: () => Effect.void, + clear: () => Effect.void, + clearAll: Effect.void, + items: Effect.succeed([]), +}; + +/** + * Thread statuses produced by live provider sessions. The default drops every + * update so adapters construct without it in tests; the live layer must be the + * same reference the WebSocket server reads, so layer memoization shares one + * store between producers and subscribers. + */ +export class ContributionStatusStore extends Context.Reference( + "@t3tools/provider-core/server/ContributionStatusStore", + { + defaultValue: () => ({ + openSource: () => Effect.succeed(noopHandle), + snapshot: Effect.succeed(emptySnapshot), + subscribe: Effect.succeed({ latest: emptySnapshot, changes: Stream.empty }), + }), + }, +) {} + +// Line breaks, tabs, other C0/C1 controls, DEL, and Unicode line/paragraph separators. +// eslint-disable-next-line no-control-regex +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g; +// eslint-disable-next-line no-control-regex -- matches the control characters on purpose, as above +const HAS_CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; + +/** + * Keys are identities, not display text, so they are never rewritten: two + * different keys must never collide. A key that is empty, longer than the + * contract allows, or contains a control character or lone UTF-16 surrogate + * is rejected on set and clear alike. + */ +const isValidKey = (key: string) => + key.length > 0 && + key.length <= CONTRIBUTION_STATUS_KEY_MAX_LENGTH && + key.isWellFormed() && + !HAS_CONTROL_CHARACTER.test(key); + +/** + * Producer text as one plain line: terminal styling removed, lone surrogates + * replaced with U+FFFD, controls turned into spaces, whitespace collapsed, and + * at most `maxLength` UTF-16 units without splitting a surrogate pair. A + * truncated value ends in `…`. + */ +function normalizeContributionStatusText(raw: string, maxLength: number): string { + const text = NodeUtil.stripVTControlCharacters(raw.toWellFormed()) + .replace(CONTROL_CHARACTERS, " ") + .replace(/\s+/g, " ") + .trim(); + if (text.length <= maxLength) return text; + let truncated = ""; + for (const codePoint of text) { + if (truncated.length + codePoint.length > maxLength - 1) break; + truncated += codePoint; + } + return `${truncated.trimEnd()}…`; +} + +interface SourceSlot { + readonly generation: number; + readonly source: ContributionStatusSource; + readonly items: Map; +} + +/** + * Which handles compete for one entry on a thread. A thread hosts one provider + * session at a time, so every provider-session source shares a slot and a new + * session takes the old one over; plugins will each get their own slot. + */ +const slotKey = (source: ContributionStatusSource) => source.kind; + +const sameItem = (left: ContributionStatusItem | undefined, right: ContributionStatusItem) => + left !== undefined && + left.text === right.text && + left.tone === right.tone && + left.tooltip === right.tooltip; + +const compareStrings = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0); + +const sourceRank = (source: ContributionStatusSource) => + source.kind === "provider-session" ? 0 : 1; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.fn("contributions.status.make")(function* () { + const threads = new Map>(); + const changes = yield* PubSub.sliding(1); + const mutex = yield* Semaphore.make(1); + let nextGeneration = 0; + + /** + * Binding a thread only records ownership; capacity is taken by the first + * visible item and returned by the last clear, so silent producers never + * crowd out ones that show something, and a rejected producer's later set + * is admitted once capacity frees up. + */ + const admitsNewItem = (threadId: ThreadId, slot: SourceSlot) => { + if (slot.items.size >= CONTRIBUTION_STATUS_MAX_ITEMS_PER_SOURCE) return false; + let items = 0; + let visibleThreads = 0; + for (const slots of threads.values()) { + let visible = false; + for (const other of slots.values()) { + items += other.items.size; + visible ||= other.items.size > 0; + } + if (visible) visibleThreads += 1; + } + if (items >= CONTRIBUTION_STATUS_MAX_ITEMS) return false; + if (slot.items.size > 0) return true; + let visibleSources = 0; + for (const other of threads.get(threadId)?.values() ?? []) { + if (other.items.size > 0) visibleSources += 1; + } + if (visibleSources >= CONTRIBUTION_STATUS_MAX_SOURCES_PER_THREAD) return false; + return visibleSources > 0 || visibleThreads < CONTRIBUTION_STATUS_MAX_THREADS; + }; + + const currentSnapshot = (): ContributionStatusSnapshot => ({ + entries: Array.from(threads.keys()) + .toSorted(compareStrings) + .flatMap((threadId) => + Array.from(threads.get(threadId)?.values() ?? []) + .filter((slot) => slot.items.size > 0) + .toSorted( + (left, right) => + sourceRank(left.source) - sourceRank(right.source) || + compareStrings( + contributionStatusSourceKey(left.source), + contributionStatusSourceKey(right.source), + ), + ) + .map((slot) => ({ + threadId, + source: slot.source, + items: Array.from(slot.items.values()).toSorted((left, right) => + compareStrings(left.key, right.key), + ), + })), + ), + }); + + /** Runs `mutate` under the store lock and publishes once when it reports a visible change. */ + const update = (mutate: () => boolean) => + mutex.withPermits(1)( + Effect.suspend(() => + mutate() ? PubSub.publish(changes, currentSnapshot()).pipe(Effect.asVoid) : Effect.void, + ), + ); + + const openSource: ContributionStatusStoreShape["openSource"] = (source) => + Effect.gen(function* () { + let threadId: ThreadId | null = null; + let generation = 0; + let retired = false; + + const slotId = slotKey(source); + + /** The slot this handle still owns, or undefined when it is unbound, replaced, or retired. */ + const ownedSlot = () => { + if (retired || threadId === null) return undefined; + const slot = threads.get(threadId)?.get(slotId); + return slot?.generation === generation ? slot : undefined; + }; + + const release = () => { + const slot = ownedSlot(); + if (slot === undefined || threadId === null) return false; + const slots = threads.get(threadId); + slots?.delete(slotId); + if (slots?.size === 0) threads.delete(threadId); + return slot.items.size > 0; + }; + + const handle: ContributionStatusSourceHandle = { + bindThread: (nextThreadId) => + update(() => { + if (retired) return false; + if (nextThreadId !== null && nextThreadId === threadId && ownedSlot() !== undefined) { + return false; + } + const released = release(); + threadId = nextThreadId; + if (nextThreadId === null) return released; + let slots = threads.get(nextThreadId); + if (slots === undefined) { + slots = new Map(); + threads.set(nextThreadId, slots); + } + const replaced = slots.get(slotId); + generation = ++nextGeneration; + slots.set(slotId, { generation, source, items: new Map() }); + return released || (replaced !== undefined && replaced.items.size > 0); + }), + set: (input) => + update(() => { + const slot = ownedSlot(); + const key = input.key; + if (slot === undefined || threadId === null || !isValidKey(key)) return false; + const text = normalizeContributionStatusText( + input.text, + CONTRIBUTION_STATUS_TEXT_MAX_LENGTH, + ); + if (text.length === 0) return slot.items.delete(key); + const previous = slot.items.get(key); + if (previous === undefined && !admitsNewItem(threadId, slot)) return false; + const tooltip = + input.tooltip === undefined + ? "" + : normalizeContributionStatusText( + input.tooltip, + CONTRIBUTION_STATUS_TOOLTIP_MAX_LENGTH, + ); + const item: ContributionStatusItem = { + key, + text, + ...(input.tone === undefined || input.tone === "neutral" ? {} : { tone: input.tone }), + ...(tooltip.length === 0 ? {} : { tooltip }), + }; + if (sameItem(previous, item)) return false; + slot.items.set(key, item); + return true; + }), + clear: (key) => + update(() => { + const slot = ownedSlot(); + if (slot === undefined || !isValidKey(key)) return false; + return slot.items.delete(key); + }), + clearAll: update(() => { + const slot = ownedSlot(); + if (slot === undefined || slot.items.size === 0) return false; + slot.items.clear(); + return true; + }), + items: mutex.withPermits(1)( + Effect.sync(() => Array.from(ownedSlot()?.items.values() ?? [])), + ), + }; + + yield* Effect.addFinalizer(() => + update(() => { + const released = release(); + retired = true; + return released; + }), + ); + return handle; + }); + + return ContributionStatusStore.of({ + openSource, + snapshot: mutex.withPermits(1)(Effect.sync(currentSnapshot)), + // Subscribes under the lock so no change lands between the snapshot and the stream. + subscribe: mutex.withPermits(1)( + Effect.map(PubSub.subscribe(changes), (subscription) => ({ + latest: currentSnapshot(), + changes: Stream.fromSubscription(subscription), + })), + ), + }); +}); + +export const layer = Layer.effect(ContributionStatusStore, make()); + +/** The `subscribeContributionStatus` stream: the current snapshot, then each replacement. */ +export const subscriptionStream = (store: ContributionStatusStoreShape) => + Stream.unwrap( + Effect.map(store.subscribe, ({ latest, changes }) => + Stream.concat(Stream.make(latest), changes), + ), + ); diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index cab0043ab185..153083cd02da 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -12,6 +12,7 @@ import { RunId, ThreadId, type ChatAttachment, + type ContributionStatusSnapshot, type ModelSelection, type OrchestrationV2AppThread, type OrchestrationV2ProviderThread, @@ -19,13 +20,17 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -40,6 +45,7 @@ import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter" import { handoffBudget } from "@t3tools/provider-core/server/handoffBudget"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { toolOutputImages, compactDynamicToolOutput } from "@t3tools/shared/toolOutput"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; import { makePiAdapterV2, PiAdapterV2Driver, @@ -99,8 +105,14 @@ interface FakePi { readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork") => void; /** Hold a model-select extension hook until its UI request is answered. */ readonly deferNextModelSelection: () => void; + /** Send the held lifecycle request's normal response. */ + readonly resolveDeferredLifecycle: Effect.Effect; readonly queueModels: (models: ReadonlyArray) => void; readonly vetoNextNewSession: () => void; + /** Make the next `fork` ack report an extension veto. */ + readonly vetoNextFork: () => void; + /** Make the next `fork` fail with `success: false`, leaving Pi on its session. */ + readonly rejectNextFork: () => void; /** Every request received by the fake process. */ readonly allRequests: () => ReadonlyArray; /** Data returned by the next `get_session_stats` acks, consumed in order. */ @@ -150,7 +162,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 = []; @@ -201,8 +216,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 } }; + } default: return base; } @@ -227,6 +249,7 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { } if (record["type"] === deferredLifecycle) { deferredLifecycle = undefined; + deferredLifecycleRequest = record; continue; } const response = respondTo(record); @@ -301,12 +324,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; @@ -387,6 +423,95 @@ const openRuntime = Effect.fnUntraced(function* ( return { runtime, takeEvent }; }); +const statusMap = (snapshot: ContributionStatusSnapshot) => + Object.fromEntries( + snapshot.entries.map((entry) => [ + entry.threadId, + entry.items.map((item) => `${item.key}=${item.text}`), + ]), + ); + +/** Pi's fire-and-forget `ctx.ui.setStatus`; a missing text clears the key. */ +const emitStatus = (fake: FakePi, statusKey: string, statusText?: string) => + fake.emit({ + type: "extension_ui_request", + id: `status-${statusKey}-${statusText ?? "clear"}`, + method: "setStatus", + statusKey, + ...(statusText === undefined ? {} : { statusText }), + }); + +/** Waits for the store to publish a snapshot matching `predicate`, the receipt for status events. */ +const waitForStatuses = ( + subscription: { readonly changes: Stream.Stream }, + predicate: (statuses: Record>) => boolean, +) => + subscription.changes.pipe( + Stream.map(statusMap), + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + +/** + * A status store whose "gate" status holds the session event pump until + * released. The pump handles records in order, so `pumpHeld` is also the + * receipt that every earlier record has been handled. + */ +const makeGatedStatusStore = Effect.fnUntraced(function* () { + const store = yield* ContributionStatusStore.make(); + const pumpHeld = yield* Deferred.make(); + const releasePump = yield* Deferred.make(); + const gated: ContributionStatusStore.ContributionStatusStoreShape = { + ...store, + openSource: (source) => + Effect.map(store.openSource(source), (handle) => ({ + ...handle, + set: (input) => + input.key === "gate" + ? Deferred.succeed(pumpHeld, undefined).pipe( + Effect.andThen(Deferred.await(releasePump)), + Effect.andThen(handle.set(input)), + ) + : handle.set(input), + })), + }; + return { + store, + provide: Effect.provideService(ContributionStatusStore.ContributionStatusStore, gated), + holdPump: (fake: FakePi) => + emitStatus(fake, "gate", "held").pipe(Effect.andThen(Deferred.await(pumpHeld))), + releasePump: Deferred.succeed(releasePump, undefined), + }; +}); + +/** Rolls `providerThread` back to its start, which forks Pi's session before the first turn. */ +const rollbackToThreadStart = ( + runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime, + providerThread: OrchestrationV2ProviderThread, +) => + runtime.rollbackThread({ + providerThread, + providerThreadTurns: [ + { + id: ProviderTurnId.make("turn-1"), + providerThreadId: providerThread.id, + nodeId: NodeId.make("node-1"), + runAttemptId: null, + nativeTurnRef: { driver: PI_PROVIDER, nativeId: "u1", strength: "strong" }, + ordinal: 1, + status: "completed", + startedAt: null, + completedAt: null, + }, + ], + target: { + type: "thread_start", + checkpointId: CheckpointId.make("checkpoint-pi-rollback"), + appRunOrdinal: 0, + }, + }); + const makeAppThread = Effect.fnUntraced(function* (model: string, threadId = THREAD_ID) { const now = yield* DateTime.now; return { @@ -1333,6 +1458,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 96d2783ecf3f..fe35e62430ba 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -20,8 +20,20 @@ * Dialog methods become v2 runtime requests (`confirm` → approval_request, * `select`/`input`/`editor` → user_input_request); answers travel back as * `extension_ui_response`. `notify` becomes a completed activity item. - * Terminal-only decoration such as status, widget, title, and editor-text - * updates has no matching T3 surface and is ignored. + * `setStatus` feeds the thread's contribution status, an advisory channel + * owned by this Pi process for the T3 provider session's lifetime. Before a + * T3-initiated switch, new session, or fork, a queued marker clears the + * statuses read so far and sends later ones to the target thread; a failed + * registration or rollback sends them nowhere until a thread registers. A + * rollback's marker keeps the statuses the store held for this session; if an + * extension cancels the fork, the keys the session has not written since come + * back, and once Pi answers either way the kept copy is dropped. Pi's + * RPC stdout marks no native-session boundary, so old-session writes after + * the marker (such as session_shutdown handlers) look like the new session's + * and may persist, and extension-initiated switches or reloads are not + * tracked. Closing this session clears its statuses. + * Other terminal decoration (widget, title, editor text) has no matching T3 + * surface and is ignored. */ import * as HostProcess from "@t3tools/shared/HostProcess"; import { AgentScope } from "@t3tools/shared/AgentScope"; @@ -32,6 +44,7 @@ import { defaultInstanceIdForDriver, ProviderDriverKind, type ChatAttachment, + type ContributionStatusItem, type ModelSelection, type OrchestrationV2ExecutionNode, type OrchestrationV2ProviderCapabilities, @@ -55,6 +68,7 @@ import * as Deferred from "effect/Deferred"; import * as Option from "effect/Option"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -72,6 +86,7 @@ import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; import { ProviderAdapterDriverCreateError, type ProviderAdapterDriver, @@ -498,6 +513,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( const idAllocator = yield* IdAllocator.IdAllocatorV2; const host = yield* ProviderHost.ProviderHost; const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + const statusStore = yield* ContributionStatusStore.ContributionStatusStore; const { continuationRequests } = options; const protocolError = (detail: string, payload?: unknown) => @@ -594,6 +610,34 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( // serialize the two paths to stop `turn.terminal` from overtaking the // dialog's own resolution updates. const sessionEventPermit = yield* Semaphore.make(1); + // Extensions set statuses from session_start, before the first thread + // registration, so the source starts on the thread this session opened for. + const statusSource = yield* statusStore.openSource({ + kind: "provider-session", + providerSessionId: input.providerSessionId, + providerInstanceId: options.instanceId, + driver: PI_PROVIDER, + }); + yield* statusSource.bindThread(input.threadId); + // The statuses a pending rollback's marker cleared, as the store held + // them, minus every key the session has written since. At most one + // source's capped items, dropped once the rollback's fork is answered. + let rollbackStatuses: Map | null = null; + /** + * 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; @@ -725,22 +769,36 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( return null; }; - const lifecycleRequest = (record: PiRpcRecord) => - request(record, PI_SESSION_TIMEOUT_MS).pipe( - // A local timeout does not cancel Pi's lifecycle hook. Retire the - // process before fallback can race its eventual switch/new-session. - Effect.tapError((error) => - Effect.logWarning("Pi session lifecycle request failed", { - providerSessionId: input.providerSessionId, - operation: record["type"], - errorTag: error._tag, - }), + /** + * Switches Pi to another native session whose statuses belong to + * `statusThreadId`. Pi rebinds every extension for the new session, and + * their startup statuses can arrive before the response, so the status + * generation starts before the request is written. + */ + const lifecycleRequest = ( + record: PiRpcRecord, + statusThreadId: OrchestrationV2ProviderThread["appThreadId"], + rollback = false, + ) => + queueStatusGeneration(statusThreadId, rollback).pipe( + Effect.andThen( + request(record, PI_SESSION_TIMEOUT_MS).pipe( + // A local timeout does not cancel Pi's lifecycle hook. Retire the + // process before fallback can race its eventual switch/new-session. + Effect.tapError((error) => + Effect.logWarning("Pi session lifecycle request failed", { + providerSessionId: input.providerSessionId, + operation: record["type"], + errorTag: error._tag, + }), + ), + Effect.catchTags({ + PiRpcTimeoutError: (error) => + connection.terminate.pipe(Effect.andThen(Effect.fail(error))), + }), + Effect.onInterrupt(() => connection.terminate), + ), ), - Effect.catchTags({ - PiRpcTimeoutError: (error) => - connection.terminate.pipe(Effect.andThen(Effect.fail(error))), - }), - Effect.onInterrupt(() => connection.terminate), ); const tokenUsageFromStats = ( @@ -1348,6 +1406,16 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( }); return; } + if (method === "setStatus") { + // Pi serializes a cleared status as a missing `statusText`. + const key = recordString(event, "statusKey"); + if (key === undefined) return; + const text = recordString(event, "statusText"); + // The session has replaced or cleared this key since the rollback marker. + rollbackStatuses?.delete(key); + yield* text === undefined ? statusSource.clear(key) : statusSource.set({ key, text }); + return; + } if ( method !== "select" && method !== "confirm" && @@ -2170,6 +2238,31 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( yield* cancelPrompt(pending, yield* DateTime.now); return; } + case "t3.status_generation": { + // Statuses queued before this marker came from the session Pi is + // leaving. Its shutdown writes can still follow; see the header. + rollbackStatuses = + event["rollback"] === true + ? new Map((yield* statusSource.items).map((item) => [item.key, item])) + : null; + yield* statusSource.clearAll; + yield* statusSource.bindThread( + (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": { // Startup extension failures are informational and do not block // Pi, so attach them to the next real turn instead of creating a @@ -2291,7 +2384,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( // ── session runtime ─────────────────────────────────── - const registerThread = Effect.fnUntraced(function* ( + const registerThreadUnguarded = Effect.fnUntraced(function* ( threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, publish = true, ) { @@ -2318,8 +2411,9 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( const existing = threadInput.existingProviderThread; const resumeId = existing?.nativeThreadRef?.nativeId; const needsNewSession = resumeId == null && registrationAttempted; + const switchesSession = resumeId != null || needsNewSession; registrationAttempted = true; - if (resumeId != null || needsNewSession) { + if (switchesSession) { lastNativeThreadId = resumeId ?? lastNativeThreadId; // Even a failed lifecycle operation can change Pi's native session. // Never leave the old app binding or model defaults usable afterward. @@ -2334,6 +2428,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( resumeId != null ? { type: "switch_session", sessionPath: resumeId } : { type: "new_session" }, + existing === undefined ? threadInput.threadId : existing.appThreadId, ); if (recordField(result, "cancelled") === true) { return yield* protocolError("A Pi extension cancelled the session switch"); @@ -2407,6 +2502,8 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( updatedAt: createdAt, }; threadState = { providerThread, activeTurn: null }; + // A session switch moved the statuses in event order already. + if (!switchesSession) yield* statusSource.bindThread(providerThread.appThreadId); // Baseline the session-tree leaf so the first turn's user entry can // be located with a `since` cursor instead of a full entry scan. const baselineEntries = yield* request({ type: "get_entries" }).pipe( @@ -2426,6 +2523,16 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( return providerThread; }); + // A failed registration leaves no thread bound, so no thread shows this + // session's statuses until a later registration succeeds. + const registerThread = ( + threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, + publish = true, + ) => + registerThreadUnguarded(threadInput, publish).pipe( + Effect.onError(() => (threadState === null ? queueStatusGeneration(null) : Effect.void)), + ); + const applySelection = Effect.fnUntraced(function* (modelSelection: ModelSelection) { const thinking = getModelSelectionStringOptionValue(modelSelection, "thinking"); if (modelSelection.model === PI_INHERIT_MODEL_SLUG) { @@ -3062,19 +3169,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(