From e0874b651d5c5fa5c578be932aada627b95c3f52 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 09:53:53 -0700 Subject: [PATCH 01/26] Raise slow RPC ack warning threshold to 15s (#1760) --- apps/web/src/rpc/requestLatencyState.ts | 12 +++++++++--- apps/web/src/wsTransport.test.ts | 12 +++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index 01c59ea0d5..d21e37b529 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -3,8 +3,9 @@ import { Atom } from "effect/unstable/reactivity"; import { appAtomRegistry } from "./atomRegistry"; -export const SLOW_RPC_ACK_THRESHOLD_MS = 2_500; +export const SLOW_RPC_ACK_THRESHOLD_MS = 15_000; export const MAX_TRACKED_RPC_ACK_REQUESTS = 256; +let slowRpcAckThresholdMs = SLOW_RPC_ACK_THRESHOLD_MS; export interface SlowRpcAckRequest { readonly requestId: string; @@ -56,12 +57,12 @@ export function trackRpcRequestSent(requestId: string, tag: string): void { startedAt: new Date(startedAtMs).toISOString(), startedAtMs, tag, - thresholdMs: SLOW_RPC_ACK_THRESHOLD_MS, + thresholdMs: slowRpcAckThresholdMs, }; const timeoutId = setTimeout(() => { pendingRpcAckRequests.delete(requestId); appendSlowRpcAckRequest(request); - }, SLOW_RPC_ACK_THRESHOLD_MS); + }, slowRpcAckThresholdMs); pendingRpcAckRequests.set(requestId, { request, @@ -119,9 +120,14 @@ function evictOldestPendingRpcRequestIfNeeded(): void { } export function resetRequestLatencyStateForTests(): void { + slowRpcAckThresholdMs = SLOW_RPC_ACK_THRESHOLD_MS; clearAllTrackedRpcRequests(); } +export function setSlowRpcAckThresholdMsForTests(thresholdMs: number): void { + slowRpcAckThresholdMs = thresholdMs; +} + export function useSlowRpcAckRequests(): ReadonlyArray { return useAtomValue(slowRpcAckRequestsAtom); } diff --git a/apps/web/src/wsTransport.test.ts b/apps/web/src/wsTransport.test.ts index af02a5288f..da5404b239 100644 --- a/apps/web/src/wsTransport.test.ts +++ b/apps/web/src/wsTransport.test.ts @@ -5,7 +5,11 @@ import { __resetClientTracingForTests, configureClientTracing, } from "./observability/clientTracing"; -import { getSlowRpcAckRequests, resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; +import { + getSlowRpcAckRequests, + resetRequestLatencyStateForTests, + setSlowRpcAckThresholdMsForTests, +} from "./rpc/requestLatencyState"; import { getWsConnectionStatus, getWsConnectionUiState, @@ -292,6 +296,8 @@ describe("WsTransport", () => { }); it("marks unary requests as slow until the first server ack arrives", async () => { + const slowAckThresholdMs = 25; + setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); const transport = new WsTransport("ws://localhost:3020"); const requestPromise = transport.request((client) => @@ -320,7 +326,7 @@ describe("WsTransport", () => { tag: WS_METHODS.serverUpsertKeybinding, }, ]); - }, 5_000); + }, 1_000); socket.serverMessage( JSON.stringify({ @@ -343,7 +349,7 @@ describe("WsTransport", () => { expect(getSlowRpcAckRequests()).toEqual([]); await transport.dispose(); - }, 10_000); + }, 5_000); it("sends unary RPC requests and resolves successful exits", async () => { const transport = new WsTransport("ws://localhost:3020"); From cf2c628bc005f3c88911a6fcb957f56e6d1ce45f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 5 Apr 2026 11:22:24 -0700 Subject: [PATCH 02/26] Use active worktree path for workspace saves (#1762) --- apps/web/src/components/ChatView.browser.tsx | 49 ++++++++++++++++++++ apps/web/src/components/ChatView.tsx | 5 +- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 84302386dc..a727b89ea3 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2818,6 +2818,55 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("uses the active worktree path when saving a proposed plan to the workspace", async () => { + const snapshot = createSnapshotWithLongProposedPlan(); + const threads = snapshot.threads.slice(); + const targetThreadIndex = threads.findIndex((thread) => thread.id === THREAD_ID); + const targetThread = targetThreadIndex >= 0 ? threads[targetThreadIndex] : undefined; + if (targetThread) { + threads[targetThreadIndex] = { + ...targetThread, + worktreePath: "/repo/worktrees/plan-thread", + }; + } + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...snapshot, + threads, + }, + }); + + try { + const planActionsButton = await waitForElement( + () => document.querySelector('button[aria-label="Plan actions"]'), + "Unable to find proposed plan actions button.", + ); + planActionsButton.click(); + + const saveToWorkspaceItem = await waitForElement( + () => + (Array.from(document.querySelectorAll('[data-slot="menu-item"]')).find( + (item) => item.textContent?.trim() === "Save to workspace", + ) ?? null) as HTMLElement | null, + 'Unable to find "Save to workspace" menu item.', + ); + saveToWorkspaceItem.click(); + + await vi.waitFor( + () => { + expect(document.body.textContent).toContain( + "Enter a path relative to /repo/worktrees/plan-thread.", + ); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps pending-question footer actions inside the composer after a real resize", async () => { const mounted = await mountChatView({ viewport: WIDE_FOOTER_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f995bb4ce7..aeab2d083a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1526,6 +1526,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; + const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; const activeTerminalLaunchContext = terminalLaunchContext?.threadId === activeThreadId ? terminalLaunchContext @@ -4004,7 +4005,7 @@ export default function ChatView({ threadId }: ChatViewProps) { markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} - workspaceRoot={activeProject?.cwd ?? undefined} + workspaceRoot={activeWorkspaceRoot} /> @@ -4438,7 +4439,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activePlan={activePlan} activeProposedPlan={sidebarProposedPlan} markdownCwd={gitCwd ?? undefined} - workspaceRoot={activeProject?.cwd ?? undefined} + workspaceRoot={activeWorkspaceRoot} timestampFormat={timestampFormat} onClose={() => { setPlanSidebarOpen(false); From 53a552e80d5783c1824a81dbe0d8e49302601e31 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 09:26:29 -0700 Subject: [PATCH 03/26] Stream git status updates over WebSocket (#1763) Co-authored-by: codex --- apps/server/src/git/Layers/GitCore.test.ts | 3 +- apps/server/src/git/Layers/GitCore.ts | 41 +- apps/server/src/git/Layers/GitManager.ts | 136 ++++-- .../git/Layers/GitStatusBroadcaster.test.ts | 276 +++++++++++++ .../src/git/Layers/GitStatusBroadcaster.ts | 307 ++++++++++++++ apps/server/src/git/Services/GitCore.ts | 15 +- apps/server/src/git/Services/GitManager.ts | 31 ++ .../src/git/Services/GitStatusBroadcaster.ts | 23 ++ apps/server/src/server.test.ts | 389 +++++++++++++++++- apps/server/src/server.ts | 18 +- apps/server/src/ws.ts | 83 +++- .../BranchToolbarBranchSelector.tsx | 70 ++-- apps/web/src/components/ChatView.browser.tsx | 71 ++-- apps/web/src/components/ChatView.tsx | 4 +- apps/web/src/components/DiffPanel.tsx | 4 +- .../components/GitActionsControl.browser.tsx | 78 +++- apps/web/src/components/GitActionsControl.tsx | 46 ++- .../components/KeybindingsToast.browser.tsx | 57 ++- apps/web/src/components/Sidebar.tsx | 71 +--- .../src/components/chat/MessagesTimeline.tsx | 2 +- .../settings/SettingsPanels.browser.tsx | 8 +- .../web/src/components/timelineHeight.test.ts | 8 +- apps/web/src/components/timelineHeight.ts | 5 +- apps/web/src/lib/gitReactQuery.test.ts | 29 -- apps/web/src/lib/gitReactQuery.ts | 98 ++--- apps/web/src/lib/gitStatusState.test.ts | 112 +++++ apps/web/src/lib/gitStatusState.ts | 223 ++++++++++ apps/web/src/nativeApi.ts | 4 +- apps/web/src/wsNativeApi.test.ts | 47 ++- apps/web/src/wsNativeApi.ts | 11 +- apps/web/src/wsRpcClient.test.ts | 94 +++++ apps/web/src/wsRpcClient.ts | 24 +- apps/web/test/wsRpcHarness.ts | 3 +- packages/contracts/src/git.ts | 50 ++- packages/contracts/src/ipc.ts | 15 +- packages/contracts/src/rpc.ts | 22 +- packages/shared/src/git.test.ts | 67 +++ packages/shared/src/git.ts | 139 ++++++- 38 files changed, 2302 insertions(+), 382 deletions(-) create mode 100644 apps/server/src/git/Layers/GitStatusBroadcaster.test.ts create mode 100644 apps/server/src/git/Layers/GitStatusBroadcaster.ts create mode 100644 apps/server/src/git/Services/GitStatusBroadcaster.ts create mode 100644 apps/web/src/lib/gitStatusState.test.ts create mode 100644 apps/web/src/lib/gitStatusState.ts create mode 100644 apps/web/src/wsRpcClient.test.ts create mode 100644 packages/shared/src/git.test.ts diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 5e4416d8b9..5ff2714b61 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -949,11 +949,12 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["checkout", defaultBranch]); yield* git(source, ["branch", "-D", featureBranch]); - yield* (yield* GitCore).checkoutBranch({ + const checkoutResult = yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: `${remoteName}/${featureBranch}`, }); + expect(checkoutResult.branch).toBe("upstream/feature"); expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); const realGitCore = yield* GitCore; let fetchArgs: readonly string[] | null = null; diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 1178a4b67e..911a601955 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -1177,9 +1177,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return branchLastCommit; }); - const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { - yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); - + const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { const statusResult = yield* executeGit( "GitCore.statusDetails.status", cwd, @@ -1312,6 +1310,17 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }; }); + const statusDetailsLocal: GitCoreShape["statusDetailsLocal"] = Effect.fn("statusDetailsLocal")( + function* (cwd) { + return yield* readStatusDetailsLocal(cwd); + }, + ); + + const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { + yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); + return yield* readStatusDetailsLocal(cwd); + }); + const status: GitCoreShape["status"] = (input) => statusDetails(input.cwd).pipe( Effect.map((details) => ({ @@ -2000,12 +2009,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return { branch: targetBranch }; }); - const createBranch: GitCoreShape["createBranch"] = (input) => - executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { - timeoutMs: 10_000, - fallbackErrorMessage: "git branch create failed", - }).pipe(Effect.asVoid); - const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")( function* (input) { const [localInputExists, remoteExists] = yield* Effect.all( @@ -2078,9 +2081,28 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { timeoutMs: 10_000, fallbackErrorMessage: "git checkout failed", }); + + const branch = yield* runGitStdout("GitCore.checkoutBranch.currentBranch", input.cwd, [ + "branch", + "--show-current", + ]).pipe(Effect.map((stdout) => stdout.trim() || null)); + + return { branch }; }, ); + const createBranch: GitCoreShape["createBranch"] = Effect.fn("createBranch")(function* (input) { + yield* executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { + timeoutMs: 10_000, + fallbackErrorMessage: "git branch create failed", + }); + if (input.checkout) { + yield* checkoutBranch({ cwd: input.cwd, branch: input.branch }); + } + + return { branch: input.branch }; + }); + const initRepo: GitCoreShape["initRepo"] = (input) => executeGit("GitCore.initRepo", input.cwd, ["init"], { timeoutMs: 10_000, @@ -2106,6 +2128,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { execute, status, statusDetails, + statusDetailsLocal, prepareCommitContext, commit, pushCurrentBranch, diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 7fedb15714..d5e7eca217 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -8,9 +8,13 @@ import { GitCommandError, GitRunStackedActionResult, GitStackedAction, + type GitStatusLocalResult, + type GitStatusRemoteResult, ModelSelection, } from "@t3tools/contracts"; import { + detectGitHostingProviderFromRemoteUrl, + mergeGitStatusParts, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, @@ -695,26 +699,55 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); - const readStatus = Effect.fn("readStatus")(function* (cwd: string) { - const details = yield* gitCore.statusDetails(cwd).pipe( - Effect.catchIf(isNotGitRepositoryError, () => - Effect.succeed({ - isRepo: false, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, - upstreamRef: null, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - } satisfies GitStatusDetails), - ), - ); + const nonRepositoryStatusDetails = { + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + upstreamRef: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + } satisfies GitStatusDetails; + const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { + const details = yield* gitCore + .statusDetailsLocal(cwd) + .pipe( + Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), + ); + const hostingProvider = details.isRepo + ? yield* resolveHostingProvider(cwd, details.branch) + : null; + + return { + isRepo: details.isRepo, + ...(hostingProvider ? { hostingProvider } : {}), + hasOriginRemote: details.hasOriginRemote, + isDefaultBranch: details.isDefaultBranch, + branch: details.branch, + hasWorkingTreeChanges: details.hasWorkingTreeChanges, + workingTree: details.workingTree, + } satisfies GitStatusLocalResult; + }); + const localStatusResultCache = yield* Cache.makeWith({ + capacity: STATUS_RESULT_CACHE_CAPACITY, + lookup: readLocalStatus, + timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), + }); + const invalidateLocalStatusResultCache = (cwd: string) => + Cache.invalidate(localStatusResultCache, normalizeStatusCacheKey(cwd)); + const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) { + const details = yield* gitCore + .statusDetails(cwd) + .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); + if (details === null || !details.isRepo) { + return null; + } const pr = - details.isRepo && details.branch !== null + details.branch !== null ? yield* findLatestPr(cwd, { branch: details.branch, upstreamRef: details.upstreamRef, @@ -725,29 +758,38 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { : null; return { - isRepo: details.isRepo, - hasOriginRemote: details.hasOriginRemote, - isDefaultBranch: details.isDefaultBranch, - branch: details.branch, - hasWorkingTreeChanges: details.hasWorkingTreeChanges, - workingTree: details.workingTree, hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, pr, - }; + } satisfies GitStatusRemoteResult; }); - const statusResultCache = yield* Cache.makeWith({ + const remoteStatusResultCache = yield* Cache.makeWith({ capacity: STATUS_RESULT_CACHE_CAPACITY, - lookup: readStatus, + lookup: readRemoteStatus, timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), }); - const invalidateStatusResultCache = (cwd: string) => - Cache.invalidate(statusResultCache, normalizeStatusCacheKey(cwd)); + const invalidateRemoteStatusResultCache = (cwd: string) => + Cache.invalidate(remoteStatusResultCache, normalizeStatusCacheKey(cwd)); const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); + const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* ( + cwd: string, + branch: string | null, + ) { + const preferredRemoteName = + branch === null + ? "origin" + : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin"); + const remoteUrl = + (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? + (yield* readConfigValueNullable(cwd, "remote.origin.url")); + + return remoteUrl ? detectGitHostingProviderFromRemoteUrl(remoteUrl) : null; + }); + const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( cwd: string, remoteName: string | null, @@ -1311,9 +1353,34 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; }); + const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) { + return yield* Cache.get(localStatusResultCache, normalizeStatusCacheKey(input.cwd)); + }); + const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")( + function* (input) { + return yield* Cache.get(remoteStatusResultCache, normalizeStatusCacheKey(input.cwd)); + }, + ); const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { - return yield* Cache.get(statusResultCache, normalizeStatusCacheKey(input.cwd)); + const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]); + return mergeGitStatusParts(local, remote); }); + const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn( + "invalidateLocalStatus", + )(function* (cwd) { + yield* invalidateLocalStatusResultCache(cwd); + }); + const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn( + "invalidateRemoteStatus", + )(function* (cwd) { + yield* invalidateRemoteStatusResultCache(cwd); + }); + const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")( + function* (cwd) { + yield* invalidateLocalStatusResultCache(cwd); + yield* invalidateRemoteStatusResultCache(cwd); + }, + ); const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( function* (input) { @@ -1488,7 +1555,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { branch: worktree.worktree.branch, worktreePath: worktree.worktree.path, }; - }).pipe(Effect.ensuring(invalidateStatusResultCache(input.cwd))); + }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); }); const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( @@ -1692,7 +1759,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); return yield* runAction().pipe( - Effect.ensuring(invalidateStatusResultCache(input.cwd)), + Effect.ensuring(invalidateStatus(input.cwd)), Effect.tapError((error) => Effect.flatMap(Ref.get(currentPhase), (phase) => progress.emit({ @@ -1707,7 +1774,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ); return { + localStatus, + remoteStatus, status, + invalidateLocalStatus, + invalidateRemoteStatus, + invalidateStatus, resolvePullRequest, preparePullRequestThread, runStackedAction, diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts new file mode 100644 index 0000000000..fbe418ab7d --- /dev/null +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts @@ -0,0 +1,276 @@ +import { assert, it } from "@effect/vitest"; +import { Deferred, Effect, Exit, Layer, Option, Scope, Stream } from "effect"; +import type { + GitStatusLocalResult, + GitStatusRemoteResult, + GitStatusResult, + GitStatusStreamEvent, +} from "@t3tools/contracts"; +import { describe } from "vitest"; + +import { GitStatusBroadcaster } from "../Services/GitStatusBroadcaster.ts"; +import { GitStatusBroadcasterLive } from "./GitStatusBroadcaster.ts"; +import { type GitManagerShape, GitManager } from "../Services/GitManager.ts"; + +const baseLocalStatus: GitStatusLocalResult = { + isRepo: true, + hostingProvider: { + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/status-broadcast", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, +}; + +const baseRemoteStatus: GitStatusRemoteResult = { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + +const baseStatus: GitStatusResult = { + ...baseLocalStatus, + ...baseRemoteStatus, +}; + +function makeTestLayer(state: { + currentLocalStatus: GitStatusLocalResult; + currentRemoteStatus: GitStatusRemoteResult | null; + localStatusCalls: number; + remoteStatusCalls: number; + localInvalidationCalls: number; + remoteInvalidationCalls: number; +}) { + const gitManager: GitManagerShape = { + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.sync(() => { + state.remoteStatusCalls += 1; + return state.currentRemoteStatus; + }), + status: () => Effect.die("status should not be called in this test"), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"), + resolvePullRequest: () => Effect.die("resolvePullRequest should not be called in this test"), + preparePullRequestThread: () => + Effect.die("preparePullRequestThread should not be called in this test"), + runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), + }; + + return GitStatusBroadcasterLive.pipe(Layer.provide(Layer.succeed(GitManager, gitManager))); +} + +describe("GitStatusBroadcasterLive", () => { + it.effect("reuses the cached git status across repeated reads", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* GitStatusBroadcaster; + + const first = yield* broadcaster.getStatus({ cwd: "/repo" }); + const second = yield* broadcaster.getStatus({ cwd: "/repo" }); + + assert.deepStrictEqual(first, baseStatus); + assert.deepStrictEqual(second, baseStatus); + assert.equal(state.localStatusCalls, 1); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.localInvalidationCalls, 0); + assert.equal(state.remoteInvalidationCalls, 0); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("refreshes the cached snapshot after explicit invalidation", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* GitStatusBroadcaster; + const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); + + state.currentLocalStatus = { + ...baseLocalStatus, + branch: "feature/updated-status", + }; + state.currentRemoteStatus = { + ...baseRemoteStatus, + aheadCount: 2, + }; + const refreshed = yield* broadcaster.refreshStatus("/repo"); + const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); + + assert.deepStrictEqual(initial, baseStatus); + assert.deepStrictEqual(refreshed, { + ...state.currentLocalStatus, + ...state.currentRemoteStatus, + }); + assert.deepStrictEqual(cached, { + ...state.currentLocalStatus, + ...state.currentRemoteStatus, + }); + assert.equal(state.localStatusCalls, 2); + assert.equal(state.remoteStatusCalls, 2); + assert.equal(state.localInvalidationCalls, 1); + assert.equal(state.remoteInvalidationCalls, 1); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("streams a local snapshot first and remote updates later", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* GitStatusBroadcaster; + const snapshotDeferred = yield* Deferred.make(); + const remoteUpdatedDeferred = yield* Deferred.make(); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + if (event._tag === "remoteUpdated") { + return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkScoped); + + const snapshot = yield* Deferred.await(snapshotDeferred); + yield* broadcaster.refreshStatus("/repo"); + const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); + + assert.deepStrictEqual(snapshot, { + _tag: "snapshot", + local: baseLocalStatus, + remote: null, + } satisfies GitStatusStreamEvent); + assert.deepStrictEqual(remoteUpdated, { + _tag: "remoteUpdated", + remote: baseRemoteStatus, + } satisfies GitStatusStreamEvent); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("stops the remote poller after the last stream subscriber disconnects", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + let remoteInterruptedDeferred: Deferred.Deferred | null = null; + let remoteStartedDeferred: Deferred.Deferred | null = null; + const testLayer = GitStatusBroadcasterLive.pipe( + Layer.provide( + Layer.succeed(GitManager, { + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.sync(() => { + state.remoteStatusCalls += 1; + }).pipe( + Effect.andThen( + remoteStartedDeferred + ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Effect.andThen(Effect.never as Effect.Effect), + Effect.onInterrupt(() => + remoteInterruptedDeferred + ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + status: () => Effect.die("status should not be called in this test"), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"), + resolvePullRequest: () => + Effect.die("resolvePullRequest should not be called in this test"), + preparePullRequestThread: () => + Effect.die("preparePullRequestThread should not be called in this test"), + runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), + } satisfies GitManagerShape), + ), + ); + + return Effect.gen(function* () { + const remoteInterrupted = yield* Deferred.make(); + const remoteStarted = yield* Deferred.make(); + remoteInterruptedDeferred = remoteInterrupted; + remoteStartedDeferred = remoteStarted; + + const broadcaster = yield* GitStatusBroadcaster; + const firstSnapshot = yield* Deferred.make(); + const secondSnapshot = yield* Deferred.make(); + const firstScope = yield* Scope.make(); + const secondScope = yield* Scope.make(); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => + event._tag === "snapshot" + ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(firstScope)); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => + event._tag === "snapshot" + ? Deferred.succeed(secondSnapshot, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(secondScope)); + + yield* Deferred.await(firstSnapshot); + yield* Deferred.await(secondSnapshot); + yield* Deferred.await(remoteStarted); + + assert.equal(state.remoteStatusCalls, 1); + + yield* Scope.close(firstScope, Exit.void); + assert.equal(Option.isNone(yield* Deferred.poll(remoteInterrupted)), true); + + yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped); + yield* Deferred.await(remoteInterrupted); + assert.equal(Option.isSome(yield* Deferred.poll(remoteInterrupted)), true); + }).pipe(Effect.provide(testLayer)); + }); +}); diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.ts new file mode 100644 index 0000000000..78d4abf40d --- /dev/null +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.ts @@ -0,0 +1,307 @@ +import { realpathSync } from "node:fs"; + +import { + Duration, + Effect, + Exit, + Fiber, + Layer, + PubSub, + Ref, + Scope, + Stream, + SynchronizedRef, +} from "effect"; +import type { + GitStatusInput, + GitStatusLocalResult, + GitStatusRemoteResult, + GitStatusStreamEvent, +} from "@t3tools/contracts"; +import { mergeGitStatusParts } from "@t3tools/shared/git"; + +import { + GitStatusBroadcaster, + type GitStatusBroadcasterShape, +} from "../Services/GitStatusBroadcaster.ts"; +import { GitManager } from "../Services/GitManager.ts"; + +const GIT_STATUS_REFRESH_INTERVAL = Duration.seconds(30); + +interface GitStatusChange { + readonly cwd: string; + readonly event: GitStatusStreamEvent; +} + +interface CachedValue { + readonly fingerprint: string; + readonly value: T; +} + +interface CachedGitStatus { + readonly local: CachedValue | null; + readonly remote: CachedValue | null; +} + +interface ActiveRemotePoller { + readonly fiber: Fiber.Fiber; + readonly subscriberCount: number; +} + +function normalizeCwd(cwd: string): string { + try { + return realpathSync.native(cwd); + } catch { + return cwd; + } +} + +function fingerprintStatusPart(status: unknown): string { + return JSON.stringify(status); +} + +export const GitStatusBroadcasterLive = Layer.effect( + GitStatusBroadcaster, + Effect.gen(function* () { + const gitManager = yield* GitManager; + const changesPubSub = yield* Effect.acquireRelease( + PubSub.unbounded(), + (pubsub) => PubSub.shutdown(pubsub), + ); + const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const cacheRef = yield* Ref.make(new Map()); + const pollersRef = yield* SynchronizedRef.make(new Map()); + + const getCachedStatus = Effect.fn("getCachedStatus")(function* (cwd: string) { + return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); + }); + + const updateCachedLocalStatus = Effect.fn("updateCachedLocalStatus")(function* ( + cwd: string, + local: GitStatusLocalResult, + options?: { publish?: boolean }, + ) { + const nextLocal = { + fingerprint: fingerprintStatusPart(local), + value: local, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + local: nextLocal, + }); + return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + }); + + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "localUpdated", + local, + }, + }); + } + + return local; + }); + + const updateCachedRemoteStatus = Effect.fn("updateCachedRemoteStatus")(function* ( + cwd: string, + remote: GitStatusRemoteResult | null, + options?: { publish?: boolean }, + ) { + const nextRemote = { + fingerprint: fingerprintStatusPart(remote), + value: remote, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + remote: nextRemote, + }); + return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; + }); + + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "remoteUpdated", + remote, + }, + }); + } + + return remote; + }); + + const loadLocalStatus = Effect.fn("loadLocalStatus")(function* (cwd: string) { + const local = yield* gitManager.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local); + }); + + const loadRemoteStatus = Effect.fn("loadRemoteStatus")(function* (cwd: string) { + const remote = yield* gitManager.remoteStatus({ cwd }); + return yield* updateCachedRemoteStatus(cwd, remote); + }); + + const getOrLoadLocalStatus = Effect.fn("getOrLoadLocalStatus")(function* (cwd: string) { + const cached = yield* getCachedStatus(cwd); + if (cached?.local) { + return cached.local.value; + } + return yield* loadLocalStatus(cwd); + }); + + const getOrLoadRemoteStatus = Effect.fn("getOrLoadRemoteStatus")(function* (cwd: string) { + const cached = yield* getCachedStatus(cwd); + if (cached?.remote) { + return cached.remote.value; + } + return yield* loadRemoteStatus(cwd); + }); + + const getStatus: GitStatusBroadcasterShape["getStatus"] = Effect.fn("getStatus")(function* ( + input: GitStatusInput, + ) { + const normalizedCwd = normalizeCwd(input.cwd); + const [local, remote] = yield* Effect.all([ + getOrLoadLocalStatus(normalizedCwd), + getOrLoadRemoteStatus(normalizedCwd), + ]); + return mergeGitStatusParts(local, remote); + }); + + const refreshLocalStatus = Effect.fn("refreshLocalStatus")(function* (cwd: string) { + yield* gitManager.invalidateLocalStatus(cwd); + const local = yield* gitManager.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + }); + + const refreshRemoteStatus = Effect.fn("refreshRemoteStatus")(function* (cwd: string) { + yield* gitManager.invalidateRemoteStatus(cwd); + const remote = yield* gitManager.remoteStatus({ cwd }); + return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + }); + + const refreshStatus: GitStatusBroadcasterShape["refreshStatus"] = Effect.fn("refreshStatus")( + function* (cwd) { + const normalizedCwd = normalizeCwd(cwd); + const [local, remote] = yield* Effect.all([ + refreshLocalStatus(normalizedCwd), + refreshRemoteStatus(normalizedCwd), + ]); + return mergeGitStatusParts(local, remote); + }, + ); + + const makeRemoteRefreshLoop = (cwd: string) => { + const logRefreshFailure = (error: Error) => + Effect.logWarning("git remote status refresh failed", { + cwd, + detail: error.message, + }); + + return refreshRemoteStatus(cwd).pipe( + Effect.catch(logRefreshFailure), + Effect.andThen( + Effect.forever( + Effect.sleep(GIT_STATUS_REFRESH_INTERVAL).pipe( + Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))), + ), + ), + ), + ); + }; + + const retainRemotePoller = Effect.fn("retainRemotePoller")(function* (cwd: string) { + yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (existing) { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount + 1, + }); + return Effect.succeed([undefined, nextPollers] as const); + } + + return makeRemoteRefreshLoop(cwd).pipe( + Effect.forkIn(broadcasterScope), + Effect.map((fiber) => { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + fiber, + subscriberCount: 1, + }); + return [undefined, nextPollers] as const; + }), + ); + }); + }); + + const releaseRemotePoller = Effect.fn("releaseRemotePoller")(function* (cwd: string) { + const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (!existing) { + return [null, activePollers] as const; + } + + if (existing.subscriberCount > 1) { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount - 1, + }); + return [null, nextPollers] as const; + } + + const nextPollers = new Map(activePollers); + nextPollers.delete(cwd); + return [existing.fiber, nextPollers] as const; + }); + + if (pollerToInterrupt) { + yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); + } + }); + + const streamStatus: GitStatusBroadcasterShape["streamStatus"] = (input) => + Stream.unwrap( + Effect.gen(function* () { + const normalizedCwd = normalizeCwd(input.cwd); + const subscription = yield* PubSub.subscribe(changesPubSub); + const initialLocal = yield* getOrLoadLocalStatus(normalizedCwd); + const initialRemote = (yield* getCachedStatus(normalizedCwd))?.remote?.value ?? null; + yield* retainRemotePoller(normalizedCwd); + + const release = releaseRemotePoller(normalizedCwd).pipe(Effect.ignore, Effect.asVoid); + + return Stream.concat( + Stream.make({ + _tag: "snapshot" as const, + local: initialLocal, + remote: initialRemote, + }), + Stream.fromSubscription(subscription).pipe( + Stream.filter((event) => event.cwd === normalizedCwd), + Stream.map((event) => event.event), + ), + ).pipe(Stream.ensuring(release)); + }), + ); + + return { + getStatus, + refreshStatus, + streamStatus, + } satisfies GitStatusBroadcasterShape; + }), +); diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index d7a28d1763..015efa8bbd 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -7,10 +7,12 @@ * @module GitCore */ import { ServiceMap } from "effect"; -import type { Effect, Scope } from "effect"; +import type { Effect } from "effect"; import type { GitCheckoutInput, + GitCheckoutResult, GitCreateBranchInput, + GitCreateBranchResult, GitCreateWorktreeInput, GitCreateWorktreeResult, GitInitInput, @@ -156,6 +158,11 @@ export interface GitCoreShape { */ readonly statusDetails: (cwd: string) => Effect.Effect; + /** + * Read detailed working tree / branch status without refreshing remote tracking refs. + */ + readonly statusDetailsLocal: (cwd: string) => Effect.Effect; + /** * Build staged change context for commit generation. */ @@ -278,14 +285,16 @@ export interface GitCoreShape { /** * Create a local branch. */ - readonly createBranch: (input: GitCreateBranchInput) => Effect.Effect; + readonly createBranch: ( + input: GitCreateBranchInput, + ) => Effect.Effect; /** * Checkout an existing branch and refresh its upstream metadata in background. */ readonly checkoutBranch: ( input: GitCheckoutInput, - ) => Effect.Effect; + ) => Effect.Effect; /** * Initialize a repository in the provided directory. diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index 86842257b4..0e04ceedcb 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -14,6 +14,8 @@ import { GitResolvePullRequestResult, GitRunStackedActionInput, GitRunStackedActionResult, + GitStatusLocalResult, + GitStatusRemoteResult, GitStatusInput, GitStatusResult, } from "@t3tools/contracts"; @@ -41,6 +43,35 @@ export interface GitManagerShape { input: GitStatusInput, ) => Effect.Effect; + /** + * Read local repository status without remote hosting enrichment. + */ + readonly localStatus: ( + input: GitStatusInput, + ) => Effect.Effect; + + /** + * Read remote tracking / PR status for a repository. + */ + readonly remoteStatus: ( + input: GitStatusInput, + ) => Effect.Effect; + + /** + * Clear any cached local status snapshot for a repository. + */ + readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; + + /** + * Clear any cached remote status snapshot for a repository. + */ + readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; + + /** + * Clear any cached status snapshot for a repository so the next read is fresh. + */ + readonly invalidateStatus: (cwd: string) => Effect.Effect; + /** * Resolve a pull request by URL/number against the current repository. */ diff --git a/apps/server/src/git/Services/GitStatusBroadcaster.ts b/apps/server/src/git/Services/GitStatusBroadcaster.ts new file mode 100644 index 0000000000..0f3f622d17 --- /dev/null +++ b/apps/server/src/git/Services/GitStatusBroadcaster.ts @@ -0,0 +1,23 @@ +import { ServiceMap } from "effect"; +import type { Effect, Stream } from "effect"; +import type { + GitManagerServiceError, + GitStatusInput, + GitStatusResult, + GitStatusStreamEvent, +} from "@t3tools/contracts"; + +export interface GitStatusBroadcasterShape { + readonly getStatus: ( + input: GitStatusInput, + ) => Effect.Effect; + readonly refreshStatus: (cwd: string) => Effect.Effect; + readonly streamStatus: ( + input: GitStatusInput, + ) => Stream.Stream; +} + +export class GitStatusBroadcaster extends ServiceMap.Service< + GitStatusBroadcaster, + GitStatusBroadcasterShape +>()("t3/git/Services/GitStatusBroadcaster") {} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 7a23058fc7..072e1ca172 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -22,7 +22,16 @@ import { } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; -import { Effect, FileSystem, Layer, ManagedRuntime, Path, Stream } from "effect"; +import { + Deferred, + Duration, + Effect, + FileSystem, + Layer, + ManagedRuntime, + Path, + Stream, +} from "effect"; import { FetchHttpClient, HttpBody, @@ -44,6 +53,7 @@ import { } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts"; import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; +import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; import { Open, type OpenShape } from "./open.ts"; import { @@ -294,6 +304,10 @@ const buildAppUnderTest = (options?: { ...options?.config, }; const layerConfig = Layer.succeed(ServerConfig, config); + const gitManagerLayer = Layer.mock(GitManager)({ + ...options?.layers?.gitManager, + }); + const gitStatusBroadcasterLayer = GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer)); const appLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, @@ -333,11 +347,8 @@ const buildAppUnderTest = (options?: { ...options?.layers?.gitCore, }), ), - Layer.provide( - Layer.mock(GitManager)({ - ...options?.layers?.gitManager, - }), - ), + Layer.provide(gitManagerLayer), + Layer.provideMerge(gitStatusBroadcasterLayer), Layer.provide( Layer.mock(ProjectSetupScriptRunner)({ runForThread: () => Effect.succeed({ status: "no-script" as const }), @@ -1260,6 +1271,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { gitManager: { + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, + branch: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.succeed({ + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), status: () => Effect.succeed({ isRepo: true, @@ -1373,8 +1403,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { worktree: { path: "/tmp/wt", branch: "feature/demo" }, }), removeWorktree: () => Effect.void, - createBranch: () => Effect.void, - checkoutBranch: () => Effect.void, + createBranch: (input) => Effect.succeed({ branch: input.branch }), + checkoutBranch: (input) => Effect.succeed({ branch: input.branch }), initRepo: () => Effect.void, }, }, @@ -1382,16 +1412,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); - const status = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitStatus]({ cwd: "/tmp/repo" })), - ); - assert.equal(status.branch, "main"); - const pull = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), ); assert.equal(pull.status, "pulled"); + const refreshedStatus = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitRefreshStatus]({ cwd: "/tmp/repo" }), + ), + ); + assert.equal(refreshedStatus.isRepo, true); + const stackedEvents = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitRunStackedAction]({ @@ -1494,11 +1526,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => { cwd: "/tmp/repo", detail: "upstream missing", }); + let invalidationCalls = 0; + let statusCalls = 0; yield* buildAppUnderTest({ layers: { gitCore: { pullCurrentBranch: () => Effect.fail(gitError), }, + gitManager: { + invalidateLocalStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + invalidateStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, + branch: "main", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.sync(() => { + statusCalls += 1; + return { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + }), + status: () => + Effect.sync(() => { + statusCalls += 1; + return { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, + branch: "main", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + }), + }, }, }); @@ -1510,9 +1593,289 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assertFailure(result, gitError); + assert.equal(invalidationCalls, 0); + assert.equal(statusCalls, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc git.runStackedAction errors after refreshing git status", () => + Effect.gen(function* () { + const gitError = new GitCommandError({ + operation: "commit", + command: "git commit", + cwd: "/tmp/repo", + detail: "nothing to commit", + }); + let invalidationCalls = 0; + let statusCalls = 0; + yield* buildAppUnderTest({ + layers: { + gitManager: { + invalidateLocalStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + invalidateStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.sync(() => { + statusCalls += 1; + return { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + }), + status: () => + Effect.sync(() => { + statusCalls += 1; + return { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + }), + runStackedAction: () => Effect.fail(gitError), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitRunStackedAction]({ + actionId: "action-1", + cwd: "/tmp/repo", + action: "commit", + }).pipe(Stream.runCollect, Effect.result), + ), + ); + + assertFailure(result, gitError); + assert.equal(invalidationCalls, 0); + assert.equal(statusCalls, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("completes websocket rpc git.pull before background git status refresh finishes", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + gitCore: { + pullCurrentBranch: () => + Effect.succeed({ + status: "pulled" as const, + branch: "main", + upstreamBranch: "origin/main", + }), + }, + gitManager: { + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, + branch: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.sleep(Duration.seconds(2)).pipe( + Effect.as({ + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const startedAt = Date.now(); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), + ); + const elapsedMs = Date.now() - startedAt; + + assert.equal(result.status, "pulled"); + assertTrue(elapsedMs < 1_000); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "completes websocket rpc git.runStackedAction before background git status refresh finishes", + () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + gitManager: { + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.sleep(Duration.seconds(2)).pipe( + Effect.as({ + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ), + runStackedAction: () => + Effect.succeed({ + action: "commit" as const, + branch: { status: "skipped_not_requested" as const }, + commit: { + status: "created" as const, + commitSha: "abc123", + subject: "feat: demo", + }, + push: { status: "skipped_not_requested" as const }, + pr: { status: "skipped_not_requested" as const }, + toast: { + title: "Committed abc123", + description: "feat: demo", + cta: { + kind: "run_action" as const, + label: "Push", + action: { + kind: "push" as const, + }, + }, + }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const startedAt = Date.now(); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitRunStackedAction]({ + actionId: "action-1", + cwd: "/tmp/repo", + action: "commit", + }).pipe(Stream.runCollect), + ), + ); + const elapsedMs = Date.now() - startedAt; + + assertTrue(elapsedMs < 1_000); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "starts a background local git status refresh after a successful git.runStackedAction", + () => + Effect.gen(function* () { + const localRefreshStarted = yield* Deferred.make(); + + yield* buildAppUnderTest({ + layers: { + gitManager: { + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + localStatus: () => + Deferred.succeed(localRefreshStarted, undefined).pipe( + Effect.ignore, + Effect.andThen( + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + ), + ), + remoteStatus: () => + Effect.sleep(Duration.seconds(2)).pipe( + Effect.as({ + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ), + runStackedAction: () => + Effect.succeed({ + action: "commit" as const, + branch: { status: "skipped_not_requested" as const }, + commit: { + status: "created" as const, + commitSha: "abc123", + subject: "feat: demo", + }, + push: { status: "skipped_not_requested" as const }, + pr: { status: "skipped_not_requested" as const }, + toast: { + title: "Committed abc123", + description: "feat: demo", + cta: { + kind: "run_action" as const, + label: "Push", + action: { + kind: "push" as const, + }, + }, + }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitRunStackedAction]({ + actionId: "action-1", + cwd: "/tmp/repo", + action: "commit", + }).pipe(Stream.runCollect), + ), + ); + + yield* Deferred.await(localRefreshStarted); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc orchestration methods", () => Effect.gen(function* () { const now = new Date().toISOString(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f56edde6fa..1d6f6ac66e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -30,6 +30,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; +import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster"; import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; import { GitManagerLive } from "./git/Layers/GitManager"; @@ -161,15 +162,16 @@ const ProviderLayerLive = Layer.unwrap( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); +const GitManagerLayerLive = GitManagerLive.pipe( + Layer.provideMerge(ProjectSetupScriptRunnerLive), + Layer.provideMerge(GitCoreLive), + Layer.provideMerge(GitHubCliLive), + Layer.provideMerge(RoutingTextGenerationLive), +); + const GitLayerLive = Layer.empty.pipe( - Layer.provideMerge( - GitManagerLive.pipe( - Layer.provideMerge(ProjectSetupScriptRunnerLive), - Layer.provideMerge(GitCoreLive), - Layer.provideMerge(GitHubCliLive), - Layer.provideMerge(RoutingTextGenerationLive), - ), - ), + Layer.provideMerge(GitManagerLayerLive), + Layer.provideMerge(GitStatusBroadcasterLive.pipe(Layer.provide(GitManagerLayerLive))), Layer.provideMerge(GitCoreLive), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 33a0518611..ca096bff33 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -27,6 +27,7 @@ import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuer import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore"; import { GitManager } from "./git/Services/GitManager"; +import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster"; import { Keybindings } from "./keybindings"; import { Open, resolveAvailableEditors } from "./open"; import { normalizeDispatchCommand } from "./orchestration/Normalizer"; @@ -56,6 +57,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( const open = yield* Open; const gitManager = yield* GitManager; const git = yield* GitCore; + const gitStatusBroadcaster = yield* GitStatusBroadcaster; const terminalManager = yield* TerminalManager; const providerRegistry = yield* ProviderRegistry; const config = yield* ServerConfig; @@ -65,7 +67,6 @@ const WsRpcLayer = WsRpcGroup.toLayer( const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; - const serverCommandId = (tag: string) => CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); @@ -348,6 +349,11 @@ const WsRpcLayer = WsRpcGroup.toLayer( }; }); + const refreshGitStatus = (cwd: string) => + gitStatusBroadcaster + .refreshStatus(cwd) + .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); + return WsRpcGroup.of({ [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => observeRpcEffect( @@ -559,14 +565,30 @@ const WsRpcLayer = WsRpcGroup.toLayer( observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { "rpc.aggregate": "workspace", }), - [WS_METHODS.gitStatus]: (input) => - observeRpcEffect(WS_METHODS.gitStatus, gitManager.status(input), { + [WS_METHODS.subscribeGitStatus]: (input) => + observeRpcStream(WS_METHODS.subscribeGitStatus, gitStatusBroadcaster.streamStatus(input), { "rpc.aggregate": "git", }), + [WS_METHODS.gitRefreshStatus]: (input) => + observeRpcEffect( + WS_METHODS.gitRefreshStatus, + gitStatusBroadcaster.refreshStatus(input.cwd), + { + "rpc.aggregate": "git", + }, + ), [WS_METHODS.gitPull]: (input) => - observeRpcEffect(WS_METHODS.gitPull, git.pullCurrentBranch(input.cwd), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitPull, + git.pullCurrentBranch(input.cwd).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Effect.failCause(cause), + onSuccess: (result) => + refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), + }), + ), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitRunStackedAction]: (input) => observeRpcStream( WS_METHODS.gitRunStackedAction, @@ -581,7 +603,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( .pipe( Effect.matchCauseEffect({ onFailure: (cause) => Queue.failCause(queue, cause), - onSuccess: () => Queue.end(queue).pipe(Effect.asVoid), + onSuccess: () => + refreshGitStatus(input.cwd).pipe( + Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), + ), }), ), ), @@ -594,7 +619,9 @@ const WsRpcLayer = WsRpcGroup.toLayer( [WS_METHODS.gitPreparePullRequestThread]: (input) => observeRpcEffect( WS_METHODS.gitPreparePullRequestThread, - gitManager.preparePullRequestThread(input), + gitManager + .preparePullRequestThread(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "git" }, ), [WS_METHODS.gitListBranches]: (input) => @@ -602,23 +629,37 @@ const WsRpcLayer = WsRpcGroup.toLayer( "rpc.aggregate": "git", }), [WS_METHODS.gitCreateWorktree]: (input) => - observeRpcEffect(WS_METHODS.gitCreateWorktree, git.createWorktree(input), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitCreateWorktree, + git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitRemoveWorktree]: (input) => - observeRpcEffect(WS_METHODS.gitRemoveWorktree, git.removeWorktree(input), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitRemoveWorktree, + git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitCreateBranch]: (input) => - observeRpcEffect(WS_METHODS.gitCreateBranch, git.createBranch(input), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitCreateBranch, + git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitCheckout]: (input) => - observeRpcEffect(WS_METHODS.gitCheckout, Effect.scoped(git.checkoutBranch(input)), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitCheckout, + Effect.scoped(git.checkoutBranch(input)).pipe( + Effect.tap(() => refreshGitStatus(input.cwd)), + ), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitInit]: (input) => - observeRpcEffect(WS_METHODS.gitInit, git.initRepo(input), { "rpc.aggregate": "git" }), + observeRpcEffect( + WS_METHODS.gitInit, + git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.terminalOpen]: (input) => observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { "rpc.aggregate": "terminal", diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index e1dbb8756c..a1623a41db 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,5 +1,5 @@ import type { GitBranch } from "@t3tools/contracts"; -import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ChevronDownIcon } from "lucide-react"; import { @@ -14,12 +14,8 @@ import { useTransition, } from "react"; -import { - gitBranchSearchInfiniteQueryOptions, - gitQueryKeys, - gitStatusQueryOptions, - invalidateGitQueries, -} from "../lib/gitReactQuery"; +import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; +import { useGitStatus } from "../lib/gitStatusState"; import { readNativeApi } from "../nativeApi"; import { parsePullRequestReference } from "../pullRequestReference"; import { @@ -89,7 +85,7 @@ export function BranchToolbarBranchSelector({ const [branchQuery, setBranchQuery] = useState(""); const deferredBranchQuery = useDeferredValue(branchQuery); - const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd)); + const branchStatusQuery = useGitStatus(branchCwd); const trimmedBranchQuery = branchQuery.trim(); const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); @@ -110,7 +106,6 @@ export function BranchToolbarBranchSelector({ gitBranchSearchInfiniteQueryOptions({ cwd: branchCwd, query: deferredTrimmedBranchQuery, - enabled: isBranchMenuOpen, }), ); const branches = useMemo( @@ -188,7 +183,9 @@ export function BranchToolbarBranchSelector({ const runBranchAction = (action: () => Promise) => { startBranchActionTransition(async () => { await action().catch(() => undefined); - await invalidateGitQueries(queryClient).catch(() => undefined); + await queryClient + .invalidateQueries({ queryKey: gitQueryKeys.branches(branchCwd) }) + .catch(() => undefined); }); }; @@ -226,29 +223,26 @@ export function BranchToolbarBranchSelector({ onComposerFocusRequest?.(); runBranchAction(async () => { + const previousBranch = resolvedActiveBranch; setOptimisticBranch(selectedBranchName); try { - await api.git.checkout({ cwd: selectionTarget.checkoutCwd, branch: branch.name }); - await invalidateGitQueries(queryClient); + const checkoutResult = await api.git.checkout({ + cwd: selectionTarget.checkoutCwd, + branch: branch.name, + }); + const nextBranchName = branch.isRemote + ? (checkoutResult.branch ?? selectedBranchName) + : selectedBranchName; + setOptimisticBranch(nextBranchName); + onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); } catch (error) { + setOptimisticBranch(previousBranch); toastManager.add({ type: "error", title: "Failed to checkout branch.", description: toBranchActionErrorMessage(error), }); - return; } - - let nextBranchName = selectedBranchName; - if (branch.isRemote) { - const status = await api.git.status({ cwd: selectionTarget.checkoutCwd }).catch(() => null); - if (status?.branch) { - nextBranchName = status.branch; - } - } - - setOptimisticBranch(nextBranchName); - onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); }); }; @@ -261,32 +255,24 @@ export function BranchToolbarBranchSelector({ onComposerFocusRequest?.(); runBranchAction(async () => { + const previousBranch = resolvedActiveBranch; setOptimisticBranch(name); - try { - await api.git.createBranch({ cwd: branchCwd, branch: name }); - try { - await api.git.checkout({ cwd: branchCwd, branch: name }); - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to checkout branch.", - description: toBranchActionErrorMessage(error), - }); - return; - } + const createBranchResult = await api.git.createBranch({ + cwd: branchCwd, + branch: name, + checkout: true, + }); + setOptimisticBranch(createBranchResult.branch); + onSetThreadBranch(createBranchResult.branch, activeWorktreePath); } catch (error) { + setOptimisticBranch(previousBranch); toastManager.add({ type: "error", - title: "Failed to create branch.", + title: "Failed to create and checkout branch.", description: toBranchActionErrorMessage(error), }); - return; } - - setOptimisticBranch(name); - onSetThreadBranch(name, activeWorktreePath); - setBranchQuery(""); }); }; diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index a727b89ea3..24099fc7f6 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -31,6 +31,8 @@ import { } from "../lib/terminalContext"; import { isMacPlatform } from "../lib/utils"; import { __resetNativeApiForTests } from "../nativeApi"; +import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; +import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; @@ -38,12 +40,19 @@ import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test import { estimateTimelineMessageHeight } from "./timelineHeight"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +vi.mock("../lib/gitStatusState", () => ({ + useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }), + useGitStatuses: () => new Map(), + refreshGitStatus: () => Promise.resolve(null), + resetGitStatusStateForTests: () => undefined, +})); + const THREAD_ID = "thread-browser-test" as ThreadId; const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_ID = "project-1" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); -const ATTACHMENT_SVG = ""; +const ATTACHMENT_SVG = ""; interface TestFixture { snapshot: OrchestrationReadModel; @@ -93,9 +102,9 @@ const TEXT_VIEWPORT_MATRIX = [ { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, ] as const satisfies readonly ViewportSpec[]; const ATTACHMENT_VIEWPORT_MATRIX = [ - DEFAULT_VIEWPORT, - { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, - { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, + { ...DEFAULT_VIEWPORT, attachmentTolerancePx: 120 }, + { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 120 }, + { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 120 }, ] as const satisfies readonly ViewportSpec[]; interface UserRowMeasurement { @@ -224,6 +233,7 @@ function createSnapshotForTargetUser(options: { name: `attachment-${attachmentIndex + 1}.png`, mimeType: "image/png", sizeBytes: 128, + previewUrl: `/attachments/attachment-${attachmentIndex + 1}`, })) : undefined; @@ -397,6 +407,22 @@ async function waitForWsClient(): Promise { (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents, ), ).toBe(true); + expect( + wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), + ).toBe(true); + expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe( + true, + ); + }, + { timeout: 8_000, interval: 16 }, + ); +} + +async function waitForAppBootstrap(): Promise { + await vi.waitFor( + () => { + expect(getServerConfig()).not.toBeNull(); + expect(useStore.getState().bootstrapComplete).toBe(true); }, { timeout: 8_000, interval: 16 }, ); @@ -651,24 +677,6 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { ], }; } - if (tag === WS_METHODS.gitStatus) { - return { - isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", - hasWorkingTreeChanges: false, - workingTree: { - files: [], - insertions: 0, - deletions: 0, - }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }; - } if (tag === WS_METHODS.projectsSearchEntries) { return { entries: [], @@ -1044,10 +1052,17 @@ async function mountChatView(options: { }), ); - const screen = await render(, { - container: host, - }); + const screen = await render( + + + , + { + container: host, + }, + ); + await waitForWsClient(); + await waitForAppBootstrap(); await waitForLayout(); const cleanup = async () => { @@ -1138,7 +1153,7 @@ describe("ChatView timeline estimator parity (full app)", () => { return []; }, }); - __resetNativeApiForTests(); + await __resetNativeApiForTests(); await setViewport(DEFAULT_VIEWPORT); localStorage.clear(); document.body.innerHTML = ""; @@ -1299,7 +1314,7 @@ describe("ChatView timeline estimator parity (full app)", () => { snapshot: createSnapshotForTargetUser({ targetMessageId, targetText: userText, - targetAttachmentCount: 3, + targetAttachmentCount: 2, }), }); @@ -1313,7 +1328,7 @@ describe("ChatView timeline estimator parity (full app)", () => { { role: "user", text: userText, - attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }], + attachments: [{ id: "attachment-1" }, { id: "attachment-2" }], }, { timelineWidthPx: timelineWidthMeasuredPx }, ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index aeab2d083a..185fa7c2ee 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -27,7 +27,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useQuery } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { gitStatusQueryOptions } from "~/lib/gitReactQuery"; +import { useGitStatus } from "~/lib/gitStatusState"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; @@ -1399,7 +1399,7 @@ export default function ChatView({ threadId }: ChatViewProps) { (debouncerState) => ({ isPending: debouncerState.isPending }), ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; - const gitStatusQuery = useQuery(gitStatusQueryOptions(gitCwd)); + const gitStatusQuery = useGitStatus(gitCwd); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); const modelOptionsByProvider = useMemo( diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index dc376a5b3d..ff216baed7 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -19,7 +19,7 @@ import { useState, } from "react"; import { openInPreferredEditor } from "../editorPreferences"; -import { gitStatusQueryOptions } from "~/lib/gitReactQuery"; +import { useGitStatus } from "~/lib/gitStatusState"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; import { readNativeApi } from "../nativeApi"; @@ -189,7 +189,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; - const gitStatusQuery = useQuery(gitStatusQueryOptions(activeCwd ?? null)); + const gitStatusQuery = useGitStatus(activeCwd ?? null); const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index a975a65bbe..92874f7404 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -10,7 +10,7 @@ const BRANCH_NAME = "feature/toast-scope"; const { invalidateGitQueriesSpy, - invalidateGitStatusQuerySpy, + refreshGitStatusSpy, runStackedActionMutateAsyncSpy, setThreadBranchSpy, toastAddSpy, @@ -19,7 +19,7 @@ const { toastUpdateSpy, } = vi.hoisted(() => ({ invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), - invalidateGitStatusQuerySpy: vi.fn(() => Promise.resolve()), + refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)), runStackedActionMutateAsyncSpy: vi.fn(() => new Promise(() => undefined)), setThreadBranchSpy: vi.fn(), toastAddSpy: vi.fn(() => "toast-1"), @@ -57,21 +57,6 @@ vi.mock("@tanstack/react-query", async () => { }; }), useQuery: vi.fn((options: { queryKey?: string[] }) => { - if (options.queryKey?.[0] === "git-status") { - return { - data: { - branch: BRANCH_NAME, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 1, - behindCount: 0, - pr: null, - }, - error: null, - }; - } - if (options.queryKey?.[0] === "git-branches") { return { data: { @@ -110,7 +95,6 @@ vi.mock("~/editorPreferences", () => ({ })); vi.mock("~/lib/gitReactQuery", () => ({ - gitBranchesQueryOptions: vi.fn(() => ({ queryKey: ["git-branches"] })), gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })), gitMutationKeys: { pull: vi.fn(() => ["pull"]), @@ -118,9 +102,25 @@ vi.mock("~/lib/gitReactQuery", () => ({ }, gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })), gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })), - gitStatusQueryOptions: vi.fn(() => ({ queryKey: ["git-status"] })), invalidateGitQueries: invalidateGitQueriesSpy, - invalidateGitStatusQuery: invalidateGitStatusQuerySpy, +})); + +vi.mock("~/lib/gitStatusState", () => ({ + refreshGitStatus: refreshGitStatusSpy, + resetGitStatusStateForTests: () => undefined, + useGitStatus: vi.fn(() => ({ + data: { + branch: BRANCH_NAME, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 1, + behindCount: 0, + pr: null, + }, + error: null, + isPending: false, + })), })); vi.mock("~/lib/utils", async () => { @@ -235,4 +235,42 @@ describe("GitActionsControl thread-scoped progress toast", () => { host.remove(); } }); + + it("debounces focus-driven git status refreshes", async () => { + vi.useFakeTimers(); + + const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); + let visibilityState: DocumentVisibilityState = "hidden"; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => visibilityState, + }); + + const host = document.createElement("div"); + document.body.append(host); + const screen = await render(, { + container: host, + }); + + try { + window.dispatchEvent(new Event("focus")); + visibilityState = "visible"; + document.dispatchEvent(new Event("visibilitychange")); + + expect(refreshGitStatusSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(249); + expect(refreshGitStatusSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1); + expect(refreshGitStatusSpy).toHaveBeenCalledWith(GIT_CWD); + } finally { + if (originalVisibilityState) { + Object.defineProperty(document, "visibilityState", originalVisibilityState); + } + await screen.unmount(); + host.remove(); + } + }); }); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 42882d000d..d641d4c36b 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -5,7 +5,7 @@ import type { GitStatusResult, ThreadId, } from "@t3tools/contracts"; -import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; import { GitHubIcon } from "./Icons"; @@ -45,9 +45,8 @@ import { gitMutationKeys, gitPullMutationOptions, gitRunStackedActionMutationOptions, - gitStatusQueryOptions, - invalidateGitStatusQuery, } from "~/lib/gitReactQuery"; +import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; @@ -92,6 +91,8 @@ interface RunGitActionWithToastInput { filePaths?: string[]; } +const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; + function formatElapsedDescription(startedAtMs: number | null): string | undefined { if (startedAtMs === null) { return undefined; @@ -275,7 +276,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions [persistThreadBranchSync], ); - const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); + const { data: gitStatus = null, error: gitStatusError } = useGitStatus(gitCwd); // Default to true while loading so we don't flash init controls. const isRepo = gitStatus?.isRepo ?? true; const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; @@ -359,6 +360,39 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }; }, [updateActiveProgressToast]); + useEffect(() => { + if (gitCwd === null) { + return; + } + + let refreshTimeout: number | null = null; + const scheduleRefreshCurrentGitStatus = () => { + if (refreshTimeout !== null) { + window.clearTimeout(refreshTimeout); + } + refreshTimeout = window.setTimeout(() => { + refreshTimeout = null; + void refreshGitStatus(gitCwd).catch(() => undefined); + }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + scheduleRefreshCurrentGitStatus(); + } + }; + + window.addEventListener("focus", scheduleRefreshCurrentGitStatus); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + if (refreshTimeout !== null) { + window.clearTimeout(refreshTimeout); + } + window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [gitCwd]); + const openExistingPr = useCallback(async () => { const api = readNativeApi(); if (!api) { @@ -801,7 +835,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions { - if (open) void invalidateGitStatusQuery(queryClient, gitCwd); + if (open) { + void refreshGitStatus(gitCwd).catch(() => undefined); + } }} > ({ + useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }), + useGitStatuses: () => new Map(), + refreshGitStatus: () => Promise.resolve(null), + resetGitStatusStateForTests: () => undefined, +})); + const THREAD_ID = "thread-kb-toast-test" as ThreadId; const PROJECT_ID = "project-1" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; @@ -170,20 +179,6 @@ function resolveWsRpc(tag: string): unknown { branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], }; } - if (tag === WS_METHODS.gitStatus) { - return { - isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }; - } if (tag === WS_METHODS.projectsSearchEntries) { return { entries: [], truncated: false }; } @@ -258,6 +253,29 @@ async function waitForNoToast(title: string): Promise { ); } +async function waitForInitialWsSubscriptions(): Promise { + await vi.waitFor( + () => { + expect( + rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), + ).toBe(true); + expect( + rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerConfig), + ).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); +} + +async function waitForServerConfigSnapshot(): Promise { + await vi.waitFor( + () => { + expect(getServerConfig()).not.toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); +} + async function mountApp(): Promise<{ cleanup: () => Promise }> { const host = document.createElement("div"); host.style.position = "fixed"; @@ -270,8 +288,15 @@ async function mountApp(): Promise<{ cleanup: () => Promise }> { const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] })); - const screen = await render(, { container: host }); + const screen = await render( + + + , + { container: host }, + ); await waitForComposerEditor(); + await waitForInitialWsSubscriptions(); + await waitForServerConfigSnapshot(); return { cleanup: async () => { @@ -322,7 +347,7 @@ describe("Keybindings update toast", () => { return []; }, }); - __resetNativeApiForTests(); + await __resetNativeApiForTests(); localStorage.clear(); document.body.innerHTML = ""; useComposerDraftStore.setState({ diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 89ced46454..d227e3a803 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -49,7 +49,6 @@ import { ThreadId, type GitStatusResult, } from "@t3tools/contracts"; -import { useQueries } from "@tanstack/react-query"; import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { type SidebarProjectSortOrder, @@ -70,7 +69,7 @@ import { threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; -import { gitStatusQueryOptions } from "../lib/gitReactQuery"; +import { useGitStatus } from "../lib/gitStatusState"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -245,8 +244,20 @@ function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { return null; } +function resolveThreadPr( + threadBranch: string | null, + gitStatus: GitStatusResult | null, +): ThreadPr | null { + if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { + return null; + } + + return gitStatus.pr ?? null; +} + interface SidebarThreadRowProps { threadId: ThreadId; + projectCwd: string | null; orderedProjectThreadIds: readonly ThreadId[]; routeThreadId: ThreadId | null; selectedThreadIds: ReadonlySet; @@ -277,7 +288,6 @@ interface SidebarThreadRowProps { cancelRename: () => void; attemptArchiveThread: (threadId: ThreadId) => Promise; openPrLink: (event: MouseEvent, prUrl: string) => void; - pr: ThreadPr | null; } function SidebarThreadRow(props: SidebarThreadRowProps) { @@ -287,6 +297,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { (state) => selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, ); + const gitCwd = thread?.worktreePath ?? props.projectCwd; + const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null); if (!thread) { return null; @@ -303,7 +315,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { lastVisitedAt, }, }); - const prStatus = prStatusIndicator(props.pr); + const pr = resolveThreadPr(thread.branch, gitStatus.data); + const prStatus = prStatusIndicator(pr); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning; const threadMetaClassName = isConfirmingArchive @@ -762,54 +775,6 @@ export default function Sidebar() { }), [platform, routeTerminalOpen], ); - const threadGitTargets = useMemo( - () => - sidebarThreads.map((thread) => ({ - threadId: thread.id, - branch: thread.branch, - cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, - })), - [projectCwdById, sidebarThreads], - ); - const threadGitStatusCwds = useMemo( - () => [ - ...new Set( - threadGitTargets - .filter((target) => target.branch !== null) - .map((target) => target.cwd) - .filter((cwd): cwd is string => cwd !== null), - ), - ], - [threadGitTargets], - ); - const threadGitStatusQueries = useQueries({ - queries: threadGitStatusCwds.map((cwd) => ({ - ...gitStatusQueryOptions(cwd), - staleTime: 30_000, - refetchInterval: 60_000, - })), - }); - const prByThreadId = useMemo(() => { - const statusByCwd = new Map(); - for (let index = 0; index < threadGitStatusCwds.length; index += 1) { - const cwd = threadGitStatusCwds[index]; - if (!cwd) continue; - const status = threadGitStatusQueries[index]?.data; - if (status) { - statusByCwd.set(cwd, status); - } - } - - const map = new Map(); - for (const target of threadGitTargets) { - const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; - const branchMatches = - target.branch !== null && status?.branch !== null && status?.branch === target.branch; - map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); - } - return map; - }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); - const openPrLink = useCallback((event: MouseEvent, prUrl: string) => { event.preventDefault(); event.stopPropagation(); @@ -1718,6 +1683,7 @@ export default function Sidebar() { ))} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8cb8b89684..e823569c13 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -385,7 +385,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {image.name} diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 090f6f12ad..f0ea32d4be 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -30,16 +30,16 @@ function createBaseServerConfig(): ServerConfig { } describe("GeneralSettingsPanel observability", () => { - beforeEach(() => { + beforeEach(async () => { resetServerStateForTests(); - __resetNativeApiForTests(); + await __resetNativeApiForTests(); localStorage.clear(); document.body.innerHTML = ""; }); - afterEach(() => { + afterEach(async () => { resetServerStateForTests(); - __resetNativeApiForTests(); + await __resetNativeApiForTests(); document.body.innerHTML = ""; }); diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index df2a21dab1..35c90d0120 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -30,7 +30,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }], }), - ).toBe(346); + ).toBe(234); expect( estimateTimelineMessageHeight({ @@ -38,7 +38,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }, { id: "2" }], }), - ).toBe(346); + ).toBe(234); }); it("adds a second attachment row for three or four user attachments", () => { @@ -48,7 +48,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }, { id: "2" }, { id: "3" }], }), - ).toBe(574); + ).toBe(350); expect( estimateTimelineMessageHeight({ @@ -56,7 +56,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }], }), - ).toBe(574); + ).toBe(350); }); it("does not cap long user message estimates", () => { diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 57a15f26ed..776fe9ad88 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -11,8 +11,9 @@ const ASSISTANT_LINE_HEIGHT_PX = 22.75; const ASSISTANT_BASE_HEIGHT_PX = 41; const USER_BASE_HEIGHT_PX = 96; const ATTACHMENTS_PER_ROW = 2; -// Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap. -const USER_ATTACHMENT_ROW_HEIGHT_PX = 228; +// Full-app browser measurements land closer to a ~116px attachment row once +// the bubble shrinks to content width, so calibrate the estimate to that DOM. +const USER_ATTACHMENT_ROW_HEIGHT_PX = 116; const USER_BUBBLE_WIDTH_RATIO = 0.8; const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index d260c2aee8..254b93eb6d 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -15,12 +15,9 @@ import type { GitListBranchesResult } from "@t3tools/contracts"; import { gitBranchSearchInfiniteQueryOptions, gitMutationKeys, - gitQueryKeys, gitPreparePullRequestThreadMutationOptions, gitPullMutationOptions, gitRunStackedActionMutationOptions, - invalidateGitStatusQuery, - gitStatusQueryOptions, invalidateGitQueries, } from "./gitReactQuery"; @@ -84,7 +81,6 @@ describe("invalidateGitQueries", () => { it("can invalidate a single cwd without blasting other git query scopes", async () => { const queryClient = new QueryClient(); - queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" }); queryClient.setQueryData( gitBranchSearchInfiniteQueryOptions({ cwd: "/repo/a", @@ -92,7 +88,6 @@ describe("invalidateGitQueries", () => { }).queryKey, BRANCH_SEARCH_RESULT, ); - queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" }); queryClient.setQueryData( gitBranchSearchInfiniteQueryOptions({ cwd: "/repo/b", @@ -103,9 +98,6 @@ describe("invalidateGitQueries", () => { await invalidateGitQueries(queryClient, { cwd: "/repo/a" }); - expect( - queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated, - ).toBe(true); expect( queryClient.getQueryState( gitBranchSearchInfiniteQueryOptions({ @@ -114,9 +106,6 @@ describe("invalidateGitQueries", () => { }).queryKey, )?.isInvalidated, ).toBe(true); - expect( - queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated, - ).toBe(false); expect( queryClient.getQueryState( gitBranchSearchInfiniteQueryOptions({ @@ -127,21 +116,3 @@ describe("invalidateGitQueries", () => { ).toBe(false); }); }); - -describe("invalidateGitStatusQuery", () => { - it("invalidates only status for the selected cwd", async () => { - const queryClient = new QueryClient(); - - queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" }); - queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" }); - - await invalidateGitStatusQuery(queryClient, "/repo/a"); - - expect( - queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated, - ).toBe(true); - expect( - queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated, - ).toBe(false); - }); -}); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index bfac623db9..a2611ebe25 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -12,15 +12,12 @@ import { import { ensureNativeApi } from "../nativeApi"; import { getWsRpcClient } from "../wsRpcClient"; -const GIT_STATUS_STALE_TIME_MS = 5_000; -const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000; const GIT_BRANCHES_STALE_TIME_MS = 15_000; const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; const GIT_BRANCHES_PAGE_SIZE = 100; export const gitQueryKeys = { all: ["git"] as const, - status: (cwd: string | null) => ["git", "status", cwd] as const, branches: (cwd: string | null) => ["git", "branches", cwd] as const, branchSearch: (cwd: string | null, query: string) => ["git", "branches", cwd, "search", query] as const, @@ -38,37 +35,18 @@ export const gitMutationKeys = { export function invalidateGitQueries(queryClient: QueryClient, input?: { cwd?: string | null }) { const cwd = input?.cwd ?? null; if (cwd !== null) { - return Promise.all([ - queryClient.invalidateQueries({ queryKey: gitQueryKeys.status(cwd) }), - queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }), - ]); + return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }); } return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); } -export function invalidateGitStatusQuery(queryClient: QueryClient, cwd: string | null) { +function invalidateGitBranchQueries(queryClient: QueryClient, cwd: string | null) { if (cwd === null) { return Promise.resolve(); } - return queryClient.invalidateQueries({ queryKey: gitQueryKeys.status(cwd) }); -} - -export function gitStatusQueryOptions(cwd: string | null) { - return queryOptions({ - queryKey: gitQueryKeys.status(cwd), - queryFn: async () => { - const api = ensureNativeApi(); - if (!cwd) throw new Error("Git status is unavailable."); - return api.git.status({ cwd }); - }, - enabled: cwd !== null, - staleTime: GIT_STATUS_STALE_TIME_MS, - refetchOnWindowFocus: "always", - refetchOnReconnect: "always", - refetchInterval: GIT_STATUS_REFETCH_INTERVAL_MS, - }); + return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }); } export function gitBranchSearchInfiniteQueryOptions(input: { @@ -128,8 +106,8 @@ export function gitInitMutationOptions(input: { cwd: string | null; queryClient: if (!input.cwd) throw new Error("Git init is unavailable."); return api.git.init({ cwd: input.cwd }); }, - onSuccess: async () => { - await invalidateGitQueries(input.queryClient); + onSettled: async () => { + await invalidateGitBranchQueries(input.queryClient, input.cwd); }, }); } @@ -145,8 +123,8 @@ export function gitCheckoutMutationOptions(input: { if (!input.cwd) throw new Error("Git checkout is unavailable."); return api.git.checkout({ cwd: input.cwd, branch }); }, - onSuccess: async () => { - await invalidateGitQueries(input.queryClient); + onSettled: async () => { + await invalidateGitBranchQueries(input.queryClient, input.cwd); }, }); } @@ -175,18 +153,18 @@ export function gitRunStackedActionMutationOptions(input: { if (!input.cwd) throw new Error("Git action is unavailable."); return getWsRpcClient().git.runStackedAction( { + action, actionId, cwd: input.cwd, - action, ...(commitMessage ? { commitMessage } : {}), - ...(featureBranch ? { featureBranch } : {}), - ...(filePaths ? { filePaths } : {}), + ...(featureBranch ? { featureBranch: true } : {}), + ...(filePaths && filePaths.length > 0 ? { filePaths } : {}), }, ...(onProgress ? [{ onProgress }] : []), ); }, - onSettled: async () => { - await invalidateGitQueries(input.queryClient); + onSuccess: async () => { + await invalidateGitBranchQueries(input.queryClient, input.cwd); }, }); } @@ -199,31 +177,19 @@ export function gitPullMutationOptions(input: { cwd: string | null; queryClient: if (!input.cwd) throw new Error("Git pull is unavailable."); return api.git.pull({ cwd: input.cwd }); }, - onSettled: async () => { - await invalidateGitQueries(input.queryClient); + onSuccess: async () => { + await invalidateGitBranchQueries(input.queryClient, input.cwd); }, }); } export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) { return mutationOptions({ - mutationFn: async ({ - cwd, - branch, - newBranch, - path, - }: { - cwd: string; - branch: string; - newBranch: string; - path?: string | null; - }) => { - const api = ensureNativeApi(); - if (!cwd) throw new Error("Git worktree creation is unavailable."); - return api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null }); - }, mutationKey: ["git", "mutation", "create-worktree"] as const, - onSettled: async () => { + mutationFn: ( + args: Parameters["git"]["createWorktree"]>[0], + ) => ensureNativeApi().git.createWorktree(args), + onSuccess: async () => { await invalidateGitQueries(input.queryClient); }, }); @@ -231,13 +197,11 @@ export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClie export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) { return mutationOptions({ - mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => { - const api = ensureNativeApi(); - if (!cwd) throw new Error("Git worktree removal is unavailable."); - return api.git.removeWorktree({ cwd, path, force }); - }, mutationKey: ["git", "mutation", "remove-worktree"] as const, - onSettled: async () => { + mutationFn: ( + args: Parameters["git"]["removeWorktree"]>[0], + ) => ensureNativeApi().git.removeWorktree(args), + onSuccess: async () => { await invalidateGitQueries(input.queryClient); }, }); @@ -248,11 +212,8 @@ export function gitPreparePullRequestThreadMutationOptions(input: { queryClient: QueryClient; }) { return mutationOptions({ - mutationFn: async ({ - reference, - mode, - threadId, - }: { + mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), + mutationFn: async (args: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId; @@ -261,14 +222,13 @@ export function gitPreparePullRequestThreadMutationOptions(input: { if (!input.cwd) throw new Error("Pull request thread preparation is unavailable."); return api.git.preparePullRequestThread({ cwd: input.cwd, - reference, - mode, - ...(threadId ? { threadId } : {}), + reference: args.reference, + mode: args.mode, + ...(args.threadId ? { threadId: args.threadId } : {}), }); }, - mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), - onSettled: async () => { - await invalidateGitQueries(input.queryClient); + onSuccess: async () => { + await invalidateGitBranchQueries(input.queryClient, input.cwd); }, }); } diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts new file mode 100644 index 0000000000..757130db9b --- /dev/null +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -0,0 +1,112 @@ +import type { GitStatusResult } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + getGitStatusSnapshot, + resetGitStatusStateForTests, + refreshGitStatus, + watchGitStatus, +} from "./gitStatusState"; + +function registerListener(listeners: Set<(event: T) => void>, listener: (event: T) => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); + +const BASE_STATUS: GitStatusResult = { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/push-status", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + +const gitClient = { + refreshStatus: vi.fn(async (input: { cwd: string }) => ({ + ...BASE_STATUS, + branch: `${input.cwd}-refreshed`, + })), + onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) => + registerListener(gitStatusListeners, listener), + ), +}; + +function emitGitStatus(event: GitStatusResult) { + for (const listener of gitStatusListeners) { + listener(event); + } +} + +afterEach(() => { + gitStatusListeners.clear(); + gitClient.onStatus.mockClear(); + gitClient.refreshStatus.mockClear(); + resetGitStatusStateForTests(); +}); + +describe("gitStatusState", () => { + it("starts fresh cwd state in a pending state", () => { + expect(getGitStatusSnapshot("/fresh")).toEqual({ + data: null, + error: null, + cause: null, + isPending: true, + }); + }); + + it("shares one live subscription per cwd and updates the per-cwd atom snapshot", () => { + const releaseA = watchGitStatus("/repo", gitClient); + const releaseB = watchGitStatus("/repo", gitClient); + + expect(gitClient.onStatus).toHaveBeenCalledOnce(); + expect(getGitStatusSnapshot("/repo")).toEqual({ + data: null, + error: null, + cause: null, + isPending: true, + }); + + emitGitStatus(BASE_STATUS); + + expect(getGitStatusSnapshot("/repo")).toEqual({ + data: BASE_STATUS, + error: null, + cause: null, + isPending: false, + }); + + releaseA(); + expect(gitStatusListeners.size).toBe(1); + + releaseB(); + expect(gitStatusListeners.size).toBe(0); + }); + + it("refreshes git status through the unary RPC without restarting the stream", async () => { + const release = watchGitStatus("/repo", gitClient); + + emitGitStatus(BASE_STATUS); + const refreshed = await refreshGitStatus("/repo", gitClient); + + expect(gitClient.onStatus).toHaveBeenCalledOnce(); + expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); + expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" }); + expect(getGitStatusSnapshot("/repo")).toEqual({ + data: BASE_STATUS, + error: null, + cause: null, + isPending: false, + }); + + release(); + }); +}); diff --git a/apps/web/src/lib/gitStatusState.ts b/apps/web/src/lib/gitStatusState.ts new file mode 100644 index 0000000000..1c1cf00864 --- /dev/null +++ b/apps/web/src/lib/gitStatusState.ts @@ -0,0 +1,223 @@ +import { useAtomValue } from "@effect/atom-react"; +import { type GitManagerServiceError, type GitStatusResult } from "@t3tools/contracts"; +import { Cause } from "effect"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect } from "react"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { getWsRpcClient, type WsRpcClient } from "../wsRpcClient"; + +export type GitStatusStreamError = GitManagerServiceError; + +export interface GitStatusState { + readonly data: GitStatusResult | null; + readonly error: GitStatusStreamError | null; + readonly cause: Cause.Cause | null; + readonly isPending: boolean; +} + +type GitStatusClient = Pick; + +interface WatchedGitStatus { + refCount: number; + unsubscribe: () => void; +} + +const EMPTY_GIT_STATUS_STATE = Object.freeze({ + data: null, + error: null, + cause: null, + isPending: false, +}); +const INITIAL_GIT_STATUS_STATE = Object.freeze({ + ...EMPTY_GIT_STATUS_STATE, + isPending: true, +}); +const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe( + Atom.keepAlive, + Atom.withLabel("git-status:null"), +); + +const NOOP: () => void = () => undefined; +const watchedGitStatuses = new Map(); +const knownGitStatusCwds = new Set(); +const gitStatusRefreshInFlight = new Map>(); +const gitStatusLastRefreshAtByCwd = new Map(); + +const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000; + +let sharedGitStatusClient: GitStatusClient | null = null; + +const gitStatusStateAtom = Atom.family((cwd: string) => { + knownGitStatusCwds.add(cwd); + return Atom.make(INITIAL_GIT_STATUS_STATE).pipe( + Atom.keepAlive, + Atom.withLabel(`git-status:${cwd}`), + ); +}); + +export function getGitStatusSnapshot(cwd: string | null): GitStatusState { + if (cwd === null) { + return EMPTY_GIT_STATUS_STATE; + } + + return appAtomRegistry.get(gitStatusStateAtom(cwd)); +} + +export function watchGitStatus( + cwd: string | null, + client: GitStatusClient = getWsRpcClient().git, +): () => void { + if (cwd === null) { + return NOOP; + } + + ensureGitStatusClient(client); + + const watched = watchedGitStatuses.get(cwd); + if (watched) { + watched.refCount += 1; + return () => unwatchGitStatus(cwd); + } + + watchedGitStatuses.set(cwd, { + refCount: 1, + unsubscribe: subscribeToGitStatus(cwd), + }); + + return () => unwatchGitStatus(cwd); +} + +export function refreshGitStatus( + cwd: string | null, + client: GitStatusClient = getWsRpcClient().git, +): Promise { + if (cwd === null) { + return Promise.resolve(null); + } + + ensureGitStatusClient(client); + + const currentInFlight = gitStatusRefreshInFlight.get(cwd); + if (currentInFlight) { + return currentInFlight; + } + + const lastRequestedAt = gitStatusLastRefreshAtByCwd.get(cwd) ?? 0; + if (Date.now() - lastRequestedAt < GIT_STATUS_REFRESH_DEBOUNCE_MS) { + return Promise.resolve(getGitStatusSnapshot(cwd).data); + } + + gitStatusLastRefreshAtByCwd.set(cwd, Date.now()); + const refreshPromise = client.refreshStatus({ cwd }).finally(() => { + gitStatusRefreshInFlight.delete(cwd); + }); + gitStatusRefreshInFlight.set(cwd, refreshPromise); + return refreshPromise; +} + +export function resetGitStatusStateForTests(): void { + for (const watched of watchedGitStatuses.values()) { + watched.unsubscribe(); + } + watchedGitStatuses.clear(); + gitStatusRefreshInFlight.clear(); + gitStatusLastRefreshAtByCwd.clear(); + sharedGitStatusClient = null; + + for (const cwd of knownGitStatusCwds) { + appAtomRegistry.set(gitStatusStateAtom(cwd), INITIAL_GIT_STATUS_STATE); + } + knownGitStatusCwds.clear(); +} + +export function useGitStatus(cwd: string | null): GitStatusState { + useEffect(() => watchGitStatus(cwd), [cwd]); + + const state = useAtomValue(cwd !== null ? gitStatusStateAtom(cwd) : EMPTY_GIT_STATUS_ATOM); + return cwd === null ? EMPTY_GIT_STATUS_STATE : state; +} + +function ensureGitStatusClient(client: GitStatusClient): void { + if (sharedGitStatusClient === client) { + return; + } + + if (sharedGitStatusClient !== null) { + resetLiveGitStatusSubscriptions(); + } + + sharedGitStatusClient = client; +} + +function resetLiveGitStatusSubscriptions(): void { + for (const watched of watchedGitStatuses.values()) { + watched.unsubscribe(); + } + watchedGitStatuses.clear(); +} + +function unwatchGitStatus(cwd: string): void { + const watched = watchedGitStatuses.get(cwd); + if (!watched) { + return; + } + + watched.refCount -= 1; + if (watched.refCount > 0) { + return; + } + + watched.unsubscribe(); + watchedGitStatuses.delete(cwd); +} + +function subscribeToGitStatus(cwd: string): () => void { + const client = sharedGitStatusClient; + if (!client) { + return NOOP; + } + + markGitStatusPending(cwd); + return client.onStatus( + { cwd }, + (status) => { + appAtomRegistry.set(gitStatusStateAtom(cwd), { + data: status, + error: null, + cause: null, + isPending: false, + }); + }, + { + onResubscribe: () => { + markGitStatusPending(cwd); + }, + }, + ); +} + +function markGitStatusPending(cwd: string): void { + const atom = gitStatusStateAtom(cwd); + const current = appAtomRegistry.get(atom); + const next = + current.data === null + ? INITIAL_GIT_STATUS_STATE + : { + ...current, + error: null, + cause: null, + isPending: true, + }; + + if ( + current.data === next.data && + current.error === next.error && + current.cause === next.cause && + current.isPending === next.isPending + ) { + return; + } + + appAtomRegistry.set(atom, next); +} diff --git a/apps/web/src/nativeApi.ts b/apps/web/src/nativeApi.ts index 9f528b6342..f9b0607347 100644 --- a/apps/web/src/nativeApi.ts +++ b/apps/web/src/nativeApi.ts @@ -25,7 +25,7 @@ export function ensureNativeApi(): NativeApi { return api; } -export function __resetNativeApiForTests() { +export async function __resetNativeApiForTests() { cachedApi = undefined; - __resetWsNativeApiForTests(); + await __resetWsNativeApiForTests(); } diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index cfa6ca6942..ae56f85991 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_SERVER_SETTINGS, type DesktopBridge, EventId, + type GitStatusResult, ProjectId, type OrchestrationEvent, type ServerConfig, @@ -31,6 +32,7 @@ function registerListener(listeners: Set<(event: T) => void>, listener: (even const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>(); +const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); const rpcClientMock = { dispose: vi.fn(), @@ -54,7 +56,10 @@ const rpcClientMock = { }, git: { pull: vi.fn(), - status: vi.fn(), + refreshStatus: vi.fn(), + onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) => + registerListener(gitStatusListeners, listener), + ), runStackedAction: vi.fn(), listBranches: vi.fn(), createWorktree: vi.fn(), @@ -168,12 +173,26 @@ const baseServerConfig: ServerConfig = { settings: DEFAULT_SERVER_SETTINGS, }; +const baseGitStatus: GitStatusResult = { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/streamed", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); showContextMenuFallbackMock.mockReset(); terminalEventListeners.clear(); orchestrationEventListeners.clear(); + gitStatusListeners.clear(); Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); }); @@ -243,6 +262,32 @@ describe("wsNativeApi", () => { expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); }); + it("forwards git status stream events", async () => { + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + const onStatus = vi.fn(); + + api.git.onStatus({ cwd: "/repo" }, onStatus); + + const gitStatus = baseGitStatus; + emitEvent(gitStatusListeners, gitStatus); + + expect(rpcClientMock.git.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined); + expect(onStatus).toHaveBeenCalledWith(gitStatus); + }); + + it("forwards git status refreshes directly to the RPC client", async () => { + rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + + await api.git.refreshStatus({ cwd: "/repo" }); + + expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); + }); + it("forwards orchestration stream subscription options to the RPC client", async () => { const { createWsNativeApi } = await import("./wsNativeApi"); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 99045dbf07..3cfb976e09 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -1,6 +1,8 @@ import { type ContextMenuItem, type NativeApi } from "@t3tools/contracts"; +import { resetGitStatusStateForTests } from "./lib/gitStatusState"; import { showContextMenuFallback } from "./contextMenuFallback"; +import { __resetWsRpcAtomClientForTests } from "./rpc/client"; import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; import { resetServerStateForTests } from "./rpc/serverState"; import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; @@ -8,9 +10,11 @@ import { __resetWsRpcClientForTests, getWsRpcClient } from "./wsRpcClient"; let instance: { api: NativeApi } | null = null; -export function __resetWsNativeApiForTests() { +export async function __resetWsNativeApiForTests() { instance = null; - __resetWsRpcClientForTests(); + await __resetWsRpcAtomClientForTests(); + await __resetWsRpcClientForTests(); + resetGitStatusStateForTests(); resetRequestLatencyStateForTests(); resetServerStateForTests(); resetWsConnectionStateForTests(); @@ -65,7 +69,8 @@ export function createWsNativeApi(): NativeApi { }, git: { pull: rpcClient.git.pull, - status: rpcClient.git.status, + refreshStatus: rpcClient.git.refreshStatus, + onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options), listBranches: rpcClient.git.listBranches, createWorktree: rpcClient.git.createWorktree, removeWorktree: rpcClient.git.removeWorktree, diff --git a/apps/web/src/wsRpcClient.test.ts b/apps/web/src/wsRpcClient.test.ts new file mode 100644 index 0000000000..36467eed9a --- /dev/null +++ b/apps/web/src/wsRpcClient.test.ts @@ -0,0 +1,94 @@ +import type { + GitStatusLocalResult, + GitStatusRemoteResult, + GitStatusStreamEvent, +} from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vitest"; + +import { createWsRpcClient } from "./wsRpcClient"; +import { type WsTransport } from "./wsTransport"; + +const baseLocalStatus: GitStatusLocalResult = { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, +}; + +const baseRemoteStatus: GitStatusRemoteResult = { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + +describe("wsRpcClient", () => { + it("reduces git status stream events into flat status snapshots", () => { + const subscribe = vi.fn((_connect: unknown, listener: (value: TValue) => void) => { + for (const event of [ + { + _tag: "snapshot", + local: baseLocalStatus, + remote: null, + }, + { + _tag: "remoteUpdated", + remote: baseRemoteStatus, + }, + { + _tag: "localUpdated", + local: { + ...baseLocalStatus, + hasWorkingTreeChanges: true, + }, + }, + ] satisfies GitStatusStreamEvent[]) { + listener(event as TValue); + } + return () => undefined; + }); + + const transport = { + dispose: vi.fn(async () => undefined), + reconnect: vi.fn(async () => undefined), + request: vi.fn(), + requestStream: vi.fn(), + subscribe, + } satisfies Pick< + WsTransport, + "dispose" | "reconnect" | "request" | "requestStream" | "subscribe" + >; + + const client = createWsRpcClient(transport as unknown as WsTransport); + const listener = vi.fn(); + + client.git.onStatus({ cwd: "/repo" }, listener); + + expect(listener.mock.calls).toEqual([ + [ + { + ...baseLocalStatus, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }, + ], + [ + { + ...baseLocalStatus, + ...baseRemoteStatus, + }, + ], + [ + { + ...baseLocalStatus, + ...baseRemoteStatus, + hasWorkingTreeChanges: true, + }, + ], + ]); + }); +}); diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index 1d411aa1b9..997b83d2d7 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -2,11 +2,14 @@ import { type GitActionProgressEvent, type GitRunStackedActionInput, type GitRunStackedActionResult, + type GitStatusResult, + type GitStatusStreamEvent, type NativeApi, ORCHESTRATION_WS_METHODS, type ServerSettingsPatch, WS_METHODS, } from "@t3tools/contracts"; +import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; import { Effect, Stream } from "effect"; import { type WsRpcProtocolClient } from "./rpc/protocol"; @@ -64,7 +67,12 @@ export interface WsRpcClient { }; readonly git: { readonly pull: RpcUnaryMethod; - readonly status: RpcUnaryMethod; + readonly refreshStatus: RpcUnaryMethod; + readonly onStatus: ( + input: RpcInput, + listener: (status: GitStatusResult) => void, + options?: StreamSubscriptionOptions, + ) => () => void; readonly runStackedAction: ( input: GitRunStackedActionInput, options?: GitRunStackedActionOptions, @@ -149,7 +157,19 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { }, git: { pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), - status: (input) => transport.request((client) => client[WS_METHODS.gitStatus](input)), + refreshStatus: (input) => + transport.request((client) => client[WS_METHODS.gitRefreshStatus](input)), + onStatus: (input, listener, options) => { + let current: GitStatusResult | null = null; + return transport.subscribe( + (client) => client[WS_METHODS.subscribeGitStatus](input), + (event: GitStatusStreamEvent) => { + current = applyGitStatusStreamEvent(current, event); + listener(current); + }, + options, + ); + }, runStackedAction: async (input, options) => { let result: GitRunStackedActionResult | null = null; diff --git a/apps/web/test/wsRpcHarness.ts b/apps/web/test/wsRpcHarness.ts index dcb6dc7252..aeae92d101 100644 --- a/apps/web/test/wsRpcHarness.ts +++ b/apps/web/test/wsRpcHarness.ts @@ -24,6 +24,7 @@ interface BrowserWsRpcHarnessOptions { const STREAM_METHODS = new Set([ WS_METHODS.gitRunStackedAction, + WS_METHODS.subscribeGitStatus, WS_METHODS.subscribeOrchestrationDomainEvents, WS_METHODS.subscribeTerminalEvents, WS_METHODS.subscribeServerConfig, @@ -108,7 +109,7 @@ export class BrowserWsRpcHarness { async onMessage(rawData: string): Promise { const server = await this.serverReady; if (!server) { - throw new Error("RPC test server is not connected"); + return; } const messages = this.parser.decode(rawData); for (const message of messages) { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index f28a74f6de..345208acf9 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -44,6 +44,14 @@ const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); const GitPullRequestReference = TrimmedNonEmptyStringSchema; const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]); +export const GitHostingProviderKind = Schema.Literals(["github", "gitlab", "unknown"]); +export type GitHostingProviderKind = typeof GitHostingProviderKind.Type; +export const GitHostingProvider = Schema.Struct({ + kind: GitHostingProviderKind, + name: TrimmedNonEmptyStringSchema, + baseUrl: Schema.String, +}); +export type GitHostingProvider = typeof GitHostingProvider.Type; export const GitRunStackedActionToastRunAction = Schema.Struct({ kind: GitStackedAction, }); @@ -161,9 +169,15 @@ export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type; export const GitCreateBranchInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, branch: TrimmedNonEmptyStringSchema, + checkout: Schema.optional(Schema.Boolean), }); export type GitCreateBranchInput = typeof GitCreateBranchInput.Type; +export const GitCreateBranchResult = Schema.Struct({ + branch: TrimmedNonEmptyStringSchema, +}); +export type GitCreateBranchResult = typeof GitCreateBranchResult.Type; + export const GitCheckoutInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, branch: TrimmedNonEmptyStringSchema, @@ -186,8 +200,9 @@ const GitStatusPr = Schema.Struct({ state: GitStatusPrState, }); -export const GitStatusResult = Schema.Struct({ +const GitStatusLocalShape = { isRepo: Schema.Boolean, + hostingProvider: Schema.optional(GitHostingProvider), hasOriginRemote: Schema.Boolean, isDefaultBranch: Schema.Boolean, branch: Schema.NullOr(TrimmedNonEmptyStringSchema), @@ -203,13 +218,41 @@ export const GitStatusResult = Schema.Struct({ insertions: NonNegativeInt, deletions: NonNegativeInt, }), +}; + +const GitStatusRemoteShape = { hasUpstream: Schema.Boolean, aheadCount: NonNegativeInt, behindCount: NonNegativeInt, pr: Schema.NullOr(GitStatusPr), +}; + +export const GitStatusLocalResult = Schema.Struct(GitStatusLocalShape); +export type GitStatusLocalResult = typeof GitStatusLocalResult.Type; + +export const GitStatusRemoteResult = Schema.Struct(GitStatusRemoteShape); +export type GitStatusRemoteResult = typeof GitStatusRemoteResult.Type; + +export const GitStatusResult = Schema.Struct({ + ...GitStatusLocalShape, + ...GitStatusRemoteShape, }); export type GitStatusResult = typeof GitStatusResult.Type; +export const GitStatusStreamEvent = Schema.Union([ + Schema.TaggedStruct("snapshot", { + local: GitStatusLocalResult, + remote: Schema.NullOr(GitStatusRemoteResult), + }), + Schema.TaggedStruct("localUpdated", { + local: GitStatusLocalResult, + }), + Schema.TaggedStruct("remoteUpdated", { + remote: Schema.NullOr(GitStatusRemoteResult), + }), +]); +export type GitStatusStreamEvent = typeof GitStatusStreamEvent.Type; + export const GitListBranchesResult = Schema.Struct({ branches: Schema.Array(GitBranch), isRepo: Schema.Boolean, @@ -236,6 +279,11 @@ export const GitPreparePullRequestThreadResult = Schema.Struct({ }); export type GitPreparePullRequestThreadResult = typeof GitPreparePullRequestThreadResult.Type; +export const GitCheckoutResult = Schema.Struct({ + branch: Schema.NullOr(TrimmedNonEmptyStringSchema), +}); +export type GitCheckoutResult = typeof GitCheckoutResult.Type; + export const GitRunStackedActionResult = Schema.Struct({ action: GitStackedAction, branch: Schema.Struct({ diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 57a1c4c3dc..630ccd8249 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,5 +1,6 @@ import type { GitCheckoutInput, + GitCheckoutResult, GitCreateBranchInput, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, @@ -15,6 +16,7 @@ import type { GitResolvePullRequestResult, GitStatusInput, GitStatusResult, + GitCreateBranchResult, } from "./git"; import type { ProjectSearchEntriesInput, @@ -148,8 +150,8 @@ export interface NativeApi { listBranches: (input: GitListBranchesInput) => Promise; createWorktree: (input: GitCreateWorktreeInput) => Promise; removeWorktree: (input: GitRemoveWorktreeInput) => Promise; - createBranch: (input: GitCreateBranchInput) => Promise; - checkout: (input: GitCheckoutInput) => Promise; + createBranch: (input: GitCreateBranchInput) => Promise; + checkout: (input: GitCheckoutInput) => Promise; init: (input: GitInitInput) => Promise; resolvePullRequest: (input: GitPullRequestRefInput) => Promise; preparePullRequestThread: ( @@ -157,7 +159,14 @@ export interface NativeApi { ) => Promise; // Stacked action API pull: (input: GitPullInput) => Promise; - status: (input: GitStatusInput) => Promise; + refreshStatus: (input: GitStatusInput) => Promise; + onStatus: ( + input: GitStatusInput, + callback: (status: GitStatusResult) => void, + options?: { + onResubscribe?: () => void; + }, + ) => () => void; }; contextMenu: { show: ( diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 34968e66ec..a3d10299df 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -6,8 +6,10 @@ import { OpenError, OpenInEditorInput } from "./editor"; import { GitActionProgressEvent, GitCheckoutInput, + GitCheckoutResult, GitCommandError, GitCreateBranchInput, + GitCreateBranchResult, GitCreateWorktreeInput, GitCreateWorktreeResult, GitInitInput, @@ -24,6 +26,7 @@ import { GitRunStackedActionInput, GitStatusInput, GitStatusResult, + GitStatusStreamEvent, } from "./git"; import { KeybindingsConfigError } from "./keybindings"; import { @@ -83,7 +86,7 @@ export const WS_METHODS = { // Git methods gitPull: "git.pull", - gitStatus: "git.status", + gitRefreshStatus: "git.refreshStatus", gitRunStackedAction: "git.runStackedAction", gitListBranches: "git.listBranches", gitCreateWorktree: "git.createWorktree", @@ -110,6 +113,7 @@ export const WS_METHODS = { serverUpdateSettings: "server.updateSettings", // Streaming subscriptions + subscribeGitStatus: "subscribeGitStatus", subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents", subscribeTerminalEvents: "subscribeTerminalEvents", subscribeServerConfig: "subscribeServerConfig", @@ -162,10 +166,11 @@ export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { error: OpenError, }); -export const WsGitStatusRpc = Rpc.make(WS_METHODS.gitStatus, { +export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, { payload: GitStatusInput, - success: GitStatusResult, + success: GitStatusStreamEvent, error: GitManagerServiceError, + stream: true, }); export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, { @@ -174,6 +179,12 @@ export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, { error: GitCommandError, }); +export const WsGitRefreshStatusRpc = Rpc.make(WS_METHODS.gitRefreshStatus, { + payload: GitStatusInput, + success: GitStatusResult, + error: GitManagerServiceError, +}); + export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { payload: GitRunStackedActionInput, success: GitActionProgressEvent, @@ -212,11 +223,13 @@ export const WsGitRemoveWorktreeRpc = Rpc.make(WS_METHODS.gitRemoveWorktree, { export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, { payload: GitCreateBranchInput, + success: GitCreateBranchResult, error: GitCommandError, }); export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, { payload: GitCheckoutInput, + success: GitCheckoutResult, error: GitCommandError, }); @@ -330,8 +343,9 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, - WsGitStatusRpc, + WsSubscribeGitStatusRpc, WsGitPullRpc, + WsGitRefreshStatusRpc, WsGitRunStackedActionRpc, WsGitResolvePullRequestRpc, WsGitPreparePullRequestThreadRpc, diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts new file mode 100644 index 0000000000..7beb7a75de --- /dev/null +++ b/packages/shared/src/git.test.ts @@ -0,0 +1,67 @@ +import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { applyGitStatusStreamEvent } from "./git"; + +describe("applyGitStatusStreamEvent", () => { + it("treats a remote-only update as a repository when local state is missing", () => { + const remote: GitStatusRemoteResult = { + hasUpstream: true, + aheadCount: 2, + behindCount: 1, + pr: null, + }; + + expect(applyGitStatusStreamEvent(null, { _tag: "remoteUpdated", remote })).toEqual({ + isRepo: true, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 2, + behindCount: 1, + pr: null, + }); + }); + + it("preserves local-only fields when applying a remote update", () => { + const current: GitStatusResult = { + isRepo: true, + hostingProvider: { + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: true, + workingTree: { + files: [{ path: "src/demo.ts", insertions: 1, deletions: 0 }], + insertions: 1, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + + const remote: GitStatusRemoteResult = { + hasUpstream: true, + aheadCount: 2, + behindCount: 1, + pr: null, + }; + + expect(applyGitStatusStreamEvent(current, { _tag: "remoteUpdated", remote })).toEqual({ + ...current, + hasUpstream: true, + aheadCount: 2, + behindCount: 1, + pr: null, + }); + }); +}); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 90bc655a76..16171315b7 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -1,4 +1,11 @@ -import type { GitBranch } from "@t3tools/contracts"; +import type { + GitBranch, + GitHostingProvider, + GitStatusLocalResult, + GitStatusRemoteResult, + GitStatusResult, + GitStatusStreamEvent, +} from "@t3tools/contracts"; /** * Sanitize an arbitrary string into a valid, lowercase git branch fragment. @@ -119,3 +126,133 @@ export function dedupeRemoteBranchesWithLocalMatches( return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); }); } + +function parseGitRemoteHost(remoteUrl: string): string | null { + const trimmed = remoteUrl.trim(); + if (trimmed.length === 0) { + return null; + } + + if (trimmed.startsWith("git@")) { + const hostWithPath = trimmed.slice("git@".length); + const separatorIndex = hostWithPath.search(/[:/]/); + if (separatorIndex <= 0) { + return null; + } + return hostWithPath.slice(0, separatorIndex).toLowerCase(); + } + + try { + return new URL(trimmed).hostname.toLowerCase(); + } catch { + return null; + } +} + +function toBaseUrl(host: string): string { + return `https://${host}`; +} + +function isGitHubHost(host: string): boolean { + return host === "github.com" || host.includes("github"); +} + +function isGitLabHost(host: string): boolean { + return host === "gitlab.com" || host.includes("gitlab"); +} + +export function detectGitHostingProviderFromRemoteUrl( + remoteUrl: string, +): GitHostingProvider | null { + const host = parseGitRemoteHost(remoteUrl); + if (!host) { + return null; + } + + if (isGitHubHost(host)) { + return { + kind: "github", + name: host === "github.com" ? "GitHub" : "GitHub Self-Hosted", + baseUrl: toBaseUrl(host), + }; + } + + if (isGitLabHost(host)) { + return { + kind: "gitlab", + name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", + baseUrl: toBaseUrl(host), + }; + } + + return { + kind: "unknown", + name: host, + baseUrl: toBaseUrl(host), + }; +} + +const EMPTY_GIT_STATUS_REMOTE: GitStatusRemoteResult = { + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + +export function mergeGitStatusParts( + local: GitStatusLocalResult, + remote: GitStatusRemoteResult | null, +): GitStatusResult { + return { + ...local, + ...(remote ?? EMPTY_GIT_STATUS_REMOTE), + }; +} + +function toRemoteStatusPart(status: GitStatusResult): GitStatusRemoteResult { + return { + hasUpstream: status.hasUpstream, + aheadCount: status.aheadCount, + behindCount: status.behindCount, + pr: status.pr, + }; +} + +function toLocalStatusPart(status: GitStatusResult): GitStatusLocalResult { + return { + isRepo: status.isRepo, + ...(status.hostingProvider ? { hostingProvider: status.hostingProvider } : {}), + hasOriginRemote: status.hasOriginRemote, + isDefaultBranch: status.isDefaultBranch, + branch: status.branch, + hasWorkingTreeChanges: status.hasWorkingTreeChanges, + workingTree: status.workingTree, + }; +} + +export function applyGitStatusStreamEvent( + current: GitStatusResult | null, + event: GitStatusStreamEvent, +): GitStatusResult { + switch (event._tag) { + case "snapshot": + return mergeGitStatusParts(event.local, event.remote); + case "localUpdated": + return mergeGitStatusParts(event.local, current ? toRemoteStatusPart(current) : null); + case "remoteUpdated": + if (current === null) { + return mergeGitStatusParts( + { + isRepo: true, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }, + event.remote, + ); + } + return mergeGitStatusParts(toLocalStatusPart(current), event.remote); + } +} From 340dbbb36e5198aa6a9a1bc1b20dbf5b86d3adb9 Mon Sep 17 00:00:00 2001 From: legs <145564979+justsomelegs@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:19:27 +0100 Subject: [PATCH 04/26] fix(web): unwrap windows shell command wrappers (#1719) --- .../src/components/chat/MessagesTimeline.tsx | 65 +++++-- apps/web/src/session-logic.test.ts | 91 +++++++++ apps/web/src/session-logic.ts | 181 ++++++++++++++++-- 3 files changed, 312 insertions(+), 25 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e823569c13..9be521b3be 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -48,6 +48,7 @@ import { type MessagesTimelineRow, } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { deriveDisplayedUserMessageState, type ParsedTerminalContextEntry, @@ -792,6 +793,16 @@ function workEntryPreview( : `${firstPath} +${workEntry.changedFiles!.length - 1} more`; } +function workEntryRawCommand( + workEntry: Pick, +): string | null { + const rawCommand = workEntry.rawCommand?.trim(); + if (!rawCommand || !workEntry.command) { + return null; + } + return rawCommand === workEntry.command.trim() ? null : rawCommand; +} + function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon { if (workEntry.requestKind === "command") return TerminalIcon; if (workEntry.requestKind === "file-read") return EyeIcon; @@ -840,6 +851,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { const EntryIcon = workEntryIcon(workEntry); const heading = toolWorkEntryHeading(workEntry); const preview = workEntryPreview(workEntry); + const rawCommand = workEntryRawCommand(workEntry); const displayText = preview ? `${heading} - ${preview}` : heading; const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0; const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail; @@ -853,19 +865,46 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
-

- - {heading} - - {preview && - {preview}} -

+
+

+ + {heading} + + {preview && + (rawCommand ? ( + + + {" "} + - {preview} + + } + /> + +

+ {rawCommand} +
+ + + ) : ( + - {preview} + ))} +

+
{hasChangedFiles && !previewIsChangedFiles && ( diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index e05c3b5e93..1c976a53c3 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -709,6 +709,97 @@ describe("deriveWorkLogEntries", () => { expect(entry?.command).toBe("bun run lint"); }); + it("unwraps PowerShell command wrappers for displayed command text", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-windows-wrapper", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + data: { + item: { + command: "\"C:\\Program Files\\PowerShell\\7\\pwsh.exe\" -Command 'bun run lint'", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.command).toBe("bun run lint"); + expect(entry?.rawCommand).toBe( + "\"C:\\Program Files\\PowerShell\\7\\pwsh.exe\" -Command 'bun run lint'", + ); + }); + + it("unwraps PowerShell command wrappers from argv-style command payloads", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-windows-wrapper-argv", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + data: { + item: { + command: ["C:\\Program Files\\PowerShell\\7\\pwsh.exe", "-Command", "rg -n foo ."], + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.command).toBe("rg -n foo ."); + expect(entry?.rawCommand).toBe( + '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -Command "rg -n foo ."', + ); + }); + + it("extracts command text from command detail when structured command metadata is missing", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-windows-detail-fallback", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + detail: + '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoLogo -NoProfile -Command \'rg -n -F "new Date()" .\' ', + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.command).toBe('rg -n -F "new Date()" .'); + expect(entry?.rawCommand).toBe( + `"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoLogo -NoProfile -Command 'rg -n -F "new Date()" .'`, + ); + }); + + it("does not unwrap shell commands when no wrapper flag is present", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "command-tool-shell-script", + kind: "tool.completed", + summary: "Ran command", + payload: { + itemType: "command_execution", + data: { + item: { + command: "bash script.sh", + }, + }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.command).toBe("bash script.sh"); + expect(entry?.rawCommand).toBeUndefined(); + }); + it("keeps compact Codex tool metadata used for icons and labels", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index fc33827014..b818dc1065 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -38,6 +38,7 @@ export interface WorkLogEntry { label: string; detail?: string; command?: string; + rawCommand?: string; changedFiles?: ReadonlyArray; tone: "thinking" | "tool" | "info" | "error"; toolTitle?: string; @@ -489,7 +490,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo activity.payload && typeof activity.payload === "object" ? (activity.payload as Record) : null; - const command = extractToolCommand(payload); + const commandPreview = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); const title = extractToolTitle(payload); const entry: DerivedWorkLogEntry = { @@ -507,8 +508,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo entry.detail = detail; } } - if (command) { - entry.command = command; + if (commandPreview.command) { + entry.command = commandPreview.command; + } + if (commandPreview.rawCommand) { + entry.rawCommand = commandPreview.rawCommand; } if (changedFiles.length > 0) { entry.changedFiles = changedFiles; @@ -567,6 +571,7 @@ function mergeDerivedWorkLogEntries( const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles); const detail = next.detail ?? previous.detail; const command = next.command ?? previous.command; + const rawCommand = next.rawCommand ?? previous.rawCommand; const toolTitle = next.toolTitle ?? previous.toolTitle; const itemType = next.itemType ?? previous.itemType; const requestKind = next.requestKind ?? previous.requestKind; @@ -576,6 +581,7 @@ function mergeDerivedWorkLogEntries( ...next, ...(detail ? { detail } : {}), ...(command ? { command } : {}), + ...(rawCommand ? { rawCommand } : {}), ...(changedFiles.length > 0 ? { changedFiles } : {}), ...(toolTitle ? { toolTitle } : {}), ...(itemType ? { itemType } : {}), @@ -636,7 +642,121 @@ function asTrimmedString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } -function normalizeCommandValue(value: unknown): string | null { +function trimMatchingOuterQuotes(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith('"') && trimmed.endsWith('"')) + ) { + const unquoted = trimmed.slice(1, -1).trim(); + return unquoted.length > 0 ? unquoted : trimmed; + } + return trimmed; +} + +function executableBasename(value: string): string | null { + const trimmed = trimMatchingOuterQuotes(value); + if (trimmed.length === 0) { + return null; + } + const normalized = trimmed.replace(/\\/g, "/"); + const segments = normalized.split("/"); + const last = segments.at(-1)?.trim() ?? ""; + return last.length > 0 ? last.toLowerCase() : null; +} + +function splitExecutableAndRest(value: string): { executable: string; rest: string } | null { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return null; + } + + if (trimmed.startsWith('"') || trimmed.startsWith("'")) { + const quote = trimmed.charAt(0); + const closeIndex = trimmed.indexOf(quote, 1); + if (closeIndex <= 0) { + return null; + } + return { + executable: trimmed.slice(0, closeIndex + 1), + rest: trimmed.slice(closeIndex + 1).trim(), + }; + } + + const firstWhitespace = trimmed.search(/\s/); + if (firstWhitespace < 0) { + return { + executable: trimmed, + rest: "", + }; + } + + return { + executable: trimmed.slice(0, firstWhitespace), + rest: trimmed.slice(firstWhitespace).trim(), + }; +} + +const SHELL_WRAPPER_SPECS = [ + { + executables: ["pwsh", "pwsh.exe", "powershell", "powershell.exe"], + wrapperFlagPattern: /(?:^|\s)-command\s+/i, + }, + { + executables: ["cmd", "cmd.exe"], + wrapperFlagPattern: /(?:^|\s)\/c\s+/i, + }, + { + executables: ["bash", "sh", "zsh"], + wrapperFlagPattern: /(?:^|\s)-(?:l)?c\s+/i, + }, +] as const; + +function findShellWrapperSpec(shell: string) { + return SHELL_WRAPPER_SPECS.find((spec) => + (spec.executables as ReadonlyArray).includes(shell), + ); +} + +function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): string | null { + const match = wrapperFlagPattern.exec(value); + if (!match) { + return null; + } + + const command = value.slice(match.index + match[0].length).trim(); + if (command.length === 0) { + return null; + } + + const unwrapped = trimMatchingOuterQuotes(command); + return unwrapped.length > 0 ? unwrapped : null; +} + +function unwrapKnownShellCommandWrapper(value: string): string { + const split = splitExecutableAndRest(value); + if (!split || split.rest.length === 0) { + return value; + } + + const shell = executableBasename(split.executable); + if (!shell) { + return value; + } + + const spec = findShellWrapperSpec(shell); + if (!spec) { + return value; + } + + return unwrapCommandRemainder(split.rest, spec.wrapperFlagPattern) ?? value; +} + +function formatCommandArrayPart(value: string): string { + return /[\s"'`]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value; +} + +function formatCommandValue(value: unknown): string | null { const direct = asTrimmedString(value); if (direct) { return direct; @@ -647,21 +767,58 @@ function normalizeCommandValue(value: unknown): string | null { const parts = value .map((entry) => asTrimmedString(entry)) .filter((entry): entry is string => entry !== null); - return parts.length > 0 ? parts.join(" ") : null; + if (parts.length === 0) { + return null; + } + return parts.map((part) => formatCommandArrayPart(part)).join(" "); } -function extractToolCommand(payload: Record | null): string | null { +function normalizeCommandValue(value: unknown): string | null { + const formatted = formatCommandValue(value); + return formatted ? unwrapKnownShellCommandWrapper(formatted) : null; +} + +function toRawToolCommand(value: unknown, normalizedCommand: string | null): string | null { + const formatted = formatCommandValue(value); + if (!formatted || normalizedCommand === null) { + return null; + } + return formatted === normalizedCommand ? null : formatted; +} + +function extractToolCommand(payload: Record | null): { + command: string | null; + rawCommand: string | null; +} { const data = asRecord(payload?.data); const item = asRecord(data?.item); const itemResult = asRecord(item?.result); const itemInput = asRecord(item?.input); - const candidates = [ - normalizeCommandValue(item?.command), - normalizeCommandValue(itemInput?.command), - normalizeCommandValue(itemResult?.command), - normalizeCommandValue(data?.command), + const itemType = asTrimmedString(payload?.itemType); + const detail = asTrimmedString(payload?.detail); + const candidates: unknown[] = [ + item?.command, + itemInput?.command, + itemResult?.command, + data?.command, + itemType === "command_execution" && detail ? stripTrailingExitCode(detail).output : null, ]; - return candidates.find((candidate) => candidate !== null) ?? null; + + for (const candidate of candidates) { + const command = normalizeCommandValue(candidate); + if (!command) { + continue; + } + return { + command, + rawCommand: toRawToolCommand(candidate, command), + }; + } + + return { + command: null, + rawCommand: null, + }; } function extractToolTitle(payload: Record | null): string | null { From c6f57a106493e893233a38b3b1132978c942c88e Mon Sep 17 00:00:00 2001 From: sonder <168988030+heysonder@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:29:58 -0500 Subject: [PATCH 05/26] Rename "Chat" to "Build" in interaction mode toggle (#1769) Co-authored-by: Julius Marminge --- apps/web/src/components/ChatView.browser.tsx | 10 +++++----- apps/web/src/components/ChatView.tsx | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 24099fc7f6..1d9108aab9 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -874,7 +874,7 @@ async function expectComposerActionsContained(): Promise { } async function waitForInteractionModeButton( - expectedLabel: "Chat" | "Plan", + expectedLabel: "Build" | "Plan", ): Promise { return waitForElement( () => @@ -2111,7 +2111,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - const initialModeButton = await waitForInteractionModeButton("Chat"); + const initialModeButton = await waitForInteractionModeButton("Build"); expect(initialModeButton.title).toContain("enter plan mode"); window.dispatchEvent( @@ -2124,7 +2124,7 @@ describe("ChatView timeline estimator parity (full app)", () => { ); await waitForLayout(); - expect((await waitForInteractionModeButton("Chat")).title).toContain("enter plan mode"); + expect((await waitForInteractionModeButton("Build")).title).toContain("enter plan mode"); const composerEditor = await waitForComposerEditor(); composerEditor.focus(); @@ -2140,7 +2140,7 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( async () => { expect((await waitForInteractionModeButton("Plan")).title).toContain( - "return to normal chat mode", + "return to normal build mode", ); }, { timeout: 8_000, interval: 16 }, @@ -2157,7 +2157,7 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( async () => { - expect((await waitForInteractionModeButton("Chat")).title).toContain("enter plan mode"); + expect((await waitForInteractionModeButton("Build")).title).toContain("enter plan mode"); }, { timeout: 8_000, interval: 16 }, ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 185fa7c2ee..5d160f4bda 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1476,7 +1476,7 @@ export default function ChatView({ threadId }: ChatViewProps) { type: "slash-command", command: "default", label: "/default", - description: "Switch this thread back to normal chat mode", + description: "Switch this thread back to normal build mode", }, ] satisfies ReadonlyArray>; const query = composerTrigger.query.trim().toLowerCase(); @@ -4029,7 +4029,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
- {interactionMode === "plan" ? "Plan" : "Chat"} + {interactionMode === "plan" ? "Plan" : "Build"} From 226ed997e1a6493e6b29d5264e0b0f8173e7c630 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 15:29:11 -0700 Subject: [PATCH 06/26] Assign default capabilities to Codex custom models (#1793) --- .../src/provider/Layers/ClaudeProvider.ts | 24 ++++++++++----- .../src/provider/Layers/CodexProvider.ts | 29 ++++++++++++++----- apps/server/src/provider/providerSnapshot.ts | 4 ++- 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 761b795fe5..9feec28637 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -27,6 +27,14 @@ import { ClaudeProvider } from "../Services/ClaudeProvider"; import { ServerSettingsService } from "../../serverSettings"; import { ServerSettingsError } from "@t3tools/contracts"; +const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], +}; + const PROVIDER = "claudeAgent" as const; const BUILT_IN_MODELS: ReadonlyArray = [ { @@ -87,13 +95,8 @@ const BUILT_IN_MODELS: ReadonlyArray = [ export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities { const slug = model?.trim(); return ( - BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - } + BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? + DEFAULT_CLAUDE_MODEL_CAPABILITIES ); } @@ -450,7 +453,12 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( Effect.map((settings) => settings.providers.claudeAgent), ); const checkedAt = new Date().toISOString(); - const models = providerModelsFromSettings(BUILT_IN_MODELS, PROVIDER, claudeSettings.customModels); + const models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + claudeSettings.customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, + ); if (!claudeSettings.enabled) { return buildServerProvider({ diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 667bdf048b..3509fa9257 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -49,6 +49,19 @@ import { CodexProvider } from "../Services/CodexProvider"; import { ServerSettingsService } from "../../serverSettings"; import { ServerSettingsError } from "@t3tools/contracts"; +const DEFAULT_CODEX_MODEL_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + { value: "medium", label: "Medium" }, + { value: "low", label: "Low" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], +}; + const PROVIDER = "codex" as const; const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); const BUILT_IN_MODELS: ReadonlyArray = [ @@ -159,13 +172,8 @@ const BUILT_IN_MODELS: ReadonlyArray = [ export function getCodexModelCapabilities(model: string | null | undefined): ModelCapabilities { const slug = model?.trim(); return ( - BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - } + BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? + DEFAULT_CODEX_MODEL_CAPABILITIES ); } @@ -339,7 +347,12 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu Effect.map((settings) => settings.providers.codex), ); const checkedAt = new Date().toISOString(); - const models = providerModelsFromSettings(BUILT_IN_MODELS, PROVIDER, codexSettings.customModels); + const models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + codexSettings.customModels, + DEFAULT_CODEX_MODEL_CAPABILITIES, + ); if (!codexSettings.enabled) { return buildServerProvider({ diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index e1243c4bd0..4c80d78e20 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -1,4 +1,5 @@ import type { + ModelCapabilities, ServerProvider, ServerProviderAuth, ServerProviderModel, @@ -102,6 +103,7 @@ export function providerModelsFromSettings( builtInModels: ReadonlyArray, provider: ServerProvider["provider"], customModels: ReadonlyArray, + customModelCapabilities: ModelCapabilities, ): ReadonlyArray { const resolvedBuiltInModels = [...builtInModels]; const seen = new Set(resolvedBuiltInModels.map((model) => model.slug)); @@ -117,7 +119,7 @@ export function providerModelsFromSettings( slug: normalized, name: normalized, isCustom: true, - capabilities: null, + capabilities: customModelCapabilities, }); } From a221542921fd01271f67c6bfa2a0fe7f7ff78dc3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 16:06:36 -0700 Subject: [PATCH 07/26] Add project rename support in the sidebar (#1798) --- apps/web/src/components/Sidebar.tsx | 148 ++++++++++++++++++++++++---- apps/web/src/store.test.ts | 21 ++++ 2 files changed, 152 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d227e3a803..5b4da4655c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -267,8 +267,9 @@ interface SidebarThreadRowProps { renamingThreadId: ThreadId | null; renamingTitle: string; setRenamingTitle: (title: string) => void; - renamingInputRef: MutableRefObject; - renamingCommittedRef: MutableRefObject; + onRenamingInputMount: (element: HTMLInputElement | null) => void; + hasRenameCommitted: () => boolean; + markRenameCommitted: () => void; confirmingArchiveThreadId: ThreadId | null; setConfirmingArchiveThreadId: Dispatch>; confirmArchiveButtonRefs: MutableRefObject>; @@ -400,13 +401,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { {threadStatus && } {props.renamingThreadId === thread.id ? ( { - if (element && props.renamingInputRef.current !== element) { - props.renamingInputRef.current = element; - element.focus(); - element.select(); - } - }} + ref={props.onRenamingInputMount} className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5" value={props.renamingTitle} onChange={(event) => props.setRenamingTitle(event.target.value)} @@ -414,16 +409,16 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { event.stopPropagation(); if (event.key === "Enter") { event.preventDefault(); - props.renamingCommittedRef.current = true; + props.markRenameCommitted(); void props.commitRename(thread.id, props.renamingTitle, thread.title); } else if (event.key === "Escape") { event.preventDefault(); - props.renamingCommittedRef.current = true; + props.markRenameCommitted(); props.cancelRename(); } }} onBlur={() => { - if (!props.renamingCommittedRef.current) { + if (!props.hasRenameCommitted()) { void props.commitRename(thread.id, props.renamingTitle, thread.title); } }} @@ -718,6 +713,8 @@ export default function Sidebar() { const [isAddingProject, setIsAddingProject] = useState(false); const [addProjectError, setAddProjectError] = useState(null); const addProjectInputRef = useRef(null); + const [renamingProjectId, setRenamingProjectId] = useState(null); + const [renamingProjectTitle, setRenamingProjectTitle] = useState(""); const [renamingThreadId, setRenamingThreadId] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState(null); @@ -725,6 +722,8 @@ export default function Sidebar() { ReadonlySet >(() => new Set()); const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); + const projectRenamingCommittedRef = useRef(false); + const projectRenamingInputRef = useRef(null); const renamingCommittedRef = useRef(false); const renamingInputRef = useRef(null); const confirmArchiveButtonRefs = useRef(new Map()); @@ -937,6 +936,28 @@ export default function Sidebar() { renamingInputRef.current = null; }, []); + const handleRenamingInputMount = useCallback((element: HTMLInputElement | null) => { + if (element && renamingInputRef.current !== element) { + renamingInputRef.current = element; + element.focus(); + element.select(); + return; + } + if (element === null && renamingInputRef.current !== null) { + renamingInputRef.current = null; + } + }, []); + + const hasRenameCommitted = useCallback(() => renamingCommittedRef.current, []); + const markRenameCommitted = useCallback(() => { + renamingCommittedRef.current = true; + }, []); + + const cancelProjectRename = useCallback(() => { + setRenamingProjectId(null); + projectRenamingInputRef.current = null; + }, []); + const commitRename = useCallback( async (threadId: ThreadId, newTitle: string, originalTitle: string) => { const finishRename = () => { @@ -984,6 +1005,53 @@ export default function Sidebar() { [], ); + const commitProjectRename = useCallback( + async (projectId: ProjectId, newTitle: string, originalTitle: string) => { + const finishRename = () => { + setRenamingProjectId((current) => { + if (current !== projectId) return current; + projectRenamingInputRef.current = null; + return null; + }); + }; + + const trimmed = newTitle.trim(); + if (trimmed.length === 0) { + toastManager.add({ + type: "warning", + title: "Project title cannot be empty", + }); + finishRename(); + return; + } + if (trimmed === originalTitle) { + finishRename(); + return; + } + const api = readNativeApi(); + if (!api) { + finishRename(); + return; + } + try { + await api.orchestration.dispatchCommand({ + type: "project.meta.update", + commandId: newCommandId(), + projectId, + title: trimmed, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to rename project", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + finishRename(); + }, + [], + ); + const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId; }>({ @@ -1040,6 +1108,8 @@ export default function Sidebar() { ); if (clicked === "rename") { + setRenamingProjectId(null); + projectRenamingInputRef.current = null; setRenamingThreadId(threadId); setRenamingTitle(thread.title); renamingCommittedRef.current = false; @@ -1206,11 +1276,20 @@ export default function Sidebar() { const clicked = await api.contextMenu.show( [ + { id: "rename", label: "Rename project" }, { id: "copy-path", label: "Copy Project Path" }, { id: "delete", label: "Remove project", destructive: true }, ], position, ); + if (clicked === "rename") { + setRenamingThreadId(null); + renamingInputRef.current = null; + setRenamingProjectId(projectId); + setRenamingProjectTitle(project.name); + projectRenamingCommittedRef.current = false; + return; + } if (clicked === "copy-path") { copyPathToClipboard(project.cwd, { path: project.cwd }); return; @@ -1602,9 +1681,43 @@ export default function Sidebar() { /> )} - - {project.name} - + {renamingProjectId === project.id ? ( + { + if (element && projectRenamingInputRef.current !== element) { + projectRenamingInputRef.current = element; + element.focus(); + element.select(); + } + }} + className="min-w-0 flex-1 truncate rounded border border-ring bg-transparent px-0.5 text-xs font-medium text-foreground/90 outline-none" + value={renamingProjectTitle} + onChange={(event) => setRenamingProjectTitle(event.target.value)} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + projectRenamingCommittedRef.current = true; + void commitProjectRename(project.id, renamingProjectTitle, project.name); + } else if (event.key === "Escape") { + event.preventDefault(); + projectRenamingCommittedRef.current = true; + cancelProjectRename(); + } + }} + onBlur={() => { + if (!projectRenamingCommittedRef.current) { + void commitProjectRename(project.id, renamingProjectTitle, project.name); + } + }} + onClick={(event) => event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + /> + ) : ( + + {project.name} + + )} { expect(next.bootstrapComplete).toBe(false); }); + it("updates the existing project title when project.meta-updated arrives", () => { + const projectId = ProjectId.makeUnsafe("project-1"); + const state = makeState( + makeThread({ + projectId, + }), + ); + + const next = applyOrchestrationEvent( + state, + makeEvent("project.meta-updated", { + projectId, + title: "Renamed Project", + updatedAt: "2026-02-27T00:00:01.000Z", + }), + ); + + expect(next.projects[0]?.name).toBe("Renamed Project"); + expect(next.projects[0]?.updatedAt).toBe("2026-02-27T00:00:01.000Z"); + }); + it("preserves state identity for no-op project and thread deletes", () => { const thread = makeThread(); const state = makeState(thread); From 11d456f6c8404be6617b7299a9db9ad5538ee061 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 6 Apr 2026 16:08:46 -0700 Subject: [PATCH 08/26] Support multi-select pending user inputs (#1797) --- .../src/provider/Layers/CodexAdapter.test.ts | 2 + .../src/provider/Layers/CodexAdapter.ts | 2 + apps/web/src/components/ChatView.browser.tsx | 212 +++++++++++++++--- apps/web/src/components/ChatView.tsx | 18 +- .../chat/ComposerPendingUserInputPanel.tsx | 42 ++-- apps/web/src/pendingUserInput.test.ts | 168 +++++++++----- apps/web/src/pendingUserInput.ts | 78 +++++-- apps/web/src/session-logic.test.ts | 4 + apps/web/src/session-logic.ts | 1 + 9 files changed, 398 insertions(+), 129 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index b5eb873e85..db91e8da0d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -723,6 +723,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { description: "Allow workspace writes only", }, ], + multiSelect: true, }, ], }, @@ -749,6 +750,7 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { if (events[0]?.type === "user-input.requested") { assert.equal(events[0].requestId, "req-user-input-1"); assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode"); + assert.equal(events[0].payload.questions[0]?.multiSelect, true); } assert.equal(events[1]?.type, "user-input.resolved"); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 8b9f3b59e7..957ae1b2bc 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -382,6 +382,7 @@ function toUserInputQuestions(payload: Record | undefined) { header, question: prompt, options, + multiSelect: question.multiSelect === true, }; }) .filter( @@ -392,6 +393,7 @@ function toUserInputQuestions(payload: Record | undefined) { header: string; question: string; options: Array<{ label: string; description: string }>; + multiSelect: boolean; } => question !== undefined, ); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 1d9108aab9..8e848205da 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -12,6 +12,7 @@ import { type ServerLifecycleWelcomePayload, type ThreadId, type TurnId, + type UserInputQuestion, WS_METHODS, OrchestrationSessionStatus, DEFAULT_SERVER_SETTINGS, @@ -541,12 +542,51 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } -function createSnapshotWithPendingUserInput(): OrchestrationReadModel { +function createSnapshotWithPendingUserInput(options?: { + questions?: ReadonlyArray; +}): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-pending-input-target" as MessageId, targetText: "question thread", }); + const questions = + options?.questions ?? + ([ + { + id: "scope", + header: "Scope", + question: "What should this change cover?", + options: [ + { + label: "Tight", + description: "Touch only the footer layout logic.", + }, + { + label: "Broad", + description: "Also adjust the related composer controls.", + }, + ], + multiSelect: false, + }, + { + id: "risk", + header: "Risk", + question: "How aggressive should the imaginary plan be?", + options: [ + { + label: "Conservative", + description: "Favor reliability and low-risk changes.", + }, + { + label: "Balanced", + description: "Mix quick wins with one structural improvement.", + }, + ], + multiSelect: false, + }, + ] satisfies ReadonlyArray); + return { ...snapshot, threads: snapshot.threads.map((thread) => @@ -561,38 +601,7 @@ function createSnapshotWithPendingUserInput(): OrchestrationReadModel { summary: "User input requested", payload: { requestId: "req-browser-user-input", - questions: [ - { - id: "scope", - header: "Scope", - question: "What should this change cover?", - options: [ - { - label: "Tight", - description: "Touch only the footer layout logic.", - }, - { - label: "Broad", - description: "Also adjust the related composer controls.", - }, - ], - }, - { - id: "risk", - header: "Risk", - question: "How aggressive should the imaginary plan be?", - options: [ - { - label: "Conservative", - description: "Favor reliability and low-risk changes.", - }, - { - label: "Balanced", - description: "Mix quick wins with one structural improvement.", - }, - ], - }, - ], + questions, }, turnId: null, sequence: 1, @@ -2902,6 +2911,143 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("does not trigger numeric option shortcuts while the composer is focused", async () => { + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createSnapshotWithPendingUserInput(), + }); + + try { + const composerEditor = await waitForComposerEditor(); + composerEditor.focus(); + + const event = new KeyboardEvent("keydown", { + key: "2", + bubbles: true, + cancelable: true, + }); + composerEditor.dispatchEvent(event); + await waitForLayout(); + + expect(event.defaultPrevented).toBe(false); + expect(document.body.textContent).toContain("What should this change cover?"); + expect(document.body.textContent).not.toContain( + "How aggressive should the imaginary plan be?", + ); + await waitForButtonByText("Next question"); + } finally { + await mounted.cleanup(); + } + }); + + it("submits multi-select questionnaire answers as arrays", async () => { + const mounted = await mountChatView({ + viewport: WIDE_FOOTER_VIEWPORT, + snapshot: createSnapshotWithPendingUserInput({ + questions: [ + { + id: "scope", + header: "Scope", + question: "Which areas should this change cover?", + options: [ + { + label: "Server", + description: "Touch server orchestration.", + }, + { + label: "Web", + description: "Touch the browser UI.", + }, + ], + multiSelect: true, + }, + { + id: "risk", + header: "Risk", + question: "How aggressive should the imaginary plan be?", + options: [ + { + label: "Balanced", + description: "Mix quick wins with one structural improvement.", + }, + ], + multiSelect: false, + }, + ], + }), + resolveRpc: (body) => { + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + return undefined; + }, + }); + + try { + const serverOption = await waitForButtonContainingText("Server"); + serverOption.click(); + await waitForLayout(); + + expect(document.body.textContent).toContain("Which areas should this change cover?"); + + const webOption = await waitForButtonContainingText("Web"); + webOption.click(); + await waitForLayout(); + + expect(document.body.textContent).toContain("Which areas should this change cover?"); + + const nextButton = await waitForButtonByText("Next question"); + expect(nextButton.disabled).toBe(false); + nextButton.click(); + + await vi.waitFor( + () => { + expect(document.body.textContent).toContain( + "How aggressive should the imaginary plan be?", + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + const balancedOption = await waitForButtonContainingText("Balanced"); + balancedOption.click(); + + const submitButton = await waitForButtonByText("Submit answers"); + expect(submitButton.disabled).toBe(false); + submitButton.click(); + + await vi.waitFor( + () => { + const dispatchRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.user-input.respond", + ) as + | { + _tag: string; + type?: string; + answers?: Record; + } + | undefined; + + expect(dispatchRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + type: "thread.user-input.respond", + answers: { + scope: ["Server", "Web"], + risk: "Balanced", + }, + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps plan follow-up footer actions fused and aligned after a real resize", async () => { const mounted = await mountChatView({ viewport: WIDE_FOOTER_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5d160f4bda..7c649b5003 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -61,6 +61,7 @@ import { buildPendingUserInputAnswers, derivePendingUserInputProgress, setPendingUserInputCustomAnswer, + togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { useStore } from "../store"; @@ -3207,19 +3208,24 @@ export default function ChatView({ threadId }: ChatViewProps) { [activePendingUserInput], ); - const onSelectActivePendingUserInputOption = useCallback( + const onToggleActivePendingUserInputOption = useCallback( (questionId: string, optionLabel: string) => { if (!activePendingUserInput) { return; } + const question = activePendingUserInput.questions.find((entry) => entry.id === questionId); + if (!question) { + return; + } setPendingUserInputAnswersByRequestId((existing) => ({ ...existing, [activePendingUserInput.requestId]: { ...existing[activePendingUserInput.requestId], - [questionId]: { - selectedOptionLabel: optionLabel, - customAnswer: "", - }, + [questionId]: togglePendingUserInputOptionSelection( + question, + existing[activePendingUserInput.requestId]?.[questionId], + optionLabel, + ), }, })); promptRef.current = ""; @@ -4063,7 +4069,7 @@ export default function ChatView({ threadId }: ChatViewProps) { respondingRequestIds={respondingRequestIds} answers={activePendingDraftAnswers} questionIndex={activePendingQuestionIndex} - onSelectOption={onSelectActivePendingUserInputOption} + onToggleOption={onToggleActivePendingUserInputOption} onAdvance={onAdvanceActivePendingUserInput} />
diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index c8cad7bf36..2a1efb4b20 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -13,7 +13,7 @@ interface PendingUserInputPanelProps { respondingRequestIds: ApprovalRequestId[]; answers: Record; questionIndex: number; - onSelectOption: (questionId: string, optionLabel: string) => void; + onToggleOption: (questionId: string, optionLabel: string) => void; onAdvance: () => void; } @@ -22,7 +22,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn respondingRequestIds, answers, questionIndex, - onSelectOption, + onToggleOption, onAdvance, }: PendingUserInputPanelProps) { if (pendingUserInputs.length === 0) return null; @@ -36,7 +36,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn isResponding={respondingRequestIds.includes(activePrompt.requestId)} answers={answers} questionIndex={questionIndex} - onSelectOption={onSelectOption} + onToggleOption={onToggleOption} onAdvance={onAdvance} /> ); @@ -47,14 +47,14 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( isResponding, answers, questionIndex, - onSelectOption, + onToggleOption, onAdvance, }: { prompt: PendingUserInput; isResponding: boolean; answers: Record; questionIndex: number; - onSelectOption: (questionId: string, optionLabel: string) => void; + onToggleOption: (questionId: string, optionLabel: string) => void; onAdvance: () => void; }) { const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); @@ -70,9 +70,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }; }, []); - const selectOptionAndAutoAdvance = useCallback( + const handleOptionSelection = useCallback( (questionId: string, optionLabel: string) => { - onSelectOption(questionId, optionLabel); + onToggleOption(questionId, optionLabel); + if (activeQuestion?.multiSelect) { + return; + } if (autoAdvanceTimerRef.current !== null) { window.clearTimeout(autoAdvanceTimerRef.current); } @@ -81,13 +84,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( onAdvance(); }, 200); }, - [onSelectOption, onAdvance], + [activeQuestion?.multiSelect, onAdvance, onToggleOption], ); - // Keyboard shortcut: number keys 1-9 select corresponding option and auto-advance. - // Works even when the Lexical composer (contenteditable) has focus — the composer - // doubles as a custom-answer field during user input, and when it's empty the digit - // keys should pick options instead of typing into the editor. + // Keyboard shortcut: number keys 1-9 select corresponding options when focus is + // outside editable fields. Multi-select prompts toggle options in place; single- + // select prompts keep the existing auto-advance behavior. useEffect(() => { if (!activeQuestion || isResponding) return; const handler = (event: globalThis.KeyboardEvent) => { @@ -96,11 +98,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { return; } - // If the user has started typing a custom answer in the contenteditable - // composer, let digit keys pass through so they can type numbers. if (target instanceof HTMLElement && target.isContentEditable) { - const hasCustomText = progress.customAnswer.length > 0; - if (hasCustomText) return; + return; } const digit = Number.parseInt(event.key, 10); if (Number.isNaN(digit) || digit < 1 || digit > 9) return; @@ -109,11 +108,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const option = activeQuestion.options[optionIndex]; if (!option) return; event.preventDefault(); - selectOptionAndAutoAdvance(activeQuestion.id, option.label); + handleOptionSelection(activeQuestion.id, option.label); }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, [activeQuestion, isResponding, selectOptionAndAutoAdvance, progress.customAnswer.length]); + }, [activeQuestion, handleOptionSelection, isResponding]); if (!activeQuestion) { return null; @@ -134,16 +133,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(

{activeQuestion.question}

+ {activeQuestion.multiSelect ? ( +

Select one or more options.

+ ) : null}
{activeQuestion.options.map((option, index) => { - const isSelected = progress.selectedOptionLabel === option.label; + const isSelected = progress.selectedOptionLabels.includes(option.label); const shortcutKey = index < 9 ? index + 1 : null; return (
From 1ec346c22e2a4df96721502c66c16c98b1f4f14d Mon Sep 17 00:00:00 2001 From: legs <145564979+justsomelegs@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:25:49 +0100 Subject: [PATCH 13/26] Refactor web stores into atomic slices ready to split ChatView (#1708) --- CLAUDE.md | 2 +- apps/web/src/components/BranchToolbar.tsx | 14 +- apps/web/src/components/ChatView.browser.tsx | 18 +- .../web/src/components/ChatView.logic.test.ts | 204 ++-- apps/web/src/components/ChatView.logic.ts | 6 +- apps/web/src/components/ChatView.tsx | 114 +-- apps/web/src/components/DiffPanel.tsx | 7 +- .../components/GitActionsControl.browser.tsx | 8 +- apps/web/src/components/GitActionsControl.tsx | 2 +- .../components/KeybindingsToast.browser.tsx | 18 +- apps/web/src/components/Sidebar.tsx | 16 +- .../components/chat/MessagesTimeline.test.tsx | 2 +- .../components/settings/SettingsPanels.tsx | 19 +- apps/web/src/hooks/useHandleNewThread.ts | 8 +- apps/web/src/hooks/useThreadActions.ts | 13 +- apps/web/src/routes/__root.tsx | 15 +- apps/web/src/routes/_chat.$threadId.tsx | 2 +- apps/web/src/store.test.ts | 318 ++++-- apps/web/src/store.ts | 908 +++++++++++++----- apps/web/src/storeSelectors.ts | 158 ++- apps/web/src/types.ts | 21 + 21 files changed, 1329 insertions(+), 544 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 47dc3e3d86..c317064255 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md \ No newline at end of file +AGENTS.md diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 79c453c0f5..92929f78fc 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -34,15 +34,15 @@ export default function BranchToolbar({ onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarProps) { - const threads = useStore((store) => store.threads); - const projects = useStore((store) => store.projects); + const serverThread = useStore((store) => store.threadShellById[threadId]); + const serverSession = useStore((store) => store.threadSessionById[threadId] ?? null); const setThreadBranchAction = useStore((store) => store.setThreadBranch); const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - - const serverThread = threads.find((thread) => thread.id === threadId); const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; - const activeProject = projects.find((project) => project.id === activeProjectId); + const activeProject = useStore((store) => + activeProjectId ? store.projectById[activeProjectId] : undefined, + ); const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; @@ -60,7 +60,7 @@ export default function BranchToolbar({ const api = readNativeApi(); // If the effective cwd is about to change, stop the running session so the // next message creates a new one with the correct cwd. - if (serverThread?.session && worktreePath !== activeWorktreePath && api) { + if (serverSession && worktreePath !== activeWorktreePath && api) { void api.orchestration .dispatchCommand({ type: "thread.session.stop", @@ -96,7 +96,7 @@ export default function BranchToolbar({ }, [ activeThreadId, - serverThread?.session, + serverSession, activeWorktreePath, hasServerThread, setThreadBranchAction, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 8e848205da..74968e1b3c 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1176,8 +1176,22 @@ describe("ChatView timeline estimator parity (full app)", () => { stickyActiveProvider: null, }); useStore.setState({ - projects: [], - threads: [], + projectIds: [], + projectById: {}, + threadIds: [], + threadIdsByProjectId: {}, + threadShellById: {}, + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + sidebarThreadSummaryById: {}, bootstrapComplete: false, }); useTerminalStateStore.persist.clearStorage(); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 8d49bc07f2..cad565247d 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,6 +1,7 @@ import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useStore } from "../store"; +import { type Thread } from "../types"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, @@ -178,7 +179,7 @@ const makeThread = (input?: { startedAt: string | null; completedAt: string | null; } | null; -}) => ({ +}): Thread => ({ id: input?.id ?? ThreadId.makeUnsafe("thread-1"), codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), @@ -205,94 +206,172 @@ const makeThread = (input?: { activities: [], }); +function setStoreThreads(threads: ReadonlyArray>) { + const projectId = ProjectId.makeUnsafe("project-1"); + useStore.setState({ + projectIds: [projectId], + projectById: { + [projectId]: { + id: projectId, + name: "Project", + cwd: "/tmp/project", + defaultModelSelection: { + provider: "codex", + model: "gpt-5.4", + }, + createdAt: "2026-03-29T00:00:00.000Z", + updatedAt: "2026-03-29T00:00:00.000Z", + scripts: [], + }, + }, + threadIds: threads.map((thread) => thread.id), + threadIdsByProjectId: { + [projectId]: threads.map((thread) => thread.id), + }, + threadShellById: Object.fromEntries( + threads.map((thread) => [ + thread.id, + { + id: thread.id, + codexThreadId: thread.codexThreadId, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + error: thread.error, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + }, + ]), + ), + threadSessionById: Object.fromEntries(threads.map((thread) => [thread.id, thread.session])), + threadTurnStateById: Object.fromEntries( + threads.map((thread) => [ + thread.id, + { + latestTurn: thread.latestTurn, + ...(thread.pendingSourceProposedPlan + ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } + : {}), + }, + ]), + ), + messageIdsByThreadId: Object.fromEntries( + threads.map((thread) => [thread.id, thread.messages.map((message) => message.id)]), + ), + messageByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + Object.fromEntries(thread.messages.map((message) => [message.id, message])), + ]), + ), + activityIdsByThreadId: Object.fromEntries( + threads.map((thread) => [thread.id, thread.activities.map((activity) => activity.id)]), + ), + activityByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + Object.fromEntries(thread.activities.map((activity) => [activity.id, activity])), + ]), + ), + proposedPlanIdsByThreadId: Object.fromEntries( + threads.map((thread) => [thread.id, thread.proposedPlans.map((plan) => plan.id)]), + ), + proposedPlanByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + Object.fromEntries(thread.proposedPlans.map((plan) => [plan.id, plan])), + ]), + ), + turnDiffIdsByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + thread.turnDiffSummaries.map((summary) => summary.turnId), + ]), + ), + turnDiffSummaryByThreadId: Object.fromEntries( + threads.map((thread) => [ + thread.id, + Object.fromEntries(thread.turnDiffSummaries.map((summary) => [summary.turnId, summary])), + ]), + ), + sidebarThreadSummaryById: {}, + bootstrapComplete: true, + }); +} + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); - useStore.setState((state) => ({ - ...state, - projects: [], - threads: [], - bootstrapComplete: true, - })); + setStoreThreads([]); }); describe("waitForStartedServerThread", () => { it("resolves immediately when the thread is already started", async () => { const threadId = ThreadId.makeUnsafe("thread-started"); - useStore.setState((state) => ({ - ...state, - threads: [ - makeThread({ - id: threadId, - latestTurn: { - turnId: TurnId.makeUnsafe("turn-started"), - state: "running", - requestedAt: "2026-03-29T00:00:01.000Z", - startedAt: "2026-03-29T00:00:01.000Z", - completedAt: null, - }, - }), - ], - })); + setStoreThreads([ + makeThread({ + id: threadId, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-started"), + state: "running", + requestedAt: "2026-03-29T00:00:01.000Z", + startedAt: "2026-03-29T00:00:01.000Z", + completedAt: null, + }, + }), + ]); await expect(waitForStartedServerThread(threadId)).resolves.toBe(true); }); it("waits for the thread to start via subscription updates", async () => { const threadId = ThreadId.makeUnsafe("thread-wait"); - useStore.setState((state) => ({ - ...state, - threads: [makeThread({ id: threadId })], - })); + setStoreThreads([makeThread({ id: threadId })]); const promise = waitForStartedServerThread(threadId, 500); - useStore.setState((state) => ({ - ...state, - threads: [ - makeThread({ - id: threadId, - latestTurn: { - turnId: TurnId.makeUnsafe("turn-started"), - state: "running", - requestedAt: "2026-03-29T00:00:01.000Z", - startedAt: "2026-03-29T00:00:01.000Z", - completedAt: null, - }, - }), - ], - })); + setStoreThreads([ + makeThread({ + id: threadId, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-started"), + state: "running", + requestedAt: "2026-03-29T00:00:01.000Z", + startedAt: "2026-03-29T00:00:01.000Z", + completedAt: null, + }, + }), + ]); await expect(promise).resolves.toBe(true); }); it("handles the thread starting between the initial read and subscription setup", async () => { const threadId = ThreadId.makeUnsafe("thread-race"); - useStore.setState((state) => ({ - ...state, - threads: [makeThread({ id: threadId })], - })); + setStoreThreads([makeThread({ id: threadId })]); const originalSubscribe = useStore.subscribe.bind(useStore); let raced = false; vi.spyOn(useStore, "subscribe").mockImplementation((listener) => { if (!raced) { raced = true; - useStore.setState((state) => ({ - ...state, - threads: [ - makeThread({ - id: threadId, - latestTurn: { - turnId: TurnId.makeUnsafe("turn-race"), - state: "running", - requestedAt: "2026-03-29T00:00:01.000Z", - startedAt: "2026-03-29T00:00:01.000Z", - completedAt: null, - }, - }), - ], - })); + setStoreThreads([ + makeThread({ + id: threadId, + latestTurn: { + turnId: TurnId.makeUnsafe("turn-race"), + state: "running", + requestedAt: "2026-03-29T00:00:01.000Z", + startedAt: "2026-03-29T00:00:01.000Z", + completedAt: null, + }, + }), + ]); } return originalSubscribe(listener); }); @@ -304,10 +383,7 @@ describe("waitForStartedServerThread", () => { vi.useFakeTimers(); const threadId = ThreadId.makeUnsafe("thread-timeout"); - useStore.setState((state) => ({ - ...state, - threads: [makeThread({ id: threadId })], - })); + setStoreThreads([makeThread({ id: threadId })]); const promise = waitForStartedServerThread(threadId, 500); await vi.advanceTimersByTimeAsync(500); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index ca2a671c11..6a0aa4d0c8 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -3,7 +3,7 @@ import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } import { randomUUID } from "~/lib/utils"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import { Schema } from "effect"; -import { useStore } from "../store"; +import { selectThreadById, useStore } from "../store"; import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, @@ -202,7 +202,7 @@ export async function waitForStartedServerThread( threadId: ThreadId, timeoutMs = 1_000, ): Promise { - const getThread = () => useStore.getState().threads.find((thread) => thread.id === threadId); + const getThread = () => selectThreadById(threadId)(useStore.getState()); const thread = getThread(); if (threadHasStarted(thread)) { @@ -225,7 +225,7 @@ export async function waitForStartedServerThread( }; const unsubscribe = useStore.subscribe((state) => { - if (!threadHasStarted(state.threads.find((thread) => thread.id === threadId))) { + if (!threadHasStarted(selectThreadById(threadId)(state))) { return; } finish(true); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7c649b5003..55dd63761c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -65,7 +65,7 @@ import { type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { useStore } from "../store"; -import { useProjectById, useThreadById } from "../storeSelectors"; +import { createThreadSelector } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, @@ -83,8 +83,6 @@ import { type Thread, type TurnDiffSummary, } from "../types"; -import { LRUCache } from "../lib/lruCache"; - import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; @@ -211,77 +209,28 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record; -const MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES = 500; -const MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES = 512 * 1024; -const threadPlanCatalogCache = new LRUCache<{ - proposedPlans: Thread["proposedPlans"]; - entry: ThreadPlanCatalogEntry; -}>(MAX_THREAD_PLAN_CATALOG_CACHE_ENTRIES, MAX_THREAD_PLAN_CATALOG_CACHE_MEMORY_BYTES); - -function estimateThreadPlanCatalogEntrySize(thread: Thread): number { - return Math.max( - 64, - thread.id.length + - thread.proposedPlans.reduce( - (total, plan) => - total + - plan.id.length + - plan.planMarkdown.length + - plan.updatedAt.length + - (plan.turnId?.length ?? 0), - 0, - ), - ); -} - -function toThreadPlanCatalogEntry(thread: Thread): ThreadPlanCatalogEntry { - const cached = threadPlanCatalogCache.get(thread.id); - if (cached && cached.proposedPlans === thread.proposedPlans) { - return cached.entry; - } - - const entry: ThreadPlanCatalogEntry = { - id: thread.id, - proposedPlans: thread.proposedPlans, - }; - threadPlanCatalogCache.set( - thread.id, - { - proposedPlans: thread.proposedPlans, - entry, - }, - estimateThreadPlanCatalogEntrySize(thread), - ); - return entry; -} - function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { - const selector = useMemo(() => { - let previousThreads: Array | null = null; - let previousEntries: ThreadPlanCatalogEntry[] = []; + const threadShellById = useStore((state) => state.threadShellById); + const proposedPlanIdsByThreadId = useStore((state) => state.proposedPlanIdsByThreadId); + const proposedPlanByThreadId = useStore((state) => state.proposedPlanByThreadId); - return (state: { threads: Thread[] }): ThreadPlanCatalogEntry[] => { - const nextThreads = threadIds.map((threadId) => - state.threads.find((thread) => thread.id === threadId), - ); - const cachedThreads = previousThreads; - if ( - cachedThreads && - nextThreads.length === cachedThreads.length && - nextThreads.every((thread, index) => thread === cachedThreads[index]) - ) { - return previousEntries; - } + return useMemo( + () => + threadIds.flatMap((threadId) => { + if (!threadShellById[threadId]) { + return []; + } - previousThreads = nextThreads; - previousEntries = nextThreads.flatMap((thread) => - thread ? [toThreadPlanCatalogEntry(thread)] : [], - ); - return previousEntries; - }; - }, [threadIds]); + const proposedPlans = + proposedPlanIdsByThreadId[threadId]?.flatMap((planId) => { + const plan = proposedPlanByThreadId[threadId]?.[planId]; + return plan ? [plan] : []; + }) ?? []; - return useStore(selector); + return [{ id: threadId, proposedPlans }]; + }), + [proposedPlanByThreadId, proposedPlanIdsByThreadId, threadIds, threadShellById], + ); } function formatOutgoingPrompt(params: { @@ -429,11 +378,17 @@ function PersistentThreadTerminalDrawer({ closeShortcutLabel, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { - const serverThread = useThreadById(threadId); + const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId])); const draftThread = useComposerDraftStore( (store) => store.draftThreadsByThreadId[threadId] ?? null, ); - const project = useProjectById(serverThread?.projectId ?? draftThread?.projectId); + const project = useStore((state) => + serverThread?.projectId + ? state.projectById[serverThread.projectId] + : draftThread?.projectId + ? state.projectById[draftThread.projectId] + : undefined, + ); const terminalState = useTerminalStateStore((state) => selectThreadTerminalState(state.terminalStateByThreadId, threadId), ); @@ -576,7 +531,7 @@ function PersistentThreadTerminalDrawer({ } export default function ChatView({ threadId }: ChatViewProps) { - const serverThread = useThreadById(threadId); + const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId])); const setStoreThreadError = useStore((store) => store.setError); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore( @@ -751,14 +706,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); + const serverThreadIds = useStore((state) => state.threadIds); const storeServerTerminalLaunchContext = useTerminalStateStore( (s) => s.terminalLaunchContextByThreadId[threadId] ?? null, ); const storeClearTerminalLaunchContext = useTerminalStateStore( (s) => s.clearTerminalLaunchContext, ); - const threads = useStore((state) => state.threads); - const serverThreadIds = useMemo(() => threads.map((thread) => thread.id), [threads]); const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); const draftThreadIds = useMemo( () => Object.keys(draftThreadsByThreadId) as ThreadId[], @@ -819,7 +773,9 @@ export default function ChatView({ threadId }: ChatViewProps) { [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], ); - const fallbackDraftProject = useProjectById(draftThread?.projectId); + const fallbackDraftProject = useStore((state) => + draftThread?.projectId ? state.projectById[draftThread.projectId] : undefined, + ); const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); const localDraftThread = useMemo( () => @@ -884,7 +840,9 @@ export default function ChatView({ threadId }: ChatViewProps) { }); }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); - const activeProject = useProjectById(activeThread?.projectId); + const activeProject = useStore((state) => + activeThread?.projectId ? state.projectById[activeThread.projectId] : undefined, + ); const openPullRequestDialog = useCallback( (reference?: string) => { @@ -1603,7 +1561,7 @@ export default function ChatView({ threadId }: ChatViewProps) { (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; const nextError = sanitizeThreadErrorMessage(error); - if (useStore.getState().threads.some((thread) => thread.id === targetThreadId)) { + if (useStore.getState().threadShellById[targetThreadId] !== undefined) { setStoreThreadError(targetThreadId, nextError); return; } diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index ff216baed7..6b8875e4fc 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -30,6 +30,7 @@ import { buildPatchCacheKey } from "../lib/diffRendering"; import { resolveDiffThemeName } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useStore } from "../store"; +import { createThreadSelector } from "../storeSelectors"; import { useSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; @@ -181,12 +182,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); const diffOpen = diffSearch.diff === "1"; const activeThreadId = routeThreadId; - const activeThread = useStore((store) => - activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, + const activeThread = useStore( + useMemo(() => createThreadSelector(activeThreadId), [activeThreadId]), ); const activeProjectId = activeThread?.projectId ?? null; const activeProject = useStore((store) => - activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, + activeProjectId ? store.projectById[activeProjectId] : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; const gitStatusQuery = useGitStatus(activeCwd ?? null); diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index 92874f7404..ffdb01e9d5 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -141,10 +141,10 @@ vi.mock("~/store", () => ({ useStore: (selector: (state: unknown) => unknown) => selector({ setThreadBranch: setThreadBranchSpy, - threads: [ - { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null }, - { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null }, - ], + threadShellById: { + [THREAD_A]: { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null }, + [THREAD_B]: { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null }, + }, }), })); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index d641d4c36b..2c9222ee36 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -212,7 +212,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions [activeThreadId], ); const activeServerThread = useStore((store) => - activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, + activeThreadId ? store.threadShellById[activeThreadId] : undefined, ); const setThreadBranch = useStore((store) => store.setThreadBranch); const queryClient = useQueryClient(); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index fbbf9782b6..1ee13f460f 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -356,8 +356,22 @@ describe("Keybindings update toast", () => { projectDraftThreadIdByProjectId: {}, }); useStore.setState({ - projects: [], - threads: [], + projectIds: [], + projectById: {}, + threadIds: [], + threadIdsByProjectId: {}, + threadShellById: {}, + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + sidebarThreadSummaryById: {}, bootstrapComplete: false, }); }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5b4da4655c..74cdf6eefe 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -128,7 +128,6 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import { useSidebarThreadSummaryById } from "../storeSelectors"; import type { Project } from "../types"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { @@ -292,7 +291,7 @@ interface SidebarThreadRowProps { } function SidebarThreadRow(props: SidebarThreadRowProps) { - const thread = useSidebarThreadSummaryById(props.threadId); + const thread = useStore((state) => state.sidebarThreadSummaryById[props.threadId]); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); const runningTerminalIds = useTerminalStateStore( (state) => @@ -674,8 +673,9 @@ function SortableProjectItem({ } export default function Sidebar() { - const projects = useStore((store) => store.projects); - const sidebarThreadsById = useStore((store) => store.sidebarThreadsById); + const projectIds = useStore((store) => store.projectIds); + const projectById = useStore((store) => store.projectById); + const sidebarThreadsById = useStore((store) => store.sidebarThreadSummaryById); const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId); const { projectExpandedById, projectOrder, threadLastVisitedAtById } = useUiStateStore( useShallow((store) => ({ @@ -741,6 +741,14 @@ export default function Sidebar() { const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const projects = useMemo( + () => + projectIds.flatMap((projectId) => { + const project = projectById[projectId]; + return project ? [project] : []; + }), + [projectById, projectIds], + ); const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 692438c74a..40d34b36c1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -95,7 +95,7 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Terminal 1 lines 1-5"); expect(markup).toContain("lucide-terminal"); expect(markup).toContain("yoo what's "); - }); + }, 10_000); it("renders context compaction entries in the normal work log", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d534eefaa4..97c84271a7 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1479,12 +1479,21 @@ export function GeneralSettingsPanel() { } export function ArchivedThreadsPanel() { - const projects = useStore((store) => store.projects); - const threads = useStore((store) => store.threads); + const projectIds = useStore((store) => store.projectIds); + const projectById = useStore((store) => store.projectById); + const threadIds = useStore((store) => store.threadIds); + const threadShellById = useStore((store) => store.threadShellById); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); const archivedGroups = useMemo(() => { - const projectById = new Map(projects.map((project) => [project.id, project] as const)); - return [...projectById.values()] + const projects = projectIds.flatMap((projectId) => { + const project = projectById[projectId]; + return project ? [project] : []; + }); + const threads = threadIds.flatMap((threadId) => { + const thread = threadShellById[threadId]; + return thread ? [thread] : []; + }); + return projects .map((project) => ({ project, threads: threads @@ -1496,7 +1505,7 @@ export function ArchivedThreadsPanel() { }), })) .filter((group) => group.threads.length > 0); - }, [projects, threads]); + }, [projectById, projectIds, threadIds, threadShellById]); const handleArchivedThreadContextMenu = useCallback( async (threadId: ThreadId, position: { x: number; y: number }) => { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 1547035bf4..f08b2c7a57 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -10,18 +10,20 @@ import { import { newThreadId } from "../lib/utils"; import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; import { useStore } from "../store"; -import { useThreadById } from "../storeSelectors"; +import { createThreadSelector } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; export function useHandleNewThread() { - const projectIds = useStore(useShallow((store) => store.projects.map((project) => project.id))); + const projectIds = useStore(useShallow((store) => store.projectIds)); const projectOrder = useUiStateStore((store) => store.projectOrder); const navigate = useNavigate(); const routeThreadId = useParams({ strict: false, select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), }); - const activeThread = useThreadById(routeThreadId); + const activeThread = useStore( + useMemo(() => createThreadSelector(routeThreadId), [routeThreadId]), + ); const activeDraftThread = useComposerDraftStore((store) => routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null, ); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index d5557b4a96..bc13b872cd 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -9,7 +9,7 @@ import { useHandleNewThread } from "./useHandleNewThread"; import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; import { newCommandId } from "../lib/utils"; import { readNativeApi } from "../nativeApi"; -import { useStore } from "../store"; +import { selectProjectById, selectThreadById, selectThreads, useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { toastManager } from "../components/ui/toast"; @@ -35,7 +35,7 @@ export function useThreadActions() { async (threadId: ThreadId) => { const api = readNativeApi(); if (!api) return; - const thread = useStore.getState().threads.find((entry) => entry.id === threadId); + const thread = selectThreadById(threadId)(useStore.getState()); if (!thread) return; if (thread.session?.status === "running" && thread.session.activeTurnId != null) { throw new Error("Cannot archive a running thread."); @@ -68,10 +68,11 @@ export function useThreadActions() { async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet } = {}) => { const api = readNativeApi(); if (!api) return; - const { projects, threads } = useStore.getState(); - const thread = threads.find((entry) => entry.id === threadId); + const state = useStore.getState(); + const threads = selectThreads(state); + const thread = selectThreadById(threadId)(state); if (!thread) return; - const threadProject = projects.find((project) => project.id === thread.projectId); + const threadProject = selectProjectById(thread.projectId)(state); const deletedIds = opts.deletedThreadIds; const survivingThreads = deletedIds && deletedIds.size > 0 @@ -179,7 +180,7 @@ export function useThreadActions() { async (threadId: ThreadId) => { const api = readNativeApi(); if (!api) return; - const thread = useStore.getState().threads.find((entry) => entry.id === threadId); + const thread = selectThreadById(threadId)(useStore.getState()); if (!thread) return; if (appSettings.confirmThreadDelete) { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8f3667d937..48c835ae79 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,7 +1,7 @@ import { OrchestrationEvent, type ServerLifecycleWelcomePayload, - type ThreadId, + ThreadId, } from "@t3tools/contracts"; import { Outlet, @@ -38,7 +38,7 @@ import { clearPromotedDraftThreads, useComposerDraftStore, } from "../composerDraftStore"; -import { useStore } from "../store"; +import { selectProjects, selectThreadById, selectThreads, useStore } from "../store"; import { useUiStateStore } from "../uiStateStore"; import { useTerminalStateStore } from "../terminalStateStore"; import { migrateLocalSettingsToServer } from "../hooks/useSettings"; @@ -328,8 +328,9 @@ function EventRouter() { let flushPendingDomainEventsScheduled = false; const reconcileSnapshotDerivedState = () => { - const threads = useStore.getState().threads; - const projects = useStore.getState().projects; + const storeState = useStore.getState(); + const threads = selectThreads(storeState); + const projects = selectProjects(storeState); syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); syncThreads( threads.map((thread) => ({ @@ -392,14 +393,14 @@ function EventRouter() { applyOrchestrationEvents(uiEvents); if (needsProjectUiSync) { - const projects = useStore.getState().projects; + const projects = selectProjects(useStore.getState()); syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); } const needsThreadUiSync = nextEvents.some( (event) => event.type === "thread.created" || event.type === "thread.deleted", ); if (needsThreadUiSync) { - const threads = useStore.getState().threads; + const threads = selectThreads(useStore.getState()); syncThreads( threads.map((thread) => ({ id: thread.id, @@ -555,7 +556,7 @@ function EventRouter() { }, ); const unsubTerminalEvent = api.terminal.onEvent((event) => { - const thread = useStore.getState().threads.find((entry) => entry.id === event.threadId); + const thread = selectThreadById(ThreadId.makeUnsafe(event.threadId))(useStore.getState()); if (thread && thread.archivedAt !== null) { return; } diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 31920cf40f..99ecc05e7d 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -167,7 +167,7 @@ function ChatThreadRouteView() { select: (params) => ThreadId.makeUnsafe(params.threadId), }); const search = Route.useSearch(); - const threadExists = useStore((store) => store.threads.some((thread) => thread.id === threadId)); + const threadExists = useStore((store) => store.threadShellById[threadId] !== undefined); const draftThreadExists = useComposerDraftStore((store) => Object.hasOwn(store.draftThreadsByThreadId, threadId), ); diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 9deb578d3e..063f148f9c 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -14,6 +14,8 @@ import { describe, expect, it } from "vitest"; import { applyOrchestrationEvent, applyOrchestrationEvents, + selectProjects, + selectThreads, syncServerReadModel, type AppState, } from "./store"; @@ -47,29 +49,125 @@ function makeThread(overrides: Partial = {}): Thread { } function makeState(thread: Thread): AppState { + const projectId = ProjectId.makeUnsafe("project-1"); + const project = { + id: projectId, + name: "Project", + cwd: "/tmp/project", + defaultModelSelection: { + provider: "codex" as const, + model: "gpt-5-codex", + }, + createdAt: "2026-02-13T00:00:00.000Z", + updatedAt: "2026-02-13T00:00:00.000Z", + scripts: [], + }; const threadIdsByProjectId: AppState["threadIdsByProjectId"] = { [thread.projectId]: [thread.id], }; return { - projects: [ - { - id: ProjectId.makeUnsafe("project-1"), - name: "Project", - cwd: "/tmp/project", - defaultModelSelection: { - provider: "codex", - model: "gpt-5-codex", - }, - scripts: [], - }, - ], - threads: [thread], - sidebarThreadsById: {}, + projectIds: [projectId], + projectById: { + [projectId]: project, + }, + threadIds: [thread.id], threadIdsByProjectId, + threadShellById: { + [thread.id]: { + id: thread.id, + codexThreadId: thread.codexThreadId, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + error: thread.error, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + }, + }, + threadSessionById: { + [thread.id]: thread.session, + }, + threadTurnStateById: { + [thread.id]: { + latestTurn: thread.latestTurn, + ...(thread.pendingSourceProposedPlan + ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } + : {}), + }, + }, + messageIdsByThreadId: { + [thread.id]: thread.messages.map((message) => message.id), + }, + messageByThreadId: { + [thread.id]: Object.fromEntries( + thread.messages.map((message) => [message.id, message] as const), + ) as AppState["messageByThreadId"][ThreadId], + }, + activityIdsByThreadId: { + [thread.id]: thread.activities.map((activity) => activity.id), + }, + activityByThreadId: { + [thread.id]: Object.fromEntries( + thread.activities.map((activity) => [activity.id, activity] as const), + ) as AppState["activityByThreadId"][ThreadId], + }, + proposedPlanIdsByThreadId: { + [thread.id]: thread.proposedPlans.map((plan) => plan.id), + }, + proposedPlanByThreadId: { + [thread.id]: Object.fromEntries( + thread.proposedPlans.map((plan) => [plan.id, plan] as const), + ) as AppState["proposedPlanByThreadId"][ThreadId], + }, + turnDiffIdsByThreadId: { + [thread.id]: thread.turnDiffSummaries.map((summary) => summary.turnId), + }, + turnDiffSummaryByThreadId: { + [thread.id]: Object.fromEntries( + thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), + ) as AppState["turnDiffSummaryByThreadId"][ThreadId], + }, + sidebarThreadSummaryById: {}, + bootstrapComplete: true, + }; +} + +function makeEmptyState(overrides: Partial = {}): AppState { + return { + projectIds: [], + projectById: {}, + threadIds: [], + threadIdsByProjectId: {}, + threadShellById: {}, + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + sidebarThreadSummaryById: {}, bootstrapComplete: true, + ...overrides, }; } +function projectsOf(state: AppState) { + return selectProjects(state); +} + +function threadsOf(state: AppState) { + return selectThreads(state); +} + function makeEvent( type: T, payload: Extract["payload"], @@ -191,7 +289,7 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.threads[0]?.modelSelection.model).toBe("claude-opus-4-6"); + expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-opus-4-6"); }); it("resolves claude aliases when session provider is claudeAgent", () => { @@ -216,7 +314,7 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.threads[0]?.modelSelection.model).toBe("claude-sonnet-4-6"); + expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-sonnet-4-6"); }); it("preserves project and thread updatedAt timestamps from the read model", () => { @@ -229,8 +327,8 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.projects[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z"); - expect(next.threads[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z"); + expect(projectsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z"); + expect(threadsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z"); }); it("maps archivedAt from the read model", () => { @@ -245,16 +343,17 @@ describe("store read model sync", () => { ), ); - expect(next.threads[0]?.archivedAt).toBe(archivedAt); + expect(threadsOf(next)[0]?.archivedAt).toBe(archivedAt); }); it("replaces projects using snapshot order during recovery", () => { const project1 = ProjectId.makeUnsafe("project-1"); const project2 = ProjectId.makeUnsafe("project-2"); const project3 = ProjectId.makeUnsafe("project-3"); - const initialState: AppState = { - projects: [ - { + const initialState: AppState = makeEmptyState({ + projectIds: [project2, project1], + projectById: { + [project2]: { id: project2, name: "Project 2", cwd: "/tmp/project-2", @@ -262,9 +361,11 @@ describe("store read model sync", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], }, - { + [project1]: { id: project1, name: "Project 1", cwd: "/tmp/project-1", @@ -272,14 +373,12 @@ describe("store read model sync", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], }, - ], - threads: [], - sidebarThreadsById: {}, - threadIdsByProjectId: {}, - bootstrapComplete: true, - }; + }, + }); const readModel: OrchestrationReadModel = { snapshotSequence: 2, updatedAt: "2026-02-27T00:00:00.000Z", @@ -305,7 +404,7 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.projects.map((project) => project.id)).toEqual([project1, project2, project3]); + expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]); }); }); @@ -345,8 +444,8 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.projects[0]?.name).toBe("Renamed Project"); - expect(next.projects[0]?.updatedAt).toBe("2026-02-27T00:00:01.000Z"); + expect(next.projectById[projectId]?.name).toBe("Renamed Project"); + expect(next.projectById[projectId]?.updatedAt).toBe("2026-02-27T00:00:01.000Z"); }); it("preserves state identity for no-op project and thread deletes", () => { @@ -375,9 +474,10 @@ describe("incremental orchestration updates", () => { it("reuses an existing project row when project.created arrives with a new id for the same cwd", () => { const originalProjectId = ProjectId.makeUnsafe("project-1"); const recreatedProjectId = ProjectId.makeUnsafe("project-2"); - const state: AppState = { - projects: [ - { + const state: AppState = makeEmptyState({ + projectIds: [originalProjectId], + projectById: { + [originalProjectId]: { id: originalProjectId, name: "Project", cwd: "/tmp/project", @@ -385,14 +485,12 @@ describe("incremental orchestration updates", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], }, - ], - threads: [], - sidebarThreadsById: {}, - threadIdsByProjectId: {}, - bootstrapComplete: true, - }; + }, + }); const next = applyOrchestrationEvent( state, @@ -410,10 +508,13 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.projects).toHaveLength(1); - expect(next.projects[0]?.id).toBe(recreatedProjectId); - expect(next.projects[0]?.cwd).toBe("/tmp/project"); - expect(next.projects[0]?.name).toBe("Project Recreated"); + expect(projectsOf(next)).toHaveLength(1); + expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId); + expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project"); + expect(projectsOf(next)[0]?.name).toBe("Project Recreated"); + expect(next.projectIds).toEqual([recreatedProjectId]); + expect(next.projectById[originalProjectId]).toBeUndefined(); + expect(next.projectById[recreatedProjectId]?.id).toBe(recreatedProjectId); }); it("removes stale project index entries when thread.created recreates a thread under a new project", () => { @@ -425,8 +526,10 @@ describe("incremental orchestration updates", () => { projectId: originalProjectId, }); const state: AppState = { - projects: [ - { + ...makeState(thread), + projectIds: [originalProjectId, recreatedProjectId], + projectById: { + [originalProjectId]: { id: originalProjectId, name: "Project 1", cwd: "/tmp/project-1", @@ -434,9 +537,11 @@ describe("incremental orchestration updates", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], }, - { + [recreatedProjectId]: { id: recreatedProjectId, name: "Project 2", cwd: "/tmp/project-2", @@ -444,15 +549,11 @@ describe("incremental orchestration updates", () => { provider: "codex", model: DEFAULT_MODEL_BY_PROVIDER.codex, }, + createdAt: "2026-02-27T00:00:00.000Z", + updatedAt: "2026-02-27T00:00:00.000Z", scripts: [], }, - ], - threads: [thread], - sidebarThreadsById: {}, - threadIdsByProjectId: { - [originalProjectId]: [threadId], }, - bootstrapComplete: true, }; const next = applyOrchestrationEvent( @@ -474,8 +575,8 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.threads).toHaveLength(1); - expect(next.threads[0]?.projectId).toBe(recreatedProjectId); + expect(threadsOf(next)).toHaveLength(1); + expect(threadsOf(next)[0]?.projectId).toBe(recreatedProjectId); expect(next.threadIdsByProjectId[originalProjectId]).toBeUndefined(); expect(next.threadIdsByProjectId[recreatedProjectId]).toEqual([threadId]); }); @@ -498,7 +599,73 @@ describe("incremental orchestration updates", () => { const thread2 = makeThread({ id: ThreadId.makeUnsafe("thread-2") }); const state: AppState = { ...makeState(thread1), - threads: [thread1, thread2], + threadIds: [thread1.id, thread2.id], + threadShellById: { + ...makeState(thread1).threadShellById, + [thread2.id]: { + id: thread2.id, + codexThreadId: thread2.codexThreadId, + projectId: thread2.projectId, + title: thread2.title, + modelSelection: thread2.modelSelection, + runtimeMode: thread2.runtimeMode, + interactionMode: thread2.interactionMode, + error: thread2.error, + createdAt: thread2.createdAt, + archivedAt: thread2.archivedAt, + updatedAt: thread2.updatedAt, + branch: thread2.branch, + worktreePath: thread2.worktreePath, + }, + }, + threadSessionById: { + ...makeState(thread1).threadSessionById, + [thread2.id]: thread2.session, + }, + threadTurnStateById: { + ...makeState(thread1).threadTurnStateById, + [thread2.id]: { + latestTurn: thread2.latestTurn, + }, + }, + messageIdsByThreadId: { + ...makeState(thread1).messageIdsByThreadId, + [thread2.id]: [], + }, + messageByThreadId: { + ...makeState(thread1).messageByThreadId, + [thread2.id]: {}, + }, + activityIdsByThreadId: { + ...makeState(thread1).activityIdsByThreadId, + [thread2.id]: [], + }, + activityByThreadId: { + ...makeState(thread1).activityByThreadId, + [thread2.id]: {}, + }, + proposedPlanIdsByThreadId: { + ...makeState(thread1).proposedPlanIdsByThreadId, + [thread2.id]: [], + }, + proposedPlanByThreadId: { + ...makeState(thread1).proposedPlanByThreadId, + [thread2.id]: {}, + }, + turnDiffIdsByThreadId: { + ...makeState(thread1).turnDiffIdsByThreadId, + [thread2.id]: [], + }, + turnDiffSummaryByThreadId: { + ...makeState(thread1).turnDiffSummaryByThreadId, + [thread2.id]: {}, + }, + sidebarThreadSummaryById: { + ...makeState(thread1).sidebarThreadSummaryById, + }, + threadIdsByProjectId: { + [thread1.projectId]: [thread1.id, thread2.id], + }, }; const next = applyOrchestrationEvent( @@ -515,9 +682,12 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.threads[0]?.messages[0]?.text).toBe("hello world"); - expect(next.threads[0]?.latestTurn?.state).toBe("running"); - expect(next.threads[1]).toBe(thread2); + expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world"); + expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); + expect(next.threadShellById[thread2.id]).toBe(state.threadShellById[thread2.id]); + expect(next.threadSessionById[thread2.id]).toBe(state.threadSessionById[thread2.id]); + expect(next.messageIdsByThreadId[thread2.id]).toBe(state.messageIdsByThreadId[thread2.id]); + expect(next.messageByThreadId[thread2.id]).toBe(state.messageByThreadId[thread2.id]); }); it("applies replay batches in sequence and updates session state", () => { @@ -566,9 +736,9 @@ describe("incremental orchestration updates", () => { ), ]); - expect(next.threads[0]?.session?.status).toBe("running"); - expect(next.threads[0]?.latestTurn?.state).toBe("completed"); - expect(next.threads[0]?.messages).toHaveLength(1); + expect(threadsOf(next)[0]?.session?.status).toBe("running"); + expect(threadsOf(next)[0]?.latestTurn?.state).toBe("completed"); + expect(threadsOf(next)[0]?.messages).toHaveLength(1); }); it("does not regress latestTurn when an older turn diff completes late", () => { @@ -599,8 +769,8 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.threads[0]?.turnDiffSummaries).toHaveLength(1); - expect(next.threads[0]?.latestTurn).toEqual(state.threads[0]?.latestTurn); + expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1); + expect(threadsOf(next)[0]?.latestTurn).toEqual(threadsOf(state)[0]?.latestTurn); }); it("rebinds live turn diffs to the authoritative assistant message when it arrives later", () => { @@ -643,10 +813,10 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.threads[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( + expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( MessageId.makeUnsafe("assistant-real"), ); - expect(next.threads[0]?.latestTurn?.assistantMessageId).toBe( + expect(threadsOf(next)[0]?.latestTurn?.assistantMessageId).toBe( MessageId.makeUnsafe("assistant-real"), ); }); @@ -752,15 +922,15 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.threads[0]?.messages.map((message) => message.id)).toEqual([ + expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([ "user-1", "assistant-1", ]); - expect(next.threads[0]?.proposedPlans.map((plan) => plan.id)).toEqual(["plan-1"]); - expect(next.threads[0]?.activities.map((activity) => activity.id)).toEqual([ + expect(threadsOf(next)[0]?.proposedPlans.map((plan) => plan.id)).toEqual(["plan-1"]); + expect(threadsOf(next)[0]?.activities.map((activity) => activity.id)).toEqual([ EventId.makeUnsafe("activity-1"), ]); - expect(next.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ + expect(threadsOf(next)[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ TurnId.makeUnsafe("turn-1"), ]); }); @@ -810,7 +980,7 @@ describe("incremental orchestration updates", () => { }), ); - expect(reverted.threads[0]?.pendingSourceProposedPlan).toBeUndefined(); + expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined(); const next = applyOrchestrationEvent( reverted, @@ -828,10 +998,10 @@ describe("incremental orchestration updates", () => { }), ); - expect(next.threads[0]?.latestTurn).toMatchObject({ + expect(threadsOf(next)[0]?.latestTurn).toMatchObject({ turnId: TurnId.makeUnsafe("turn-3"), state: "running", }); - expect(next.threads[0]?.latestTurn?.sourceProposedPlan).toBeUndefined(); + expect(threadsOf(next)[0]?.latestTurn?.sourceProposedPlan).toBeUndefined(); }); }); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 6e768c4ef8..4fbb11942c 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,84 +1,101 @@ import { + type MessageId, + type OrchestrationCheckpointSummary, type OrchestrationEvent, type OrchestrationMessage, type OrchestrationProposedPlan, - type ProjectId, - type ProviderKind, - ThreadId, type OrchestrationReadModel, type OrchestrationSession, - type OrchestrationCheckpointSummary, - type OrchestrationThread, type OrchestrationSessionStatus, + type OrchestrationThread, + type OrchestrationThreadActivity, + type ProjectId, + type ProviderKind, + ThreadId, + type TurnId, } from "@t3tools/contracts"; import { resolveModelSlugForProvider } from "@t3tools/shared/model"; import { create } from "zustand"; import { - findLatestProposedPlan, - hasActionableProposedPlan, derivePendingApprovals, derivePendingUserInputs, + findLatestProposedPlan, + hasActionableProposedPlan, } from "./session-logic"; +import { + type ChatMessage, + type Project, + type ProposedPlan, + type SidebarThreadSummary, + type Thread, + type ThreadSession, + type ThreadShell, + type ThreadTurnState, + type TurnDiffSummary, +} from "./types"; import { sanitizeThreadErrorMessage } from "./rpc/transportError"; -import { type ChatMessage, type Project, type SidebarThreadSummary, type Thread } from "./types"; - -// ── State ──────────────────────────────────────────────────────────── export interface AppState { - projects: Project[]; - threads: Thread[]; - sidebarThreadsById: Record; - threadIdsByProjectId: Record; + projectIds: ProjectId[]; + projectById: Record; + threadIds: ThreadId[]; + threadIdsByProjectId: Record; + threadShellById: Record; + threadSessionById: Record; + threadTurnStateById: Record; + messageIdsByThreadId: Record; + messageByThreadId: Record>; + activityIdsByThreadId: Record; + activityByThreadId: Record>; + proposedPlanIdsByThreadId: Record; + proposedPlanByThreadId: Record>; + turnDiffIdsByThreadId: Record; + turnDiffSummaryByThreadId: Record>; + sidebarThreadSummaryById: Record; bootstrapComplete: boolean; } const initialState: AppState = { - projects: [], - threads: [], - sidebarThreadsById: {}, + projectIds: [], + projectById: {}, + threadIds: [], threadIdsByProjectId: {}, + threadShellById: {}, + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + sidebarThreadSummaryById: {}, bootstrapComplete: false, }; + const MAX_THREAD_MESSAGES = 2_000; const MAX_THREAD_CHECKPOINTS = 500; const MAX_THREAD_PROPOSED_PLANS = 200; const MAX_THREAD_ACTIVITIES = 500; const EMPTY_THREAD_IDS: ThreadId[] = []; - -// ── Pure helpers ────────────────────────────────────────────────────── - -function updateThread( - threads: Thread[], - threadId: ThreadId, - updater: (t: Thread) => Thread, -): Thread[] { - let changed = false; - const next = threads.map((t) => { - if (t.id !== threadId) return t; - const updated = updater(t); - if (updated !== t) changed = true; - return updated; - }); - return changed ? next : threads; -} - -function updateProject( - projects: Project[], - projectId: Project["id"], - updater: (project: Project) => Project, -): Project[] { - let changed = false; - const next = projects.map((project) => { - if (project.id !== projectId) { - return project; - } - const updated = updater(project); - if (updated !== project) { - changed = true; - } - return updated; - }); - return changed ? next : projects; +const EMPTY_MESSAGE_IDS: MessageId[] = []; +const EMPTY_ACTIVITY_IDS: string[] = []; +const EMPTY_PROPOSED_PLAN_IDS: string[] = []; +const EMPTY_TURN_IDS: TurnId[] = []; +const EMPTY_MESSAGES: ChatMessage[] = []; +const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; +const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; +const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; +const EMPTY_MESSAGE_MAP: Record = {}; +const EMPTY_ACTIVITY_MAP: Record = {}; +const EMPTY_PROPOSED_PLAN_MAP: Record = {}; +const EMPTY_TURN_DIFF_MAP: Record = {}; +const EMPTY_THREAD_TURN_STATE: ThreadTurnState = Object.freeze({ latestTurn: null }); + +function arraysEqual(left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); } function normalizeModelSelection( @@ -94,7 +111,7 @@ function mapProjectScripts(scripts: ReadonlyArray): return scripts.map((script) => ({ ...script })); } -function mapSession(session: OrchestrationSession): Thread["session"] { +function mapSession(session: OrchestrationSession): ThreadSession { return { provider: toLegacyProvider(session.providerName), status: toLegacySessionStatus(session.status), @@ -128,7 +145,7 @@ function mapMessage(message: OrchestrationMessage): ChatMessage { }; } -function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): Thread["proposedPlans"][number] { +function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): ProposedPlan { return { id: proposedPlan.id, turnId: proposedPlan.turnId, @@ -140,9 +157,7 @@ function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): Thread["propo }; } -function mapTurnDiffSummary( - checkpoint: OrchestrationCheckpointSummary, -): Thread["turnDiffSummaries"][number] { +function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDiffSummary { return { turnId: checkpoint.turnId, completedAt: checkpoint.completedAt, @@ -154,6 +169,20 @@ function mapTurnDiffSummary( }; } +function mapProject(project: OrchestrationReadModel["projects"][number]): Project { + return { + id: project.id, + name: project.title, + cwd: project.workspaceRoot, + defaultModelSelection: project.defaultModelSelection + ? normalizeModelSelection(project.defaultModelSelection) + : null, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + scripts: mapProjectScripts(project.scripts), + }; +} + function mapThread(thread: OrchestrationThread): Thread { return { id: thread.id, @@ -179,25 +208,35 @@ function mapThread(thread: OrchestrationThread): Thread { }; } -function mapProject(project: OrchestrationReadModel["projects"][number]): Project { +function toThreadShell(thread: Thread): ThreadShell { return { - id: project.id, - name: project.title, - cwd: project.workspaceRoot, - defaultModelSelection: project.defaultModelSelection - ? normalizeModelSelection(project.defaultModelSelection) - : null, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - scripts: mapProjectScripts(project.scripts), + id: thread.id, + codexThreadId: thread.codexThreadId, + projectId: thread.projectId, + title: thread.title, + modelSelection: thread.modelSelection, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + error: thread.error, + createdAt: thread.createdAt, + archivedAt: thread.archivedAt, + updatedAt: thread.updatedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, }; } -function getLatestUserMessageAt( - messages: ReadonlyArray, -): string | null { - let latestUserMessageAt: string | null = null; +function toThreadTurnState(thread: Thread): ThreadTurnState { + return { + latestTurn: thread.latestTurn, + ...(thread.pendingSourceProposedPlan + ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } + : {}), + }; +} +function getLatestUserMessageAt(messages: ReadonlyArray): string | null { + let latestUserMessageAt: string | null = null; for (const message of messages) { if (message.role !== "user") { continue; @@ -206,7 +245,6 @@ function getLatestUserMessageAt( latestUserMessageAt = message.createdAt; } } - return latestUserMessageAt; } @@ -256,62 +294,378 @@ function sidebarThreadSummariesEqual( ); } -function appendThreadIdByProjectId( - threadIdsByProjectId: Record, - projectId: ProjectId, - threadId: ThreadId, -): Record { - const existingThreadIds = threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS; - if (existingThreadIds.includes(threadId)) { - return threadIdsByProjectId; - } +function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): boolean { + return ( + left !== undefined && + left.id === right.id && + left.codexThreadId === right.codexThreadId && + left.projectId === right.projectId && + left.title === right.title && + left.modelSelection === right.modelSelection && + left.runtimeMode === right.runtimeMode && + left.interactionMode === right.interactionMode && + left.error === right.error && + left.createdAt === right.createdAt && + left.archivedAt === right.archivedAt && + left.updatedAt === right.updatedAt && + left.branch === right.branch && + left.worktreePath === right.worktreePath + ); +} + +function threadTurnStatesEqual(left: ThreadTurnState | undefined, right: ThreadTurnState): boolean { + return ( + left !== undefined && + left.latestTurn === right.latestTurn && + left.pendingSourceProposedPlan === right.pendingSourceProposedPlan + ); +} + +function appendId(ids: readonly T[], id: T): T[] { + return ids.includes(id) ? [...ids] : [...ids, id]; +} + +function removeId(ids: readonly T[], id: T): T[] { + return ids.filter((value) => value !== id); +} + +function buildMessageSlice(thread: Thread): { + ids: MessageId[]; + byId: Record; +} { + return { + ids: thread.messages.map((message) => message.id), + byId: Object.fromEntries( + thread.messages.map((message) => [message.id, message] as const), + ) as Record, + }; +} + +function buildActivitySlice(thread: Thread): { + ids: string[]; + byId: Record; +} { + return { + ids: thread.activities.map((activity) => activity.id), + byId: Object.fromEntries( + thread.activities.map((activity) => [activity.id, activity] as const), + ) as Record, + }; +} + +function buildProposedPlanSlice(thread: Thread): { + ids: string[]; + byId: Record; +} { + return { + ids: thread.proposedPlans.map((plan) => plan.id), + byId: Object.fromEntries( + thread.proposedPlans.map((plan) => [plan.id, plan] as const), + ) as Record, + }; +} + +function buildTurnDiffSlice(thread: Thread): { + ids: TurnId[]; + byId: Record; +} { return { - ...threadIdsByProjectId, - [projectId]: [...existingThreadIds, threadId], + ids: thread.turnDiffSummaries.map((summary) => summary.turnId), + byId: Object.fromEntries( + thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), + ) as Record, }; } -function removeThreadIdByProjectId( - threadIdsByProjectId: Record, - projectId: ProjectId, +function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[] { + const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS; + const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP; + if (ids.length === 0) { + return EMPTY_MESSAGES; + } + return ids.flatMap((id) => { + const message = byId[id]; + return message ? [message] : []; + }); +} + +function selectThreadActivities( + state: AppState, threadId: ThreadId, -): Record { - const existingThreadIds = threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS; - if (!existingThreadIds.includes(threadId)) { - return threadIdsByProjectId; +): OrchestrationThreadActivity[] { + const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS; + const byId = state.activityByThreadId[threadId] ?? EMPTY_ACTIVITY_MAP; + if (ids.length === 0) { + return EMPTY_ACTIVITIES; } - const nextThreadIds = existingThreadIds.filter( - (existingThreadId) => existingThreadId !== threadId, - ); - if (nextThreadIds.length === existingThreadIds.length) { - return threadIdsByProjectId; + return ids.flatMap((id) => { + const activity = byId[id]; + return activity ? [activity] : []; + }); +} + +function selectThreadProposedPlans(state: AppState, threadId: ThreadId): ProposedPlan[] { + const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS; + const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP; + if (ids.length === 0) { + return EMPTY_PROPOSED_PLANS; } - if (nextThreadIds.length === 0) { - const nextThreadIdsByProjectId = { ...threadIdsByProjectId }; - delete nextThreadIdsByProjectId[projectId]; - return nextThreadIdsByProjectId; + return ids.flatMap((id) => { + const plan = byId[id]; + return plan ? [plan] : []; + }); +} + +function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): TurnDiffSummary[] { + const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS; + const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP; + if (ids.length === 0) { + return EMPTY_TURN_DIFF_SUMMARIES; } + return ids.flatMap((id) => { + const summary = byId[id]; + return summary ? [summary] : []; + }); +} + +function getThread(state: AppState, threadId: ThreadId): Thread | undefined { + const shell = state.threadShellById[threadId]; + if (!shell) { + return undefined; + } + const turnState = state.threadTurnStateById[threadId] ?? EMPTY_THREAD_TURN_STATE; return { - ...threadIdsByProjectId, - [projectId]: nextThreadIds, + ...shell, + session: state.threadSessionById[threadId] ?? null, + latestTurn: turnState.latestTurn, + pendingSourceProposedPlan: turnState.pendingSourceProposedPlan, + messages: selectThreadMessages(state, threadId), + activities: selectThreadActivities(state, threadId), + proposedPlans: selectThreadProposedPlans(state, threadId), + turnDiffSummaries: selectThreadTurnDiffSummaries(state, threadId), }; } -function buildThreadIdsByProjectId(threads: ReadonlyArray): Record { - const threadIdsByProjectId: Record = {}; - for (const thread of threads) { - const existingThreadIds = threadIdsByProjectId[thread.projectId] ?? EMPTY_THREAD_IDS; - threadIdsByProjectId[thread.projectId] = [...existingThreadIds, thread.id]; +function getProjects(state: AppState): Project[] { + return state.projectIds.flatMap((projectId) => { + const project = state.projectById[projectId]; + return project ? [project] : []; + }); +} + +function getThreads(state: AppState): Thread[] { + return state.threadIds.flatMap((threadId) => { + const thread = getThread(state, threadId); + return thread ? [thread] : []; + }); +} + +function writeThreadState(state: AppState, nextThread: Thread, previousThread?: Thread): AppState { + const nextShell = toThreadShell(nextThread); + const nextTurnState = toThreadTurnState(nextThread); + const previousShell = state.threadShellById[nextThread.id]; + const previousTurnState = state.threadTurnStateById[nextThread.id]; + const previousSummary = state.sidebarThreadSummaryById[nextThread.id]; + const nextSummary = buildSidebarThreadSummary(nextThread); + + let nextState = state; + + if (!state.threadIds.includes(nextThread.id)) { + nextState = { + ...nextState, + threadIds: [...nextState.threadIds, nextThread.id], + }; + } + + const previousProjectId = previousThread?.projectId; + const nextProjectId = nextThread.projectId; + if (previousProjectId !== nextProjectId) { + let threadIdsByProjectId = nextState.threadIdsByProjectId; + if (previousProjectId) { + const previousIds = threadIdsByProjectId[previousProjectId] ?? EMPTY_THREAD_IDS; + const nextIds = removeId(previousIds, nextThread.id); + if (nextIds.length === 0) { + const { [previousProjectId]: _removed, ...rest } = threadIdsByProjectId; + threadIdsByProjectId = rest as Record; + } else if (!arraysEqual(previousIds, nextIds)) { + threadIdsByProjectId = { + ...threadIdsByProjectId, + [previousProjectId]: nextIds, + }; + } + } + const projectThreadIds = threadIdsByProjectId[nextProjectId] ?? EMPTY_THREAD_IDS; + const nextProjectThreadIds = appendId(projectThreadIds, nextThread.id); + if (!arraysEqual(projectThreadIds, nextProjectThreadIds)) { + threadIdsByProjectId = { + ...threadIdsByProjectId, + [nextProjectId]: nextProjectThreadIds, + }; + } + if (threadIdsByProjectId !== nextState.threadIdsByProjectId) { + nextState = { + ...nextState, + threadIdsByProjectId, + }; + } + } + + if (!threadShellsEqual(previousShell, nextShell)) { + nextState = { + ...nextState, + threadShellById: { + ...nextState.threadShellById, + [nextThread.id]: nextShell, + }, + }; + } + + if ((previousThread?.session ?? null) !== nextThread.session) { + nextState = { + ...nextState, + threadSessionById: { + ...nextState.threadSessionById, + [nextThread.id]: nextThread.session, + }, + }; + } + + if (!threadTurnStatesEqual(previousTurnState, nextTurnState)) { + nextState = { + ...nextState, + threadTurnStateById: { + ...nextState.threadTurnStateById, + [nextThread.id]: nextTurnState, + }, + }; + } + + if (previousThread?.messages !== nextThread.messages) { + const nextMessageSlice = buildMessageSlice(nextThread); + nextState = { + ...nextState, + messageIdsByThreadId: { + ...nextState.messageIdsByThreadId, + [nextThread.id]: nextMessageSlice.ids, + }, + messageByThreadId: { + ...nextState.messageByThreadId, + [nextThread.id]: nextMessageSlice.byId, + }, + }; + } + + if (previousThread?.activities !== nextThread.activities) { + const nextActivitySlice = buildActivitySlice(nextThread); + nextState = { + ...nextState, + activityIdsByThreadId: { + ...nextState.activityIdsByThreadId, + [nextThread.id]: nextActivitySlice.ids, + }, + activityByThreadId: { + ...nextState.activityByThreadId, + [nextThread.id]: nextActivitySlice.byId, + }, + }; + } + + if (previousThread?.proposedPlans !== nextThread.proposedPlans) { + const nextProposedPlanSlice = buildProposedPlanSlice(nextThread); + nextState = { + ...nextState, + proposedPlanIdsByThreadId: { + ...nextState.proposedPlanIdsByThreadId, + [nextThread.id]: nextProposedPlanSlice.ids, + }, + proposedPlanByThreadId: { + ...nextState.proposedPlanByThreadId, + [nextThread.id]: nextProposedPlanSlice.byId, + }, + }; + } + + if (previousThread?.turnDiffSummaries !== nextThread.turnDiffSummaries) { + const nextTurnDiffSlice = buildTurnDiffSlice(nextThread); + nextState = { + ...nextState, + turnDiffIdsByThreadId: { + ...nextState.turnDiffIdsByThreadId, + [nextThread.id]: nextTurnDiffSlice.ids, + }, + turnDiffSummaryByThreadId: { + ...nextState.turnDiffSummaryByThreadId, + [nextThread.id]: nextTurnDiffSlice.byId, + }, + }; } - return threadIdsByProjectId; + + if (!sidebarThreadSummariesEqual(previousSummary, nextSummary)) { + nextState = { + ...nextState, + sidebarThreadSummaryById: { + ...nextState.sidebarThreadSummaryById, + [nextThread.id]: nextSummary, + }, + }; + } + + return nextState; } -function buildSidebarThreadsById( - threads: ReadonlyArray, -): Record { - return Object.fromEntries( - threads.map((thread) => [thread.id, buildSidebarThreadSummary(thread)]), - ); +function removeThreadState(state: AppState, threadId: ThreadId): AppState { + const shell = state.threadShellById[threadId]; + if (!shell) { + return state; + } + + const nextThreadIds = removeId(state.threadIds, threadId); + const currentProjectThreadIds = state.threadIdsByProjectId[shell.projectId] ?? EMPTY_THREAD_IDS; + const nextProjectThreadIds = removeId(currentProjectThreadIds, threadId); + const nextThreadIdsByProjectId = + nextProjectThreadIds.length === 0 + ? (() => { + const { [shell.projectId]: _removed, ...rest } = state.threadIdsByProjectId; + return rest as Record; + })() + : { + ...state.threadIdsByProjectId, + [shell.projectId]: nextProjectThreadIds, + }; + + const { [threadId]: _removedShell, ...threadShellById } = state.threadShellById; + const { [threadId]: _removedSession, ...threadSessionById } = state.threadSessionById; + const { [threadId]: _removedTurnState, ...threadTurnStateById } = state.threadTurnStateById; + const { [threadId]: _removedMessageIds, ...messageIdsByThreadId } = state.messageIdsByThreadId; + const { [threadId]: _removedMessages, ...messageByThreadId } = state.messageByThreadId; + const { [threadId]: _removedActivityIds, ...activityIdsByThreadId } = state.activityIdsByThreadId; + const { [threadId]: _removedActivities, ...activityByThreadId } = state.activityByThreadId; + const { [threadId]: _removedPlanIds, ...proposedPlanIdsByThreadId } = + state.proposedPlanIdsByThreadId; + const { [threadId]: _removedPlans, ...proposedPlanByThreadId } = state.proposedPlanByThreadId; + const { [threadId]: _removedTurnDiffIds, ...turnDiffIdsByThreadId } = state.turnDiffIdsByThreadId; + const { [threadId]: _removedTurnDiffs, ...turnDiffSummaryByThreadId } = + state.turnDiffSummaryByThreadId; + const { [threadId]: _removedSidebarSummary, ...sidebarThreadSummaryById } = + state.sidebarThreadSummaryById; + + return { + ...state, + threadIds: nextThreadIds, + threadIdsByProjectId: nextThreadIdsByProjectId, + threadShellById, + threadSessionById, + threadTurnStateById, + messageIdsByThreadId, + messageByThreadId, + activityIdsByThreadId, + activityByThreadId, + proposedPlanIdsByThreadId, + proposedPlanByThreadId, + turnDiffIdsByThreadId, + turnDiffSummaryByThreadId, + sidebarThreadSummaryById, + }; } function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { @@ -367,10 +721,10 @@ function buildLatestTurn(params: { } function rebindTurnDiffSummariesForAssistantMessage( - turnDiffSummaries: ReadonlyArray, - turnId: Thread["turnDiffSummaries"][number]["turnId"], + turnDiffSummaries: ReadonlyArray, + turnId: TurnId, assistantMessageId: NonNullable["assistantMessageId"], -): Thread["turnDiffSummaries"] { +): TurnDiffSummary[] { let changed = false; const nextSummaries = turnDiffSummaries.map((summary) => { if (summary.turnId !== turnId || summary.assistantMessageId === assistantMessageId) { @@ -457,18 +811,18 @@ function retainThreadMessagesAfterRevert( } function retainThreadActivitiesAfterRevert( - activities: ReadonlyArray, + activities: ReadonlyArray, retainedTurnIds: ReadonlySet, -): Thread["activities"] { +): OrchestrationThreadActivity[] { return activities.filter( (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId), ); } function retainThreadProposedPlansAfterRevert( - proposedPlans: ReadonlyArray, + proposedPlans: ReadonlyArray, retainedTurnIds: ReadonlySet, -): Thread["proposedPlans"] { +): ProposedPlan[] { return proposedPlans.filter( (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId), ); @@ -537,56 +891,113 @@ function updateThreadState( threadId: ThreadId, updater: (thread: Thread) => Thread, ): AppState { - let updatedThread: Thread | null = null; - const threads = updateThread(state.threads, threadId, (thread) => { - const nextThread = updater(thread); - if (nextThread !== thread) { - updatedThread = nextThread; - } - return nextThread; - }); - if (threads === state.threads || updatedThread === null) { + const currentThread = getThread(state, threadId); + if (!currentThread) { + return state; + } + const nextThread = updater(currentThread); + if (nextThread === currentThread) { return state; } + return writeThreadState(state, nextThread, currentThread); +} - const nextSummary = buildSidebarThreadSummary(updatedThread); - const previousSummary = state.sidebarThreadsById[threadId]; - const sidebarThreadsById = sidebarThreadSummariesEqual(previousSummary, nextSummary) - ? state.sidebarThreadsById - : { - ...state.sidebarThreadsById, - [threadId]: nextSummary, - }; +function buildProjectState( + projects: ReadonlyArray, +): Pick { + return { + projectIds: projects.map((project) => project.id), + projectById: Object.fromEntries( + projects.map((project) => [project.id, project] as const), + ) as Record, + }; +} - if (sidebarThreadsById === state.sidebarThreadsById) { - return { - ...state, - threads, - }; +function buildThreadState( + threads: ReadonlyArray, +): Pick< + AppState, + | "threadIds" + | "threadIdsByProjectId" + | "threadShellById" + | "threadSessionById" + | "threadTurnStateById" + | "messageIdsByThreadId" + | "messageByThreadId" + | "activityIdsByThreadId" + | "activityByThreadId" + | "proposedPlanIdsByThreadId" + | "proposedPlanByThreadId" + | "turnDiffIdsByThreadId" + | "turnDiffSummaryByThreadId" + | "sidebarThreadSummaryById" +> { + const threadIds: ThreadId[] = []; + const threadIdsByProjectId: Record = {}; + const threadShellById: Record = {}; + const threadSessionById: Record = {}; + const threadTurnStateById: Record = {}; + const messageIdsByThreadId: Record = {}; + const messageByThreadId: Record> = {}; + const activityIdsByThreadId: Record = {}; + const activityByThreadId: Record> = {}; + const proposedPlanIdsByThreadId: Record = {}; + const proposedPlanByThreadId: Record> = {}; + const turnDiffIdsByThreadId: Record = {}; + const turnDiffSummaryByThreadId: Record> = {}; + const sidebarThreadSummaryById: Record = {}; + + for (const thread of threads) { + threadIds.push(thread.id); + threadIdsByProjectId[thread.projectId] = [ + ...(threadIdsByProjectId[thread.projectId] ?? EMPTY_THREAD_IDS), + thread.id, + ]; + threadShellById[thread.id] = toThreadShell(thread); + threadSessionById[thread.id] = thread.session; + threadTurnStateById[thread.id] = toThreadTurnState(thread); + const messageSlice = buildMessageSlice(thread); + messageIdsByThreadId[thread.id] = messageSlice.ids; + messageByThreadId[thread.id] = messageSlice.byId; + const activitySlice = buildActivitySlice(thread); + activityIdsByThreadId[thread.id] = activitySlice.ids; + activityByThreadId[thread.id] = activitySlice.byId; + const proposedPlanSlice = buildProposedPlanSlice(thread); + proposedPlanIdsByThreadId[thread.id] = proposedPlanSlice.ids; + proposedPlanByThreadId[thread.id] = proposedPlanSlice.byId; + const turnDiffSlice = buildTurnDiffSlice(thread); + turnDiffIdsByThreadId[thread.id] = turnDiffSlice.ids; + turnDiffSummaryByThreadId[thread.id] = turnDiffSlice.byId; + sidebarThreadSummaryById[thread.id] = buildSidebarThreadSummary(thread); } return { - ...state, - threads, - sidebarThreadsById, + threadIds, + threadIdsByProjectId, + threadShellById, + threadSessionById, + threadTurnStateById, + messageIdsByThreadId, + messageByThreadId, + activityIdsByThreadId, + activityByThreadId, + proposedPlanIdsByThreadId, + proposedPlanByThreadId, + turnDiffIdsByThreadId, + turnDiffSummaryByThreadId, + sidebarThreadSummaryById, }; } -// ── Pure state transition functions ──────────────────────────────────── - export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { const projects = readModel.projects .filter((project) => project.deletedAt === null) .map(mapProject); const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread); - const sidebarThreadsById = buildSidebarThreadsById(threads); - const threadIdsByProjectId = buildThreadIdsByProjectId(threads); return { ...state, - projects, - threads, - sidebarThreadsById, - threadIdsByProjectId, + ...buildProjectState(projects), + ...buildThreadState(threads), bootstrapComplete: true, }; } @@ -594,10 +1005,6 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState { switch (event.type) { case "project.created": { - const existingIndex = state.projects.findIndex( - (project) => - project.id === event.payload.projectId || project.cwd === event.payload.workspaceRoot, - ); const nextProject = mapProject({ id: event.payload.projectId, title: event.payload.title, @@ -608,17 +1015,48 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.payload.updatedAt, deletedAt: null, }); - const projects = - existingIndex >= 0 - ? state.projects.map((project, index) => - index === existingIndex ? nextProject : project, - ) - : [...state.projects, nextProject]; - return { ...state, projects }; + const existingProjectId = + state.projectIds.find( + (projectId) => + projectId === event.payload.projectId || + state.projectById[projectId]?.cwd === event.payload.workspaceRoot, + ) ?? null; + let projectById = state.projectById; + let projectIds = state.projectIds; + + if (existingProjectId !== null && existingProjectId !== nextProject.id) { + const { [existingProjectId]: _removedProject, ...restProjectById } = state.projectById; + projectById = { + ...restProjectById, + [nextProject.id]: nextProject, + }; + projectIds = state.projectIds.map((projectId) => + projectId === existingProjectId ? nextProject.id : projectId, + ); + } else { + projectById = { + ...state.projectById, + [nextProject.id]: nextProject, + }; + projectIds = + existingProjectId === null && !state.projectIds.includes(nextProject.id) + ? [...state.projectIds, nextProject.id] + : state.projectIds; + } + + return { + ...state, + projectById, + projectIds, + }; } case "project.meta-updated": { - const projects = updateProject(state.projects, event.payload.projectId, (project) => ({ + const project = state.projectById[event.payload.projectId]; + if (!project) { + return state; + } + const nextProject: Project = { ...project, ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), @@ -633,17 +1071,30 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve ? { scripts: mapProjectScripts(event.payload.scripts) } : {}), updatedAt: event.payload.updatedAt, - })); - return projects === state.projects ? state : { ...state, projects }; + }; + return { + ...state, + projectById: { + ...state.projectById, + [event.payload.projectId]: nextProject, + }, + }; } case "project.deleted": { - const projects = state.projects.filter((project) => project.id !== event.payload.projectId); - return projects.length === state.projects.length ? state : { ...state, projects }; + if (!state.projectById[event.payload.projectId]) { + return state; + } + const { [event.payload.projectId]: _removedProject, ...projectById } = state.projectById; + return { + ...state, + projectById, + projectIds: removeId(state.projectIds, event.payload.projectId), + }; } case "thread.created": { - const existing = state.threads.find((thread) => thread.id === event.payload.threadId); + const previousThread = getThread(state, event.payload.threadId); const nextThread = mapThread({ id: event.payload.threadId, projectId: event.payload.projectId, @@ -664,74 +1115,27 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve checkpoints: [], session: null, }); - const threads = existing - ? state.threads.map((thread) => (thread.id === nextThread.id ? nextThread : thread)) - : [...state.threads, nextThread]; - const nextSummary = buildSidebarThreadSummary(nextThread); - const previousSummary = state.sidebarThreadsById[nextThread.id]; - const sidebarThreadsById = sidebarThreadSummariesEqual(previousSummary, nextSummary) - ? state.sidebarThreadsById - : { - ...state.sidebarThreadsById, - [nextThread.id]: nextSummary, - }; - const nextThreadIdsByProjectId = - existing !== undefined && existing.projectId !== nextThread.projectId - ? removeThreadIdByProjectId(state.threadIdsByProjectId, existing.projectId, existing.id) - : state.threadIdsByProjectId; - const threadIdsByProjectId = appendThreadIdByProjectId( - nextThreadIdsByProjectId, - nextThread.projectId, - nextThread.id, - ); - return { - ...state, - threads, - sidebarThreadsById, - threadIdsByProjectId, - }; + return writeThreadState(state, nextThread, previousThread); } - case "thread.deleted": { - const threads = state.threads.filter((thread) => thread.id !== event.payload.threadId); - if (threads.length === state.threads.length) { - return state; - } - const deletedThread = state.threads.find((thread) => thread.id === event.payload.threadId); - const sidebarThreadsById = { ...state.sidebarThreadsById }; - delete sidebarThreadsById[event.payload.threadId]; - const threadIdsByProjectId = deletedThread - ? removeThreadIdByProjectId( - state.threadIdsByProjectId, - deletedThread.projectId, - deletedThread.id, - ) - : state.threadIdsByProjectId; - return { - ...state, - threads, - sidebarThreadsById, - threadIdsByProjectId, - }; - } + case "thread.deleted": + return removeThreadState(state, event.payload.threadId); - case "thread.archived": { + case "thread.archived": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, archivedAt: event.payload.archivedAt, updatedAt: event.payload.updatedAt, })); - } - case "thread.unarchived": { + case "thread.unarchived": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, archivedAt: null, updatedAt: event.payload.updatedAt, })); - } - case "thread.meta-updated": { + case "thread.meta-updated": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), @@ -744,25 +1148,22 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve : {}), updatedAt: event.payload.updatedAt, })); - } - case "thread.runtime-mode-set": { + case "thread.runtime-mode-set": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, runtimeMode: event.payload.runtimeMode, updatedAt: event.payload.updatedAt, })); - } - case "thread.interaction-mode-set": { + case "thread.interaction-mode-set": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, interactionMode: event.payload.interactionMode, updatedAt: event.payload.updatedAt, })); - } - case "thread.turn-start-requested": { + case "thread.turn-start-requested": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, ...(event.payload.modelSelection !== undefined @@ -773,7 +1174,6 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve pendingSourceProposedPlan: event.payload.sourceProposedPlan, updatedAt: event.occurredAt, })); - } case "thread.turn-interrupt-requested": { if (event.payload.turnId === undefined) { @@ -800,7 +1200,7 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve }); } - case "thread.message-sent": { + case "thread.message-sent": return updateThreadState(state, event.payload.threadId, (thread) => { const message = mapMessage({ id: event.payload.messageId, @@ -889,9 +1289,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - } - case "thread.session-set": { + case "thread.session-set": return updateThreadState(state, event.payload.threadId, (thread) => ({ ...thread, session: mapSession(event.payload.session), @@ -920,9 +1319,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve : thread.latestTurn, updatedAt: event.occurredAt, })); - } - case "thread.session-stop-requested": { + case "thread.session-stop-requested": return updateThreadState(state, event.payload.threadId, (thread) => thread.session === null ? thread @@ -938,9 +1336,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }, ); - } - case "thread.proposed-plan-upserted": { + case "thread.proposed-plan-upserted": return updateThreadState(state, event.payload.threadId, (thread) => { const proposedPlan = mapProposedPlan(event.payload.proposedPlan); const proposedPlans = [ @@ -958,9 +1355,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - } - case "thread.turn-diff-completed": { + case "thread.turn-diff-completed": return updateThreadState(state, event.payload.threadId, (thread) => { const checkpoint = mapTurnDiffSummary({ turnId: event.payload.turnId, @@ -1007,9 +1403,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - } - case "thread.reverted": { + case "thread.reverted": return updateThreadState(state, event.payload.threadId, (thread) => { const turnDiffSummaries = thread.turnDiffSummaries .filter( @@ -1059,9 +1454,8 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - } - case "thread.activity-appended": { + case "thread.activity-appended": return updateThreadState(state, event.payload.threadId, (thread) => { const activities = [ ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id), @@ -1075,7 +1469,6 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve updatedAt: event.occurredAt, }; }); - } case "thread.approval-response-requested": case "thread.user-input-response-requested": @@ -1095,30 +1488,29 @@ export function applyOrchestrationEvents( return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state); } +export const selectProjects = (state: AppState): Project[] => getProjects(state); +export const selectThreads = (state: AppState): Thread[] => getThreads(state); export const selectProjectById = (projectId: Project["id"] | null | undefined) => (state: AppState): Project | undefined => - projectId ? state.projects.find((project) => project.id === projectId) : undefined; - + projectId ? state.projectById[projectId] : undefined; export const selectThreadById = (threadId: ThreadId | null | undefined) => (state: AppState): Thread | undefined => - threadId ? state.threads.find((thread) => thread.id === threadId) : undefined; - + threadId ? getThread(state, threadId) : undefined; export const selectSidebarThreadSummaryById = (threadId: ThreadId | null | undefined) => (state: AppState): SidebarThreadSummary | undefined => - threadId ? state.sidebarThreadsById[threadId] : undefined; - + threadId ? state.sidebarThreadSummaryById[threadId] : undefined; export const selectThreadIdsByProjectId = (projectId: ProjectId | null | undefined) => (state: AppState): ThreadId[] => projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { - return updateThreadState(state, threadId, (t) => { - if (t.error === error) return t; - return { ...t, error }; + return updateThreadState(state, threadId, (thread) => { + if (thread.error === error) return thread; + return { ...thread, error }; }); } @@ -1128,11 +1520,11 @@ export function setThreadBranch( branch: string | null, worktreePath: string | null, ): AppState { - return updateThreadState(state, threadId, (t) => { - if (t.branch === branch && t.worktreePath === worktreePath) return t; - const cwdChanged = t.worktreePath !== worktreePath; + return updateThreadState(state, threadId, (thread) => { + if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; + const cwdChanged = thread.worktreePath !== worktreePath; return { - ...t, + ...thread, branch, worktreePath, ...(cwdChanged ? { session: null } : {}), @@ -1140,8 +1532,6 @@ export function setThreadBranch( }); } -// ── Zustand store ──────────────────────────────────────────────────── - interface AppStore extends AppState { syncServerReadModel: (readModel: OrchestrationReadModel) => void; applyOrchestrationEvent: (event: OrchestrationEvent) => void; diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts index 65f8e6caaa..a7a7440eb2 100644 --- a/apps/web/src/storeSelectors.ts +++ b/apps/web/src/storeSelectors.ts @@ -1,26 +1,146 @@ -import { type ThreadId } from "@t3tools/contracts"; -import { useMemo } from "react"; +import { type MessageId, type ProjectId, type ThreadId, type TurnId } from "@t3tools/contracts"; +import { type AppState } from "./store"; import { - selectProjectById, - selectSidebarThreadSummaryById, - selectThreadById, - useStore, -} from "./store"; -import { type Project, type SidebarThreadSummary, type Thread } from "./types"; - -export function useProjectById(projectId: Project["id"] | null | undefined): Project | undefined { - const selector = useMemo(() => selectProjectById(projectId), [projectId]); - return useStore(selector); + type ChatMessage, + type Project, + type ProposedPlan, + type SidebarThreadSummary, + type Thread, + type ThreadSession, + type ThreadTurnState, + type TurnDiffSummary, +} from "./types"; + +const EMPTY_MESSAGES: ChatMessage[] = []; +const EMPTY_ACTIVITIES: Thread["activities"] = []; +const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; +const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; + +function collectByIds( + ids: readonly TKey[] | undefined, + byId: Record | undefined, +): TValue[] { + if (!ids || ids.length === 0 || !byId) { + return []; + } + + return ids.flatMap((id) => { + const value = byId[id]; + return value ? [value] : []; + }); } -export function useThreadById(threadId: ThreadId | null | undefined): Thread | undefined { - const selector = useMemo(() => selectThreadById(threadId), [threadId]); - return useStore(selector); +export function createProjectSelector( + projectId: ProjectId | null | undefined, +): (state: AppState) => Project | undefined { + return (state) => (projectId ? state.projectById[projectId] : undefined); +} + +export function createSidebarThreadSummarySelector( + threadId: ThreadId | null | undefined, +): (state: AppState) => SidebarThreadSummary | undefined { + return (state) => (threadId ? state.sidebarThreadSummaryById[threadId] : undefined); } -export function useSidebarThreadSummaryById( +export function createThreadSelector( threadId: ThreadId | null | undefined, -): SidebarThreadSummary | undefined { - const selector = useMemo(() => selectSidebarThreadSummaryById(threadId), [threadId]); - return useStore(selector); +): (state: AppState) => Thread | undefined { + let previousShell: AppState["threadShellById"][ThreadId] | undefined; + let previousSession: ThreadSession | null | undefined; + let previousTurnState: ThreadTurnState | undefined; + let previousMessageIds: MessageId[] | undefined; + let previousMessagesById: AppState["messageByThreadId"][ThreadId] | undefined; + let previousActivityIds: string[] | undefined; + let previousActivitiesById: AppState["activityByThreadId"][ThreadId] | undefined; + let previousProposedPlanIds: string[] | undefined; + let previousProposedPlansById: AppState["proposedPlanByThreadId"][ThreadId] | undefined; + let previousTurnDiffIds: TurnId[] | undefined; + let previousTurnDiffsById: AppState["turnDiffSummaryByThreadId"][ThreadId] | undefined; + let previousThread: Thread | undefined; + + return (state) => { + if (!threadId) { + return undefined; + } + + const shell = state.threadShellById[threadId]; + if (!shell) { + return undefined; + } + + const session = state.threadSessionById[threadId] ?? null; + const turnState = state.threadTurnStateById[threadId]; + const messageIds = state.messageIdsByThreadId[threadId]; + const messageById = state.messageByThreadId[threadId]; + const activityIds = state.activityIdsByThreadId[threadId]; + const activityById = state.activityByThreadId[threadId]; + const proposedPlanIds = state.proposedPlanIdsByThreadId[threadId]; + const proposedPlanById = state.proposedPlanByThreadId[threadId]; + const turnDiffIds = state.turnDiffIdsByThreadId[threadId]; + const turnDiffById = state.turnDiffSummaryByThreadId[threadId]; + + if ( + previousThread && + previousShell === shell && + previousSession === session && + previousTurnState === turnState && + previousMessageIds === messageIds && + previousMessagesById === messageById && + previousActivityIds === activityIds && + previousActivitiesById === activityById && + previousProposedPlanIds === proposedPlanIds && + previousProposedPlansById === proposedPlanById && + previousTurnDiffIds === turnDiffIds && + previousTurnDiffsById === turnDiffById + ) { + return previousThread; + } + + const nextThread: Thread = { + ...shell, + session, + latestTurn: turnState?.latestTurn ?? null, + pendingSourceProposedPlan: turnState?.pendingSourceProposedPlan, + messages: collectByIds(messageIds, messageById) as Thread["messages"] extends ChatMessage[] + ? ChatMessage[] + : never, + activities: collectByIds(activityIds, activityById) as Thread["activities"] extends Array< + infer _ + > + ? Thread["activities"] + : never, + proposedPlans: collectByIds( + proposedPlanIds, + proposedPlanById, + ) as Thread["proposedPlans"] extends ProposedPlan[] ? ProposedPlan[] : never, + turnDiffSummaries: collectByIds( + turnDiffIds, + turnDiffById, + ) as Thread["turnDiffSummaries"] extends TurnDiffSummary[] ? TurnDiffSummary[] : never, + }; + + previousShell = shell; + previousSession = session; + previousTurnState = turnState; + previousMessageIds = messageIds; + previousMessagesById = messageById; + previousActivityIds = activityIds; + previousActivitiesById = activityById; + previousProposedPlanIds = proposedPlanIds; + previousProposedPlansById = proposedPlanById; + previousTurnDiffIds = turnDiffIds; + previousTurnDiffsById = turnDiffById; + previousThread = { + ...nextThread, + messages: nextThread.messages.length === 0 ? EMPTY_MESSAGES : nextThread.messages, + activities: nextThread.activities.length === 0 ? EMPTY_ACTIVITIES : nextThread.activities, + proposedPlans: + nextThread.proposedPlans.length === 0 ? EMPTY_PROPOSED_PLANS : nextThread.proposedPlans, + turnDiffSummaries: + nextThread.turnDiffSummaries.length === 0 + ? EMPTY_TURN_DIFF_SUMMARIES + : nextThread.turnDiffSummaries, + }; + return previousThread; + }; } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 0599b9c989..972cf42bab 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -111,6 +111,27 @@ export interface Thread { activities: OrchestrationThreadActivity[]; } +export interface ThreadShell { + id: ThreadId; + codexThreadId: string | null; + projectId: ProjectId; + title: string; + modelSelection: ModelSelection; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + error: string | null; + createdAt: string; + archivedAt: string | null; + updatedAt?: string | undefined; + branch: string | null; + worktreePath: string | null; +} + +export interface ThreadTurnState { + latestTurn: OrchestrationLatestTurn | null; + pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; +} + export interface SidebarThreadSummary { id: ThreadId; projectId: ProjectId; From 72b7f90c5010cc43af87125b3e845a04589b7e5b Mon Sep 17 00:00:00 2001 From: Kyle Gottfried <6462596+Spitfire1900@users.noreply.github.com> Date: Wed, 8 Apr 2026 21:50:58 -0400 Subject: [PATCH 14/26] Add VSCode Insiders and VSCodium icons (#1847) --- apps/web/src/components/Icons.tsx | 121 ++++++++++++++++++ apps/web/src/components/chat/OpenInPicker.tsx | 6 +- 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 2e95b54e25..37a47bb01b 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -128,6 +128,127 @@ export const VisualStudioCode: Icon = (props) => { ); }; +export const VisualStudioCodeInsiders: Icon = (props) => { + const id = useId(); + const maskId = `${id}-vscode-insiders-a`; + const topShadowFilterId = `${id}-vscode-insiders-b`; + const sideShadowFilterId = `${id}-vscode-insiders-c`; + const overlayGradientId = `${id}-vscode-insiders-d`; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export const VSCodium: Icon = (props) => { + const id = useId(); + const gradientId = `${id}-vscodium-gradient`; + + return ( + + + + + + + + + + ); +}; + export const Zed: Icon = (props) => { const id = useId(); const clipPathId = `${id}-zed-logo-a`; diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 703bfadaa3..72ad6aabd1 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -13,6 +13,8 @@ import { TraeIcon, IntelliJIdeaIcon, VisualStudioCode, + VisualStudioCodeInsiders, + VSCodium, Zed, } from "../Icons"; import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; @@ -37,12 +39,12 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray Date: Wed, 8 Apr 2026 19:05:20 -0700 Subject: [PATCH 15/26] Prepare datamodel for multi-environment (#1765) Co-authored-by: justsomelegs <145564979+justsomelegs@users.noreply.github.com> Co-authored-by: codex Co-authored-by: Cursor Agent Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com> --- apps/desktop/src/main.ts | 9 + apps/desktop/src/preload.ts | 8 + .../OrchestrationEngineHarness.integration.ts | 2 + apps/server/src/config.ts | 2 + .../Layers/ServerEnvironment.test.ts | 124 + .../environment/Layers/ServerEnvironment.ts | 94 + .../environment/Services/ServerEnvironment.ts | 13 + apps/server/src/git/Layers/GitManager.test.ts | 6 +- .../Layers/CheckpointReactor.test.ts | 2 + .../Layers/OrchestrationEngine.test.ts | 5 + .../Layers/ProjectionPipeline.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.test.ts | 7 +- .../Layers/ProjectionSnapshotQuery.ts | 534 +-- .../Layers/ProviderCommandReactor.test.ts | 2 + .../Layers/ProviderRuntimeIngestion.test.ts | 2 + .../Layers/RepositoryIdentityResolver.test.ts | 193 + .../Layers/RepositoryIdentityResolver.ts | 147 + .../Services/RepositoryIdentityResolver.ts | 12 + apps/server/src/server.test.ts | 246 +- apps/server/src/server.ts | 4 + apps/server/src/serverLifecycleEvents.test.ts | 10 + apps/server/src/serverRuntimeStartup.ts | 14 +- apps/server/src/ws.ts | 56 +- apps/web/index.html | 1 + apps/web/package.json | 1 + apps/web/src/components/BranchToolbar.tsx | 50 +- .../BranchToolbarBranchSelector.tsx | 23 +- apps/web/src/components/ChatMarkdown.tsx | 4 +- apps/web/src/components/ChatView.browser.tsx | 559 +-- .../web/src/components/ChatView.logic.test.ts | 64 +- apps/web/src/components/ChatView.logic.ts | 45 +- apps/web/src/components/ChatView.tsx | 776 ++-- apps/web/src/components/DiffPanel.tsx | 41 +- .../components/GitActionsControl.browser.tsx | 120 +- apps/web/src/components/GitActionsControl.tsx | 62 +- .../components/KeybindingsToast.browser.tsx | 49 +- apps/web/src/components/PlanSidebar.tsx | 9 +- .../components/PullRequestThreadDialog.tsx | 10 +- apps/web/src/components/Sidebar.logic.test.ts | 6 +- apps/web/src/components/Sidebar.tsx | 3485 ++++++++++------- .../ThreadTerminalDrawer.browser.tsx | 247 ++ .../src/components/ThreadTerminalDrawer.tsx | 36 +- apps/web/src/components/chat/ChatHeader.tsx | 11 +- .../CompactComposerControlsMenu.browser.tsx | 62 +- .../chat/ComposerPendingUserInputPanel.tsx | 38 +- .../components/chat/MessagesTimeline.test.tsx | 4 + .../src/components/chat/MessagesTimeline.tsx | 5 +- ...essagesTimeline.virtualization.browser.tsx | 29 +- apps/web/src/components/chat/OpenInPicker.tsx | 6 +- .../src/components/chat/ProposedPlanCard.tsx | 7 +- .../components/chat/TraitsPicker.browser.tsx | 54 +- apps/web/src/components/chat/TraitsPicker.tsx | 17 +- .../chat/composerProviderRegistry.test.tsx | 34 +- .../chat/composerProviderRegistry.tsx | 146 +- .../settings/SettingsPanels.browser.tsx | 24 +- .../components/settings/SettingsPanels.tsx | 71 +- apps/web/src/composerDraftStore.test.ts | 490 ++- apps/web/src/composerDraftStore.ts | 2520 +++++++----- apps/web/src/editorPreferences.ts | 4 +- apps/web/src/environmentApi.ts | 67 + apps/web/src/environmentBootstrap.ts | 65 + apps/web/src/hooks/useHandleNewThread.ts | 123 +- apps/web/src/hooks/useSettings.ts | 10 +- apps/web/src/hooks/useThreadActions.ts | 192 +- apps/web/src/lib/gitReactQuery.test.ts | 43 +- apps/web/src/lib/gitReactQuery.ts | 158 +- apps/web/src/lib/gitStatusState.test.ts | 56 +- apps/web/src/lib/gitStatusState.ts | 154 +- apps/web/src/lib/projectReactQuery.ts | 25 +- apps/web/src/lib/providerReactQuery.test.ts | 15 +- apps/web/src/lib/providerReactQuery.ts | 15 +- apps/web/src/lib/terminalStateCleanup.test.ts | 33 +- apps/web/src/lib/terminalStateCleanup.ts | 20 +- apps/web/src/lib/utils.test.ts | 42 +- apps/web/src/lib/utils.ts | 21 +- .../{wsNativeApi.test.ts => localApi.test.ts} | 77 +- apps/web/src/localApi.ts | 94 + apps/web/src/logicalProject.ts | 19 + apps/web/src/nativeApi.ts | 31 - .../web/src/orchestrationEventEffects.test.ts | 6 +- apps/web/src/orchestrationEventEffects.ts | 8 +- apps/web/src/routeTree.gen.ts | 56 +- apps/web/src/routes/__root.tsx | 485 ++- ...tsx => _chat.$environmentId.$threadId.tsx} | 85 +- apps/web/src/routes/_chat.draft.$draftId.tsx | 86 + apps/web/src/routes/_chat.tsx | 28 +- apps/web/src/rpc/serverState.test.ts | 18 + apps/web/src/store.test.ts | 271 +- apps/web/src/store.ts | 428 +- apps/web/src/storeSelectors.ts | 95 +- apps/web/src/terminalStateStore.test.ts | 205 +- apps/web/src/terminalStateStore.ts | 292 +- apps/web/src/threadRoutes.test.ts | 67 + apps/web/src/threadRoutes.ts | 59 + apps/web/src/threadSelectionStore.test.ts | 106 +- apps/web/src/threadSelectionStore.ts | 104 +- apps/web/src/types.ts | 7 + apps/web/src/uiStateStore.test.ts | 22 +- apps/web/src/uiStateStore.ts | 65 +- apps/web/src/vite-env.d.ts | 4 +- apps/web/src/worktreeCleanup.test.ts | 5 +- apps/web/src/wsNativeApi.ts | 117 - apps/web/src/wsRpcClient.test.ts | 40 +- apps/web/src/wsRpcClient.ts | 165 +- apps/web/src/wsTransport.test.ts | 21 + bun.lock | 15 + packages/client-runtime/package.json | 25 + packages/client-runtime/src/index.ts | 2 + .../src/knownEnvironment.test.ts | 63 + .../client-runtime/src/knownEnvironment.ts | 39 + packages/client-runtime/src/scoped.ts | 64 + packages/client-runtime/tsconfig.json | 4 + packages/contracts/src/baseSchemas.ts | 2 + packages/contracts/src/environment.ts | 77 + packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 66 +- packages/contracts/src/orchestration.ts | 4 + packages/contracts/src/server.ts | 4 + packages/shared/src/git.test.ts | 49 +- packages/shared/src/git.ts | 50 + scripts/release-smoke.ts | 1 + 121 files changed, 10206 insertions(+), 4969 deletions(-) create mode 100644 apps/server/src/environment/Layers/ServerEnvironment.test.ts create mode 100644 apps/server/src/environment/Layers/ServerEnvironment.ts create mode 100644 apps/server/src/environment/Services/ServerEnvironment.ts create mode 100644 apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts create mode 100644 apps/server/src/project/Layers/RepositoryIdentityResolver.ts create mode 100644 apps/server/src/project/Services/RepositoryIdentityResolver.ts create mode 100644 apps/web/src/components/ThreadTerminalDrawer.browser.tsx create mode 100644 apps/web/src/environmentApi.ts create mode 100644 apps/web/src/environmentBootstrap.ts rename apps/web/src/{wsNativeApi.test.ts => localApi.test.ts} (84%) create mode 100644 apps/web/src/localApi.ts create mode 100644 apps/web/src/logicalProject.ts delete mode 100644 apps/web/src/nativeApi.ts rename apps/web/src/routes/{_chat.$threadId.tsx => _chat.$environmentId.$threadId.tsx} (73%) create mode 100644 apps/web/src/routes/_chat.draft.$draftId.tsx create mode 100644 apps/web/src/threadRoutes.test.ts create mode 100644 apps/web/src/threadRoutes.ts delete mode 100644 apps/web/src/wsNativeApi.ts create mode 100644 packages/client-runtime/package.json create mode 100644 packages/client-runtime/src/index.ts create mode 100644 packages/client-runtime/src/knownEnvironment.test.ts create mode 100644 packages/client-runtime/src/knownEnvironment.ts create mode 100644 packages/client-runtime/src/scoped.ts create mode 100644 packages/client-runtime/tsconfig.json create mode 100644 packages/contracts/src/environment.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3d61571df9..de327d0ff8 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -60,6 +60,7 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; +const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SCHEME = "t3"; @@ -1172,6 +1173,14 @@ function registerIpcHandlers(): void { event.returnValue = backendWsUrl; }); + ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); + ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { + event.returnValue = { + label: "Local environment", + wsUrl: backendWsUrl || null, + } as const; + }); + ipcMain.removeHandler(PICK_FOLDER_CHANNEL); ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 3d59db1714..bd678844ef 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -13,12 +13,20 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; +const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: () => { const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL); return typeof result === "string" ? result : null; }, + getLocalEnvironmentBootstrap: () => { + const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); + if (typeof result !== "object" || result === null) { + return null; + } + return result as ReturnType; + }, pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 87c81f08c8..dfadff9e70 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -45,6 +45,7 @@ import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; import { ProviderService } from "../src/provider/Services/ProviderService.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; +import { RepositoryIdentityResolverLive } from "../src/project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; @@ -338,6 +339,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(orchestrationReactorLayer), Layer.provide(persistenceLayer), + Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 9ceea4c13c..887eb11c4f 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -30,6 +30,7 @@ export interface ServerDerivedPaths { readonly providerEventLogPath: string; readonly terminalLogsDir: string; readonly anonymousIdPath: string; + readonly environmentIdPath: string; } /** @@ -83,6 +84,7 @@ export const deriveServerPaths = Effect.fn(function* ( providerEventLogPath: join(providerLogsDir, "events.log"), terminalLogsDir: join(logsDir, "terminals"), anonymousIdPath: join(stateDir, "anonymous-id"), + environmentIdPath: join(stateDir, "environment-id"), }; }); diff --git a/apps/server/src/environment/Layers/ServerEnvironment.test.ts b/apps/server/src/environment/Layers/ServerEnvironment.test.ts new file mode 100644 index 0000000000..a9668760f2 --- /dev/null +++ b/apps/server/src/environment/Layers/ServerEnvironment.test.ts @@ -0,0 +1,124 @@ +import * as nodePath from "node:path"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect"; + +import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "../../config.ts"; +import { ServerEnvironment } from "../Services/ServerEnvironment.ts"; +import { ServerEnvironmentLive } from "./ServerEnvironment.ts"; + +const makeServerEnvironmentLayer = (baseDir: string) => + ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); + +const makeServerConfig = Effect.fn(function* (baseDir: string) { + const derivedPaths = yield* deriveServerPaths(baseDir, undefined); + + return { + ...derivedPaths, + logLevel: "Error", + traceMinLevel: "Info", + traceTimingEnabled: true, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + cwd: process.cwd(), + baseDir, + mode: "web", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + port: 0, + host: undefined, + authToken: undefined, + staticDir: undefined, + devUrl: undefined, + noBrowser: false, + } satisfies ServerConfigShape; +}); + +it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { + it.effect("persists the environment id across service restarts", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-test-", + }); + + const first = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); + const second = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); + + expect(first.environmentId).toBe(second.environmentId); + expect(second.capabilities.repositoryIdentity).toBe(true); + }), + ); + + it.effect("fails instead of overwriting a persisted id when reading the file errors", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-read-error-test-", + }); + const serverConfig = yield* makeServerConfig(baseDir); + const environmentIdPath = serverConfig.environmentIdPath; + yield* fileSystem.makeDirectory(nodePath.dirname(environmentIdPath), { recursive: true }); + yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n"); + const writeAttempts: string[] = []; + const failingFileSystemLayer = FileSystem.layerNoop({ + exists: (path) => Effect.succeed(path === environmentIdPath), + readFileString: (path) => + path === environmentIdPath + ? Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + description: "permission denied", + pathOrDescriptor: path, + }), + ) + : Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFileString", + description: "not found", + pathOrDescriptor: path, + }), + ), + writeFileString: (path) => { + writeAttempts.push(path); + return Effect.void; + }, + }); + + const exit = yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironmentLive.pipe( + Layer.provide( + Layer.merge(Layer.succeed(ServerConfig, serverConfig), failingFileSystemLayer), + ), + ), + ), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(writeAttempts).toEqual([]); + expect(yield* fileSystem.readFileString(environmentIdPath)).toBe( + "persisted-environment-id\n", + ); + }), + ); +}); diff --git a/apps/server/src/environment/Layers/ServerEnvironment.ts b/apps/server/src/environment/Layers/ServerEnvironment.ts new file mode 100644 index 0000000000..fd58425dee --- /dev/null +++ b/apps/server/src/environment/Layers/ServerEnvironment.ts @@ -0,0 +1,94 @@ +import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import { Effect, FileSystem, Layer, Path, Random } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts"; +import { version } from "../../../package.json" with { type: "json" }; + +function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] { + switch (process.platform) { + case "darwin": + return "darwin"; + case "linux": + return "linux"; + case "win32": + return "windows"; + default: + return "unknown"; + } +} + +function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] { + switch (process.arch) { + case "arm64": + return "arm64"; + case "x64": + return "x64"; + default: + return "other"; + } +} + +export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + + const readPersistedEnvironmentId = Effect.gen(function* () { + const exists = yield* fileSystem + .exists(serverConfig.environmentIdPath) + .pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return null; + } + + const raw = yield* fileSystem + .readFileString(serverConfig.environmentIdPath) + .pipe(Effect.map((value) => value.trim())); + + return raw.length > 0 ? raw : null; + }); + + const persistEnvironmentId = (value: string) => + fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`); + + const environmentIdRaw = yield* Effect.gen(function* () { + const persisted = yield* readPersistedEnvironmentId; + if (persisted) { + return persisted; + } + + const generated = yield* Random.nextUUIDv4; + yield* persistEnvironmentId(generated); + return generated; + }); + + const environmentId = EnvironmentId.makeUnsafe(environmentIdRaw); + const cwdBaseName = path.basename(serverConfig.cwd).trim(); + const label = + serverConfig.mode === "desktop" + ? "Local environment" + : cwdBaseName.length > 0 + ? cwdBaseName + : "T3 environment"; + + const descriptor: ExecutionEnvironmentDescriptor = { + environmentId, + label, + platform: { + os: platformOs(), + arch: platformArch(), + }, + serverVersion: version, + capabilities: { + repositoryIdentity: true, + }, + }; + + return { + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.succeed(descriptor), + } satisfies ServerEnvironmentShape; +}); + +export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment()); diff --git a/apps/server/src/environment/Services/ServerEnvironment.ts b/apps/server/src/environment/Services/ServerEnvironment.ts new file mode 100644 index 0000000000..9cf432ca72 --- /dev/null +++ b/apps/server/src/environment/Services/ServerEnvironment.ts @@ -0,0 +1,13 @@ +import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface ServerEnvironmentShape { + readonly getEnvironmentId: Effect.Effect; + readonly getDescriptor: Effect.Effect; +} + +export class ServerEnvironment extends ServiceMap.Service< + ServerEnvironment, + ServerEnvironmentShape +>()("t3/environment/Services/ServerEnvironment") {} diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 005bdb5bc6..38cbd13014 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -854,7 +854,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), - 12_000, + 20_000, ); it.effect( @@ -962,7 +962,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ), ).toBe(false); }), - 12_000, + 20_000, ); it.effect("status returns merged PR state when latest PR was merged", () => @@ -1685,7 +1685,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { false, ); }), - 12_000, + 20_000, ); it.effect( diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 72adb175f9..d97d0f71fa 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -20,6 +20,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; import { GitCoreLive } from "../../git/Layers/GitCore.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -256,6 +257,7 @@ describe("CheckpointReactor", () => { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 5a0a6113f0..77b12e86ae 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -19,6 +19,7 @@ import { OrchestrationEventStore, type OrchestrationEventStoreShape, } from "../../persistence/Services/OrchestrationEventStore.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; @@ -45,6 +46,7 @@ async function createOrchestrationSystem() { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), @@ -623,6 +625,7 @@ describe("OrchestrationEngine", () => { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), @@ -719,6 +722,7 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ), ); @@ -861,6 +865,7 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 1850745469..6835f79d01 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -20,6 +20,7 @@ import { SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { ORCHESTRATION_PROJECTOR_NAMES, @@ -1846,6 +1847,7 @@ const engineLayer = it.layer( Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index c038bc9d2c..0a9d90107f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -4,6 +4,7 @@ import { Effect, Layer } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; @@ -15,7 +16,10 @@ const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); const projectionSnapshotLayer = it.layer( - OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + OrchestrationProjectionSnapshotQueryLive.pipe( + Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(SqlitePersistenceMemory), + ), ); projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { @@ -234,6 +238,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", + repositoryIdentity: null, defaultModelSelection: { provider: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index da7c695674..b0f883f940 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -38,6 +38,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; +import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { ProjectionSnapshotQuery, @@ -163,6 +164,8 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st const makeProjectionSnapshotQuery = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const repositoryIdentityResolver = yield* RepositoryIdentityResolver; + const repositoryIdentityResolutionConcurrency = 4; const listProjectRows = SqlSchema.findAll({ Request: Schema.Void, @@ -436,269 +439,283 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () => sql .withTransaction( - Effect.gen(function* () { - const [ - projectRows, - threadRows, - messageRows, - proposedPlanRows, - activityRows, - sessionRows, - checkpointRows, - latestTurnRows, - stateRows, - ] = yield* Effect.all([ - listProjectRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listProjects:query", - "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows", - ), - ), - ), - listThreadRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreads:query", - "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows", - ), + Effect.all([ + listProjectRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listProjects:query", + "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows", ), ), - listThreadMessageRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query", - "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows", - ), + ), + listThreadRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreads:query", + "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows", ), ), - listThreadProposedPlanRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query", - "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows", - ), + ), + listThreadMessageRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows", ), ), - listThreadActivityRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query", - "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows", - ), + ), + listThreadProposedPlanRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows", ), ), - listThreadSessionRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query", - "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows", - ), + ), + listThreadActivityRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows", ), ), - listCheckpointRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query", - "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows", - ), + ), + listThreadSessionRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query", + "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows", ), ), - listLatestTurnRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query", - "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows", - ), + ), + listCheckpointRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query", + "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows", ), ), - listProjectionStateRows(undefined).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query", - "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows", - ), + ), + listLatestTurnRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query", + "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows", ), ), - ]); - - const messagesByThread = new Map>(); - const proposedPlansByThread = new Map>(); - const activitiesByThread = new Map>(); - const checkpointsByThread = new Map>(); - const sessionsByThread = new Map(); - const latestTurnByThread = new Map(); - - let updatedAt: string | null = null; - - for (const row of projectRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of threadRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of stateRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - - for (const row of messageRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - const threadMessages = messagesByThread.get(row.threadId) ?? []; - threadMessages.push({ - id: row.messageId, - role: row.role, - text: row.text, - ...(row.attachments !== null ? { attachments: row.attachments } : {}), - turnId: row.turnId, - streaming: row.isStreaming === 1, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }); - messagesByThread.set(row.threadId, threadMessages); - } - - for (const row of proposedPlanRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? []; - threadProposedPlans.push({ - id: row.planId, - turnId: row.turnId, - planMarkdown: row.planMarkdown, - implementedAt: row.implementedAt, - implementationThreadId: row.implementationThreadId, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }); - proposedPlansByThread.set(row.threadId, threadProposedPlans); - } - - for (const row of activityRows) { - updatedAt = maxIso(updatedAt, row.createdAt); - const threadActivities = activitiesByThread.get(row.threadId) ?? []; - threadActivities.push({ - id: row.activityId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - turnId: row.turnId, - ...(row.sequence !== null ? { sequence: row.sequence } : {}), - createdAt: row.createdAt, - }); - activitiesByThread.set(row.threadId, threadActivities); - } - - for (const row of checkpointRows) { - updatedAt = maxIso(updatedAt, row.completedAt); - const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? []; - threadCheckpoints.push({ - turnId: row.turnId, - checkpointTurnCount: row.checkpointTurnCount, - checkpointRef: row.checkpointRef, - status: row.status, - files: row.files, - assistantMessageId: row.assistantMessageId, - completedAt: row.completedAt, - }); - checkpointsByThread.set(row.threadId, threadCheckpoints); - } - - for (const row of latestTurnRows) { - updatedAt = maxIso(updatedAt, row.requestedAt); - if (row.startedAt !== null) { - updatedAt = maxIso(updatedAt, row.startedAt); - } - if (row.completedAt !== null) { - updatedAt = maxIso(updatedAt, row.completedAt); - } - if (latestTurnByThread.has(row.threadId)) { - continue; - } - latestTurnByThread.set(row.threadId, { - turnId: row.turnId, - state: - row.state === "error" - ? "error" - : row.state === "interrupted" - ? "interrupted" - : row.state === "completed" - ? "completed" - : "running", - requestedAt: row.requestedAt, - startedAt: row.startedAt, - completedAt: row.completedAt, - assistantMessageId: row.assistantMessageId, - ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null - ? { - sourceProposedPlan: { - threadId: row.sourceProposedPlanThreadId, - planId: row.sourceProposedPlanId, - }, - } - : {}), - }); - } - - for (const row of sessionRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - sessionsByThread.set(row.threadId, { - threadId: row.threadId, - status: row.status, - providerName: row.providerName, - runtimeMode: row.runtimeMode, - activeTurnId: row.activeTurnId, - lastError: row.lastError, - updatedAt: row.updatedAt, - }); - } - - const projects: ReadonlyArray = projectRows.map((row) => ({ - id: row.projectId, - title: row.title, - workspaceRoot: row.workspaceRoot, - defaultModelSelection: row.defaultModelSelection, - scripts: row.scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - })); - - const threads: ReadonlyArray = threadRows.map((row) => ({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - modelSelection: row.modelSelection, - runtimeMode: row.runtimeMode, - interactionMode: row.interactionMode, - branch: row.branch, - worktreePath: row.worktreePath, - latestTurn: latestTurnByThread.get(row.threadId) ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - archivedAt: row.archivedAt, - deletedAt: row.deletedAt, - messages: messagesByThread.get(row.threadId) ?? [], - proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], - activities: activitiesByThread.get(row.threadId) ?? [], - checkpoints: checkpointsByThread.get(row.threadId) ?? [], - session: sessionsByThread.get(row.threadId) ?? null, - })); - - const snapshot = { - snapshotSequence: computeSnapshotSequence(stateRows), - projects, - threads, - updatedAt: updatedAt ?? new Date(0).toISOString(), - }; - - return yield* decodeReadModel(snapshot).pipe( + ), + listProjectionStateRows(undefined).pipe( Effect.mapError( - toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"), + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query", + "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows", + ), ), - ); - }), + ), + ]), ) .pipe( + Effect.flatMap( + ([ + projectRows, + threadRows, + messageRows, + proposedPlanRows, + activityRows, + sessionRows, + checkpointRows, + latestTurnRows, + stateRows, + ]) => + Effect.gen(function* () { + const messagesByThread = new Map>(); + const proposedPlansByThread = new Map>(); + const activitiesByThread = new Map>(); + const checkpointsByThread = new Map>(); + const sessionsByThread = new Map(); + const latestTurnByThread = new Map(); + + let updatedAt: string | null = null; + + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + + for (const row of messageRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + const threadMessages = messagesByThread.get(row.threadId) ?? []; + threadMessages.push({ + id: row.messageId, + role: row.role, + text: row.text, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + messagesByThread.set(row.threadId, threadMessages); + } + + for (const row of proposedPlanRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? []; + threadProposedPlans.push({ + id: row.planId, + turnId: row.turnId, + planMarkdown: row.planMarkdown, + implementedAt: row.implementedAt, + implementationThreadId: row.implementationThreadId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + proposedPlansByThread.set(row.threadId, threadProposedPlans); + } + + for (const row of activityRows) { + updatedAt = maxIso(updatedAt, row.createdAt); + const threadActivities = activitiesByThread.get(row.threadId) ?? []; + threadActivities.push({ + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + ...(row.sequence !== null ? { sequence: row.sequence } : {}), + createdAt: row.createdAt, + }); + activitiesByThread.set(row.threadId, threadActivities); + } + + for (const row of checkpointRows) { + updatedAt = maxIso(updatedAt, row.completedAt); + const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? []; + threadCheckpoints.push({ + turnId: row.turnId, + checkpointTurnCount: row.checkpointTurnCount, + checkpointRef: row.checkpointRef, + status: row.status, + files: row.files, + assistantMessageId: row.assistantMessageId, + completedAt: row.completedAt, + }); + checkpointsByThread.set(row.threadId, threadCheckpoints); + } + + for (const row of latestTurnRows) { + updatedAt = maxIso(updatedAt, row.requestedAt); + if (row.startedAt !== null) { + updatedAt = maxIso(updatedAt, row.startedAt); + } + if (row.completedAt !== null) { + updatedAt = maxIso(updatedAt, row.completedAt); + } + if (latestTurnByThread.has(row.threadId)) { + continue; + } + latestTurnByThread.set(row.threadId, { + turnId: row.turnId, + state: + row.state === "error" + ? "error" + : row.state === "interrupted" + ? "interrupted" + : row.state === "completed" + ? "completed" + : "running", + requestedAt: row.requestedAt, + startedAt: row.startedAt, + completedAt: row.completedAt, + assistantMessageId: row.assistantMessageId, + ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null + ? { + sourceProposedPlan: { + threadId: row.sourceProposedPlanThreadId, + planId: row.sourceProposedPlanId, + }, + } + : {}), + }); + } + + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + sessionsByThread.set(row.threadId, { + threadId: row.threadId, + status: row.status, + providerName: row.providerName, + runtimeMode: row.runtimeMode, + activeTurnId: row.activeTurnId, + lastError: row.lastError, + updatedAt: row.updatedAt, + }); + } + + const repositoryIdentities = new Map( + yield* Effect.forEach( + projectRows, + (row) => + repositoryIdentityResolver + .resolve(row.workspaceRoot) + .pipe(Effect.map((identity) => [row.projectId, identity] as const)), + { concurrency: repositoryIdentityResolutionConcurrency }, + ), + ); + + const projects: ReadonlyArray = projectRows.map((row) => ({ + id: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, + defaultModelSelection: row.defaultModelSelection, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + })); + + const threads: ReadonlyArray = threadRows.map((row) => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurn: latestTurnByThread.get(row.threadId) ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + deletedAt: row.deletedAt, + messages: messagesByThread.get(row.threadId) ?? [], + proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], + activities: activitiesByThread.get(row.threadId) ?? [], + checkpoints: checkpointsByThread.get(row.threadId) ?? [], + session: sessionsByThread.get(row.threadId) ?? null, + })); + + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects, + threads, + updatedAt: updatedAt ?? new Date(0).toISOString(), + }; + + return yield* decodeReadModel(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"), + ), + ); + }), + ), Effect.mapError((error) => { if (isPersistenceError(error)) { return error; @@ -732,19 +749,24 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow", ), ), - Effect.map( - Option.map( - (row): OrchestrationProject => ({ - id: row.projectId, - title: row.title, - workspaceRoot: row.workspaceRoot, - defaultModelSelection: row.defaultModelSelection, - scripts: row.scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - }), - ), + Effect.flatMap((option) => + Option.isNone(option) + ? Effect.succeed(Option.none()) + : repositoryIdentityResolver.resolve(option.value.workspaceRoot).pipe( + Effect.map((repositoryIdentity) => + Option.some({ + id: option.value.projectId, + title: option.value.title, + workspaceRoot: option.value.workspaceRoot, + repositoryIdentity, + defaultModelSelection: option.value.defaultModelSelection, + scripts: option.value.scripts, + createdAt: option.value.createdAt, + updatedAt: option.value.updatedAt, + deletedAt: option.value.deletedAt, + } satisfies OrchestrationProject), + ), + ), ), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index fe6cb9caf5..f41a596ceb 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -28,6 +28,7 @@ import { } from "../../provider/Services/ProviderService.ts"; import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; @@ -218,6 +219,7 @@ describe("ProviderCommandReactor", () => { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ); const layer = ProviderCommandReactorLive.pipe( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 85f4d966e3..30f43365d9 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -29,6 +29,7 @@ import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; @@ -204,6 +205,7 @@ describe("ProviderRuntimeIngestion", () => { Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), ); const layer = ProviderRuntimeIngestionLive.pipe( diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts new file mode 100644 index 0000000000..57f4464804 --- /dev/null +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts @@ -0,0 +1,193 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Duration, Effect, FileSystem, Layer } from "effect"; +import { TestClock } from "effect/testing"; + +import { runProcess } from "../../processRunner.ts"; +import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts"; +import { + makeRepositoryIdentityResolver, + RepositoryIdentityResolverLive, +} from "./RepositoryIdentityResolver.ts"; + +const git = (cwd: string, args: ReadonlyArray) => + Effect.promise(() => runProcess("git", ["-C", cwd, ...args])); + +const makeRepositoryIdentityResolverTestLayer = (options: { + readonly positiveCacheTtl?: Duration.Input; + readonly negativeCacheTtl?: Duration.Input; +}) => + Layer.effect( + RepositoryIdentityResolver, + makeRepositoryIdentityResolver({ + cacheCapacity: 16, + ...options, + }), + ); + +it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { + it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(identity?.displayName).toBe("t3tools/t3code"); + expect(identity?.provider).toBe("github"); + expect(identity?.owner).toBe("t3tools"); + expect(identity?.name).toBe("t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + + it.effect("returns null for non-git folders and repos without remotes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-non-git-", + }); + const gitDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-no-remote-", + }); + + yield* git(gitDir, ["init"]); + + const resolver = yield* RepositoryIdentityResolver; + const nonGitIdentity = yield* resolver.resolve(nonGitDir); + const noRemoteIdentity = yield* resolver.resolve(gitDir); + + expect(nonGitIdentity).toBeNull(); + expect(noRemoteIdentity).toBeNull(); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + + it.effect("prefers upstream over origin when both remotes are configured", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-upstream-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); + yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity).not.toBeNull(); + expect(identity?.locator.remoteName).toBe("upstream"); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(identity?.displayName).toBe("t3tools/t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + + it.effect("uses the last remote path segment as the repository name for nested groups", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-nested-group-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@gitlab.com:T3Tools/platform/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const identity = yield* resolver.resolve(cwd); + + expect(identity).not.toBeNull(); + expect(identity?.canonicalKey).toBe("gitlab.com/t3tools/platform/t3code"); + expect(identity?.displayName).toBe("t3tools/platform/t3code"); + expect(identity?.owner).toBe("t3tools"); + expect(identity?.name).toBe("t3code"); + }).pipe(Effect.provide(RepositoryIdentityResolverLive)), + ); + + it.effect( + "refreshes cached null identities after the negative TTL when a remote is configured later", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-late-remote-test-", + }); + + yield* git(cwd, ["init"]); + + const resolver = yield* RepositoryIdentityResolver; + const initialIdentity = yield* resolver.resolve(cwd); + expect(initialIdentity).toBeNull(); + + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const cachedIdentity = yield* resolver.resolve(cwd); + expect(cachedIdentity).toBeNull(); + + yield* TestClock.adjust(Duration.millis(120)); + + const refreshedIdentity = yield* resolver.resolve(cwd); + expect(refreshedIdentity).not.toBeNull(); + expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(refreshedIdentity?.name).toBe("t3code"); + }).pipe( + Effect.provide( + Layer.merge( + TestClock.layer(), + makeRepositoryIdentityResolverTestLayer({ + negativeCacheTtl: Duration.millis(50), + positiveCacheTtl: Duration.seconds(1), + }), + ), + ), + ), + ); + + it.effect("refreshes cached identities after the positive TTL when a remote changes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-remote-change-test-", + }); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]); + + const resolver = yield* RepositoryIdentityResolver; + const initialIdentity = yield* resolver.resolve(cwd); + expect(initialIdentity).not.toBeNull(); + expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); + + yield* git(cwd, ["remote", "set-url", "origin", "git@github.com:T3Tools/t3code-next.git"]); + + const cachedIdentity = yield* resolver.resolve(cwd); + expect(cachedIdentity).not.toBeNull(); + expect(cachedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code"); + + yield* TestClock.adjust(Duration.millis(180)); + + const refreshedIdentity = yield* resolver.resolve(cwd); + expect(refreshedIdentity).not.toBeNull(); + expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code-next"); + expect(refreshedIdentity?.displayName).toBe("t3tools/t3code-next"); + expect(refreshedIdentity?.name).toBe("t3code-next"); + }).pipe( + Effect.provide( + Layer.merge( + TestClock.layer(), + makeRepositoryIdentityResolverTestLayer({ + negativeCacheTtl: Duration.millis(50), + positiveCacheTtl: Duration.millis(100), + }), + ), + ), + ), + ); +}); diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts new file mode 100644 index 0000000000..4e33f5c162 --- /dev/null +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -0,0 +1,147 @@ +import type { RepositoryIdentity } from "@t3tools/contracts"; +import { Cache, Duration, Effect, Exit, Layer } from "effect"; +import { detectGitHostingProviderFromRemoteUrl, normalizeGitRemoteUrl } from "@t3tools/shared/git"; + +import { runProcess } from "../../processRunner.ts"; +import { + RepositoryIdentityResolver, + type RepositoryIdentityResolverShape, +} from "../Services/RepositoryIdentityResolver.ts"; + +function parseRemoteFetchUrls(stdout: string): Map { + const remotes = new Map(); + for (const line of stdout.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + if (!match) continue; + const [, remoteName = "", remoteUrl = "", direction = ""] = match; + if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { + continue; + } + remotes.set(remoteName, remoteUrl); + } + return remotes; +} + +function pickPrimaryRemote( + remotes: ReadonlyMap, +): { readonly remoteName: string; readonly remoteUrl: string } | null { + for (const preferredRemoteName of ["upstream", "origin"] as const) { + const remoteUrl = remotes.get(preferredRemoteName); + if (remoteUrl) { + return { remoteName: preferredRemoteName, remoteUrl }; + } + } + + const [remoteName, remoteUrl] = + [...remotes.entries()].toSorted(([left], [right]) => left.localeCompare(right))[0] ?? []; + return remoteName && remoteUrl ? { remoteName, remoteUrl } : null; +} + +function buildRepositoryIdentity(input: { + readonly remoteName: string; + readonly remoteUrl: string; +}): RepositoryIdentity { + const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); + const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl); + const repositoryPath = canonicalKey.split("/").slice(1).join("/"); + const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); + const [owner] = repositoryPathSegments; + const repositoryName = repositoryPathSegments.at(-1); + + return { + canonicalKey, + locator: { + source: "git-remote", + remoteName: input.remoteName, + remoteUrl: input.remoteUrl, + }, + ...(repositoryPath ? { displayName: repositoryPath } : {}), + ...(hostingProvider ? { provider: hostingProvider.kind } : {}), + ...(owner ? { owner } : {}), + ...(repositoryName ? { name: repositoryName } : {}), + }; +} + +const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; +const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1); +const DEFAULT_NEGATIVE_CACHE_TTL = Duration.seconds(10); + +interface RepositoryIdentityResolverOptions { + readonly cacheCapacity?: number; + readonly positiveCacheTtl?: Duration.Input; + readonly negativeCacheTtl?: Duration.Input; +} + +async function resolveRepositoryIdentityCacheKey(cwd: string): Promise { + let cacheKey = cwd; + + try { + const topLevelResult = await runProcess("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { + allowNonZeroExit: true, + }); + if (topLevelResult.code !== 0) { + return cacheKey; + } + + const candidate = topLevelResult.stdout.trim(); + if (candidate.length > 0) { + cacheKey = candidate; + } + } catch { + return cacheKey; + } + + return cacheKey; +} + +async function resolveRepositoryIdentityFromCacheKey( + cacheKey: string, +): Promise { + try { + const remoteResult = await runProcess("git", ["-C", cacheKey, "remote", "-v"], { + allowNonZeroExit: true, + }); + if (remoteResult.code !== 0) { + return null; + } + + const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout)); + return remote ? buildRepositoryIdentity(remote) : null; + } catch { + return null; + } +} + +export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")( + function* (options: RepositoryIdentityResolverOptions = {}) { + const repositoryIdentityCache = yield* Cache.makeWith({ + capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, + lookup: (cacheKey) => Effect.promise(() => resolveRepositoryIdentityFromCacheKey(cacheKey)), + timeToLive: Exit.match({ + onSuccess: (value) => + value === null + ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL) + : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), + onFailure: () => Duration.zero, + }), + }); + + const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn( + "RepositoryIdentityResolver.resolve", + )(function* (cwd) { + const cacheKey = yield* Effect.promise(() => resolveRepositoryIdentityCacheKey(cwd)); + return yield* Cache.get(repositoryIdentityCache, cacheKey); + }); + + return { + resolve, + } satisfies RepositoryIdentityResolverShape; + }, +); + +export const RepositoryIdentityResolverLive = Layer.effect( + RepositoryIdentityResolver, + makeRepositoryIdentityResolver(), +); diff --git a/apps/server/src/project/Services/RepositoryIdentityResolver.ts b/apps/server/src/project/Services/RepositoryIdentityResolver.ts new file mode 100644 index 0000000000..2847cbca11 --- /dev/null +++ b/apps/server/src/project/Services/RepositoryIdentityResolver.ts @@ -0,0 +1,12 @@ +import type { RepositoryIdentity } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface RepositoryIdentityResolverShape { + readonly resolve: (cwd: string) => Effect.Effect; +} + +export class RepositoryIdentityResolver extends ServiceMap.Service< + RepositoryIdentityResolver, + RepositoryIdentityResolverShape +>()("t3/project/Services/RepositoryIdentityResolver") {} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 072e1ca172..125cfd103a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5,6 +5,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { CommandId, DEFAULT_SERVER_SETTINGS, + EnvironmentId, + EventId, GitCommandError, KeybindingRule, MessageId, @@ -83,6 +85,14 @@ import { ProjectSetupScriptRunner, type ProjectSetupScriptRunnerShape, } from "./project/Services/ProjectSetupScriptRunner.ts"; +import { + RepositoryIdentityResolver, + type RepositoryIdentityResolverShape, +} from "./project/Services/RepositoryIdentityResolver.ts"; +import { + ServerEnvironment, + type ServerEnvironmentShape, +} from "./environment/Services/ServerEnvironment.ts"; import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; @@ -93,6 +103,18 @@ const defaultModelSelection = { provider: "codex", model: "gpt-5-codex", } as const; +const testEnvironmentDescriptor = { + environmentId: EnvironmentId.makeUnsafe("environment-test"), + label: "Test environment", + platform: { + os: "darwin" as const, + arch: "arm64" as const, + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, +}; const makeDefaultOrchestrationReadModel = () => { const now = new Date().toISOString(); @@ -270,6 +292,8 @@ const buildAppUnderTest = (options?: { browserTraceCollector?: Partial; serverLifecycleEvents?: Partial; serverRuntimeStartup?: Partial; + serverEnvironment?: Partial; + repositoryIdentityResolver?: Partial; }; }) => Effect.gen(function* () { @@ -416,6 +440,19 @@ const buildAppUnderTest = (options?: { ...options?.layers?.serverRuntimeStartup, }), ), + Layer.provide( + Layer.mock(ServerEnvironment)({ + getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), + getDescriptor: Effect.succeed(testEnvironmentDescriptor), + ...options?.layers?.serverEnvironment, + }), + ), + Layer.provide( + Layer.mock(RepositoryIdentityResolver)({ + resolve: () => Effect.succeed(null), + ...options?.layers?.repositoryIdentityResolver, + }), + ), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), Layer.provide(layerConfig), @@ -1069,6 +1106,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { sequence: 1, type: "welcome" as const, payload: { + environment: testEnvironmentDescriptor, cwd: "/tmp/project", projectName: "project", }, @@ -1078,7 +1116,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { version: 1 as const, sequence: 2, type: "ready" as const, - payload: { at: new Date().toISOString() }, + payload: { at: new Date().toISOString(), environment: testEnvironmentDescriptor }, }); yield* buildAppUnderTest({ @@ -1996,6 +2034,73 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("enriches replayed project events with repository identity metadata", () => + Effect.gen(function* () { + const repositoryIdentity = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:T3Tools/t3code.git", + }, + displayName: "T3Tools/t3code", + provider: "github", + owner: "T3Tools", + name: "t3code", + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + readEvents: (_fromSequenceExclusive) => + Stream.make({ + sequence: 1, + eventId: EventId.makeUnsafe("event-1"), + aggregateKind: "project", + aggregateId: defaultProjectId, + occurredAt: "2026-04-05T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "project.created", + payload: { + projectId: defaultProjectId, + title: "Default Project", + workspaceRoot: "/tmp/default-project", + defaultModelSelection, + scripts: [], + createdAt: "2026-04-05T00:00:00.000Z", + updatedAt: "2026-04-05T00:00:00.000Z", + }, + } satisfies Extract), + }, + repositoryIdentityResolver: { + resolve: () => Effect.succeed(repositoryIdentity), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const replayResult = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.replayEvents]({ + fromSequenceExclusive: 0, + }), + ), + ); + + const replayedEvent = replayResult[0]; + assert.equal(replayedEvent?.type, "project.created"); + assert.deepEqual( + replayedEvent && replayedEvent.type === "project.created" + ? replayedEvent.payload.repositoryIdentity + : null, + repositoryIdentity, + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("closes thread terminals after a successful archive command", () => Effect.gen(function* () { const threadId = ThreadId.makeUnsafe("thread-archive"); @@ -2498,6 +2603,145 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("enriches replayed project events only once before streaming them to subscribers", () => + Effect.gen(function* () { + let resolveCalls = 0; + const repositoryIdentity = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:t3tools/t3code.git", + }, + displayName: "t3tools/t3code", + provider: "github" as const, + owner: "t3tools", + name: "t3code", + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + getReadModel: () => + Effect.succeed({ + ...makeDefaultOrchestrationReadModel(), + snapshotSequence: 0, + }), + readEvents: () => + Stream.make({ + sequence: 1, + eventId: EventId.makeUnsafe("event-1"), + aggregateKind: "project", + aggregateId: defaultProjectId, + occurredAt: "2026-04-06T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "project.meta-updated", + payload: { + projectId: defaultProjectId, + title: "Replayed Project", + updatedAt: "2026-04-06T00:00:00.000Z", + }, + } satisfies Extract), + streamDomainEvents: Stream.empty, + }, + repositoryIdentityResolver: { + resolve: () => { + resolveCalls += 1; + return Effect.succeed(repositoryIdentity); + }, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + + const event = Array.from(events)[0]; + assert.equal(resolveCalls, 1); + assert.equal(event?.type, "project.meta-updated"); + assert.deepEqual( + event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null, + repositoryIdentity, + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("enriches subscribed project meta updates with repository identity metadata", () => + Effect.gen(function* () { + const repositoryIdentity = { + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote" as const, + remoteName: "upstream", + remoteUrl: "git@github.com:T3Tools/t3code.git", + }, + displayName: "T3Tools/t3code", + provider: "github", + owner: "T3Tools", + name: "t3code", + }; + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + getReadModel: () => + Effect.succeed({ + ...makeDefaultOrchestrationReadModel(), + snapshotSequence: 0, + }), + streamDomainEvents: Stream.make({ + sequence: 1, + eventId: EventId.makeUnsafe("event-1"), + aggregateKind: "project", + aggregateId: defaultProjectId, + occurredAt: "2026-04-05T00:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "project.meta-updated", + payload: { + projectId: defaultProjectId, + title: "Renamed Project", + updatedAt: "2026-04-05T00:00:00.000Z", + }, + } satisfies Extract), + }, + repositoryIdentityResolver: { + resolve: () => Effect.succeed(repositoryIdentity), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe( + Stream.take(1), + Stream.runCollect, + ), + ), + ); + + const event = Array.from(events)[0]; + assert.equal(event?.type, "project.meta-updated"); + assert.deepEqual( + event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null, + repositoryIdentity, + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc orchestration.getSnapshot errors", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1d6f6ac66e..d706d79b44 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -44,11 +44,13 @@ import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor" import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; import { ServerSettingsLive } from "./serverSettings"; import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver"; +import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver"; import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; import { ObservabilityLive } from "./observability/Layers/Observability"; +import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { @@ -199,6 +201,8 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ServerSettingsLive), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLive), + Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(ServerEnvironmentLive), // Misc. Layer.provideMerge(AnalyticsServiceLayerLive), diff --git a/apps/server/src/serverLifecycleEvents.test.ts b/apps/server/src/serverLifecycleEvents.test.ts index 1cd8c25c03..cfa5c553a9 100644 --- a/apps/server/src/serverLifecycleEvents.test.ts +++ b/apps/server/src/serverLifecycleEvents.test.ts @@ -1,3 +1,4 @@ +import { EnvironmentId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { assertTrue } from "@effect/vitest/utils"; import { Effect, Option } from "effect"; @@ -9,12 +10,20 @@ it.effect( () => Effect.gen(function* () { const lifecycleEvents = yield* ServerLifecycleEvents; + const environment = { + environmentId: EnvironmentId.makeUnsafe("environment-test"), + label: "Test environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }; const welcome = yield* lifecycleEvents .publish({ version: 1, type: "welcome", payload: { + environment, cwd: "/tmp/project", projectName: "project", }, @@ -29,6 +38,7 @@ it.effect( type: "ready", payload: { at: new Date().toISOString(), + environment, }, }) .pipe(Effect.timeoutOption("50 millis")); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 7c9231ac93..e94c322225 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -27,6 +27,7 @@ import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnap import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; import { ServerLifecycleEvents } from "./serverLifecycleEvents"; import { ServerSettingsService } from "./serverSettings"; +import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; const isWildcardHost = (host: string | undefined): boolean => @@ -262,6 +263,7 @@ const makeServerRuntimeStartup = Effect.gen(function* () { const orchestrationReactor = yield* OrchestrationReactor; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; + const serverEnvironment = yield* ServerEnvironment; const commandGate = yield* makeCommandGate; const httpListening = yield* Deferred.make(); @@ -308,7 +310,9 @@ const makeServerRuntimeStartup = Effect.gen(function* () { yield* Effect.logDebug("startup phase: preparing welcome payload"); const welcome = yield* runStartupPhase("welcome.prepare", autoBootstrapWelcome); + const environment = yield* serverEnvironment.getDescriptor; yield* Effect.logDebug("startup phase: publishing welcome event", { + environmentId: environment.environmentId, cwd: welcome.cwd, projectName: welcome.projectName, bootstrapProjectId: welcome.bootstrapProjectId, @@ -319,7 +323,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { lifecycleEvents.publish({ version: 1, type: "welcome", - payload: welcome, + payload: { + environment, + ...welcome, + }, }), ); }).pipe( @@ -354,7 +361,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { lifecycleEvents.publish({ version: 1, type: "ready", - payload: { at: new Date().toISOString() }, + payload: { + at: new Date().toISOString(), + environment: yield* serverEnvironment.getDescriptor, + }, }), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ca096bff33..16e8531386 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -47,6 +47,8 @@ import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; +import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; +import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; const WsRpcLayer = WsRpcGroup.toLayer( Effect.gen(function* () { @@ -67,6 +69,8 @@ const WsRpcLayer = WsRpcGroup.toLayer( const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; + const repositoryIdentityResolver = yield* RepositoryIdentityResolver; + const serverEnvironment = yield* ServerEnvironment; const serverCommandId = (tag: string) => CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); @@ -113,6 +117,49 @@ const WsRpcLayer = WsRpcGroup.toLayer( }); }; + const enrichProjectEvent = ( + event: OrchestrationEvent, + ): Effect.Effect => { + switch (event.type) { + case "project.created": + return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe( + Effect.map((repositoryIdentity) => ({ + ...event, + payload: { + ...event.payload, + repositoryIdentity, + }, + })), + ); + case "project.meta-updated": + return Effect.gen(function* () { + const workspaceRoot = + event.payload.workspaceRoot ?? + (yield* orchestrationEngine.getReadModel()).projects.find( + (project) => project.id === event.payload.projectId, + )?.workspaceRoot ?? + null; + if (workspaceRoot === null) { + return event; + } + + const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot); + return { + ...event, + payload: { + ...event.payload, + repositoryIdentity, + }, + } satisfies OrchestrationEvent; + }); + default: + return Effect.succeed(event); + } + }; + + const enrichOrchestrationEvents = (events: ReadonlyArray) => + Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); + const dispatchBootstrapTurnStart = ( command: Extract, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => @@ -329,8 +376,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( const keybindingsConfig = yield* keybindings.loadConfigState; const providers = yield* providerRegistry.getProviders; const settings = yield* serverSettings.getSettings; + const environment = yield* serverEnvironment.getDescriptor; return { + environment, cwd: config.cwd, keybindingsConfigPath: config.keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, @@ -435,6 +484,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( ), ).pipe( Effect.map((events) => Array.from(events)), + Effect.flatMap(enrichOrchestrationEvents), Effect.mapError( (cause) => new OrchestrationReplayEventsError({ @@ -455,10 +505,14 @@ const WsRpcLayer = WsRpcGroup.toLayer( orchestrationEngine.readEvents(fromSequenceExclusive), ).pipe( Effect.map((events) => Array.from(events)), + Effect.flatMap(enrichOrchestrationEvents), Effect.catch(() => Effect.succeed([] as Array)), ); const replayStream = Stream.fromIterable(replayEvents); - const source = Stream.merge(replayStream, orchestrationEngine.streamDomainEvents); + const liveStream = orchestrationEngine.streamDomainEvents.pipe( + Stream.mapEffect(enrichProjectEvent), + ); + const source = Stream.merge(replayStream, liveStream); type SequenceState = { readonly nextSequence: number; readonly pendingBySequence: Map; diff --git a/apps/web/index.html b/apps/web/index.html index 0322f2d019..45f30f7164 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -12,6 +12,7 @@ rel="stylesheet" /> T3 Code (Alpha) +
diff --git a/apps/web/package.json b/apps/web/package.json index 499943c3f0..d127743705 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "@formkit/auto-animate": "^0.9.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", + "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 92929f78fc..cbcaf6f2fe 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,11 +1,13 @@ -import type { ThreadId } from "@t3tools/contracts"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { FolderIcon, GitForkIcon } from "lucide-react"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; +import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { readEnvironmentApi } from "../environmentApi"; import { newCommandId } from "../lib/utils"; -import { readNativeApi } from "../nativeApi"; -import { useComposerDraftStore } from "../composerDraftStore"; import { useStore } from "../store"; +import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { EnvMode, resolveDraftEnvModeAfterBranchChange, @@ -20,7 +22,9 @@ const envModeItems = [ ] as const; interface BranchToolbarProps { + environmentId: EnvironmentId; threadId: ThreadId; + draftId?: DraftId; onEnvModeChange: (mode: EnvMode) => void; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; @@ -28,21 +32,34 @@ interface BranchToolbarProps { } export default function BranchToolbar({ + environmentId, threadId, + draftId, onEnvModeChange, envLocked, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarProps) { - const serverThread = useStore((store) => store.threadShellById[threadId]); - const serverSession = useStore((store) => store.threadSessionById[threadId] ?? null); + const threadRef = useMemo( + () => scopeThreadRef(environmentId, threadId), + [environmentId, threadId], + ); + const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); + const serverThread = useStore(serverThreadSelector); + const serverSession = serverThread?.session ?? null; const setThreadBranchAction = useStore((store) => store.setThreadBranch); - const draftThread = useComposerDraftStore((store) => store.getDraftThread(threadId)); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const activeProjectId = serverThread?.projectId ?? draftThread?.projectId ?? null; - const activeProject = useStore((store) => - activeProjectId ? store.projectById[activeProjectId] : undefined, + const activeProjectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const activeProjectSelector = useMemo( + () => createProjectSelectorByRef(activeProjectRef), + [activeProjectRef], ); + const activeProject = useStore(activeProjectSelector); const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; @@ -56,8 +73,8 @@ export default function BranchToolbar({ const setThreadBranch = useCallback( (branch: string | null, worktreePath: string | null) => { - if (!activeThreadId) return; - const api = readNativeApi(); + if (!activeThreadId || !activeProject) return; + const api = readEnvironmentApi(environmentId); // If the effective cwd is about to change, stop the running session so the // next message creates a new one with the correct cwd. if (serverSession && worktreePath !== activeWorktreePath && api) { @@ -88,20 +105,24 @@ export default function BranchToolbar({ currentWorktreePath: activeWorktreePath, effectiveEnvMode, }); - setDraftThreadContext(threadId, { + setDraftThreadContext(draftId ?? threadRef, { branch, worktreePath, envMode: nextDraftEnvMode, + projectRef: scopeProjectRef(environmentId, activeProject.id), }); }, [ activeThreadId, + activeProject, serverSession, activeWorktreePath, hasServerThread, setThreadBranchAction, setDraftThreadContext, - threadId, + draftId, + threadRef, + environmentId, effectiveEnvMode, ], ); @@ -156,6 +177,7 @@ export default function BranchToolbar({ )} { if (!branchCwd) return; void queryClient.prefetchInfiniteQuery( - gitBranchSearchInfiniteQueryOptions({ cwd: branchCwd, query: "" }), + gitBranchSearchInfiniteQueryOptions({ environmentId, cwd: branchCwd, query: "" }), ); - }, [branchCwd, queryClient]); + }, [branchCwd, environmentId, queryClient]); const { data: branchesSearchData, @@ -104,6 +106,7 @@ export function BranchToolbarBranchSelector({ isPending: isBranchesSearchPending, } = useInfiniteQuery( gitBranchSearchInfiniteQueryOptions({ + environmentId, cwd: branchCwd, query: deferredTrimmedBranchQuery, }), @@ -184,13 +187,13 @@ export function BranchToolbarBranchSelector({ startBranchActionTransition(async () => { await action().catch(() => undefined); await queryClient - .invalidateQueries({ queryKey: gitQueryKeys.branches(branchCwd) }) + .invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, branchCwd) }) .catch(() => undefined); }); }; const selectBranch = (branch: GitBranch) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !branchCwd || isBranchActionPending) return; // In new-worktree mode, selecting a branch sets the base branch. @@ -248,7 +251,7 @@ export function BranchToolbarBranchSelector({ const createBranch = (rawName: string) => { const name = rawName.trim(); - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !branchCwd || !name || isBranchActionPending) return; setIsBranchMenuOpen(false); @@ -302,10 +305,10 @@ export function BranchToolbarBranchSelector({ return; } void queryClient.invalidateQueries({ - queryKey: gitQueryKeys.branches(branchCwd), + queryKey: gitQueryKeys.branches(environmentId, branchCwd), }); }, - [branchCwd, queryClient], + [branchCwd, environmentId, queryClient], ); const branchListScrollElementRef = useRef(null); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b364a8e3a1..11926cd95c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -22,7 +22,7 @@ import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget } from "../markdown-links"; -import { readNativeApi } from "../nativeApi"; +import { readLocalApi } from "../localApi"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -253,7 +253,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { onClick={(event) => { event.preventDefault(); event.stopPropagation(); - const api = readNativeApi(); + const api = readLocalApi(); if (api) { void openInPreferredEditor(api, targetPath); } else { diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 74968e1b3c..f0c4a52c5f 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -4,6 +4,7 @@ import "../index.css"; import { EventId, ORCHESTRATION_WS_METHODS, + EnvironmentId, type MessageId, type OrchestrationEvent, type OrchestrationReadModel, @@ -12,11 +13,16 @@ import { type ServerLifecycleWelcomePayload, type ThreadId, type TurnId, - type UserInputQuestion, WS_METHODS, OrchestrationSessionStatus, DEFAULT_SERVER_SETTINGS, } from "@t3tools/contracts"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import { HttpResponse, http, ws } from "msw"; import { setupWorker } from "msw/browser"; @@ -24,19 +30,20 @@ import { page } from "vitest/browser"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; -import { useComposerDraftStore } from "../composerDraftStore"; +import { useComposerDraftStore, DraftId } from "../composerDraftStore"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, removeInlineTerminalContextPlaceholder, } from "../lib/terminalContext"; import { isMacPlatform } from "../lib/utils"; -import { __resetNativeApiForTests } from "../nativeApi"; +import { __resetLocalApiForTests } from "../localApi"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; -import { useStore } from "../store"; +import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; +import { useUiStateStore } from "../uiStateStore"; import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; import { estimateTimelineMessageHeight } from "./timelineHeight"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; @@ -49,8 +56,13 @@ vi.mock("../lib/gitStatusState", () => ({ })); const THREAD_ID = "thread-browser-test" as ThreadId; -const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_ID = "project-1" as ProjectId; +const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); +const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); +const THREAD_KEY = scopedThreadKey(THREAD_REF); +const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const PROJECT_DRAFT_KEY = `${LOCAL_ENVIRONMENT_ID}:${PROJECT_ID}`; +const PROJECT_KEY = scopedProjectKey(scopeProjectRef(LOCAL_ENVIRONMENT_ID, PROJECT_ID)); const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; @@ -129,6 +141,13 @@ function isoAt(offsetSeconds: number): string { function createBaseServerConfig(): ServerConfig { return { + environment: { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], @@ -314,6 +333,13 @@ function buildFixture(snapshot: OrchestrationReadModel): TestFixture { snapshot, serverConfig: createBaseServerConfig(), welcome: { + environment: { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/repo/project", projectName: "Project", bootstrapProjectId: PROJECT_ID, @@ -396,6 +422,33 @@ function createThreadCreatedEvent(threadId: ThreadId, sequence: number): Orchest }; } +function createThreadSessionSetEvent(threadId: ThreadId, sequence: number): OrchestrationEvent { + return { + sequence, + eventId: EventId.makeUnsafe(`event-thread-session-set-${sequence}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: NOW_ISO, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.session-set", + payload: { + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: `turn-${threadId}` as TurnId, + lastError: null, + updatedAt: NOW_ISO, + }, + }, + }; +} + function sendOrchestrationDomainEvent(event: OrchestrationEvent): void { rpcHarness.emitStreamValue(WS_METHODS.subscribeOrchestrationDomainEvents, event); } @@ -419,25 +472,72 @@ async function waitForWsClient(): Promise { ); } +function threadRefFor(threadId: ThreadId) { + return scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); +} + +function threadKeyFor(threadId: ThreadId): string { + return scopedThreadKey(threadRefFor(threadId)); +} + +function composerDraftFor(target: string) { + const { draftsByThreadKey } = useComposerDraftStore.getState(); + return draftsByThreadKey[target] ?? draftsByThreadKey[threadKeyFor(target as ThreadId)]; +} + +function draftIdFromPath(pathname: string) { + const segments = pathname.split("/"); + const draftId = segments[segments.length - 1]; + if (!draftId) { + throw new Error(`Expected thread path, received "${pathname}".`); + } + return DraftId.makeUnsafe(draftId); +} + +function draftThreadIdFor(draftId: ReturnType): ThreadId { + const draftSession = useComposerDraftStore.getState().getDraftSession(draftId); + if (!draftSession) { + throw new Error(`Expected draft session for "${draftId}".`); + } + return draftSession.threadId; +} + +function serverThreadPath(threadId: ThreadId): string { + return `/${LOCAL_ENVIRONMENT_ID}/${threadId}`; +} + async function waitForAppBootstrap(): Promise { await vi.waitFor( () => { expect(getServerConfig()).not.toBeNull(); - expect(useStore.getState().bootstrapComplete).toBe(true); + expect(selectBootstrapCompleteForActiveEnvironment(useStore.getState())).toBe(true); }, { timeout: 8_000, interval: 16 }, ); } -async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise { +async function materializePromotedDraftThreadViaDomainEvent(threadId: ThreadId): Promise { await waitForWsClient(); fixture.snapshot = addThreadToSnapshot(fixture.snapshot, threadId); sendOrchestrationDomainEvent( createThreadCreatedEvent(threadId, fixture.snapshot.snapshotSequence), ); +} + +async function startPromotedServerThreadViaDomainEvent(threadId: ThreadId): Promise { + sendOrchestrationDomainEvent( + createThreadSessionSetEvent(threadId, fixture.snapshot.snapshotSequence + 1), + ); +} + +async function promoteDraftThreadViaDomainEvent(threadId: ThreadId): Promise { + await materializePromotedDraftThreadViaDomainEvent(threadId); + await startPromotedServerThreadViaDomainEvent(threadId); await vi.waitFor( () => { - expect(useComposerDraftStore.getState().draftThreadsByThreadId[threadId]).toBeUndefined(); + expect(useComposerDraftStore.getState().draftThreadsByThreadKey[threadKeyFor(threadId)]).toBe( + undefined, + ); }, { timeout: 8_000, interval: 16 }, ); @@ -468,9 +568,12 @@ function withProjectScripts( function setDraftThreadWithoutWorktree(): void { useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -479,8 +582,8 @@ function setDraftThreadWithoutWorktree(): void { envMode: "local", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); } @@ -542,51 +645,12 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } -function createSnapshotWithPendingUserInput(options?: { - questions?: ReadonlyArray; -}): OrchestrationReadModel { +function createSnapshotWithPendingUserInput(): OrchestrationReadModel { const snapshot = createSnapshotForTargetUser({ targetMessageId: "msg-user-pending-input-target" as MessageId, targetText: "question thread", }); - const questions = - options?.questions ?? - ([ - { - id: "scope", - header: "Scope", - question: "What should this change cover?", - options: [ - { - label: "Tight", - description: "Touch only the footer layout logic.", - }, - { - label: "Broad", - description: "Also adjust the related composer controls.", - }, - ], - multiSelect: false, - }, - { - id: "risk", - header: "Risk", - question: "How aggressive should the imaginary plan be?", - options: [ - { - label: "Conservative", - description: "Favor reliability and low-risk changes.", - }, - { - label: "Balanced", - description: "Mix quick wins with one structural improvement.", - }, - ], - multiSelect: false, - }, - ] satisfies ReadonlyArray); - return { ...snapshot, threads: snapshot.threads.map((thread) => @@ -601,7 +665,38 @@ function createSnapshotWithPendingUserInput(options?: { summary: "User input requested", payload: { requestId: "req-browser-user-input", - questions, + questions: [ + { + id: "scope", + header: "Scope", + question: "What should this change cover?", + options: [ + { + label: "Tight", + description: "Touch only the footer layout logic.", + }, + { + label: "Broad", + description: "Also adjust the related composer controls.", + }, + ], + }, + { + id: "risk", + header: "Risk", + question: "How aggressive should the imaginary plan be?", + options: [ + { + label: "Conservative", + description: "Favor reliability and low-risk changes.", + }, + { + label: "Balanced", + description: "Mix quick wins with one structural improvement.", + }, + ], + }, + ], }, turnId: null, sequence: 1, @@ -1057,7 +1152,7 @@ async function mountChatView(options: { const router = getRouter( createMemoryHistory({ - initialEntries: [`/${THREAD_ID}`], + initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`], }), ); @@ -1162,42 +1257,32 @@ describe("ChatView timeline estimator parity (full app)", () => { return []; }, }); - await __resetNativeApiForTests(); + await __resetLocalApiForTests(); await setViewport(DEFAULT_VIEWPORT); localStorage.clear(); document.body.innerHTML = ""; wsRequests.length = 0; customWsRpcResolver = null; useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); useStore.setState({ - projectIds: [], - projectById: {}, - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - sidebarThreadSummaryById: {}, - bootstrapComplete: false, + activeEnvironmentId: null, + environmentStateById: {}, + }); + useUiStateStore.setState({ + projectExpandedById: {}, + projectOrder: [], + threadLastVisitedAtById: {}, }); useTerminalStateStore.persist.clearStorage(); useTerminalStateStore.setState({ - terminalStateByThreadId: {}, - terminalLaunchContextByThreadId: {}, + terminalStateByThreadKey: {}, + terminalLaunchContextByThreadKey: {}, terminalEventEntriesByKey: {}, nextTerminalEventId: 1, }); @@ -1241,6 +1326,35 @@ describe("ChatView timeline estimator parity (full app)", () => { }, ); + it("re-expands the bootstrap project using its scoped key", async () => { + useUiStateStore.setState({ + projectExpandedById: { + [PROJECT_KEY]: false, + }, + projectOrder: [PROJECT_KEY], + threadLastVisitedAtById: {}, + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-bootstrap-project-expand" as MessageId, + targetText: "bootstrap project expand", + }), + }); + + try { + await vi.waitFor( + () => { + expect(useUiStateStore.getState().projectExpandedById[PROJECT_KEY]).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("tracks wrapping parity while resizing an existing ChatView across the viewport matrix", async () => { const userText = "x".repeat(3_200); const targetMessageId = "msg-user-target-resize" as MessageId; @@ -1438,8 +1552,8 @@ describe("ChatView timeline estimator parity (full app)", () => { } useTerminalStateStore.setState({ - terminalStateByThreadId: { - [THREAD_ID]: { + terminalStateByThreadKey: { + [THREAD_KEY]: { terminalOpen: true, terminalHeight: 280, terminalIds: ["default"], @@ -1449,8 +1563,8 @@ describe("ChatView timeline estimator parity (full app)", () => { activeTerminalGroupId: "group-default", }, }, - terminalLaunchContextByThreadId: { - [THREAD_ID]: { + terminalLaunchContextByThreadKey: { + [THREAD_KEY]: { cwd: "/repo/project", worktreePath: null, }, @@ -1696,9 +1810,12 @@ describe("ChatView timeline estimator parity (full app)", () => { it("runs project scripts from local draft threads at the project cwd", async () => { useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -1707,8 +1824,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "local", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -1772,9 +1889,12 @@ describe("ChatView timeline estimator parity (full app)", () => { it("runs project scripts from worktree draft threads at the worktree cwd", async () => { useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -1783,8 +1903,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "worktree", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -1835,9 +1955,12 @@ describe("ChatView timeline estimator parity (full app)", () => { it("lets the server own setup after preparing a pull request worktree thread", async () => { useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -1846,8 +1969,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "local", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -1957,12 +2080,15 @@ describe("ChatView timeline estimator parity (full app)", () => { it("sends bootstrap turn-starts and waits for server setup on first-send worktree drafts", async () => { useTerminalStateStore.setState({ - terminalStateByThreadId: {}, + terminalStateByThreadKey: {}, }); useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -1971,8 +2097,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "worktree", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -1998,7 +2124,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); await waitForLayout(); const sendButton = await waitForSendButton(); @@ -2057,12 +2183,15 @@ describe("ChatView timeline estimator parity (full app)", () => { it("shows the send state once bootstrap dispatch is in flight", async () => { useTerminalStateStore.setState({ - terminalStateByThreadId: {}, + terminalStateByThreadKey: {}, }); useComposerDraftStore.setState({ - draftThreadsByThreadId: { - [THREAD_ID]: { + draftThreadsByThreadKey: { + [THREAD_KEY]: { + threadId: THREAD_ID, + environmentId: LOCAL_ENVIRONMENT_ID, projectId: PROJECT_ID, + logicalProjectKey: PROJECT_DRAFT_KEY, createdAt: NOW_ISO, runtimeMode: "full-access", interactionMode: "default", @@ -2071,8 +2200,8 @@ describe("ChatView timeline estimator parity (full app)", () => { envMode: "worktree", }, }, - projectDraftThreadIdByProjectId: { - [PROJECT_ID]: THREAD_ID, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [PROJECT_DRAFT_KEY]: THREAD_KEY, }, }); @@ -2101,7 +2230,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - useComposerDraftStore.getState().setPrompt(THREAD_ID, "Ship it"); + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Ship it"); await waitForLayout(); const sendButton = await waitForSendButton(); @@ -2193,7 +2322,7 @@ describe("ChatView timeline estimator parity (full app)", () => { const removedLabel = "Terminal 1 lines 1-2"; const addedLabel = "Terminal 2 lines 9-10"; useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, + THREAD_REF, createTerminalContext({ id: "ctx-removed", terminalLabel: "Terminal 1", @@ -2220,21 +2349,21 @@ describe("ChatView timeline estimator parity (full app)", () => { ); const store = useComposerDraftStore.getState(); - const currentPrompt = store.draftsByThreadId[THREAD_ID]?.prompt ?? ""; + const currentPrompt = store.draftsByThreadKey[THREAD_KEY]?.prompt ?? ""; const nextPrompt = removeInlineTerminalContextPlaceholder(currentPrompt, 0); - store.setPrompt(THREAD_ID, nextPrompt.prompt); - store.removeTerminalContext(THREAD_ID, "ctx-removed"); + store.setPrompt(THREAD_REF, nextPrompt.prompt); + store.removeTerminalContext(THREAD_REF, "ctx-removed"); await vi.waitFor( () => { - expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toBeUndefined(); + expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]).toBeUndefined(); expect(document.body.textContent).not.toContain(removedLabel); }, { timeout: 8_000, interval: 16 }, ); useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, + THREAD_REF, createTerminalContext({ id: "ctx-added", terminalLabel: "Terminal 2", @@ -2246,7 +2375,7 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( () => { - const draft = useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]; + const draft = useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]; expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-added"]); expect(document.body.textContent).toContain(addedLabel); expect(document.body.textContent).not.toContain(removedLabel); @@ -2261,7 +2390,7 @@ describe("ChatView timeline estimator parity (full app)", () => { it("disables send when the composer only contains an expired terminal pill", async () => { const expiredLabel = "Terminal 1 line 4"; useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, + THREAD_REF, createTerminalContext({ id: "ctx-expired-only", terminalLabel: "Terminal 1", @@ -2297,7 +2426,7 @@ describe("ChatView timeline estimator parity (full app)", () => { it("warns when sending text while omitting expired terminal pills", async () => { const expiredLabel = "Terminal 1 line 4"; useComposerDraftStore.getState().addTerminalContext( - THREAD_ID, + THREAD_REF, createTerminalContext({ id: "ctx-expired-send-warning", terminalLabel: "Terminal 1", @@ -2308,7 +2437,7 @@ describe("ChatView timeline estimator parity (full app)", () => { ); useComposerDraftStore .getState() - .setPrompt(THREAD_ID, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); + .setPrompt(THREAD_REF, `yoo${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}waddup`); const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -2448,7 +2577,7 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("keeps the new thread selected after clicking the new-thread button", async () => { + it("canonicalizes promoted draft threads to the server thread route", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotForTargetUser({ @@ -2470,21 +2599,34 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new draft thread UUID.", ); - const newThreadId = newThreadPath.slice(1) as ThreadId; + const newDraftId = draftIdFromPath(newThreadPath); + const newThreadId = draftThreadIdFor(newDraftId); // The composer editor should be present for the new draft thread. await waitForComposerEditor(); - // Simulate the steady-state promotion path: the server emits - // `thread.created`, the client materializes the thread incrementally, - // and the draft is cleared by live batch effects. - await promoteDraftThreadViaDomainEvent(newThreadId); + // `thread.created` should only mark the draft as promoting; it should + // not navigate away until the server thread has actual runtime state. + await materializePromotedDraftThreadViaDomainEvent(newThreadId); + expect(mounted.router.state.location.pathname).toBe(newThreadPath); + await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); + + // Once the server thread starts, the route should canonicalize. + await startPromotedServerThreadViaDomainEvent(newThreadId); + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().draftThreadsByThreadKey[newDraftId]).toBe( + undefined, + ); + }, + { timeout: 8_000, interval: 16 }, + ); - // The route should still be on the new thread — not redirected away. + // The route should switch to the canonical server thread path. await waitForURL( mounted.router, - (path) => path === newThreadPath, - "New thread should remain selected after server thread promotion clears the draft.", + (path) => path === serverThreadPath(newThreadId), + "Promoted drafts should canonicalize to the server thread route.", ); // The empty thread view and composer should still be visible. @@ -2497,6 +2639,48 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("canonicalizes stale promoted draft routes to the server thread route", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-draft-hydration-race-test" as MessageId, + targetText: "draft hydration race test", + }), + }); + + try { + const newThreadButton = page.getByTestId("new-thread-button"); + await expect.element(newThreadButton).toBeInTheDocument(); + + await newThreadButton.click(); + + const newThreadPath = await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Route should have changed to a new draft thread UUID.", + ); + const newDraftId = draftIdFromPath(newThreadPath); + const newThreadId = draftThreadIdFor(newDraftId); + + await promoteDraftThreadViaDomainEvent(newThreadId); + + await mounted.router.navigate({ + to: "/draft/$draftId", + params: { draftId: newDraftId }, + }); + + await waitForURL( + mounted.router, + (path) => path === serverThreadPath(newThreadId), + "Stale promoted draft routes should canonicalize to the server thread path.", + ); + + await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); + } finally { + await mounted.cleanup(); + } + }); + it("snapshots sticky codex settings into a new draft thread", async () => { useComposerDraftStore.setState({ stickyModelSelectionByProvider: { @@ -2531,9 +2715,9 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new draft thread UUID.", ); - const newThreadId = newThreadPath.slice(1) as ThreadId; + const newDraftId = draftIdFromPath(newThreadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ + expect(composerDraftFor(newDraftId)).toMatchObject({ modelSelectionByProvider: { codex: { provider: "codex", @@ -2584,9 +2768,9 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new sticky claude draft thread UUID.", ); - const newThreadId = newThreadPath.slice(1) as ThreadId; + const newDraftId = draftIdFromPath(newThreadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ + expect(composerDraftFor(newDraftId)).toMatchObject({ modelSelectionByProvider: { claudeAgent: { provider: "claudeAgent", @@ -2624,9 +2808,9 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a new draft thread UUID.", ); - const newThreadId = newThreadPath.slice(1) as ThreadId; + const newDraftId = draftIdFromPath(newThreadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toBeUndefined(); + expect(composerDraftFor(newDraftId)).toBe(undefined); } finally { await mounted.cleanup(); } @@ -2666,9 +2850,9 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a sticky draft thread UUID.", ); - const threadId = threadPath.slice(1) as ThreadId; + const draftId = draftIdFromPath(threadPath); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + expect(composerDraftFor(draftId)).toMatchObject({ modelSelectionByProvider: { codex: { provider: "codex", @@ -2681,7 +2865,7 @@ describe("ChatView timeline estimator parity (full app)", () => { activeProvider: "codex", }); - useComposerDraftStore.getState().setModelSelection(threadId, { + useComposerDraftStore.getState().setModelSelection(draftId, { provider: "codex", model: "gpt-5.4", options: { @@ -2697,7 +2881,7 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => path === threadPath, "New-thread should reuse the existing project draft thread.", ); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + expect(composerDraftFor(draftId)).toMatchObject({ modelSelectionByProvider: { codex: { provider: "codex", @@ -2804,7 +2988,8 @@ describe("ChatView timeline estimator parity (full app)", () => { (path) => UUID_ROUTE_RE.test(path), "Route should have changed to a promoted draft thread UUID.", ); - const promotedThreadId = promotedThreadPath.slice(1) as ThreadId; + const promotedDraftId = draftIdFromPath(promotedThreadPath); + const promotedThreadId = draftThreadIdFor(promotedDraftId); await promoteDraftThreadViaDomainEvent(promotedThreadId); @@ -2925,70 +3110,10 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("does not trigger numeric option shortcuts while the composer is focused", async () => { + it("submits pending user input after the final option selection resolves the draft answers", async () => { const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, + viewport: DEFAULT_VIEWPORT, snapshot: createSnapshotWithPendingUserInput(), - }); - - try { - const composerEditor = await waitForComposerEditor(); - composerEditor.focus(); - - const event = new KeyboardEvent("keydown", { - key: "2", - bubbles: true, - cancelable: true, - }); - composerEditor.dispatchEvent(event); - await waitForLayout(); - - expect(event.defaultPrevented).toBe(false); - expect(document.body.textContent).toContain("What should this change cover?"); - expect(document.body.textContent).not.toContain( - "How aggressive should the imaginary plan be?", - ); - await waitForButtonByText("Next question"); - } finally { - await mounted.cleanup(); - } - }); - - it("submits multi-select questionnaire answers as arrays", async () => { - const mounted = await mountChatView({ - viewport: WIDE_FOOTER_VIEWPORT, - snapshot: createSnapshotWithPendingUserInput({ - questions: [ - { - id: "scope", - header: "Scope", - question: "Which areas should this change cover?", - options: [ - { - label: "Server", - description: "Touch server orchestration.", - }, - { - label: "Web", - description: "Touch the browser UI.", - }, - ], - multiSelect: true, - }, - { - id: "risk", - header: "Risk", - question: "How aggressive should the imaginary plan be?", - options: [ - { - label: "Balanced", - description: "Mix quick wins with one structural improvement.", - }, - ], - multiSelect: false, - }, - ], - }), resolveRpc: (body) => { if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { return { @@ -3000,37 +3125,11 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - const serverOption = await waitForButtonContainingText("Server"); - serverOption.click(); - await waitForLayout(); - - expect(document.body.textContent).toContain("Which areas should this change cover?"); - - const webOption = await waitForButtonContainingText("Web"); - webOption.click(); - await waitForLayout(); - - expect(document.body.textContent).toContain("Which areas should this change cover?"); - - const nextButton = await waitForButtonByText("Next question"); - expect(nextButton.disabled).toBe(false); - nextButton.click(); - - await vi.waitFor( - () => { - expect(document.body.textContent).toContain( - "How aggressive should the imaginary plan be?", - ); - }, - { timeout: 8_000, interval: 16 }, - ); - - const balancedOption = await waitForButtonContainingText("Balanced"); - balancedOption.click(); + const firstOption = await waitForButtonContainingText("Tight"); + firstOption.click(); - const submitButton = await waitForButtonByText("Submit answers"); - expect(submitButton.disabled).toBe(false); - submitButton.click(); + const finalOption = await waitForButtonContainingText("Conservative"); + finalOption.click(); await vi.waitFor( () => { @@ -3042,6 +3141,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | { _tag: string; type?: string; + requestId?: string; answers?: Record; } | undefined; @@ -3049,9 +3149,10 @@ describe("ChatView timeline estimator parity (full app)", () => { expect(dispatchRequest).toMatchObject({ _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, type: "thread.user-input.respond", + requestId: "req-browser-user-input", answers: { - scope: ["Server", "Web"], - risk: "Balanced", + scope: "Tight", + risk: "Conservative", }, }); }, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index cad565247d..ce82b3f77c 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,6 +1,7 @@ -import { ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime"; +import { EnvironmentId, ProjectId, ThreadId, TurnId } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { useStore } from "../store"; +import { type EnvironmentState, useStore } from "../store"; import { type Thread } from "../types"; import { @@ -10,9 +11,12 @@ import { deriveComposerSendState, hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, + shouldWriteThreadErrorToCurrentServerThread, waitForStartedServerThread, } from "./ChatView.logic"; +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); + describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ @@ -170,6 +174,36 @@ describe("reconcileMountedTerminalThreadIds", () => { }); }); +describe("shouldWriteThreadErrorToCurrentServerThread", () => { + it("routes errors to the active server thread when route and target match", () => { + const threadId = ThreadId.makeUnsafe("thread-1"); + const routeThreadRef = scopeThreadRef(localEnvironmentId, threadId); + + expect( + shouldWriteThreadErrorToCurrentServerThread({ + serverThread: { + environmentId: localEnvironmentId, + id: threadId, + }, + routeThreadRef, + targetThreadId: threadId, + }), + ).toBe(true); + }); + + it("does not route draft-thread errors into server-backed state", () => { + const threadId = ThreadId.makeUnsafe("thread-1"); + + expect( + shouldWriteThreadErrorToCurrentServerThread({ + serverThread: undefined, + routeThreadRef: scopeThreadRef(localEnvironmentId, threadId), + targetThreadId: threadId, + }), + ).toBe(false); + }); +}); + const makeThread = (input?: { id?: ThreadId; latestTurn?: { @@ -181,6 +215,7 @@ const makeThread = (input?: { } | null; }): Thread => ({ id: input?.id ?? ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", @@ -208,11 +243,12 @@ const makeThread = (input?: { function setStoreThreads(threads: ReadonlyArray>) { const projectId = ProjectId.makeUnsafe("project-1"); - useStore.setState({ + const environmentState: EnvironmentState = { projectIds: [projectId], projectById: { [projectId]: { id: projectId, + environmentId: localEnvironmentId, name: "Project", cwd: "/tmp/project", defaultModelSelection: { @@ -233,6 +269,7 @@ function setStoreThreads(threads: ReadonlyArray>) thread.id, { id: thread.id, + environmentId: thread.environmentId, codexThreadId: thread.codexThreadId, projectId: thread.projectId, title: thread.title, @@ -301,6 +338,12 @@ function setStoreThreads(threads: ReadonlyArray>) ), sidebarThreadSummaryById: {}, bootstrapComplete: true, + }; + useStore.setState({ + activeEnvironmentId: localEnvironmentId, + environmentStateById: { + [localEnvironmentId]: environmentState, + }, }); } @@ -326,14 +369,16 @@ describe("waitForStartedServerThread", () => { }), ]); - await expect(waitForStartedServerThread(threadId)).resolves.toBe(true); + await expect( + waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId)), + ).resolves.toBe(true); }); it("waits for the thread to start via subscription updates", async () => { const threadId = ThreadId.makeUnsafe("thread-wait"); setStoreThreads([makeThread({ id: threadId })]); - const promise = waitForStartedServerThread(threadId, 500); + const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500); setStoreThreads([ makeThread({ @@ -376,7 +421,9 @@ describe("waitForStartedServerThread", () => { return originalSubscribe(listener); }); - await expect(waitForStartedServerThread(threadId, 500)).resolves.toBe(true); + await expect( + waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500), + ).resolves.toBe(true); }); it("returns false after the timeout when the thread never starts", async () => { @@ -384,7 +431,7 @@ describe("waitForStartedServerThread", () => { const threadId = ThreadId.makeUnsafe("thread-timeout"); setStoreThreads([makeThread({ id: threadId })]); - const promise = waitForStartedServerThread(threadId, 500); + const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500); await vi.advanceTimersByTimeAsync(500); @@ -414,6 +461,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { it("does not clear local dispatch before server state changes", () => { const localDispatch = createLocalDispatchSnapshot({ id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId, title: "Thread", @@ -450,6 +498,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { it("clears local dispatch when a new turn is already settled", () => { const localDispatch = createLocalDispatchSnapshot({ id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId, title: "Thread", @@ -495,6 +544,7 @@ describe("hasServerAcknowledgedLocalDispatch", () => { it("clears local dispatch when the session changes without an observed running phase", () => { const localDispatch = createLocalDispatchSnapshot({ id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId, title: "Thread", diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 6a0aa4d0c8..ffcd0cb3f5 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,9 +1,16 @@ -import { ProjectId, type ModelSelection, type ThreadId, type TurnId } from "@t3tools/contracts"; +import { + type EnvironmentId, + ProjectId, + type ModelSelection, + type ScopedThreadRef, + type ThreadId, + type TurnId, +} from "@t3tools/contracts"; import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; import { randomUUID } from "~/lib/utils"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import { Schema } from "effect"; -import { selectThreadById, useStore } from "../store"; +import { selectThreadByRef, useStore } from "../store"; import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, @@ -24,6 +31,7 @@ export function buildLocalDraftThread( ): Thread { return { id: threadId, + environmentId: draftThread.environmentId, codexThreadId: null, projectId: draftThread.projectId, title: "New thread", @@ -44,13 +52,32 @@ export function buildLocalDraftThread( }; } +export function shouldWriteThreadErrorToCurrentServerThread(input: { + serverThread: + | { + environmentId: EnvironmentId; + id: ThreadId; + } + | null + | undefined; + routeThreadRef: ScopedThreadRef; + targetThreadId: ThreadId; +}): boolean { + return Boolean( + input.serverThread && + input.targetThreadId === input.routeThreadRef.threadId && + input.serverThread.environmentId === input.routeThreadRef.environmentId && + input.serverThread.id === input.targetThreadId, + ); +} + export function reconcileMountedTerminalThreadIds(input: { - currentThreadIds: ReadonlyArray; - openThreadIds: ReadonlyArray; - activeThreadId: ThreadId | null; + currentThreadIds: ReadonlyArray; + openThreadIds: ReadonlyArray; + activeThreadId: string | null; activeThreadTerminalOpen: boolean; maxHiddenThreadCount?: number; -}): ThreadId[] { +}): string[] { const openThreadIdSet = new Set(input.openThreadIds); const hiddenThreadIds = input.currentThreadIds.filter( (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId), @@ -199,10 +226,10 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { } export async function waitForStartedServerThread( - threadId: ThreadId, + threadRef: ScopedThreadRef, timeoutMs = 1_000, ): Promise { - const getThread = () => selectThreadById(threadId)(useStore.getState()); + const getThread = () => selectThreadByRef(useStore.getState(), threadRef); const thread = getThread(); if (threadHasStarted(thread)) { @@ -225,7 +252,7 @@ export async function waitForStartedServerThread( }; const unsubscribe = useStore.subscribe((state) => { - if (!threadHasStarted(selectThreadById(threadId)(state))) { + if (!threadHasStarted(selectThreadByRef(state, threadRef))) { return; } finish(true); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 55dd63761c..cf09dd8a38 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2,6 +2,7 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, + type EnvironmentId, type MessageId, type ModelSelection, type ProjectScript, @@ -12,6 +13,7 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type ServerProvider, + type ScopedThreadRef, type ThreadId, type TurnId, type KeybindingCommand, @@ -20,6 +22,12 @@ import { RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; +import { + parseScopedThreadKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; @@ -27,9 +35,12 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useQuery } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; +import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; +import { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; +import { readLocalApi } from "../localApi"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { clampCollapsedComposerCursor, @@ -64,8 +75,8 @@ import { togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; -import { useStore } from "../store"; -import { createThreadSelector } from "../storeSelectors"; +import { selectThreadsAcrossEnvironments, useStore } from "../store"; +import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, @@ -114,8 +125,7 @@ import { projectScriptIdFromCommand, } from "~/projectScripts"; import { SidebarTrigger } from "./ui/sidebar"; -import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; -import { readNativeApi } from "~/nativeApi"; +import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, getProviderModels, @@ -124,6 +134,8 @@ import { import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; +import { deriveLogicalProjectKey } from "../logicalProject"; +import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -131,6 +143,7 @@ import { useComposerDraftStore, useEffectiveComposerModelState, useComposerThreadDraft, + type DraftId, } from "../composerDraftStore"; import { appendTerminalContextsToPrompt, @@ -187,6 +200,7 @@ import { reconcileMountedTerminalThreadIds, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, + shouldWriteThreadErrorToCurrentServerThread, threadHasStarted, waitForStartedServerThread, } from "./ChatView.logic"; @@ -203,6 +217,7 @@ const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; +const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; @@ -210,26 +225,115 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record; function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { - const threadShellById = useStore((state) => state.threadShellById); - const proposedPlanIdsByThreadId = useStore((state) => state.proposedPlanIdsByThreadId); - const proposedPlanByThreadId = useStore((state) => state.proposedPlanByThreadId); + return useStore( + useMemo(() => { + let previousThreadIds: readonly ThreadId[] = []; + let previousResult: ThreadPlanCatalogEntry[] = []; + let previousEntries = new Map< + ThreadId, + { + shell: object | null; + proposedPlanIds: readonly string[] | undefined; + proposedPlansById: Record | undefined; + entry: ThreadPlanCatalogEntry; + } + >(); + + return (state) => { + const sameThreadIds = + previousThreadIds.length === threadIds.length && + previousThreadIds.every((id, index) => id === threadIds[index]); + const nextEntries = new Map< + ThreadId, + { + shell: object | null; + proposedPlanIds: readonly string[] | undefined; + proposedPlansById: Record | undefined; + entry: ThreadPlanCatalogEntry; + } + >(); + const nextResult: ThreadPlanCatalogEntry[] = []; + let changed = !sameThreadIds; + + for (const threadId of threadIds) { + let shell: object | undefined; + let proposedPlanIds: readonly string[] | undefined; + let proposedPlansById: Record | undefined; + + for (const environmentState of Object.values(state.environmentStateById)) { + const matchedShell = environmentState.threadShellById[threadId]; + if (!matchedShell) { + continue; + } + shell = matchedShell; + proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId]; + proposedPlansById = environmentState.proposedPlanByThreadId[threadId] as + | Record + | undefined; + break; + } - return useMemo( - () => - threadIds.flatMap((threadId) => { - if (!threadShellById[threadId]) { - return []; + if (!shell) { + const previous = previousEntries.get(threadId); + if ( + previous && + previous.shell === null && + previous.proposedPlanIds === undefined && + previous.proposedPlansById === undefined + ) { + nextEntries.set(threadId, previous); + continue; + } + changed = true; + nextEntries.set(threadId, { + shell: null, + proposedPlanIds: undefined, + proposedPlansById: undefined, + entry: { id: threadId, proposedPlans: EMPTY_PROPOSED_PLANS }, + }); + continue; + } + + const previous = previousEntries.get(threadId); + if ( + previous && + previous.shell === shell && + previous.proposedPlanIds === proposedPlanIds && + previous.proposedPlansById === proposedPlansById + ) { + nextEntries.set(threadId, previous); + nextResult.push(previous.entry); + continue; + } + + changed = true; + const proposedPlans = + proposedPlanIds && proposedPlanIds.length > 0 && proposedPlansById + ? proposedPlanIds.flatMap((planId) => { + const proposedPlan = proposedPlansById?.[planId]; + return proposedPlan ? [proposedPlan] : []; + }) + : EMPTY_PROPOSED_PLANS; + const entry = { id: threadId, proposedPlans }; + nextEntries.set(threadId, { + shell, + proposedPlanIds, + proposedPlansById, + entry, + }); + nextResult.push(entry); } - const proposedPlans = - proposedPlanIdsByThreadId[threadId]?.flatMap((planId) => { - const plan = proposedPlanByThreadId[threadId]?.[planId]; - return plan ? [plan] : []; - }) ?? []; + if (!changed && previousResult.length === nextResult.length) { + return previousResult; + } - return [{ id: threadId, proposedPlans }]; - }), - [proposedPlanByThreadId, proposedPlanIdsByThreadId, threadIds, threadShellById], + previousThreadIds = threadIds; + previousEntries = nextEntries; + previousResult = nextResult; + return nextResult; + }; + }, [threadIds]), ); } @@ -278,9 +382,19 @@ const terminalContextIdListsEqual = ( ): boolean => contexts.length === ids.length && contexts.every((context, index) => context.id === ids[index]); -interface ChatViewProps { - threadId: ThreadId; -} +type ChatViewProps = + | { + environmentId: EnvironmentId; + threadId: ThreadId; + routeKind: "server"; + draftId?: never; + } + | { + environmentId: EnvironmentId; + threadId: ThreadId; + routeKind: "draft"; + draftId: DraftId; + }; interface TerminalLaunchContext { threadId: ThreadId; @@ -358,6 +472,7 @@ function useLocalDispatchState(input: { } interface PersistentThreadTerminalDrawerProps { + threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; threadId: ThreadId; visible: boolean; launchContext: PersistentTerminalLaunchContext | null; @@ -369,6 +484,7 @@ interface PersistentThreadTerminalDrawerProps { } function PersistentThreadTerminalDrawer({ + threadRef, threadId, visible, launchContext, @@ -378,19 +494,16 @@ function PersistentThreadTerminalDrawer({ closeShortcutLabel, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { - const serverThread = useStore(useMemo(() => createThreadSelector(threadId), [threadId])); - const draftThread = useComposerDraftStore( - (store) => store.draftThreadsByThreadId[threadId] ?? null, - ); - const project = useStore((state) => - serverThread?.projectId - ? state.projectById[serverThread.projectId] - : draftThread?.projectId - ? state.projectById[draftThread.projectId] - : undefined, - ); + const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [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 = useStore(useMemo(() => createProjectSelectorByRef(projectRef), [projectRef])); const terminalState = useTerminalStateStore((state) => - selectThreadTerminalState(state.terminalStateByThreadId, threadId), + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef), ); const storeSetTerminalHeight = useTerminalStateStore((state) => state.setTerminalHeight); const storeSplitTerminal = useTerminalStateStore((state) => state.splitTerminal); @@ -436,32 +549,32 @@ function PersistentThreadTerminalDrawer({ const setTerminalHeight = useCallback( (height: number) => { - storeSetTerminalHeight(threadId, height); + storeSetTerminalHeight(threadRef, height); }, - [storeSetTerminalHeight, threadId], + [storeSetTerminalHeight, threadRef], ); const splitTerminal = useCallback(() => { - storeSplitTerminal(threadId, `terminal-${randomUUID()}`); + storeSplitTerminal(threadRef, `terminal-${randomUUID()}`); bumpFocusRequestId(); - }, [bumpFocusRequestId, storeSplitTerminal, threadId]); + }, [bumpFocusRequestId, storeSplitTerminal, threadRef]); const createNewTerminal = useCallback(() => { - storeNewTerminal(threadId, `terminal-${randomUUID()}`); + storeNewTerminal(threadRef, `terminal-${randomUUID()}`); bumpFocusRequestId(); - }, [bumpFocusRequestId, storeNewTerminal, threadId]); + }, [bumpFocusRequestId, storeNewTerminal, threadRef]); const activateTerminal = useCallback( (terminalId: string) => { - storeSetActiveTerminal(threadId, terminalId); + storeSetActiveTerminal(threadRef, terminalId); bumpFocusRequestId(); }, - [bumpFocusRequestId, storeSetActiveTerminal, threadId], + [bumpFocusRequestId, storeSetActiveTerminal, threadRef], ); const closeTerminal = useCallback( (terminalId: string) => { - const api = readNativeApi(); + const api = readEnvironmentApi(threadRef.environmentId); if (!api) return; const isFinalTerminal = terminalState.terminalIds.length <= 1; const fallbackExitWrite = () => @@ -482,10 +595,10 @@ function PersistentThreadTerminalDrawer({ void fallbackExitWrite(); } - storeCloseTerminal(threadId, terminalId); + storeCloseTerminal(threadRef, terminalId); bumpFocusRequestId(); }, - [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId], + [bumpFocusRequestId, storeCloseTerminal, terminalState.terminalIds.length, threadId, threadRef], ); const handleAddTerminalContext = useCallback( @@ -505,6 +618,7 @@ function PersistentThreadTerminalDrawer({ return (
createThreadSelector(threadId), [threadId])); +export default function ChatView(props: ChatViewProps) { + const { environmentId, threadId, routeKind } = props; + const draftId = routeKind === "draft" ? props.draftId : null; + const routeThreadRef = useMemo( + () => scopeThreadRef(environmentId, threadId), + [environmentId, threadId], + ); + const composerDraftTarget: ScopedThreadRef | DraftId = + routeKind === "server" ? routeThreadRef : props.draftId; + const serverThread = useStore( + useMemo( + () => createThreadSelectorByRef(routeKind === "server" ? routeThreadRef : null), + [routeKind, routeThreadRef], + ), + ); const setStoreThreadError = useStore((store) => store.setError); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); - const activeThreadLastVisitedAt = useUiStateStore( - (store) => store.threadLastVisitedAtById[threadId], + const activeThreadLastVisitedAt = useUiStateStore((store) => + routeKind === "server" + ? store.threadLastVisitedAtById[scopedThreadKey(scopeThreadRef(environmentId, threadId))] + : undefined, ); const settings = useSettings(); const setStickyComposerModelSelection = useComposerDraftStore( @@ -548,7 +677,7 @@ export default function ChatView({ threadId }: ChatViewProps) { select: (params) => parseDiffRouteSearch(params), }); const { resolvedTheme } = useTheme(); - const composerDraft = useComposerThreadDraft(threadId); + const composerDraft = useComposerThreadDraft(composerDraftTarget); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; @@ -591,16 +720,20 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const getDraftThreadByProjectId = useComposerDraftStore( - (store) => store.getDraftThreadByProjectId, + const getDraftSessionByLogicalProjectKey = useComposerDraftStore( + (store) => store.getDraftSessionByLogicalProjectKey, ); - const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); - const setProjectDraftThreadId = useComposerDraftStore((store) => store.setProjectDraftThreadId); - const clearProjectDraftThreadId = useComposerDraftStore( - (store) => store.clearProjectDraftThreadId, + const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); + const getDraftSession = useComposerDraftStore((store) => store.getDraftSession); + const setLogicalProjectDraftThreadId = useComposerDraftStore( + (store) => store.setLogicalProjectDraftThreadId, ); - const draftThread = useComposerDraftStore( - (store) => store.draftThreadsByThreadId[threadId] ?? null, + const draftThread = useComposerDraftStore((store) => + routeKind === "server" + ? store.getDraftSessionByRef(routeThreadRef) + : draftId + ? store.getDraftSession(draftId) + : null, ); const promptRef = useRef(prompt); const [showScrollToBottom, setShowScrollToBottom] = useState(false); @@ -610,8 +743,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const composerTerminalContextsRef = useRef(composerTerminalContexts); - const [localDraftErrorsByThreadId, setLocalDraftErrorsByThreadId] = useState< - Record + const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< + Record >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); @@ -689,66 +822,73 @@ export default function ChatView({ threadId }: ChatViewProps) { setMessagesScrollElement(element); }, []); - const terminalStateByThreadId = useTerminalStateStore((state) => state.terminalStateByThreadId); - const terminalState = useMemo( - () => selectThreadTerminalState(terminalStateByThreadId, threadId), - [terminalStateByThreadId, threadId], + const terminalState = useTerminalStateStore((state) => + selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef), ); - const openTerminalThreadIds = useMemo( - () => - Object.entries(terminalStateByThreadId).flatMap(([nextThreadId, nextTerminalState]) => - nextTerminalState.terminalOpen ? [nextThreadId as ThreadId] : [], + const openTerminalThreadKeys = useTerminalStateStore( + useShallow((state) => + Object.entries(state.terminalStateByThreadKey).flatMap(([nextThreadKey, nextTerminalState]) => + nextTerminalState.terminalOpen ? [nextThreadKey] : [], ), - [terminalStateByThreadId], + ), ); const storeSetTerminalOpen = useTerminalStateStore((s) => s.setTerminalOpen); const storeSplitTerminal = useTerminalStateStore((s) => s.splitTerminal); const storeNewTerminal = useTerminalStateStore((s) => s.newTerminal); const storeSetActiveTerminal = useTerminalStateStore((s) => s.setActiveTerminal); const storeCloseTerminal = useTerminalStateStore((s) => s.closeTerminal); - const serverThreadIds = useStore((state) => state.threadIds); + const serverThreadKeys = useStore( + useShallow((state) => + selectThreadsAcrossEnvironments(state).map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ), + ); const storeServerTerminalLaunchContext = useTerminalStateStore( - (s) => s.terminalLaunchContextByThreadId[threadId] ?? null, + (s) => s.terminalLaunchContextByThreadKey[scopedThreadKey(routeThreadRef)] ?? null, ); const storeClearTerminalLaunchContext = useTerminalStateStore( (s) => s.clearTerminalLaunchContext, ); - const draftThreadsByThreadId = useComposerDraftStore((store) => store.draftThreadsByThreadId); - const draftThreadIds = useMemo( - () => Object.keys(draftThreadsByThreadId) as ThreadId[], - [draftThreadsByThreadId], + const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); + const draftThreadKeys = useMemo( + () => + Object.values(draftThreadsByThreadKey).map((draftThread) => + scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), + ), + [draftThreadsByThreadKey], ); - const [mountedTerminalThreadIds, setMountedTerminalThreadIds] = useState([]); + const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); const setPrompt = useCallback( (nextPrompt: string) => { - setComposerDraftPrompt(threadId, nextPrompt); + setComposerDraftPrompt(composerDraftTarget, nextPrompt); }, - [setComposerDraftPrompt, threadId], + [composerDraftTarget, setComposerDraftPrompt], ); const addComposerImage = useCallback( (image: ComposerImageAttachment) => { - addComposerDraftImage(threadId, image); + addComposerDraftImage(composerDraftTarget, image); }, - [addComposerDraftImage, threadId], + [addComposerDraftImage, composerDraftTarget], ); const addComposerImagesToDraft = useCallback( (images: ComposerImageAttachment[]) => { - addComposerDraftImages(threadId, images); + addComposerDraftImages(composerDraftTarget, images); }, - [addComposerDraftImages, threadId], + [addComposerDraftImages, composerDraftTarget], ); const addComposerTerminalContextsToDraft = useCallback( (contexts: TerminalContextDraft[]) => { - addComposerDraftTerminalContexts(threadId, contexts); + addComposerDraftTerminalContexts(composerDraftTarget, contexts); }, - [addComposerDraftTerminalContexts, threadId], + [addComposerDraftTerminalContexts, composerDraftTarget], ); const removeComposerImageFromDraft = useCallback( (imageId: string) => { - removeComposerDraftImage(threadId, imageId); + removeComposerDraftImage(composerDraftTarget, imageId); }, - [removeComposerDraftImage, threadId], + [composerDraftTarget, removeComposerDraftImage], ); const removeComposerTerminalContextFromDraft = useCallback( (contextId: string) => { @@ -761,7 +901,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const nextPrompt = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); promptRef.current = nextPrompt.prompt; setPrompt(nextPrompt.prompt); - removeComposerDraftTerminalContext(threadId, contextId); + removeComposerDraftTerminalContext(composerDraftTarget, contextId); setComposerCursor(nextPrompt.cursor); setComposerTrigger( detectComposerTrigger( @@ -770,13 +910,19 @@ export default function ChatView({ threadId }: ChatViewProps) { ), ); }, - [composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt, threadId], + [composerDraftTarget, composerTerminalContexts, removeComposerDraftTerminalContext, setPrompt], ); - const fallbackDraftProject = useStore((state) => - draftThread?.projectId ? state.projectById[draftThread.projectId] : undefined, + const fallbackDraftProjectRef = draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const fallbackDraftProject = useStore( + useMemo(() => createProjectSelectorByRef(fallbackDraftProjectRef), [fallbackDraftProjectRef]), ); - const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null); + const localDraftError = + routeKind === "server" && serverThread + ? null + : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null); const localDraftThread = useMemo( () => draftThread @@ -792,20 +938,25 @@ export default function ChatView({ threadId }: ChatViewProps) { : undefined, [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], ); - const activeThread = serverThread ?? localDraftThread; + const isServerThread = routeKind === "server" && serverThread !== undefined; + const activeThread = isServerThread ? serverThread : localDraftThread; const runtimeMode = composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; - const isServerThread = serverThread !== undefined; const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; const canCheckoutPullRequestIntoThread = isLocalDraftThread; const diffOpen = rawSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; - const existingOpenTerminalThreadIds = useMemo(() => { - const existingThreadIds = new Set([...serverThreadIds, ...draftThreadIds]); - return openTerminalThreadIds.filter((nextThreadId) => existingThreadIds.has(nextThreadId)); - }, [draftThreadIds, openTerminalThreadIds, serverThreadIds]); + const activeThreadRef = useMemo( + () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), + [activeThread], + ); + const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const existingOpenTerminalThreadKeys = useMemo(() => { + const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); + return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); + }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; const threadPlanCatalog = useThreadPlanCatalog( useMemo(() => { @@ -825,12 +976,12 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeThread?.activities], ); useEffect(() => { - setMountedTerminalThreadIds((currentThreadIds) => { + setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ currentThreadIds, - openThreadIds: existingOpenTerminalThreadIds, - activeThreadId, - activeThreadTerminalOpen: Boolean(activeThreadId && terminalState.terminalOpen), + openThreadIds: existingOpenTerminalThreadKeys, + activeThreadId: activeThreadKey, + activeThreadTerminalOpen: Boolean(activeThreadKey && terminalState.terminalOpen), maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, }); return currentThreadIds.length === nextThreadIds.length && @@ -838,10 +989,13 @@ export default function ChatView({ threadId }: ChatViewProps) { ? currentThreadIds : nextThreadIds; }); - }, [activeThreadId, existingOpenTerminalThreadIds, terminalState.terminalOpen]); + }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalState.terminalOpen]); const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); - const activeProject = useStore((state) => - activeThread?.projectId ? state.projectById[activeThread.projectId] : undefined, + const activeProjectRef = activeThread + ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) + : null; + const activeProject = useStore( + useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), ); const openPullRequestDialog = useCallback( @@ -867,50 +1021,71 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!activeProject) { throw new Error("No active project is available for this pull request."); } - const storedDraftThread = getDraftThreadByProjectId(activeProject.id); - if (storedDraftThread) { - setDraftThreadContext(storedDraftThread.threadId, input); - setProjectDraftThreadId(activeProject.id, storedDraftThread.threadId, input); - if (storedDraftThread.threadId !== threadId) { + const activeProjectRef = scopeProjectRef(activeProject.environmentId, activeProject.id); + const logicalProjectKey = deriveLogicalProjectKey(activeProject); + const storedDraftSession = getDraftSessionByLogicalProjectKey(logicalProjectKey); + if (storedDraftSession) { + setDraftThreadContext(storedDraftSession.draftId, input); + setLogicalProjectDraftThreadId( + logicalProjectKey, + activeProjectRef, + storedDraftSession.draftId, + { + threadId: storedDraftSession.threadId, + ...input, + }, + ); + if (routeKind !== "draft" || draftId !== storedDraftSession.draftId) { await navigate({ - to: "/$threadId", - params: { threadId: storedDraftThread.threadId }, + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(storedDraftSession.draftId), }); } - return storedDraftThread.threadId; + return storedDraftSession.threadId; } - const activeDraftThread = getDraftThread(threadId); - if (!isServerThread && activeDraftThread?.projectId === activeProject.id) { - setDraftThreadContext(threadId, input); - setProjectDraftThreadId(activeProject.id, threadId, input); - return threadId; + const activeDraftSession = routeKind === "draft" && draftId ? getDraftSession(draftId) : null; + if ( + !isServerThread && + activeDraftSession?.logicalProjectKey === logicalProjectKey && + draftId + ) { + setDraftThreadContext(draftId, input); + setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, draftId, { + threadId: activeDraftSession.threadId, + createdAt: activeDraftSession.createdAt, + runtimeMode: activeDraftSession.runtimeMode, + interactionMode: activeDraftSession.interactionMode, + ...input, + }); + return activeDraftSession.threadId; } - clearProjectDraftThreadId(activeProject.id); + const nextDraftId = newDraftId(); const nextThreadId = newThreadId(); - setProjectDraftThreadId(activeProject.id, nextThreadId, { + setLogicalProjectDraftThreadId(logicalProjectKey, activeProjectRef, nextDraftId, { + threadId: nextThreadId, createdAt: new Date().toISOString(), runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_INTERACTION_MODE, ...input, }); await navigate({ - to: "/$threadId", - params: { threadId: nextThreadId }, + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(nextDraftId), }); return nextThreadId; }, [ activeProject, - clearProjectDraftThreadId, - getDraftThread, - getDraftThreadByProjectId, + draftId, + getDraftSession, + getDraftSessionByLogicalProjectKey, isServerThread, navigate, + routeKind, setDraftThreadContext, - setProjectDraftThreadId, - threadId, + setLogicalProjectDraftThreadId, ], ); @@ -934,12 +1109,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const lastVisitedAt = activeThreadLastVisitedAt ? Date.parse(activeThreadLastVisitedAt) : NaN; if (!Number.isNaN(lastVisitedAt) && lastVisitedAt >= turnCompletedAt) return; - markThreadVisited(serverThread.id); + markThreadVisited(scopedThreadKey(scopeThreadRef(serverThread.environmentId, serverThread.id))); }, [ activeLatestTurn?.completedAt, activeThreadLastVisitedAt, latestTurnSettled, markThreadVisited, + serverThread?.environmentId, serverThread?.id, ]); @@ -959,7 +1135,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ - threadId, + threadRef: composerDraftTarget, providers: providerStatuses, selectedProvider, threadModelSelection: activeThread?.modelSelection, @@ -1358,7 +1534,7 @@ export default function ChatView({ threadId }: ChatViewProps) { (debouncerState) => ({ isPending: debouncerState.isPending }), ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; - const gitStatusQuery = useGitStatus(gitCwd); + const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); const modelOptionsByProvider = useMemo( @@ -1394,6 +1570,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const workspaceEntriesQuery = useQuery( projectSearchEntriesQueryOptions({ + environmentId, cwd: gitCwd, query: effectivePathQuery, enabled: isPathTrigger, @@ -1531,16 +1708,22 @@ export default function ChatView({ threadId }: ChatViewProps) { [keybindings, nonTerminalShortcutLabelOptions], ); const onToggleDiff = useCallback(() => { + if (!isServerThread) { + return; + } void navigate({ - to: "/$threadId", - params: { threadId }, + to: "/$environmentId/$threadId", + params: { + environmentId, + threadId, + }, replace: true, search: (previous) => { const rest = stripDiffSearchParams(previous); return diffOpen ? { ...rest, diff: undefined } : { ...rest, diff: "1" }; }, }); - }, [diffOpen, navigate, threadId]); + }, [diffOpen, environmentId, isServerThread, navigate, threadId]); const envLocked = Boolean( activeThread && @@ -1561,21 +1744,27 @@ export default function ChatView({ threadId }: ChatViewProps) { (targetThreadId: ThreadId | null, error: string | null) => { if (!targetThreadId) return; const nextError = sanitizeThreadErrorMessage(error); - if (useStore.getState().threadShellById[targetThreadId] !== undefined) { + const isCurrentServerThread = shouldWriteThreadErrorToCurrentServerThread({ + serverThread, + routeThreadRef, + targetThreadId, + }); + if (isCurrentServerThread) { setStoreThreadError(targetThreadId, nextError); return; } - setLocalDraftErrorsByThreadId((existing) => { - if ((existing[targetThreadId] ?? null) === nextError) { + const localDraftErrorKey = draftId ?? targetThreadId; + setLocalDraftErrorsByDraftId((existing) => { + if ((existing[localDraftErrorKey] ?? null) === nextError) { return existing; } return { ...existing, - [targetThreadId]: nextError, + [localDraftErrorKey]: nextError, }; }); }, - [setStoreThreadError], + [draftId, routeThreadRef, serverThread, setStoreThreadError], ); const focusComposer = useCallback(() => { @@ -1606,7 +1795,7 @@ export default function ChatView({ threadId }: ChatViewProps) { insertion.cursor, ); const inserted = insertComposerDraftTerminalContext( - activeThread.id, + scopeThreadRef(activeThread.environmentId, activeThread.id), insertion.prompt, { id: randomUUID(), @@ -1630,30 +1819,30 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const setTerminalOpen = useCallback( (open: boolean) => { - if (!activeThreadId) return; - storeSetTerminalOpen(activeThreadId, open); + if (!activeThreadRef) return; + storeSetTerminalOpen(activeThreadRef, open); }, - [activeThreadId, storeSetTerminalOpen], + [activeThreadRef, storeSetTerminalOpen], ); const toggleTerminalVisibility = useCallback(() => { - if (!activeThreadId) return; + if (!activeThreadRef) return; setTerminalOpen(!terminalState.terminalOpen); - }, [activeThreadId, setTerminalOpen, terminalState.terminalOpen]); + }, [activeThreadRef, setTerminalOpen, terminalState.terminalOpen]); const splitTerminal = useCallback(() => { - if (!activeThreadId || hasReachedSplitLimit) return; + if (!activeThreadRef || hasReachedSplitLimit) return; const terminalId = `terminal-${randomUUID()}`; - storeSplitTerminal(activeThreadId, terminalId); + storeSplitTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, hasReachedSplitLimit, storeSplitTerminal]); + }, [activeThreadRef, hasReachedSplitLimit, storeSplitTerminal]); const createNewTerminal = useCallback(() => { - if (!activeThreadId) return; + if (!activeThreadRef) return; const terminalId = `terminal-${randomUUID()}`; - storeNewTerminal(activeThreadId, terminalId); + storeNewTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, storeNewTerminal]); + }, [activeThreadRef, storeNewTerminal]); const closeTerminal = useCallback( (terminalId: string) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!activeThreadId || !api) return; const isFinalTerminal = terminalState.terminalIds.length <= 1; const fallbackExitWrite = () => @@ -1676,10 +1865,18 @@ export default function ChatView({ threadId }: ChatViewProps) { } else { void fallbackExitWrite(); } - storeCloseTerminal(activeThreadId, terminalId); + if (activeThreadRef) { + storeCloseTerminal(activeThreadRef, terminalId); + } setTerminalFocusRequestId((value) => value + 1); }, - [activeThreadId, storeCloseTerminal, terminalState.terminalIds.length], + [ + activeThreadId, + activeThreadRef, + environmentId, + storeCloseTerminal, + terminalState.terminalIds.length, + ], ); const runProjectScript = useCallback( async ( @@ -1692,7 +1889,7 @@ export default function ChatView({ threadId }: ChatViewProps) { rememberAsLastInvoked?: boolean; }, ) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !activeThreadId || !activeProject || !activeThread) return; if (options?.rememberAsLastInvoked !== false) { setLastInvokedScriptByProjectId((current) => { @@ -1719,10 +1916,13 @@ export default function ChatView({ threadId }: ChatViewProps) { worktreePath: targetWorktreePath, }); setTerminalOpen(true); + if (!activeThreadRef) { + return; + } if (shouldCreateNewTerminal) { - storeNewTerminal(activeThreadId, targetTerminalId); + storeNewTerminal(activeThreadRef, targetTerminalId); } else { - storeSetActiveTerminal(activeThreadId, targetTerminalId); + storeSetActiveTerminal(activeThreadRef, targetTerminalId); } setTerminalFocusRequestId((value) => value + 1); @@ -1769,12 +1969,14 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProject, activeThread, activeThreadId, + activeThreadRef, gitCwd, setTerminalOpen, setThreadError, storeNewTerminal, storeSetActiveTerminal, setLastInvokedScriptByProjectId, + environmentId, terminalState.activeTerminalId, terminalState.runningTerminalIds, terminalState.terminalIds, @@ -1790,7 +1992,7 @@ export default function ChatView({ threadId }: ChatViewProps) { keybinding?: string | null; keybindingCommand: KeybindingCommand; }) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api) return; await api.orchestration.dispatchCommand({ @@ -1806,10 +2008,14 @@ export default function ChatView({ threadId }: ChatViewProps) { }); if (isElectron && keybindingRule) { - await api.server.upsertKeybinding(keybindingRule); + const localApi = readLocalApi(); + if (!localApi) { + throw new Error("Local API unavailable."); + } + await localApi.server.upsertKeybinding(keybindingRule); } }, - [], + [environmentId], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput) => { @@ -1913,9 +2119,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const handleRuntimeModeChange = useCallback( (mode: RuntimeMode) => { if (mode === runtimeMode) return; - setComposerDraftRuntimeMode(threadId, mode); + setComposerDraftRuntimeMode(composerDraftTarget, mode); if (isLocalDraftThread) { - setDraftThreadContext(threadId, { runtimeMode: mode }); + setDraftThreadContext(composerDraftTarget, { runtimeMode: mode }); } scheduleComposerFocus(); }, @@ -1923,18 +2129,18 @@ export default function ChatView({ threadId }: ChatViewProps) { isLocalDraftThread, runtimeMode, scheduleComposerFocus, + composerDraftTarget, setComposerDraftRuntimeMode, setDraftThreadContext, - threadId, ], ); const handleInteractionModeChange = useCallback( (mode: ProviderInteractionMode) => { if (mode === interactionMode) return; - setComposerDraftInteractionMode(threadId, mode); + setComposerDraftInteractionMode(composerDraftTarget, mode); if (isLocalDraftThread) { - setDraftThreadContext(threadId, { interactionMode: mode }); + setDraftThreadContext(composerDraftTarget, { interactionMode: mode }); } scheduleComposerFocus(); }, @@ -1942,9 +2148,9 @@ export default function ChatView({ threadId }: ChatViewProps) { interactionMode, isLocalDraftThread, scheduleComposerFocus, + composerDraftTarget, setComposerDraftInteractionMode, setDraftThreadContext, - threadId, ], ); const toggleInteractionMode = useCallback(() => { @@ -1980,7 +2186,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!serverThread) { return; } - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api) { return; } @@ -2020,7 +2226,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } }, - [serverThread], + [environmentId, serverThread], ); // Auto-scroll on new messages @@ -2350,17 +2556,17 @@ export default function ChatView({ threadId }: ChatViewProps) { dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); - }, [resetLocalDispatch, threadId]); + }, [draftId, resetLocalDispatch, threadId]); useEffect(() => { let cancelled = false; void (async () => { if (composerImages.length === 0) { - clearComposerDraftPersistedAttachments(threadId); + clearComposerDraftPersistedAttachments(composerDraftTarget); return; } const getPersistedAttachmentsForThread = () => - useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments ?? []; + getComposerDraft(composerDraftTarget)?.persistedAttachments ?? []; try { const currentPersistedAttachments = getPersistedAttachmentsForThread(); const existingPersistedById = new Map( @@ -2391,7 +2597,7 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } // Stage attachments in persisted draft state first so persist middleware can write them. - syncComposerDraftPersistedAttachments(threadId, serialized); + syncComposerDraftPersistedAttachments(composerDraftTarget, serialized); } catch { const currentImageIds = new Set(composerImages.map((image) => image.id)); const fallbackPersistedAttachments = getPersistedAttachmentsForThread(); @@ -2405,17 +2611,18 @@ export default function ChatView({ threadId }: ChatViewProps) { if (cancelled) { return; } - syncComposerDraftPersistedAttachments(threadId, fallbackAttachments); + syncComposerDraftPersistedAttachments(composerDraftTarget, fallbackAttachments); } })(); return () => { cancelled = true; }; }, [ + composerDraftTarget, clearComposerDraftPersistedAttachments, composerImages, + getComposerDraft, syncComposerDraftPersistedAttachments, - threadId, ]); const closeExpandedImage = useCallback(() => { @@ -2476,7 +2683,7 @@ export default function ChatView({ threadId }: ChatViewProps) { useEffect(() => { if (!activeThreadId) { setTerminalLaunchContext(null); - storeClearTerminalLaunchContext(threadId); + storeClearTerminalLaunchContext(routeThreadRef); return; } setTerminalLaunchContext((current) => { @@ -2484,7 +2691,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (current.threadId === activeThreadId) return current; return null; }); - }, [activeThreadId, storeClearTerminalLaunchContext, threadId]); + }, [activeThreadId, routeThreadRef, storeClearTerminalLaunchContext]); useEffect(() => { if (!activeThreadId || !activeProjectCwd) { @@ -2502,12 +2709,20 @@ export default function ChatView({ threadId }: ChatViewProps) { settledCwd === current.cwd && (activeThreadWorktreePath ?? null) === current.worktreePath ) { - storeClearTerminalLaunchContext(activeThreadId); + if (activeThreadRef) { + storeClearTerminalLaunchContext(activeThreadRef); + } return null; } return current; }); - }, [activeProjectCwd, activeThreadId, activeThreadWorktreePath, storeClearTerminalLaunchContext]); + }, [ + activeProjectCwd, + activeThreadId, + activeThreadRef, + activeThreadWorktreePath, + storeClearTerminalLaunchContext, + ]); useEffect(() => { if (!activeThreadId || !activeProjectCwd || !storeServerTerminalLaunchContext) { @@ -2521,11 +2736,14 @@ export default function ChatView({ threadId }: ChatViewProps) { settledCwd === storeServerTerminalLaunchContext.cwd && (activeThreadWorktreePath ?? null) === storeServerTerminalLaunchContext.worktreePath ) { - storeClearTerminalLaunchContext(activeThreadId); + if (activeThreadRef) { + storeClearTerminalLaunchContext(activeThreadRef); + } } }, [ activeProjectCwd, activeThreadId, + activeThreadRef, activeThreadWorktreePath, storeClearTerminalLaunchContext, storeServerTerminalLaunchContext, @@ -2535,11 +2753,16 @@ export default function ChatView({ threadId }: ChatViewProps) { if (terminalState.terminalOpen) { return; } - if (activeThreadId) { - storeClearTerminalLaunchContext(activeThreadId); + if (activeThreadRef) { + storeClearTerminalLaunchContext(activeThreadRef); } setTerminalLaunchContext((current) => (current?.threadId === activeThreadId ? null : current)); - }, [activeThreadId, storeClearTerminalLaunchContext, terminalState.terminalOpen]); + }, [ + activeThreadId, + activeThreadRef, + storeClearTerminalLaunchContext, + terminalState.terminalOpen, + ]); useEffect(() => { if (phase !== "running") return; @@ -2552,16 +2775,16 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [phase]); useEffect(() => { - if (!activeThreadId) return; - const previous = terminalOpenByThreadRef.current[activeThreadId] ?? false; + if (!activeThreadKey) return; + const previous = terminalOpenByThreadRef.current[activeThreadKey] ?? false; const current = Boolean(terminalState.terminalOpen); if (!previous && current) { - terminalOpenByThreadRef.current[activeThreadId] = current; + terminalOpenByThreadRef.current[activeThreadKey] = current; setTerminalFocusRequestId((value) => value + 1); return; } else if (previous && !current) { - terminalOpenByThreadRef.current[activeThreadId] = current; + terminalOpenByThreadRef.current[activeThreadKey] = current; const frame = window.requestAnimationFrame(() => { focusComposer(); }); @@ -2570,8 +2793,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }; } - terminalOpenByThreadRef.current[activeThreadId] = current; - }, [activeThreadId, focusComposer, terminalState.terminalOpen]); + terminalOpenByThreadRef.current[activeThreadKey] = current; + }, [activeThreadKey, focusComposer, terminalState.terminalOpen]); useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { @@ -2766,14 +2989,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const onRevertToTurnCount = useCallback( async (turnCount: number) => { - const api = readNativeApi(); - if (!api || !activeThread || isRevertingCheckpoint) return; + const api = readEnvironmentApi(environmentId); + const localApi = readLocalApi(); + if (!api || !localApi || !activeThread || isRevertingCheckpoint) return; if (phase === "running" || isSendBusy || isConnecting) { setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); return; } - const confirmed = await api.dialogs.confirm( + const confirmed = await localApi.dialogs.confirm( [ `Revert this thread to checkpoint ${turnCount}?`, "This will discard newer messages and turn diffs in this thread.", @@ -2802,12 +3026,20 @@ export default function ChatView({ threadId }: ChatViewProps) { } setIsRevertingCheckpoint(false); }, - [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], + [ + activeThread, + environmentId, + isConnecting, + isRevertingCheckpoint, + isSendBusy, + phase, + setThreadError, + ], ); const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; if (activePendingProgress) { onAdvanceActivePendingUserInput(); @@ -2830,7 +3062,7 @@ export default function ChatView({ threadId }: ChatViewProps) { planMarkdown: activeProposedPlan.planMarkdown, }); promptRef.current = ""; - clearComposerDraftContent(activeThread.id); + clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id)); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); @@ -2847,7 +3079,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (standaloneSlashCommand) { handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; - clearComposerDraftContent(activeThread.id); + clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, activeThread.id)); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); @@ -2880,10 +3112,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const shouldCreateWorktree = isFirstMessage && envMode === "worktree" && !activeThread.worktreePath; if (shouldCreateWorktree && !activeThread.branch) { - setStoreThreadError( - threadIdForSend, - "Select a base branch before sending in New worktree mode.", - ); + setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; } @@ -2950,7 +3179,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); } promptRef.current = ""; - clearComposerDraftContent(threadIdForSend); + clearComposerDraftContent(scopeThreadRef(activeThread.environmentId, threadIdForSend)); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); @@ -3087,7 +3316,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }; const onInterrupt = async () => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !activeThread) return; await api.orchestration.dispatchCommand({ type: "thread.turn.interrupt", @@ -3099,7 +3328,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !activeThreadId) return; setRespondingRequestIds((existing) => @@ -3122,12 +3351,12 @@ export default function ChatView({ threadId }: ChatViewProps) { }); setRespondingRequestIds((existing) => existing.filter((id) => id !== requestId)); }, - [activeThreadId, setThreadError], + [activeThreadId, environmentId, setThreadError], ); const onRespondToUserInput = useCallback( async (requestId: ApprovalRequestId, answers: Record) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !activeThreadId) return; setRespondingUserInputRequestIds((existing) => @@ -3150,7 +3379,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); setRespondingUserInputRequestIds((existing) => existing.filter((id) => id !== requestId)); }, - [activeThreadId, setThreadError], + [activeThreadId, environmentId, setThreadError], ); const setActivePendingUserInputQuestionIndex = useCallback( @@ -3166,31 +3395,38 @@ export default function ChatView({ threadId }: ChatViewProps) { [activePendingUserInput], ); - const onToggleActivePendingUserInputOption = useCallback( + const onSelectActivePendingUserInputOption = useCallback( (questionId: string, optionLabel: string) => { if (!activePendingUserInput) { return; } - const question = activePendingUserInput.questions.find((entry) => entry.id === questionId); - if (!question) { - return; - } - setPendingUserInputAnswersByRequestId((existing) => ({ - ...existing, - [activePendingUserInput.requestId]: { - ...existing[activePendingUserInput.requestId], - [questionId]: togglePendingUserInputOptionSelection( - question, - existing[activePendingUserInput.requestId]?.[questionId], - optionLabel, - ), - }, - })); + setPendingUserInputAnswersByRequestId((existing) => { + const question = + (activePendingProgress?.activeQuestion?.id === questionId + ? activePendingProgress.activeQuestion + : undefined) ?? + activePendingUserInput.questions.find((entry) => entry.id === questionId); + if (!question) { + return existing; + } + + return { + ...existing, + [activePendingUserInput.requestId]: { + ...existing[activePendingUserInput.requestId], + [questionId]: togglePendingUserInputOptionSelection( + question, + existing[activePendingUserInput.requestId]?.[questionId], + optionLabel, + ), + }, + }; + }); promptRef.current = ""; setComposerCursor(0); setComposerTrigger(null); }, - [activePendingUserInput], + [activePendingProgress?.activeQuestion, activePendingUserInput], ); const onChangeActivePendingUserInputCustomAnswer = useCallback( @@ -3257,7 +3493,7 @@ export default function ChatView({ threadId }: ChatViewProps) { text: string; interactionMode: "default" | "plan"; }) => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if ( !api || !activeThread || @@ -3312,7 +3548,10 @@ export default function ChatView({ threadId }: ChatViewProps) { // Keep the mode toggle and plan-follow-up banner in sync immediately // while the same-thread implementation turn is starting. - setComposerDraftInteractionMode(threadIdForSend, nextInteractionMode); + setComposerDraftInteractionMode( + scopeThreadRef(activeThread.environmentId, threadIdForSend), + nextInteractionMode, + ); await api.orchestration.dispatchCommand({ type: "thread.turn.start", @@ -3376,11 +3615,12 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerDraftInteractionMode, setThreadError, selectedModel, + environmentId, ], ); const onImplementPlanInNewThread = useCallback(async () => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if ( !api || !activeThread || @@ -3452,17 +3692,20 @@ export default function ChatView({ threadId }: ChatViewProps) { }); }) .then(() => { - return waitForStartedServerThread(nextThreadId); + return waitForStartedServerThread(scopeThreadRef(activeThread.environmentId, nextThreadId)); }) .then(() => { // Signal that the plan sidebar should open on the new thread. planSidebarOpenOnNextThreadRef.current = true; return navigate({ - to: "/$threadId", - params: { threadId: nextThreadId }, + to: "/$environmentId/$threadId", + params: { + environmentId: activeThread.environmentId, + threadId: nextThreadId, + }, }); }) - .catch(async (err) => { + .catch(async (err: unknown) => { await api.orchestration .dispatchCommand({ type: "thread.delete", @@ -3494,6 +3737,7 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider, selectedProviderModels, selectedModel, + environmentId, ]); const onProviderModelSelect = useCallback( @@ -3514,7 +3758,10 @@ export default function ChatView({ threadId }: ChatViewProps) { provider: resolvedProvider, model: resolvedModel, }; - setComposerDraftModelSelection(activeThread.id, nextModelSelection); + setComposerDraftModelSelection( + scopeThreadRef(activeThread.environmentId, activeThread.id), + nextModelSelection, + ); setStickyComposerModelSelection(nextModelSelection); scheduleComposerFocus(); }, @@ -3546,7 +3793,8 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ provider: selectedProvider, - threadId, + ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), + ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedProvider], @@ -3555,7 +3803,8 @@ export default function ChatView({ threadId }: ChatViewProps) { }); const providerTraitsPicker = renderProviderTraitsPicker({ provider: selectedProvider, - threadId, + ...(routeKind === "server" ? { threadRef: routeThreadRef } : {}), + ...(routeKind === "draft" && draftId ? { draftId } : {}), model: selectedModel, models: selectedProviderModels, modelOptions: composerModelOptions?.[selectedProvider], @@ -3565,11 +3814,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const onEnvModeChange = useCallback( (mode: DraftThreadEnvMode) => { if (isLocalDraftThread) { - setDraftThreadContext(threadId, { envMode: mode }); + setDraftThreadContext(composerDraftTarget, { envMode: mode }); } scheduleComposerFocus(); }, - [isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext, threadId], + [composerDraftTarget, isLocalDraftThread, scheduleComposerFocus, setDraftThreadContext], ); const applyPromptReplacement = useCallback( @@ -3766,7 +4015,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setPrompt(nextPrompt); if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { setComposerDraftTerminalContexts( - threadId, + composerDraftTarget, syncTerminalContextsByIds(composerTerminalContexts, terminalContextIds), ); } @@ -3781,8 +4030,8 @@ export default function ChatView({ threadId }: ChatViewProps) { composerTerminalContexts, onChangeActivePendingUserInputCustomAnswer, setPrompt, + composerDraftTarget, setComposerDraftTerminalContexts, - threadId, ], ); @@ -3835,9 +4084,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { + if (!isServerThread) { + return; + } void navigate({ - to: "/$threadId", - params: { threadId }, + to: "/$environmentId/$threadId", + params: { + environmentId, + threadId, + }, search: (previous) => { const rest = stripDiffSearchParams(previous); return filePath @@ -3846,7 +4101,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }, }); }, - [navigate, threadId], + [environmentId, isServerThread, navigate, threadId], ); const onRevertUserMessage = (messageId: MessageId) => { const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); @@ -3892,6 +4147,7 @@ export default function ChatView({ threadId }: ChatViewProps) { )} >
@@ -4370,7 +4627,9 @@ export default function ChatView({ threadId }: ChatViewProps) { {isGitRepo && ( {/* end horizontal flex container */} - {mountedTerminalThreadIds.map((mountedThreadId) => ( - - ))} + {mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { + const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); + if (!mountedThreadRef) { + return []; + } + return [ + , + ]; + })} {expandedImage && expandedImageItem && (
(params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), + select: (params) => resolveThreadRouteRef(params), }); const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); const diffOpen = diffSearch.diff === "1"; - const activeThreadId = routeThreadId; + const activeThreadId = routeThreadRef?.threadId ?? null; const activeThread = useStore( - useMemo(() => createThreadSelector(activeThreadId), [activeThreadId]), + useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), ); const activeProjectId = activeThread?.projectId ?? null; const activeProject = useStore((store) => - activeProjectId ? store.projectById[activeProjectId] : undefined, + activeThread && activeProjectId + ? selectProjectByRef(store, { + environmentId: activeThread.environmentId, + projectId: activeProjectId, + }) + : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; - const gitStatusQuery = useGitStatus(activeCwd ?? null); + const gitStatusQuery = useGitStatus({ + environmentId: activeThread?.environmentId ?? null, + cwd: activeCwd ?? null, + }); const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); @@ -263,6 +273,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { }, [orderedTurnDiffSummaries, selectedTurn]); const activeCheckpointDiffQuery = useQuery( checkpointDiffQueryOptions({ + environmentId: activeThread?.environmentId ?? null, threadId: activeThreadId, fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, toTurnCount: activeCheckpointRange?.toTurnCount ?? null, @@ -322,7 +333,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const openDiffFileInEditor = useCallback( (filePath: string) => { - const api = readNativeApi(); + const api = readLocalApi(); if (!api) return; const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; void openInPreferredEditor(api, targetPath).catch((error) => { @@ -335,8 +346,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const selectTurn = (turnId: TurnId) => { if (!activeThread) return; void navigate({ - to: "/$threadId", - params: { threadId: activeThread.id }, + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), search: (previous) => { const rest = stripDiffSearchParams(previous); return { ...rest, diff: "1", diffTurnId: turnId }; @@ -346,8 +357,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const selectWholeConversation = () => { if (!activeThread) return; void navigate({ - to: "/$threadId", - params: { threadId: activeThread.id }, + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, activeThread.id)), search: (previous) => { const rest = stripDiffSearchParams(previous); return { ...rest, diff: "1" }; diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index ffdb01e9d5..1a1bd714f3 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -1,3 +1,4 @@ +import { scopeThreadRef } from "@t3tools/client-runtime"; import { ThreadId } from "@t3tools/contracts"; import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -5,10 +6,24 @@ import { render } from "vitest-browser-react"; const THREAD_A = ThreadId.makeUnsafe("thread-a"); const THREAD_B = ThreadId.makeUnsafe("thread-b"); +const ENVIRONMENT_ID = "environment-local" as never; const GIT_CWD = "/repo/project"; const BRANCH_NAME = "feature/toast-scope"; +function createDeferredPromise() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + + return { promise, resolve, reject }; +} + const { + activeRunStackedActionDeferredRef, invalidateGitQueriesSpy, refreshGitStatusSpy, runStackedActionMutateAsyncSpy, @@ -18,9 +33,10 @@ const { toastPromiseSpy, toastUpdateSpy, } = vi.hoisted(() => ({ + activeRunStackedActionDeferredRef: { current: createDeferredPromise() }, invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)), - runStackedActionMutateAsyncSpy: vi.fn(() => new Promise(() => undefined)), + runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise), setThreadBranchSpy: vi.fn(), toastAddSpy: vi.fn(() => "toast-1"), toastCloseSpy: vi.fn(), @@ -28,12 +44,8 @@ const { toastUpdateSpy: vi.fn(), })); -vi.mock("@tanstack/react-query", async () => { - const actual = - await vi.importActual("@tanstack/react-query"); - +vi.mock("@tanstack/react-query", () => { return { - ...actual, useIsMutating: vi.fn(() => 0), useMutation: vi.fn((options: { __kind?: string }) => { if (options.__kind === "run-stacked-action") { @@ -56,27 +68,7 @@ vi.mock("@tanstack/react-query", async () => { isPending: false, }; }), - useQuery: vi.fn((options: { queryKey?: string[] }) => { - if (options.queryKey?.[0] === "git-branches") { - return { - data: { - isRepo: true, - hasOriginRemote: true, - branches: [ - { - name: BRANCH_NAME, - current: true, - isDefault: false, - worktreePath: null, - }, - ], - }, - error: null, - }; - } - - return { data: null, error: null }; - }), + useQuery: vi.fn(() => ({ data: null, error: null })), useQueryClient: vi.fn(() => ({})), }; }); @@ -123,27 +115,44 @@ vi.mock("~/lib/gitStatusState", () => ({ })), })); -vi.mock("~/lib/utils", async () => { - const actual = await vi.importActual("~/lib/utils"); - - return { - ...actual, - newCommandId: vi.fn(() => "command-1"), - randomUUID: vi.fn(() => "action-1"), - }; -}); - -vi.mock("~/nativeApi", () => ({ - readNativeApi: vi.fn(() => null), +vi.mock("~/localApi", () => ({ + readLocalApi: vi.fn(() => null), })); vi.mock("~/store", () => ({ + selectEnvironmentState: ( + state: { environmentStateById: Record }, + environmentId: string | null, + ) => { + if (!environmentId) { + throw new Error("Missing environment id"); + } + const environmentState = state.environmentStateById[environmentId]; + if (!environmentState) { + throw new Error(`Unknown environment: ${environmentId}`); + } + return environmentState; + }, useStore: (selector: (state: unknown) => unknown) => selector({ setThreadBranch: setThreadBranchSpy, - threadShellById: { - [THREAD_A]: { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null }, - [THREAD_B]: { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null }, + environmentStateById: { + [ENVIRONMENT_ID]: { + threadShellById: { + [THREAD_A]: { id: THREAD_A, branch: BRANCH_NAME, worktreePath: null }, + [THREAD_B]: { id: THREAD_B, branch: BRANCH_NAME, worktreePath: null }, + }, + threadSessionById: {}, + threadTurnStateById: {}, + messageIdsByThreadId: {}, + messageByThreadId: {}, + activityIdsByThreadId: {}, + activityByThreadId: {}, + proposedPlanIdsByThreadId: {}, + proposedPlanByThreadId: {}, + turnDiffIdsByThreadId: {}, + turnDiffSummaryByThreadId: {}, + }, }, }), })); @@ -168,7 +177,10 @@ function Harness() { - + ); } @@ -177,6 +189,7 @@ describe("GitActionsControl thread-scoped progress toast", () => { afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); + activeRunStackedActionDeferredRef.current = createDeferredPromise(); document.body.innerHTML = ""; }); @@ -231,6 +244,9 @@ describe("GitActionsControl thread-scoped progress toast", () => { }), ); } finally { + activeRunStackedActionDeferredRef.current.reject(new Error("test cleanup")); + await Promise.resolve(); + vi.useRealTimers(); await screen.unmount(); host.remove(); } @@ -248,9 +264,15 @@ describe("GitActionsControl thread-scoped progress toast", () => { const host = document.createElement("div"); document.body.append(host); - const screen = await render(, { - container: host, - }); + const screen = await render( + , + { + container: host, + }, + ); try { window.dispatchEvent(new Event("focus")); @@ -264,11 +286,15 @@ describe("GitActionsControl thread-scoped progress toast", () => { await vi.advanceTimersByTimeAsync(1); expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1); - expect(refreshGitStatusSpy).toHaveBeenCalledWith(GIT_CWD); + expect(refreshGitStatusSpy).toHaveBeenCalledWith({ + environmentId: ENVIRONMENT_ID, + cwd: GIT_CWD, + }); } finally { if (originalVisibilityState) { Object.defineProperty(document, "visibilityState", originalVisibilityState); } + vi.useRealTimers(); await screen.unmount(); host.remove(); } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2c9222ee36..06bd21cb20 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,9 +1,9 @@ +import { type ScopedThreadRef } from "@t3tools/contracts"; import type { GitActionProgressEvent, GitRunStackedActionResult, GitStackedAction, GitStatusResult, - ThreadId, } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; @@ -49,12 +49,14 @@ import { import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; -import { readNativeApi } from "~/nativeApi"; +import { readEnvironmentApi } from "~/environmentApi"; +import { readLocalApi } from "~/localApi"; import { useStore } from "~/store"; +import { createThreadSelectorByRef } from "~/storeSelectors"; interface GitActionsControlProps { gitCwd: string | null; - activeThreadId: ThreadId | null; + activeThreadRef: ScopedThreadRef | null; } interface PendingDefaultBranchAction { @@ -206,14 +208,18 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { return ; } -export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { +export default function GitActionsControl({ gitCwd, activeThreadRef }: GitActionsControlProps) { + const activeThreadId = activeThreadRef?.threadId ?? null; + const activeEnvironmentId = activeThreadRef?.environmentId ?? null; const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], ); - const activeServerThread = useStore((store) => - activeThreadId ? store.threadShellById[activeThreadId] : undefined, + const activeServerThreadSelector = useMemo( + () => createThreadSelectorByRef(activeThreadRef), + [activeThreadRef], ); + const activeServerThread = useStore(activeServerThreadSelector); const setThreadBranch = useStore((store) => store.setThreadBranch); const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); @@ -246,7 +252,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions } const worktreePath = activeServerThread.worktreePath; - const api = readNativeApi(); + const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined; if (api) { void api.orchestration .dispatchCommand({ @@ -261,7 +267,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions setThreadBranch(activeThreadId, branch, worktreePath); }, - [activeServerThread, activeThreadId, setThreadBranch], + [activeEnvironmentId, activeServerThread, activeThreadId, setThreadBranch], ); const syncThreadBranchAfterGitAction = useCallback( @@ -276,7 +282,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions [persistThreadBranchSync], ); - const { data: gitStatus = null, error: gitStatusError } = useGitStatus(gitCwd); + const { data: gitStatus = null, error: gitStatusError } = useGitStatus({ + environmentId: activeEnvironmentId, + cwd: gitCwd, + }); // Default to true while loading so we don't flash init controls. const isRepo = gitStatus?.isRepo ?? true; const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; @@ -287,19 +296,27 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const allSelected = excludedFiles.size === 0; const noneSelected = selectedFiles.length === 0; - const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient })); + const initMutation = useMutation( + gitInitMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), + ); const runImmediateGitActionMutation = useMutation( gitRunStackedActionMutationOptions({ + environmentId: activeEnvironmentId, cwd: gitCwd, queryClient, }), ); - const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); + const pullMutation = useMutation( + gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), + ); const isRunStackedActionRunning = - useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; - const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; + useIsMutating({ + mutationKey: gitMutationKeys.runStackedAction(activeEnvironmentId, gitCwd), + }) > 0; + const isPullRunning = + useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0; const isGitActionRunning = isRunStackedActionRunning || isPullRunning; useEffect(() => { @@ -372,7 +389,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions } refreshTimeout = window.setTimeout(() => { refreshTimeout = null; - void refreshGitStatus(gitCwd).catch(() => undefined); + void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( + () => undefined, + ); }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); }; const handleVisibilityChange = () => { @@ -391,10 +410,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [gitCwd]); + }, [activeEnvironmentId, gitCwd]); const openExistingPr = useCallback(async () => { - const api = readNativeApi(); + const api = readLocalApi(); if (!api) { toastManager.add({ type: "error", @@ -412,7 +431,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }); return; } - void api.shell.openExternal(prUrl).catch((err) => { + void api.shell.openExternal(prUrl).catch((err: unknown) => { toastManager.add({ type: "error", title: "Unable to open PR link", @@ -601,7 +620,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions toastActionProps = { children: toastCta.label, onClick: () => { - const api = readNativeApi(); + const api = readLocalApi(); if (!api) return; closeResultToast(); void api.shell.openExternal(toastCta.url); @@ -760,7 +779,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const openChangedFileInEditor = useCallback( (filePath: string) => { - const api = readNativeApi(); + const api = readLocalApi(); if (!api || !gitCwd) { toastManager.add({ type: "error", @@ -836,7 +855,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions { if (open) { - void refreshGitStatus(gitCwd).catch(() => undefined); + void refreshGitStatus({ + environmentId: activeEnvironmentId, + cwd: gitCwd, + }).catch(() => undefined); } }} > diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 1ee13f460f..6ec450d662 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -2,6 +2,7 @@ import "../index.css"; import { DEFAULT_SERVER_SETTINGS, + EnvironmentId, ORCHESTRATION_WS_METHODS, type MessageId, type OrchestrationReadModel, @@ -18,7 +19,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } import { render } from "vitest-browser-react"; import { useComposerDraftStore } from "../composerDraftStore"; -import { __resetNativeApiForTests } from "../nativeApi"; +import { __resetLocalApiForTests } from "../localApi"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; @@ -34,6 +35,7 @@ vi.mock("../lib/gitStatusState", () => ({ const THREAD_ID = "thread-kb-toast-test" as ThreadId; const PROJECT_ID = "project-1" as ProjectId; +const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); const NOW_ISO = "2026-03-04T12:00:00.000Z"; interface TestFixture { @@ -49,6 +51,13 @@ const wsLink = ws.link(/ws(s)?:\/\/.*/); function createBaseServerConfig(): ServerConfig { return { + environment: { + environmentId: LOCAL_ENVIRONMENT_ID, + label: "Local environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], @@ -155,6 +164,13 @@ function buildFixture(): TestFixture { snapshot: createMinimalSnapshot(), serverConfig: createBaseServerConfig(), welcome: { + environment: { + environmentId: LOCAL_ENVIRONMENT_ID, + label: "Local environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/repo/project", projectName: "Project", bootstrapProjectId: PROJECT_ID, @@ -286,7 +302,9 @@ async function mountApp(): Promise<{ cleanup: () => Promise }> { host.style.overflow = "hidden"; document.body.append(host); - const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] })); + const router = getRouter( + createMemoryHistory({ initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`] }), + ); const screen = await render( @@ -347,32 +365,17 @@ describe("Keybindings update toast", () => { return []; }, }); - await __resetNativeApiForTests(); + await __resetLocalApiForTests(); localStorage.clear(); document.body.innerHTML = ""; useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, }); useStore.setState({ - projectIds: [], - projectById: {}, - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - sidebarThreadSummaryById: {}, - bootstrapComplete: false, + activeEnvironmentId: null, + environmentStateById: {}, }); }); diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 01341dc803..134ca2e6f3 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -1,4 +1,5 @@ import { memo, useState, useCallback } from "react"; +import type { EnvironmentId } from "@t3tools/contracts"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; @@ -24,7 +25,7 @@ import { stripDisplayedPlanMarkdown, } from "../proposedPlan"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; -import { readNativeApi } from "~/nativeApi"; +import { readEnvironmentApi } from "~/environmentApi"; import { toastManager } from "./ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; @@ -53,6 +54,7 @@ function stepStatusIcon(status: string): React.ReactNode { interface PlanSidebarProps { activePlan: ActivePlanState | null; activeProposedPlan: LatestProposedPlanState | null; + environmentId: EnvironmentId; markdownCwd: string | undefined; workspaceRoot: string | undefined; timestampFormat: TimestampFormat; @@ -62,6 +64,7 @@ interface PlanSidebarProps { const PlanSidebar = memo(function PlanSidebar({ activePlan, activeProposedPlan, + environmentId, markdownCwd, workspaceRoot, timestampFormat, @@ -87,7 +90,7 @@ const PlanSidebar = memo(function PlanSidebar({ }, [planMarkdown]); const handleSaveToWorkspace = useCallback(() => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); if (!api || !workspaceRoot || !planMarkdown) return; const filename = buildProposedPlanMarkdownFilename(planMarkdown); setIsSavingToWorkspace(true); @@ -115,7 +118,7 @@ const PlanSidebar = memo(function PlanSidebar({ () => setIsSavingToWorkspace(false), () => setIsSavingToWorkspace(false), ); - }, [planMarkdown, workspaceRoot]); + }, [environmentId, planMarkdown, workspaceRoot]); return (
diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 8fa899343e..6c134f95a0 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -1,4 +1,4 @@ -import type { GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -24,6 +24,7 @@ import { Spinner } from "./ui/spinner"; interface PullRequestThreadDialogProps { open: boolean; + environmentId: EnvironmentId; threadId: ThreadId; cwd: string | null; initialReference: string | null; @@ -33,6 +34,7 @@ interface PullRequestThreadDialogProps { export function PullRequestThreadDialog({ open, + environmentId, threadId, cwd, initialReference, @@ -72,6 +74,7 @@ export function PullRequestThreadDialog({ const parsedDebouncedReference = parsePullRequestReference(debouncedReference); const resolvePullRequestQuery = useQuery( gitResolvePullRequestQueryOptions({ + environmentId, cwd, reference: open ? parsedDebouncedReference : null, }), @@ -83,13 +86,14 @@ export function PullRequestThreadDialog({ const cached = queryClient.getQueryData([ "git", "pull-request", + environmentId, cwd, parsedReference, ]); return cached?.pullRequest ?? null; - }, [cwd, parsedReference, queryClient]); + }, [cwd, environmentId, parsedReference, queryClient]); const preparePullRequestThreadMutation = useMutation( - gitPreparePullRequestThreadMutationOptions({ cwd, queryClient }), + gitPreparePullRequestThreadMutationOptions({ environmentId, cwd, queryClient }), ); const liveResolvedPullRequest = diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a4f4468279..f9e5561a50 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -20,7 +20,7 @@ import { sortThreadsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; -import { OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, OrchestrationLatestTurn, ProjectId, ThreadId } from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -28,6 +28,8 @@ import { type Thread, } from "../types"; +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); + function makeLatestTurn(overrides?: { completedAt?: string | null; startedAt?: string | null; @@ -625,6 +627,7 @@ function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { id: ProjectId.makeUnsafe("project-1"), + environmentId: localEnvironmentId, name: "Project", cwd: "/tmp/project", defaultModelSelection: { @@ -642,6 +645,7 @@ function makeProject(overrides: Partial = {}): Project { function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 74cdf6eefe..9181434547 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -12,20 +12,7 @@ import { } from "lucide-react"; import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type Dispatch, - type KeyboardEvent, - type MouseEvent, - type MutableRefObject, - type PointerEvent, - type ReactNode, - type SetStateAction, -} from "react"; +import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { DndContext, @@ -45,11 +32,20 @@ import { CSS } from "@dnd-kit/utilities"; import { DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, + type EnvironmentId, ProjectId, + type ScopedThreadRef, + type ThreadEnvMode, ThreadId, type GitStatusResult, } from "@t3tools/contracts"; -import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; +import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { type SidebarProjectSortOrder, type SidebarThreadSortOrder, @@ -58,7 +54,13 @@ import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; -import { useStore } from "../store"; +import { + selectProjectsAcrossEnvironments, + selectSidebarThreadsForProjectRef, + selectSidebarThreadsAcrossEnvironments, + selectThreadByRef, + useStore, +} from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; import { @@ -70,11 +72,16 @@ import { threadTraversalDirectionFromCommand, } from "../keybindings"; import { useGitStatus } from "../lib/gitStatusState"; -import { readNativeApi } from "../nativeApi"; +import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useThreadActions } from "../hooks/useThreadActions"; +import { + buildThreadRouteParams, + resolveThreadRouteRef, + resolveThreadRouteTarget, +} from "../threadRoutes"; import { toastManager } from "./ui/toast"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; @@ -109,8 +116,6 @@ import { import { useThreadSelectionStore } from "../threadSelectionStore"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { - getVisibleSidebarThreadIds, - getVisibleThreadsForProject, resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, @@ -123,12 +128,14 @@ import { sortProjectsForSidebar, sortThreadsForSidebar, useThreadJumpHintVisibility, + ThreadStatusPill, } from "./Sidebar.logic"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; -import type { Project } from "../types"; +import type { Project, SidebarThreadSummary } from "../types"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", @@ -143,9 +150,58 @@ const SIDEBAR_LIST_ANIMATION_OPTIONS = { duration: 180, easing: "ease-out", } as const; +const EMPTY_THREAD_JUMP_LABELS = new Map(); + +function threadJumpLabelMapsEqual( + left: ReadonlyMap, + right: ReadonlyMap, +): boolean { + if (left === right) { + return true; + } + if (left.size !== right.size) { + return false; + } + for (const [key, value] of left) { + if (right.get(key) !== value) { + return false; + } + } + return true; +} + +function buildThreadJumpLabelMap(input: { + keybindings: ReturnType; + platform: string; + terminalOpen: boolean; + threadJumpCommandByKey: ReadonlyMap< + string, + NonNullable> + >; +}): ReadonlyMap { + if (input.threadJumpCommandByKey.size === 0) { + return EMPTY_THREAD_JUMP_LABELS; + } + + const shortcutLabelOptions = { + platform: input.platform, + context: { + terminalFocus: false, + terminalOpen: input.terminalOpen, + }, + } as const; + const mapping = new Map(); + for (const [threadKey, command] of input.threadJumpCommandByKey) { + const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); + if (label) { + mapping.set(threadKey, label); + } + } + return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; +} type SidebarProjectSnapshot = Project & { - expanded: boolean; + projectKey: string; }; interface TerminalStatusIndicator { label: "Terminal process running"; @@ -166,7 +222,7 @@ function ThreadStatusLabel({ status, compact = false, }: { - status: NonNullable>; + status: ThreadStatusPill; compact?: boolean; }) { if (compact) { @@ -255,57 +311,81 @@ function resolveThreadPr( } interface SidebarThreadRowProps { - threadId: ThreadId; + thread: SidebarThreadSummary; projectCwd: string | null; - orderedProjectThreadIds: readonly ThreadId[]; - routeThreadId: ThreadId | null; - selectedThreadIds: ReadonlySet; - showThreadJumpHints: boolean; + orderedProjectThreadKeys: readonly string[]; + isActive: boolean; jumpLabel: string | null; appSettingsConfirmThreadArchive: boolean; - renamingThreadId: ThreadId | null; + renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; - onRenamingInputMount: (element: HTMLInputElement | null) => void; - hasRenameCommitted: () => boolean; - markRenameCommitted: () => void; - confirmingArchiveThreadId: ThreadId | null; - setConfirmingArchiveThreadId: Dispatch>; - confirmArchiveButtonRefs: MutableRefObject>; + renamingInputRef: React.RefObject; + renamingCommittedRef: React.RefObject; + confirmingArchiveThreadKey: string | null; + setConfirmingArchiveThreadKey: React.Dispatch>; + confirmArchiveButtonRefs: React.RefObject>; handleThreadClick: ( - event: MouseEvent, - threadId: ThreadId, - orderedProjectThreadIds: readonly ThreadId[], + event: React.MouseEvent, + threadRef: ScopedThreadRef, + orderedProjectThreadKeys: readonly string[], ) => void; - navigateToThread: (threadId: ThreadId) => void; + navigateToThread: (threadRef: ScopedThreadRef) => void; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; handleThreadContextMenu: ( - threadId: ThreadId, + threadRef: ScopedThreadRef, position: { x: number; y: number }, ) => Promise; clearSelection: () => void; - commitRename: (threadId: ThreadId, newTitle: string, originalTitle: string) => Promise; + commitRename: ( + threadRef: ScopedThreadRef, + newTitle: string, + originalTitle: string, + ) => Promise; cancelRename: () => void; - attemptArchiveThread: (threadId: ThreadId) => Promise; - openPrLink: (event: MouseEvent, prUrl: string) => void; + attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; + openPrLink: (event: React.MouseEvent, prUrl: string) => void; } -function SidebarThreadRow(props: SidebarThreadRowProps) { - const thread = useStore((state) => state.sidebarThreadSummaryById[props.threadId]); - const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[props.threadId]); +const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { + const { + orderedProjectThreadKeys, + isActive, + jumpLabel, + appSettingsConfirmThreadArchive, + renamingThreadKey, + renamingTitle, + setRenamingTitle, + renamingInputRef, + renamingCommittedRef, + confirmingArchiveThreadKey, + setConfirmingArchiveThreadKey, + confirmArchiveButtonRefs, + handleThreadClick, + navigateToThread, + handleMultiSelectContextMenu, + handleThreadContextMenu, + clearSelection, + commitRename, + cancelRename, + attemptArchiveThread, + openPrLink, + thread, + } = props; + const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const threadKey = scopedThreadKey(threadRef); + const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); + const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); + const hasSelection = useThreadSelectionStore((state) => state.selectedThreadKeys.size > 0); const runningTerminalIds = useTerminalStateStore( (state) => - selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, + selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, ); - const gitCwd = thread?.worktreePath ?? props.projectCwd; - const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null); - - if (!thread) { - return null; - } - - const isActive = props.routeThreadId === thread.id; - const isSelected = props.selectedThreadIds.has(thread.id); + const gitCwd = thread.worktreePath ?? props.projectCwd; + const gitStatus = useGitStatus({ + environmentId: thread.environmentId, + cwd: thread.branch != null ? gitCwd : null, + }); const isHighlighted = isActive || isSelected; const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; @@ -318,32 +398,173 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { const pr = resolveThreadPr(thread.branch, gitStatus.data); const prStatus = prStatusIndicator(pr); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); - const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning; + const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive ? "pointer-events-none opacity-0" : !isThreadRunning ? "pointer-events-none transition-opacity duration-150 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" : "pointer-events-none"; + const clearConfirmingArchive = useCallback(() => { + setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); + }, [setConfirmingArchiveThreadKey, threadKey]); + const handleMouseLeave = useCallback(() => { + clearConfirmingArchive(); + }, [clearConfirmingArchive]); + const handleBlurCapture = useCallback( + (event: React.FocusEvent) => { + const currentTarget = event.currentTarget; + requestAnimationFrame(() => { + if (currentTarget.contains(document.activeElement)) { + return; + } + clearConfirmingArchive(); + }); + }, + [clearConfirmingArchive], + ); + const handleRowClick = useCallback( + (event: React.MouseEvent) => { + handleThreadClick(event, threadRef, orderedProjectThreadKeys); + }, + [handleThreadClick, orderedProjectThreadKeys, threadRef], + ); + const handleRowKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + navigateToThread(threadRef); + }, + [navigateToThread, threadRef], + ); + const handleRowContextMenu = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + if (hasSelection && isSelected) { + void handleMultiSelectContextMenu({ + x: event.clientX, + y: event.clientY, + }); + return; + } + + if (hasSelection) { + clearSelection(); + } + void handleThreadContextMenu(threadRef, { + x: event.clientX, + y: event.clientY, + }); + }, + [ + clearSelection, + handleMultiSelectContextMenu, + handleThreadContextMenu, + hasSelection, + isSelected, + threadRef, + ], + ); + const handlePrClick = useCallback( + (event: React.MouseEvent) => { + if (!prStatus) return; + openPrLink(event, prStatus.url); + }, + [openPrLink, prStatus], + ); + const handleRenameInputRef = useCallback( + (element: HTMLInputElement | null) => { + if (element && renamingInputRef.current !== element) { + renamingInputRef.current = element; + element.focus(); + element.select(); + } + }, + [renamingInputRef], + ); + const handleRenameInputChange = useCallback( + (event: React.ChangeEvent) => { + setRenamingTitle(event.target.value); + }, + [setRenamingTitle], + ); + const handleRenameInputKeyDown = useCallback( + (event: React.KeyboardEvent) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + renamingCommittedRef.current = true; + void commitRename(threadRef, renamingTitle, thread.title); + } else if (event.key === "Escape") { + event.preventDefault(); + renamingCommittedRef.current = true; + cancelRename(); + } + }, + [cancelRename, commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef], + ); + const handleRenameInputBlur = useCallback(() => { + if (!renamingCommittedRef.current) { + void commitRename(threadRef, renamingTitle, thread.title); + } + }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); + const handleRenameInputClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + }, []); + const handleConfirmArchiveRef = useCallback( + (element: HTMLButtonElement | null) => { + if (element) { + confirmArchiveButtonRefs.current.set(threadKey, element); + } else { + confirmArchiveButtonRefs.current.delete(threadKey); + } + }, + [confirmArchiveButtonRefs, threadKey], + ); + const stopPropagationOnPointerDown = useCallback( + (event: React.PointerEvent) => { + event.stopPropagation(); + }, + [], + ); + const handleConfirmArchiveClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + clearConfirmingArchive(); + void attemptArchiveThread(threadRef); + }, + [attemptArchiveThread, clearConfirmingArchive, threadRef], + ); + const handleStartArchiveConfirmation = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + setConfirmingArchiveThreadKey(threadKey); + requestAnimationFrame(() => { + confirmArchiveButtonRefs.current.get(threadKey)?.focus(); + }); + }, + [confirmArchiveButtonRefs, setConfirmingArchiveThreadKey, threadKey], + ); + const handleArchiveImmediateClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + void attemptArchiveThread(threadRef); + }, + [attemptArchiveThread, threadRef], + ); + const rowButtonRender = useMemo(() =>
, []); return ( { - props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); - }} - onBlurCapture={(event) => { - const currentTarget = event.currentTarget; - requestAnimationFrame(() => { - if (currentTarget.contains(document.activeElement)) { - return; - } - props.setConfirmingArchiveThreadId((current) => (current === thread.id ? null : current)); - }); - }} + onMouseLeave={handleMouseLeave} + onBlurCapture={handleBlurCapture} > } + render={rowButtonRender} size="sm" isActive={isActive} data-testid={`thread-row-${thread.id}`} @@ -351,31 +572,9 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { isActive, isSelected, })} relative isolate`} - onClick={(event) => { - props.handleThreadClick(event, thread.id, props.orderedProjectThreadIds); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - props.navigateToThread(thread.id); - }} - onContextMenu={(event) => { - event.preventDefault(); - if (props.selectedThreadIds.size > 0 && props.selectedThreadIds.has(thread.id)) { - void props.handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }); - } else { - if (props.selectedThreadIds.size > 0) { - props.clearSelection(); - } - void props.handleThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); - } - }} + onClick={handleRowClick} + onKeyDown={handleRowKeyDown} + onContextMenu={handleRowContextMenu} >
{prStatus && ( @@ -386,9 +585,7 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { type="button" aria-label={prStatus.tooltip} className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`} - onClick={(event) => { - props.openPrLink(event, prStatus.url); - }} + onClick={handlePrClick} > @@ -398,30 +595,15 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { )} {threadStatus && } - {props.renamingThreadId === thread.id ? ( + {renamingThreadKey === threadKey ? ( props.setRenamingTitle(event.target.value)} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - props.markRenameCommitted(); - void props.commitRename(thread.id, props.renamingTitle, thread.title); - } else if (event.key === "Escape") { - event.preventDefault(); - props.markRenameCommitted(); - props.cancelRename(); - } - }} - onBlur={() => { - if (!props.hasRenameCommitted()) { - void props.commitRename(thread.id, props.renamingTitle, thread.title); - } - }} - onClick={(event) => event.stopPropagation()} + value={renamingTitle} + onChange={handleRenameInputChange} + onKeyDown={handleRenameInputKeyDown} + onBlur={handleRenameInputBlur} + onClick={handleRenameInputClick} /> ) : ( {thread.title} @@ -441,34 +623,19 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
{isConfirmingArchive ? ( ) : !isThreadRunning ? ( - props.appSettingsConfirmThreadArchive ? ( + appSettingsConfirmThreadArchive ? (
@@ -502,14 +660,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { data-testid={`thread-archive-${thread.id}`} aria-label={`Archive ${thread.title}`} className="inline-flex size-5 cursor-pointer items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring" - onPointerDown={(event) => { - event.stopPropagation(); - }} - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - void props.attemptArchiveThread(thread.id); - }} + onPointerDown={stopPropagationOnPointerDown} + onClick={handleArchiveImmediateClick} > @@ -521,12 +673,12 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { ) ) : null} - {props.showThreadJumpHints && props.jumpLabel ? ( + {jumpLabel ? ( - {props.jumpLabel} + {jumpLabel} ) : ( ); +}); + +interface SidebarProjectThreadListProps { + projectKey: string; + projectExpanded: boolean; + hasOverflowingThreads: boolean; + hiddenThreadStatus: ThreadStatusPill | null; + orderedProjectThreadKeys: readonly string[]; + renderedThreads: readonly SidebarThreadSummary[]; + showEmptyThreadState: boolean; + shouldShowThreadPanel: boolean; + isThreadListExpanded: boolean; + projectCwd: string; + activeRouteThreadKey: string | null; + threadJumpLabelByKey: ReadonlyMap; + appSettingsConfirmThreadArchive: boolean; + renamingThreadKey: string | null; + renamingTitle: string; + setRenamingTitle: (title: string) => void; + renamingInputRef: React.RefObject; + renamingCommittedRef: React.RefObject; + confirmingArchiveThreadKey: string | null; + setConfirmingArchiveThreadKey: React.Dispatch>; + confirmArchiveButtonRefs: React.RefObject>; + attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; + handleThreadClick: ( + event: React.MouseEvent, + threadRef: ScopedThreadRef, + orderedProjectThreadKeys: readonly string[], + ) => void; + navigateToThread: (threadRef: ScopedThreadRef) => void; + handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; + handleThreadContextMenu: ( + threadRef: ScopedThreadRef, + position: { x: number; y: number }, + ) => Promise; + clearSelection: () => void; + commitRename: ( + threadRef: ScopedThreadRef, + newTitle: string, + originalTitle: string, + ) => Promise; + cancelRename: () => void; + attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; + openPrLink: (event: React.MouseEvent, prUrl: string) => void; + expandThreadListForProject: (projectKey: string) => void; + collapseThreadListForProject: (projectKey: string) => void; } -function T3Wordmark() { - return ( - - - - ); -} - -type SortableProjectHandleProps = Pick< - ReturnType, - "attributes" | "listeners" | "setActivatorNodeRef" ->; +const SidebarProjectThreadList = memo(function SidebarProjectThreadList( + props: SidebarProjectThreadListProps, +) { + const { + projectKey, + projectExpanded, + hasOverflowingThreads, + hiddenThreadStatus, + orderedProjectThreadKeys, + renderedThreads, + showEmptyThreadState, + shouldShowThreadPanel, + isThreadListExpanded, + projectCwd, + activeRouteThreadKey, + threadJumpLabelByKey, + appSettingsConfirmThreadArchive, + renamingThreadKey, + renamingTitle, + setRenamingTitle, + renamingInputRef, + renamingCommittedRef, + confirmingArchiveThreadKey, + setConfirmingArchiveThreadKey, + confirmArchiveButtonRefs, + attachThreadListAutoAnimateRef, + handleThreadClick, + navigateToThread, + handleMultiSelectContextMenu, + handleThreadContextMenu, + clearSelection, + commitRename, + cancelRename, + attemptArchiveThread, + openPrLink, + expandThreadListForProject, + collapseThreadListForProject, + } = props; + const showMoreButtonRender = useMemo(() => + + ) : null} + + + ) : null} + +
+ + Projects + +
+ + + + } + > + + + + {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} + + +
+
+ {shouldShowProjectPathEntry && ( +
+ {isElectron && ( + + )} +
+ + +
+ {addProjectError && ( +

+ {addProjectError} +

+ )} +
+ )} + + {isManualProjectSorting ? ( + + + project.projectKey)} + strategy={verticalListSortingStrategy} + > + {sortedProjects.map((project) => ( + + {(dragHandleProps) => ( + + )} + + ))} + + + + ) : ( + + {sortedProjects.map((project) => ( + + ))} + + )} + + {projectsLength === 0 && !shouldShowProjectPathEntry && ( +
+ No projects yet +
+ )} +
+ + ); +}); + +export default function Sidebar() { + const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); + const activeEnvironmentId = useStore((store) => store.activeEnvironmentId); + const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); + const projectOrder = useUiStateStore((store) => store.projectOrder); + const reorderProjects = useUiStateStore((store) => store.reorderProjects); + const navigate = useNavigate(); + const pathname = useLocation({ select: (loc) => loc.pathname }); + const isOnSettings = pathname.startsWith("/settings"); + const appSettings = useSettings(); + const { updateSettings } = useUpdateSettings(); + const { handleNewThread } = useHandleNewThread(); + const { archiveThread, deleteThread } = useThreadActions(); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + const keybindings = useServerKeybindings(); + const [addingProject, setAddingProject] = useState(false); + const [newCwd, setNewCwd] = useState(""); + const [isPickingFolder, setIsPickingFolder] = useState(false); + const [isAddingProject, setIsAddingProject] = useState(false); + const [addProjectError, setAddProjectError] = useState(null); + const addProjectInputRef = useRef(null); + const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< + ReadonlySet + >(() => new Set()); + const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility(); + const dragInProgressRef = useRef(false); + const suppressProjectClickAfterDragRef = useRef(false); + const suppressProjectClickForContextMenuRef = useRef(false); + const [desktopUpdateState, setDesktopUpdateState] = useState(null); + const selectedThreadCount = useThreadSelectionStore((s) => s.selectedThreadKeys.size); + const clearSelection = useThreadSelectionStore((s) => s.clearSelection); + const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); + const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); + const platform = navigator.platform; + const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; + const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const orderedProjects = useMemo(() => { + return orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + }); + }, [projectOrder, projects]); + const sidebarProjects = useMemo( + () => + orderedProjects.map((project) => ({ + ...project, + projectKey: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + })), + [orderedProjects], + ); + const sidebarProjectByKey = useMemo( + () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), + [sidebarProjects], + ); + const sidebarThreadByKey = useMemo( + () => + new Map( + sidebarThreads.map( + (thread) => + [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, + ), + ), + [sidebarThreads], + ); + const activeRouteProjectKey = useMemo(() => { + if (!routeThreadKey) { + return null; + } + const activeThread = sidebarThreadByKey.get(routeThreadKey); + return activeThread + ? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)) + : null; + }, [routeThreadKey, sidebarThreadByKey]); + const threadsByProjectKey = useMemo(() => { + const next = new Map(); + for (const thread of sidebarThreads) { + const projectKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const existing = next.get(projectKey); + if (existing) { + existing.push(thread); + } else { + next.set(projectKey, [thread]); } - if (clicked !== "delete") return; + } + return next; + }, [sidebarThreads]); + const getCurrentSidebarShortcutContext = useCallback( + () => ({ + terminalFocus: isTerminalFocused(), + terminalOpen: routeThreadRef + ? selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + }), + [routeThreadRef], + ); + const newThreadShortcutLabelOptions = useMemo( + () => ({ + platform, + context: { + terminalFocus: false, + terminalOpen: false, + }, + }), + [platform], + ); + const newThreadShortcutLabel = + shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? + shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); + const focusMostRecentThreadForProject = useCallback( + (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { + const projectKey = scopedProjectKey( + scopeProjectRef(projectRef.environmentId, projectRef.projectId), + ); + const latestThread = sortThreadsForSidebar( + (threadsByProjectKey.get(projectKey) ?? []).filter((thread) => thread.archivedAt === null), + appSettings.sidebarThreadSortOrder, + )[0]; + if (!latestThread) return; - const projectThreadIds = threadIdsByProjectId[projectId] ?? []; - if (projectThreadIds.length > 0) { - toastManager.add({ - type: "warning", - title: "Project is not empty", - description: "Delete all threads in this project before removing it.", + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), + }); + }, + [appSettings.sidebarThreadSortOrder, navigate, threadsByProjectKey], + ); + + const addProjectFromPath = useCallback( + async (rawCwd: string) => { + const cwd = rawCwd.trim(); + if (!cwd || isAddingProject) return; + const api = activeEnvironmentId ? readEnvironmentApi(activeEnvironmentId) : undefined; + if (!api) return; + + setIsAddingProject(true); + const finishAddingProject = () => { + setIsAddingProject(false); + setNewCwd(""); + setAddProjectError(null); + setAddingProject(false); + }; + + const existing = projects.find((project) => project.cwd === cwd); + if (existing) { + focusMostRecentThreadForProject({ + environmentId: existing.environmentId, + projectId: existing.id, }); + finishAddingProject(); return; } - const confirmed = await api.dialogs.confirm(`Remove project "${project.name}"?`); - if (!confirmed) return; - + const projectId = newProjectId(); + const createdAt = new Date().toISOString(); + const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; try { - const projectDraftThread = getDraftThreadByProjectId(projectId); - if (projectDraftThread) { - clearComposerDraftForThread(projectDraftThread.threadId); - } - clearProjectDraftThreadId(projectId); await api.orchestration.dispatchCommand({ - type: "project.delete", + type: "project.create", commandId: newCommandId(), projectId, + title, + workspaceRoot: cwd, + defaultModelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + createdAt, }); + if (activeEnvironmentId !== null) { + await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), { + envMode: appSettings.defaultThreadEnvMode, + }).catch(() => undefined); + } } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error removing project."; - console.error("Failed to remove project", { projectId, error }); - toastManager.add({ - type: "error", - title: `Failed to remove "${project.name}"`, - description: message, - }); + const description = + error instanceof Error ? error.message : "An error occurred while adding the project."; + setIsAddingProject(false); + if (shouldBrowseForProjectImmediately) { + toastManager.add({ + type: "error", + title: "Failed to add project", + description, + }); + } else { + setAddProjectError(description); + } + return; } + finishAddingProject(); }, [ - clearComposerDraftForThread, - clearProjectDraftThreadId, - copyPathToClipboard, - getDraftThreadByProjectId, + focusMostRecentThreadForProject, + activeEnvironmentId, + handleNewThread, + isAddingProject, projects, - threadIdsByProjectId, + shouldBrowseForProjectImmediately, + appSettings.defaultThreadEnvMode, ], ); + const handleAddProject = () => { + void addProjectFromPath(newCwd); + }; + + const canAddProject = newCwd.trim().length > 0 && !isAddingProject; + + const handlePickFolder = async () => { + const api = readLocalApi(); + if (!api || isPickingFolder) return; + setIsPickingFolder(true); + let pickedPath: string | null = null; + try { + pickedPath = await api.dialogs.pickFolder(); + } catch { + // Ignore picker failures and leave the current thread selection unchanged. + } + if (pickedPath) { + await addProjectFromPath(pickedPath); + } else if (!shouldBrowseForProjectImmediately) { + addProjectInputRef.current?.focus(); + } + setIsPickingFolder(false); + }; + + const handleStartAddProject = () => { + setAddProjectError(null); + if (shouldBrowseForProjectImmediately) { + void handlePickFolder(); + return; + } + setAddingProject((prev) => !prev); + }; + + const navigateToThread = useCallback( + (threadRef: ScopedThreadRef) => { + if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { + clearSelection(); + } + setSelectionAnchor(scopedThreadKey(threadRef)); + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }); + }, + [clearSelection, navigate, setSelectionAnchor], + ); + const projectDnDSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 }, @@ -1371,10 +2508,10 @@ export default function Sidebar() { dragInProgressRef.current = false; const { active, over } = event; if (!over || active.id === over.id) return; - const activeProject = sidebarProjects.find((project) => project.id === active.id); - const overProject = sidebarProjects.find((project) => project.id === over.id); + const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); + const overProject = sidebarProjects.find((project) => project.projectKey === over.id); if (!activeProject || !overProject) return; - reorderProjects(activeProject.id, overProject.id); + reorderProjects(activeProject.projectKey, overProject.projectKey); }, [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects], ); @@ -1412,148 +2549,117 @@ export default function Sidebar() { animatedThreadListsRef.current.add(node); }, []); - const handleProjectTitlePointerDownCapture = useCallback( - (event: PointerEvent) => { - suppressProjectClickForContextMenuRef.current = false; - if ( - isContextMenuPointerDown({ - button: event.button, - ctrlKey: event.ctrlKey, - isMac: isMacPlatform(navigator.platform), - }) - ) { - // Keep context-menu gestures from arming the sortable drag sensor. - event.stopPropagation(); - } - - suppressProjectClickAfterDragRef.current = false; - }, - [], - ); - const visibleThreads = useMemo( () => sidebarThreads.filter((thread) => thread.archivedAt === null), [sidebarThreads], ); - const sortedProjects = useMemo( - () => - sortProjectsForSidebar(sidebarProjects, visibleThreads, appSettings.sidebarProjectSortOrder), - [appSettings.sidebarProjectSortOrder, sidebarProjects, visibleThreads], - ); + const sortedProjects = useMemo(() => { + const sortableProjects = sidebarProjects.map((project) => ({ + ...project, + id: project.projectKey, + })); + const sortableThreads = visibleThreads.map((thread) => ({ + ...thread, + projectId: scopedProjectKey( + scopeProjectRef(thread.environmentId, thread.projectId), + ) as ProjectId, + })); + return sortProjectsForSidebar( + sortableProjects, + sortableThreads, + appSettings.sidebarProjectSortOrder, + ).flatMap((project) => { + const resolvedProject = sidebarProjectByKey.get(project.id); + return resolvedProject ? [resolvedProject] : []; + }); + }, [appSettings.sidebarProjectSortOrder, sidebarProjectByKey, sidebarProjects, visibleThreads]); const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; - const renderedProjects = useMemo( + const visibleSidebarThreadKeys = useMemo( () => - sortedProjects.map((project) => { - const resolveProjectThreadStatus = (thread: (typeof visibleThreads)[number]) => - resolveThreadStatusPill({ - thread: { - ...thread, - lastVisitedAt: threadLastVisitedAtById[thread.id], - }, - }); + sortedProjects.flatMap((project) => { const projectThreads = sortThreadsForSidebar( - (threadIdsByProjectId[project.id] ?? []) - .map((threadId) => sidebarThreadsById[threadId]) - .filter((thread): thread is NonNullable => thread !== undefined) - .filter((thread) => thread.archivedAt === null), + (threadsByProjectKey.get(project.projectKey) ?? []).filter( + (thread) => thread.archivedAt === null, + ), appSettings.sidebarThreadSortOrder, ); - const projectStatus = resolveProjectStatusIndicator( - projectThreads.map((thread) => resolveProjectThreadStatus(thread)), - ); - const activeThreadId = routeThreadId ?? undefined; - const isThreadListExpanded = expandedThreadListsByProject.has(project.id); + const projectExpanded = projectExpandedById[project.projectKey] ?? true; + const activeThreadKey = routeThreadKey ?? undefined; const pinnedCollapsedThread = - !project.expanded && activeThreadId - ? (projectThreads.find((thread) => thread.id === activeThreadId) ?? null) + !projectExpanded && activeThreadKey + ? (projectThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + activeThreadKey, + ) ?? null) : null; - const shouldShowThreadPanel = project.expanded || pinnedCollapsedThread !== null; - const { - hasHiddenThreads, - hiddenThreads, - visibleThreads: visibleProjectThreads, - } = getVisibleThreadsForProject({ - threads: projectThreads, - activeThreadId, - isThreadListExpanded, - previewLimit: THREAD_PREVIEW_LIMIT, - }); - const hiddenThreadStatus = resolveProjectStatusIndicator( - hiddenThreads.map((thread) => resolveProjectThreadStatus(thread)), + const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; + if (!shouldShowThreadPanel) { + return []; + } + const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); + const hasOverflowingThreads = projectThreads.length > THREAD_PREVIEW_LIMIT; + const previewThreads = + isThreadListExpanded || !hasOverflowingThreads + ? projectThreads + : projectThreads.slice(0, THREAD_PREVIEW_LIMIT); + const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; + return renderedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ); - const orderedProjectThreadIds = projectThreads.map((thread) => thread.id); - const renderedThreadIds = pinnedCollapsedThread - ? [pinnedCollapsedThread.id] - : visibleProjectThreads.map((thread) => thread.id); - const showEmptyThreadState = project.expanded && projectThreads.length === 0; - - return { - hasHiddenThreads, - hiddenThreadStatus, - orderedProjectThreadIds, - project, - projectStatus, - renderedThreadIds, - showEmptyThreadState, - shouldShowThreadPanel, - isThreadListExpanded, - }; }), [ appSettings.sidebarThreadSortOrder, expandedThreadListsByProject, - routeThreadId, + projectExpandedById, + routeThreadKey, sortedProjects, - sidebarThreadsById, - threadIdsByProjectId, - threadLastVisitedAtById, + threadsByProjectKey, ], ); - const visibleSidebarThreadIds = useMemo( - () => getVisibleSidebarThreadIds(renderedProjects), - [renderedProjects], - ); - const threadJumpCommandById = useMemo(() => { - const mapping = new Map>>(); - for (const [visibleThreadIndex, threadId] of visibleSidebarThreadIds.entries()) { + const threadJumpCommandByKey = useMemo(() => { + const mapping = new Map>>(); + for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); if (!jumpCommand) { return mapping; } - mapping.set(threadId, jumpCommand); + mapping.set(threadKey, jumpCommand); } return mapping; - }, [visibleSidebarThreadIds]); - const threadJumpThreadIds = useMemo( - () => [...threadJumpCommandById.keys()], - [threadJumpCommandById], - ); - const threadJumpLabelById = useMemo(() => { - const mapping = new Map(); - for (const [threadId, command] of threadJumpCommandById) { - const label = shortcutLabelForCommand(keybindings, command, sidebarShortcutLabelOptions); - if (label) { - mapping.set(threadId, label); - } - } - return mapping; - }, [keybindings, sidebarShortcutLabelOptions, threadJumpCommandById]); - const orderedSidebarThreadIds = visibleSidebarThreadIds; + }, [visibleSidebarThreadKeys]); + const threadJumpThreadKeys = useMemo( + () => [...threadJumpCommandByKey.keys()], + [threadJumpCommandByKey], + ); + const [threadJumpLabelByKey, setThreadJumpLabelByKey] = + useState>(EMPTY_THREAD_JUMP_LABELS); + const visibleThreadJumpLabelByKey = showThreadJumpHints + ? threadJumpLabelByKey + : EMPTY_THREAD_JUMP_LABELS; + const orderedSidebarThreadKeys = visibleSidebarThreadKeys; useEffect(() => { - const getShortcutContext = () => ({ - terminalFocus: isTerminalFocused(), - terminalOpen: routeTerminalOpen, - }); - const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { - updateThreadJumpHintsVisibility( - shouldShowThreadJumpHints(event, keybindings, { + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + setThreadJumpLabelByKey((current) => { + if (!shouldShowHints) { + return current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS; + } + const nextLabelMap = buildThreadJumpLabelMap({ + keybindings, platform, - context: getShortcutContext(), - }), - ); + terminalOpen: shortcutContext.terminalOpen, + threadJumpCommandByKey, + }); + return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; + }); + updateThreadJumpHintsVisibility(shouldShowHints); if (event.defaultPrevented || event.repeat) { return; @@ -1561,22 +2667,26 @@ export default function Sidebar() { const command = resolveShortcutCommand(event, keybindings, { platform, - context: getShortcutContext(), + context: shortcutContext, }); const traversalDirection = threadTraversalDirectionFromCommand(command); if (traversalDirection !== null) { - const targetThreadId = resolveAdjacentThreadId({ - threadIds: orderedSidebarThreadIds, - currentThreadId: routeThreadId, + const targetThreadKey = resolveAdjacentThreadId({ + threadIds: orderedSidebarThreadKeys, + currentThreadId: routeThreadKey, direction: traversalDirection, }); - if (!targetThreadId) { + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadByKey.get(targetThreadKey); + if (!targetThread) { return; } event.preventDefault(); event.stopPropagation(); - navigateToThread(targetThreadId); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); return; } @@ -1585,26 +2695,45 @@ export default function Sidebar() { return; } - const targetThreadId = threadJumpThreadIds[jumpIndex]; - if (!targetThreadId) { + const targetThreadKey = threadJumpThreadKeys[jumpIndex]; + if (!targetThreadKey) { + return; + } + const targetThread = sidebarThreadByKey.get(targetThreadKey); + if (!targetThread) { return; } event.preventDefault(); event.stopPropagation(); - navigateToThread(targetThreadId); + navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); }; const onWindowKeyUp = (event: globalThis.KeyboardEvent) => { - updateThreadJumpHintsVisibility( - shouldShowThreadJumpHints(event, keybindings, { + const shortcutContext = getCurrentSidebarShortcutContext(); + const shouldShowHints = shouldShowThreadJumpHints(event, keybindings, { + platform, + context: shortcutContext, + }); + setThreadJumpLabelByKey((current) => { + if (!shouldShowHints) { + return current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS; + } + const nextLabelMap = buildThreadJumpLabelMap({ + keybindings, platform, - context: getShortcutContext(), - }), - ); + terminalOpen: shortcutContext.terminalOpen, + threadJumpCommandByKey, + }); + return threadJumpLabelMapsEqual(current, nextLabelMap) ? current : nextLabelMap; + }); + updateThreadJumpHintsVisibility(shouldShowHints); }; const onWindowBlur = () => { + setThreadJumpLabelByKey((current) => + current === EMPTY_THREAD_JUMP_LABELS ? current : EMPTY_THREAD_JUMP_LABELS, + ); updateThreadJumpHintsVisibility(false); }; @@ -1618,301 +2747,21 @@ export default function Sidebar() { window.removeEventListener("blur", onWindowBlur); }; }, [ + getCurrentSidebarShortcutContext, keybindings, navigateToThread, - orderedSidebarThreadIds, + orderedSidebarThreadKeys, platform, - routeTerminalOpen, - routeThreadId, - threadJumpThreadIds, + routeThreadKey, + sidebarThreadByKey, + threadJumpCommandByKey, + threadJumpThreadKeys, updateThreadJumpHintsVisibility, ]); - function renderProjectItem( - renderedProject: (typeof renderedProjects)[number], - dragHandleProps: SortableProjectHandleProps | null, - ) { - const { - hasHiddenThreads, - hiddenThreadStatus, - orderedProjectThreadIds, - project, - projectStatus, - renderedThreadIds, - showEmptyThreadState, - shouldShowThreadPanel, - isThreadListExpanded, - } = renderedProject; - return ( - <> -
- handleProjectTitleClick(event, project.id)} - onKeyDown={(event) => handleProjectTitleKeyDown(event, project.id)} - onContextMenu={(event) => { - event.preventDefault(); - suppressProjectClickForContextMenuRef.current = true; - void handleProjectContextMenu(project.id, { - x: event.clientX, - y: event.clientY, - }); - }} - > - {!project.expanded && projectStatus ? ( - - - - } - showOnHover - className="top-1 right-1.5 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - const seedContext = resolveSidebarNewThreadSeedContext({ - projectId: project.id, - defaultEnvMode: resolveSidebarNewThreadEnvMode({ - defaultEnvMode: appSettings.defaultThreadEnvMode, - }), - activeThread: - activeThread && activeThread.projectId === project.id - ? { - projectId: activeThread.projectId, - branch: activeThread.branch, - worktreePath: activeThread.worktreePath, - } - : null, - activeDraftThread: - activeDraftThread && activeDraftThread.projectId === project.id - ? { - projectId: activeDraftThread.projectId, - branch: activeDraftThread.branch, - worktreePath: activeDraftThread.worktreePath, - envMode: activeDraftThread.envMode, - } - : null, - }); - void handleNewThread(project.id, { - ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), - ...(seedContext.worktreePath !== undefined - ? { worktreePath: seedContext.worktreePath } - : {}), - envMode: seedContext.envMode, - }); - }} - > - - - } - /> - - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} - - -
- - - {shouldShowThreadPanel && showEmptyThreadState ? ( - -
- No threads yet -
-
- ) : null} - {shouldShowThreadPanel && - renderedThreadIds.map((threadId) => ( - - ))} - - {project.expanded && hasHiddenThreads && !isThreadListExpanded && ( - - } - data-thread-selection-safe - size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" - onClick={() => { - expandThreadListForProject(project.id); - }} - > - - {hiddenThreadStatus && } - Show more - - - - )} - {project.expanded && hasHiddenThreads && isThreadListExpanded && ( - - } - data-thread-selection-safe - size="sm" - className="h-6 w-full translate-x-0 justify-start px-2 text-left text-[10px] text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground/80" - onClick={() => { - collapseThreadListForProject(project.id); - }} - > - Show less - - - )} -
- - ); - } - - const handleProjectTitleClick = useCallback( - (event: MouseEvent, projectId: ProjectId) => { - if (suppressProjectClickForContextMenuRef.current) { - suppressProjectClickForContextMenuRef.current = false; - event.preventDefault(); - event.stopPropagation(); - return; - } - if (dragInProgressRef.current) { - event.preventDefault(); - event.stopPropagation(); - return; - } - if (suppressProjectClickAfterDragRef.current) { - // Consume the synthetic click emitted after a drag release. - suppressProjectClickAfterDragRef.current = false; - event.preventDefault(); - event.stopPropagation(); - return; - } - if (selectedThreadIds.size > 0) { - clearSelection(); - } - toggleProject(projectId); - }, - [clearSelection, selectedThreadIds.size, toggleProject], - ); - - const handleProjectTitleKeyDown = useCallback( - (event: KeyboardEvent, projectId: ProjectId) => { - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - if (dragInProgressRef.current) { - return; - } - toggleProject(projectId); - }, - [toggleProject], - ); - useEffect(() => { const onMouseDown = (event: globalThis.MouseEvent) => { - if (selectedThreadIds.size === 0) return; + if (selectedThreadCount === 0) return; const target = event.target instanceof HTMLElement ? event.target : null; if (!shouldClearThreadSelectionOnMouseDown(target)) return; clearSelection(); @@ -1922,7 +2771,7 @@ export default function Sidebar() { return () => { window.removeEventListener("mousedown", onMouseDown); }; - }, [clearSelection, selectedThreadIds.size]); + }, [clearSelection, selectedThreadCount]); useEffect(() => { if (!isElectron) return; @@ -1967,10 +2816,6 @@ export default function Sidebar() { desktopUpdateState && showArm64IntelBuildWarning ? getArm64IntelBuildWarningDescription(desktopUpdateState) : null; - const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.newLocal", sidebarShortcutLabelOptions) ?? - shortcutLabelForCommand(keybindings, "chat.new", sidebarShortcutLabelOptions); - const handleDesktopUpdateButtonClick = useCallback(() => { const bridge = window.desktopBridge; if (!bridge || !desktopUpdateState) return; @@ -2033,246 +2878,82 @@ export default function Sidebar() { } }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]); - const expandThreadListForProject = useCallback((projectId: ProjectId) => { + const expandThreadListForProject = useCallback((projectKey: string) => { setExpandedThreadListsByProject((current) => { - if (current.has(projectId)) return current; + if (current.has(projectKey)) return current; const next = new Set(current); - next.add(projectId); + next.add(projectKey); return next; }); }, []); - const collapseThreadListForProject = useCallback((projectId: ProjectId) => { + const collapseThreadListForProject = useCallback((projectKey: string) => { setExpandedThreadListsByProject((current) => { - if (!current.has(projectId)) return current; + if (!current.has(projectKey)) return current; const next = new Set(current); - next.delete(projectId); + next.delete(projectKey); return next; }); }, []); - const wordmark = ( -
- - - - - - Code - - - {APP_STAGE_LABEL} - - - } - /> - - Version {APP_VERSION} - - -
- ); - return ( <> - {isElectron ? ( - - {wordmark} - - ) : ( - - {wordmark} - - )} + {isOnSettings ? ( ) : ( <> - - {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( - - - - Intel build on Apple Silicon - {arm64IntelBuildWarningDescription} - {desktopUpdateButtonAction !== "none" ? ( - - - - ) : null} - - - ) : null} - -
- - Projects - -
- { - updateSettings({ sidebarProjectSortOrder: sortOrder }); - }} - onThreadSortOrderChange={(sortOrder) => { - updateSettings({ sidebarThreadSortOrder: sortOrder }); - }} - /> - - - } - > - - - - {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} - - -
-
- {shouldShowProjectPathEntry && ( -
- {isElectron && ( - - )} -
- { - setNewCwd(event.target.value); - setAddProjectError(null); - }} - onKeyDown={(event) => { - if (event.key === "Enter") handleAddProject(); - if (event.key === "Escape") { - setAddingProject(false); - setAddProjectError(null); - } - }} - autoFocus - /> - -
- {addProjectError && ( -

- {addProjectError} -

- )} -
- )} - - {isManualProjectSorting ? ( - - - renderedProject.project.id)} - strategy={verticalListSortingStrategy} - > - {renderedProjects.map((renderedProject) => ( - - {(dragHandleProps) => renderProjectItem(renderedProject, dragHandleProps)} - - ))} - - - - ) : ( - - {renderedProjects.map((renderedProject) => ( - - {renderProjectItem(renderedProject, null)} - - ))} - - )} - - {projects.length === 0 && !shouldShowProjectPathEntry && ( -
- No projects yet -
- )} -
-
+ - - - - - void navigate({ to: "/settings" })} - > - - Settings - - - - + )} diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx new file mode 100644 index 0000000000..5f01e53af4 --- /dev/null +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx @@ -0,0 +1,247 @@ +import "../index.css"; + +import { scopeThreadRef } from "@t3tools/client-runtime"; +import { ThreadId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +const { + terminalConstructorSpy, + terminalDisposeSpy, + fitAddonFitSpy, + fitAddonLoadSpy, + environmentApiById, + readEnvironmentApiMock, + readLocalApiMock, +} = vi.hoisted(() => ({ + terminalConstructorSpy: vi.fn(), + terminalDisposeSpy: vi.fn(), + fitAddonFitSpy: vi.fn(), + fitAddonLoadSpy: vi.fn(), + environmentApiById: new Map } }>(), + readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)), + readLocalApiMock: vi.fn< + () => + | { + contextMenu: { show: ReturnType }; + shell: { openExternal: ReturnType }; + } + | undefined + >(() => ({ + contextMenu: { show: vi.fn(async () => null) }, + shell: { openExternal: vi.fn(async () => undefined) }, + })), +})); + +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: class MockFitAddon { + fit = fitAddonFitSpy; + }, +})); + +vi.mock("@xterm/xterm", () => ({ + Terminal: class MockTerminal { + cols = 80; + rows = 24; + options: { theme?: unknown } = {}; + buffer = { + active: { + viewportY: 0, + baseY: 0, + getLine: vi.fn(() => null), + }, + }; + + constructor(options: unknown) { + terminalConstructorSpy(options); + } + + loadAddon(addon: unknown) { + fitAddonLoadSpy(addon); + } + + open() {} + + write() {} + + clear() {} + + clearSelection() {} + + focus() {} + + refresh() {} + + scrollToBottom() {} + + hasSelection() { + return false; + } + + getSelection() { + return ""; + } + + getSelectionPosition() { + return null; + } + + attachCustomKeyEventHandler() { + return true; + } + + registerLinkProvider() { + return { dispose: vi.fn() }; + } + + onData() { + return { dispose: vi.fn() }; + } + + onSelectionChange() { + return { dispose: vi.fn() }; + } + + dispose() { + terminalDisposeSpy(); + } + }, +})); + +vi.mock("~/environmentApi", () => ({ + readEnvironmentApi: readEnvironmentApiMock, +})); + +vi.mock("~/localApi", () => ({ + readLocalApi: readLocalApiMock, +})); + +import { TerminalViewport } from "./ThreadTerminalDrawer"; + +const THREAD_ID = ThreadId.makeUnsafe("thread-terminal-browser"); + +function createEnvironmentApi() { + return { + terminal: { + open: vi.fn(async () => ({ + threadId: THREAD_ID, + terminalId: "default", + cwd: "/repo/project", + worktreePath: null, + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: "2026-04-07T00:00:00.000Z", + })), + write: vi.fn(async () => undefined), + resize: vi.fn(async () => undefined), + }, + }; +} + +async function mountTerminalViewport(props: { threadRef: ReturnType }) { + const host = document.createElement("div"); + host.style.width = "800px"; + host.style.height = "400px"; + document.body.append(host); + + const screen = await render( + undefined} + onAddTerminalContext={() => undefined} + focusRequestId={0} + autoFocus={false} + resizeEpoch={0} + drawerHeight={320} + />, + { container: host }, + ); + + return { + rerender: async (nextProps: { threadRef: ReturnType }) => { + await screen.rerender( + undefined} + onAddTerminalContext={() => undefined} + focusRequestId={0} + autoFocus={false} + resizeEpoch={0} + drawerHeight={320} + />, + ); + }, + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +describe("TerminalViewport", () => { + afterEach(() => { + environmentApiById.clear(); + readEnvironmentApiMock.mockClear(); + readLocalApiMock.mockClear(); + terminalConstructorSpy.mockClear(); + terminalDisposeSpy.mockClear(); + fitAddonFitSpy.mockClear(); + fitAddonLoadSpy.mockClear(); + }); + + it("does not create a terminal when APIs are unavailable", async () => { + readEnvironmentApiMock.mockReturnValueOnce(undefined); + readLocalApiMock.mockReturnValueOnce(undefined); + + const mounted = await mountTerminalViewport({ + threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), + }); + + try { + await vi.waitFor(() => { + expect(terminalConstructorSpy).not.toHaveBeenCalled(); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("reopens the terminal when the scoped thread reference changes", async () => { + const environmentA = createEnvironmentApi(); + const environmentB = createEnvironmentApi(); + environmentApiById.set("environment-a", environmentA); + environmentApiById.set("environment-b", environmentB); + + const mounted = await mountTerminalViewport({ + threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), + }); + + try { + await vi.waitFor(() => { + expect(environmentA.terminal.open).toHaveBeenCalledTimes(1); + }); + + await mounted.rerender({ + threadRef: scopeThreadRef("environment-b" as never, THREAD_ID), + }); + + await vi.waitFor(() => { + expect(environmentB.terminal.open).toHaveBeenCalledTimes(1); + }); + expect(terminalDisposeSpy).toHaveBeenCalledTimes(1); + } finally { + await mounted.cleanup(); + } + }); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index ffb7c1e4d0..de40cc0bf8 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,6 +1,7 @@ import { FitAddon } from "@xterm/addon-fit"; import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "lucide-react"; import { + type ScopedThreadRef, type TerminalEvent, type TerminalSessionSnapshot, type ThreadId, @@ -31,7 +32,8 @@ import { MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, } from "../types"; -import { readNativeApi } from "~/nativeApi"; +import { readEnvironmentApi } from "~/environmentApi"; +import { readLocalApi } from "~/localApi"; import { selectTerminalEventEntries, useTerminalStateStore } from "../terminalStateStore"; const MIN_DRAWER_HEIGHT = 180; @@ -208,6 +210,7 @@ export function shouldHandleTerminalSelectionMouseUp( } interface TerminalViewportProps { + threadRef: ScopedThreadRef; threadId: ThreadId; terminalId: string; terminalLabel: string; @@ -222,7 +225,8 @@ interface TerminalViewportProps { drawerHeight: number; } -function TerminalViewport({ +export function TerminalViewport({ + threadRef, threadId, terminalId, terminalLabel, @@ -260,6 +264,9 @@ function TerminalViewport({ if (!mount) return; let disposed = false; + const api = readEnvironmentApi(threadRef.environmentId); + const localApi = readLocalApi(); + if (!api || !localApi) return; const fitAddon = new FitAddon(); const terminal = new Terminal({ @@ -277,9 +284,6 @@ function TerminalViewport({ terminalRef.current = terminal; fitAddonRef.current = fitAddon; - const api = readNativeApi(); - if (!api) return; - const clearSelectionAction = () => { selectionActionRequestIdRef.current += 1; if (selectionActionTimerRef.current !== null) { @@ -340,7 +344,7 @@ function TerminalViewport({ const requestId = ++selectionActionRequestIdRef.current; selectionActionOpenRef.current = true; try { - const clicked = await api.contextMenu.show( + const clicked = await localApi.contextMenu.show( [{ id: "add-to-chat", label: "Add to chat" }], nextAction.position, ); @@ -416,7 +420,7 @@ function TerminalViewport({ if (!latestTerminal) return; if (match.kind === "url") { - void api.shell.openExternal(match.text).catch((error) => { + void localApi.shell.openExternal(match.text).catch((error: unknown) => { writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open link", @@ -426,7 +430,7 @@ function TerminalViewport({ } const target = resolvePathLinkTarget(match.text, cwd); - void openInPreferredEditor(api, target).catch((error) => { + void openInPreferredEditor(localApi, target).catch((error) => { writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open path", @@ -573,12 +577,12 @@ function TerminalViewport({ const previousLastEntryId = selectTerminalEventEntries( previousState.terminalEventEntriesByKey, - threadId, + threadRef, terminalId, ).at(-1)?.id ?? 0; const nextEntries = selectTerminalEventEntries( state.terminalEventEntriesByKey, - threadId, + threadRef, terminalId, ); const nextLastEntryId = nextEntries.at(-1)?.id ?? 0; @@ -608,7 +612,7 @@ function TerminalViewport({ writeTerminalSnapshot(activeTerminal, snapshot); const bufferedEntries = selectTerminalEventEntries( useTerminalStateStore.getState().terminalEventEntriesByKey, - threadId, + threadRef, terminalId, ); const replayEntries = selectTerminalEventEntriesAfterSnapshot( @@ -677,7 +681,7 @@ function TerminalViewport({ // autoFocus is intentionally omitted; // it is only read at mount time and must not trigger terminal teardown/recreation. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cwd, runtimeEnv, terminalId, threadId]); + }, [cwd, runtimeEnv, terminalId, threadId, threadRef]); useEffect(() => { if (!autoFocus) return; @@ -692,7 +696,7 @@ function TerminalViewport({ }, [autoFocus, focusRequestId]); useEffect(() => { - const api = readNativeApi(); + const api = readEnvironmentApi(threadRef.environmentId); const terminal = terminalRef.current; const fitAddon = fitAddonRef.current; if (!api || !terminal || !fitAddon) return; @@ -714,13 +718,14 @@ function TerminalViewport({ return () => { window.cancelAnimationFrame(frame); }; - }, [drawerHeight, resizeEpoch, terminalId, threadId]); + }, [drawerHeight, resizeEpoch, terminalId, threadId, threadRef]); return (
); } interface ThreadTerminalDrawerProps { + threadRef: ScopedThreadRef; threadId: ThreadId; cwd: string; worktreePath?: string | null; @@ -773,6 +778,7 @@ function TerminalActionButton({ label, className, onClick, children }: TerminalA } export default function ThreadTerminalDrawer({ + threadRef, threadId, cwd, worktreePath, @@ -1098,6 +1104,7 @@ export default function ThreadTerminalDrawer({ >
)} - {activeProjectName && } + {activeProjectName && ( + + )} ["draftsByThreadId"]; const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider]; - draftsByThreadId[threadId] = { - prompt: props?.prompt ?? "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - modelSelectionByProvider: { - [provider]: { - provider, - model, - ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}), + useComposerDraftStore.setState({ + draftsByThreadKey: { + [threadKey]: { + prompt: props?.prompt ?? "", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + modelSelectionByProvider: { + [provider]: { + provider, + model, + ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}), + }, + }, + activeProvider: provider, + runtimeMode: null, + interactionMode: null, }, }, - activeProvider: provider, - runtimeMode: null, - interactionMode: null, - }; - useComposerDraftStore.setState({ - draftsByThreadId, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, }); const host = document.createElement("div"); document.body.append(host); @@ -121,7 +129,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str { afterEach(() => { document.body.innerHTML = ""; useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, }); }); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 2a1efb4b20..98aff58b0d 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -1,5 +1,5 @@ import { type ApprovalRequestId } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useRef } from "react"; +import { memo, useEffect, useEffectEvent, useRef } from "react"; import { type PendingUserInput } from "../../session-logic"; import { derivePendingUserInputProgress, @@ -60,6 +60,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); const activeQuestion = progress.activeQuestion; const autoAdvanceTimerRef = useRef(null); + const onAdvanceRef = useRef(onAdvance); + + useEffect(() => { + onAdvanceRef.current = onAdvance; + }, [onAdvance]); // Clear auto-advance timer on unmount useEffect(() => { @@ -70,22 +75,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }; }, []); - const handleOptionSelection = useCallback( - (questionId: string, optionLabel: string) => { - onToggleOption(questionId, optionLabel); - if (activeQuestion?.multiSelect) { - return; - } - if (autoAdvanceTimerRef.current !== null) { - window.clearTimeout(autoAdvanceTimerRef.current); - } - autoAdvanceTimerRef.current = window.setTimeout(() => { - autoAdvanceTimerRef.current = null; - onAdvance(); - }, 200); - }, - [activeQuestion?.multiSelect, onAdvance, onToggleOption], - ); + const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => { + onToggleOption(questionId, optionLabel); + if (activeQuestion?.multiSelect) { + return; + } + if (autoAdvanceTimerRef.current !== null) { + window.clearTimeout(autoAdvanceTimerRef.current); + } + autoAdvanceTimerRef.current = window.setTimeout(() => { + autoAdvanceTimerRef.current = null; + onAdvanceRef.current(); + }, 200); + }); // Keyboard shortcut: number keys 1-9 select corresponding options when focus is // outside editable fields. Multi-select prompts toggle options in place; single- @@ -112,7 +114,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, [activeQuestion, handleOptionSelection, isResponding]); + }, [activeQuestion, isResponding]); if (!activeQuestion) { return null; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 40d34b36c1..c644867aac 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -42,6 +42,8 @@ beforeAll(() => { }); }); +const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never; + describe("MessagesTimeline", () => { it("renders inline terminal labels with the composer chip UI", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); @@ -85,6 +87,7 @@ describe("MessagesTimeline", () => { onRevertUserMessage={() => {}} isRevertingCheckpoint={false} onImageExpand={() => {}} + activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID} markdownCwd={undefined} resolvedTheme="light" timestampFormat="locale" @@ -130,6 +133,7 @@ describe("MessagesTimeline", () => { onRevertUserMessage={() => {}} isRevertingCheckpoint={false} onImageExpand={() => {}} + activeThreadEnvironmentId={ACTIVE_THREAD_ENVIRONMENT_ID} markdownCwd={undefined} resolvedTheme="light" timestampFormat="locale" diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9be521b3be..5100824328 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,4 +1,4 @@ -import { type MessageId, type TurnId } from "@t3tools/contracts"; +import { type EnvironmentId, type MessageId, type TurnId } from "@t3tools/contracts"; import { memo, useCallback, @@ -82,6 +82,7 @@ interface MessagesTimelineProps { onRevertUserMessage: (messageId: MessageId) => void; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; + activeThreadEnvironmentId: EnvironmentId; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; @@ -117,6 +118,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onRevertUserMessage, isRevertingCheckpoint, onImageExpand, + activeThreadEnvironmentId, markdownCwd, resolvedTheme, timestampFormat, @@ -531,6 +533,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
diff --git a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx index 1e947a3c84..6a37c5b099 100644 --- a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx @@ -19,6 +19,7 @@ const DEFAULT_VIEWPORT = { height: 1_100, }; const MARKDOWN_CWD = "/repo/project"; +const ACTIVE_THREAD_ENVIRONMENT_ID = "environment-local" as never; interface RowMeasurement { actualHeightPx: number; @@ -31,7 +32,10 @@ interface RowMeasurement { interface VirtualizationScenario { name: string; targetRowId: string; - props: Omit, "scrollContainer">; + props: Omit< + ComponentProps, + "scrollContainer" | "activeThreadEnvironmentId" + >; maxEstimateDeltaPx: number; } @@ -48,7 +52,10 @@ interface VirtualizerSnapshot { } function MessagesTimelineBrowserHarness( - props: Omit, "scrollContainer">, + props: Omit< + ComponentProps, + "scrollContainer" | "activeThreadEnvironmentId" + >, ) { const [scrollContainer, setScrollContainer] = useState(null); const [expandedWorkGroups, setExpandedWorkGroups] = useState>( @@ -73,6 +80,7 @@ function MessagesTimelineBrowserHarness( > ; onVirtualizerSnapshot?: ComponentProps["onVirtualizerSnapshot"]; -}): Omit, "scrollContainer"> { +}): Omit, "scrollContainer" | "activeThreadEnvironmentId"> { return { hasMessages: true, isWorking: false, @@ -481,7 +489,10 @@ async function waitForElement( async function measureTimelineRow(input: { host: HTMLElement; - props: Omit, "scrollContainer">; + props: Omit< + ComponentProps, + "scrollContainer" | "activeThreadEnvironmentId" + >; targetRowId: string; }): Promise { const scrollContainer = await waitForElement( @@ -550,7 +561,10 @@ async function measureTimelineRow(input: { } async function mountMessagesTimeline(input: { - props: Omit, "scrollContainer">; + props: Omit< + ComponentProps, + "scrollContainer" | "activeThreadEnvironmentId" + >; viewport?: { width: number; height: number }; }) { const viewport = input.viewport ?? DEFAULT_VIEWPORT; @@ -576,7 +590,10 @@ async function mountMessagesTimeline(input: { return { host, rerender: async ( - nextProps: Omit, "scrollContainer">, + nextProps: Omit< + ComponentProps, + "scrollContainer" | "activeThreadEnvironmentId" + >, ) => { await screen.rerender(); await waitForLayout(); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 72ad6aabd1..d30fa9be04 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -18,7 +18,7 @@ import { Zed, } from "../Icons"; import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; -import { readNativeApi } from "~/nativeApi"; +import { readLocalApi } from "~/localApi"; const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ @@ -93,7 +93,7 @@ export const OpenInPicker = memo(function OpenInPicker({ const openInEditor = useCallback( (editorId: EditorId | null) => { - const api = readNativeApi(); + const api = readLocalApi(); if (!api || !openInCwd) return; const editor = editorId ?? preferredEditor; if (!editor) return; @@ -110,7 +110,7 @@ export const OpenInPicker = memo(function OpenInPicker({ useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { - const api = readNativeApi(); + const api = readLocalApi(); if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; if (!api || !openInCwd) return; if (!preferredEditor) return; diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index fc52c33225..a36cb097cb 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -1,4 +1,5 @@ import { memo, useState, useId } from "react"; +import type { EnvironmentId } from "@t3tools/contracts"; import { buildCollapsedProposedPlanPreviewMarkdown, buildProposedPlanMarkdownFilename, @@ -24,15 +25,17 @@ import { DialogTitle, } from "../ui/dialog"; import { toastManager } from "../ui/toast"; -import { readNativeApi } from "~/nativeApi"; +import { readEnvironmentApi } from "~/environmentApi"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; export const ProposedPlanCard = memo(function ProposedPlanCard({ planMarkdown, + environmentId, cwd, workspaceRoot, }: { planMarkdown: string; + environmentId: EnvironmentId; cwd: string | undefined; workspaceRoot: string | undefined; }) { @@ -82,7 +85,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ }; const handleSaveToWorkspace = () => { - const api = readNativeApi(); + const api = readEnvironmentApi(environmentId); const relativePath = savePath.trim(); if (!api || !workspaceRoot) { return; diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 74c22e6431..4dc7240c13 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -6,10 +6,11 @@ import { CodexModelOptions, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, - ProjectId, + EnvironmentId, type ServerProvider, ThreadId, } from "@t3tools/contracts"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; import { page } from "vitest/browser"; import { useCallback } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -27,7 +28,13 @@ import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; // ── Claude TraitsPicker tests ───────────────────────────────────────── +const LOCAL_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits"); +const CLAUDE_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CLAUDE_THREAD_ID); +const CLAUDE_THREAD_KEY = scopedThreadKey(CLAUDE_THREAD_REF); +const CODEX_THREAD_ID = ThreadId.makeUnsafe("thread-codex-traits"); +const CODEX_THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, CODEX_THREAD_ID); +const CODEX_THREAD_KEY = scopedThreadKey(CODEX_THREAD_REF); const TEST_PROVIDERS: ReadonlyArray = [ { provider: "codex", @@ -120,10 +127,10 @@ function ClaudeTraitsPickerHarness(props: { fallbackModelSelection: ModelSelection | null; triggerVariant?: "ghost" | "outline"; }) { - const prompt = useComposerThreadDraft(CLAUDE_THREAD_ID).prompt; + const prompt = useComposerThreadDraft(CLAUDE_THREAD_REF).prompt; const setPrompt = useComposerDraftStore((store) => store.setPrompt); const { modelOptions, selectedModel } = useEffectiveComposerModelState({ - threadId: CLAUDE_THREAD_ID, + threadRef: CLAUDE_THREAD_REF, providers: TEST_PROVIDERS, selectedProvider: "claudeAgent", threadModelSelection: props.fallbackModelSelection, @@ -135,7 +142,7 @@ function ClaudeTraitsPickerHarness(props: { }); const handlePromptChange = useCallback( (nextPrompt: string) => { - setPrompt(CLAUDE_THREAD_ID, nextPrompt); + setPrompt(CLAUDE_THREAD_REF, nextPrompt); }, [setPrompt], ); @@ -144,7 +151,7 @@ function ClaudeTraitsPickerHarness(props: { = { - [CLAUDE_THREAD_ID]: { + const draftsByThreadKey: Record = { + [CLAUDE_THREAD_KEY]: { prompt: props?.prompt ?? "", images: [], nonPersistedImageIds: [], @@ -192,9 +199,9 @@ async function mountClaudePicker(props?: { }, }; useComposerDraftStore.setState({ - draftsByThreadId, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, }); const host = document.createElement("div"); document.body.append(host); @@ -230,9 +237,9 @@ describe("TraitsPicker (Claude)", () => { afterEach(() => { document.body.innerHTML = ""; useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, }); }); @@ -369,10 +376,9 @@ describe("TraitsPicker (Claude)", () => { // ── Codex TraitsPicker tests ────────────────────────────────────────── async function mountCodexPicker(props: { model?: string; options?: CodexModelOptions }) { - const threadId = ThreadId.makeUnsafe("thread-codex-traits"); const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex; - const draftsByThreadId: Record = { - [threadId]: { + const draftsByThreadKey: Record = { + [CODEX_THREAD_KEY]: { prompt: "", images: [], nonPersistedImageIds: [], @@ -392,10 +398,10 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt }; useComposerDraftStore.setState({ - draftsByThreadId, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: { - [ProjectId.makeUnsafe("project-codex-traits")]: threadId, + draftsByThreadKey, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + "environment-local:project-codex-traits": CODEX_THREAD_KEY, }, }); const host = document.createElement("div"); @@ -404,7 +410,7 @@ async function mountCodexPicker(props: { model?: string; options?: CodexModelOpt { document.body.innerHTML = ""; localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, }); }); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 061594ad53..14b5cdfb3c 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -3,8 +3,8 @@ import { type CodexModelOptions, type ProviderKind, type ProviderModelOptions, + type ScopedThreadRef, type ServerProviderModel, - type ThreadId, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, @@ -28,18 +28,19 @@ import { MenuSeparator as MenuDivider, MenuTrigger, } from "../ui/menu"; -import { useComposerDraftStore } from "../../composerDraftStore"; +import { useComposerDraftStore, DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import { cn } from "~/lib/utils"; type ProviderOptions = ProviderModelOptions[ProviderKind]; type TraitsPersistence = | { - threadId: ThreadId; + threadRef?: ScopedThreadRef; + draftId?: DraftId; onModelOptionsChange?: never; } | { - threadId?: undefined; + threadRef?: undefined; onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void; }; @@ -167,7 +168,13 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ persistence.onModelOptionsChange(nextOptions); return; } - setProviderModelOptions(persistence.threadId, provider, nextOptions, { persistSticky: true }); + const threadTarget = persistence.threadRef ?? persistence.draftId; + if (!threadTarget) { + return; + } + setProviderModelOptions(threadTarget, provider, nextOptions, { + persistSticky: true, + }); }, [persistence, provider, setProviderModelOptions], ); diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 4dc79832d4..1735117837 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { ServerProviderModel } from "@t3tools/contracts"; -import { getComposerProviderState } from "./composerProviderRegistry"; +import { + getComposerProviderState, + renderProviderTraitsMenuContent, + renderProviderTraitsPicker, +} from "./composerProviderRegistry"; const CODEX_MODELS: ReadonlyArray = [ { @@ -417,3 +421,31 @@ describe("getComposerProviderState", () => { expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode"); }); }); + +describe("provider traits render guards", () => { + it("returns null for codex traits picker when no thread target is provided", () => { + const content = renderProviderTraitsPicker({ + provider: "codex", + model: "gpt-5.4", + models: CODEX_MODELS, + modelOptions: undefined, + prompt: "", + onPromptChange: () => {}, + }); + + expect(content).toBeNull(); + }); + + it("returns null for claude traits menu content when no thread target is provided", () => { + const content = renderProviderTraitsMenuContent({ + provider: "claudeAgent", + model: "claude-sonnet-4-6", + models: CLAUDE_MODELS, + modelOptions: undefined, + prompt: "", + onPromptChange: () => {}, + }); + + expect(content).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 3307442db2..74d8d85cff 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -1,11 +1,12 @@ import { type ProviderKind, type ProviderModelOptions, + type ScopedThreadRef, type ServerProviderModel, - type ThreadId, } from "@t3tools/contracts"; import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model"; import type { ReactNode } from "react"; +import type { DraftId } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; import { @@ -33,7 +34,8 @@ export type ComposerProviderState = { type ProviderRegistryEntry = { getState: (input: ComposerProviderStateInput) => ComposerProviderState; renderTraitsMenuContent: (input: { - threadId: ThreadId; + threadRef?: ScopedThreadRef; + draftId?: DraftId; model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; @@ -41,7 +43,8 @@ type ProviderRegistryEntry = { onPromptChange: (prompt: string) => void; }) => ReactNode; renderTraitsPicker: (input: { - threadId: ThreadId; + threadRef?: ScopedThreadRef; + draftId?: DraftId; model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; @@ -50,6 +53,13 @@ type ProviderRegistryEntry = { }) => ReactNode; }; +function hasComposerTraitsTarget(input: { + threadRef: ScopedThreadRef | undefined; + draftId: DraftId | undefined; +}): boolean { + return input.threadRef !== undefined || input.draftId !== undefined; +} + function getProviderStateFromCapabilities( input: ComposerProviderStateInput, ): ComposerProviderState { @@ -94,66 +104,92 @@ const composerProviderRegistry: Record = { codex: { getState: (input) => getProviderStateFromCapabilities(input), renderTraitsMenuContent: ({ - threadId, + threadRef, + draftId, + model, + models, + modelOptions, + prompt, + onPromptChange, + }) => + !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( + + ), + renderTraitsPicker: ({ + threadRef, + draftId, model, models, modelOptions, prompt, onPromptChange, - }) => ( - - ), - renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( - - ), + }) => + !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( + + ), }, claudeAgent: { getState: (input) => getProviderStateFromCapabilities(input), renderTraitsMenuContent: ({ - threadId, + threadRef, + draftId, + model, + models, + modelOptions, + prompt, + onPromptChange, + }) => + !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( + + ), + renderTraitsPicker: ({ + threadRef, + draftId, model, models, modelOptions, prompt, onPromptChange, - }) => ( - - ), - renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( - - ), + }) => + !hasComposerTraitsTarget({ threadRef, draftId }) ? null : ( + + ), }, }; @@ -163,7 +199,8 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com export function renderProviderTraitsMenuContent(input: { provider: ProviderKind; - threadId: ThreadId; + threadRef?: ScopedThreadRef; + draftId?: DraftId; model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; @@ -171,7 +208,8 @@ export function renderProviderTraitsMenuContent(input: { onPromptChange: (prompt: string) => void; }): ReactNode { return composerProviderRegistry[input.provider].renderTraitsMenuContent({ - threadId: input.threadId, + ...(input.threadRef ? { threadRef: input.threadRef } : {}), + ...(input.draftId ? { draftId: input.draftId } : {}), model: input.model, models: input.models, modelOptions: input.modelOptions, @@ -182,7 +220,8 @@ export function renderProviderTraitsMenuContent(input: { export function renderProviderTraitsPicker(input: { provider: ProviderKind; - threadId: ThreadId; + threadRef?: ScopedThreadRef; + draftId?: DraftId; model: string; models: ReadonlyArray; modelOptions: ProviderModelOptions[ProviderKind] | undefined; @@ -190,7 +229,8 @@ export function renderProviderTraitsPicker(input: { onPromptChange: (prompt: string) => void; }): ReactNode { return composerProviderRegistry[input.provider].renderTraitsPicker({ - threadId: input.threadId, + ...(input.threadRef ? { threadRef: input.threadRef } : {}), + ...(input.draftId ? { draftId: input.draftId } : {}), model: input.model, models: input.models, modelOptions: input.modelOptions, diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index f0ea32d4be..ab2c8ab3f1 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -1,17 +1,29 @@ import "../../index.css"; -import { DEFAULT_SERVER_SETTINGS, type NativeApi, type ServerConfig } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + type LocalApi, + type ServerConfig, +} from "@t3tools/contracts"; import { page } from "vitest/browser"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; -import { __resetNativeApiForTests } from "../../nativeApi"; +import { __resetLocalApiForTests } from "../../localApi"; import { AppAtomRegistryProvider } from "../../rpc/atomRegistry"; import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; import { GeneralSettingsPanel } from "./SettingsPanels"; function createBaseServerConfig(): ServerConfig { return { + environment: { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { os: "darwin" as const, arch: "arm64" as const }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], @@ -32,14 +44,14 @@ function createBaseServerConfig(): ServerConfig { describe("GeneralSettingsPanel observability", () => { beforeEach(async () => { resetServerStateForTests(); - await __resetNativeApiForTests(); + await __resetLocalApiForTests(); localStorage.clear(); document.body.innerHTML = ""; }); afterEach(async () => { resetServerStateForTests(); - await __resetNativeApiForTests(); + await __resetLocalApiForTests(); document.body.innerHTML = ""; }); @@ -68,12 +80,12 @@ describe("GeneralSettingsPanel observability", () => { }); it("opens the logs folder in the preferred editor", async () => { - const openInEditor = vi.fn().mockResolvedValue(undefined); + const openInEditor = vi.fn().mockResolvedValue(undefined); window.nativeApi = { shell: { openInEditor, }, - } as unknown as NativeApi; + } as unknown as LocalApi; setServerConfigSnapshot(createBaseServerConfig()); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 97c84271a7..6251db7cd9 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -13,11 +13,12 @@ import { useQueryClient } from "@tanstack/react-query"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PROVIDER_DISPLAY_NAMES, + type ScopedThreadRef, type ProviderKind, type ServerProvider, type ServerProviderModel, - ThreadId, } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { Equal } from "effect"; @@ -45,8 +46,12 @@ import { getCustomModelOptionsByProvider, resolveAppModelSelectionState, } from "../../modelSelection"; -import { ensureNativeApi, readNativeApi } from "../../nativeApi"; -import { useStore } from "../../store"; +import { ensureLocalApi, readLocalApi } from "../../localApi"; +import { + selectProjectsAcrossEnvironments, + selectThreadsAcrossEnvironments, + useStore, +} from "../../store"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; @@ -496,8 +501,8 @@ export function useSettingsRestore(onRestored?: () => void) { const restoreDefaults = useCallback(async () => { if (changedSettingLabels.length === 0) return; - const api = readNativeApi(); - const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm( + const api = readLocalApi(); + const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm( ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join( "\n", ), @@ -554,7 +559,7 @@ export function GeneralSettingsPanel() { if (refreshingRef.current) return; refreshingRef.current = true; setIsRefreshingProviders(true); - void ensureNativeApi() + void ensureLocalApi() .server.refreshProviders() .catch((error: unknown) => { console.warn("Failed to refresh providers", error); @@ -614,7 +619,7 @@ export function GeneralSettingsPanel() { return; } - void ensureNativeApi() + void ensureLocalApi() .shell.openInEditor(path, editor) .catch((error) => { setOpenPathErrorByTarget((existing) => ({ @@ -1479,20 +1484,10 @@ export function GeneralSettingsPanel() { } export function ArchivedThreadsPanel() { - const projectIds = useStore((store) => store.projectIds); - const projectById = useStore((store) => store.projectById); - const threadIds = useStore((store) => store.threadIds); - const threadShellById = useStore((store) => store.threadShellById); + const projects = useStore(selectProjectsAcrossEnvironments); + const threads = useStore(selectThreadsAcrossEnvironments); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); const archivedGroups = useMemo(() => { - const projects = projectIds.flatMap((projectId) => { - const project = projectById[projectId]; - return project ? [project] : []; - }); - const threads = threadIds.flatMap((threadId) => { - const thread = threadShellById[threadId]; - return thread ? [thread] : []; - }); return projects .map((project) => ({ project, @@ -1505,11 +1500,11 @@ export function ArchivedThreadsPanel() { }), })) .filter((group) => group.threads.length > 0); - }, [projectById, projectIds, threadIds, threadShellById]); + }, [projects, threads]); const handleArchivedThreadContextMenu = useCallback( - async (threadId: ThreadId, position: { x: number; y: number }) => { - const api = readNativeApi(); + async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { + const api = readLocalApi(); if (!api) return; const clicked = await api.contextMenu.show( [ @@ -1521,7 +1516,7 @@ export function ArchivedThreadsPanel() { if (clicked === "unarchive") { try { - await unarchiveThread(threadId); + await unarchiveThread(threadRef); } catch (error) { toastManager.add({ type: "error", @@ -1533,7 +1528,7 @@ export function ArchivedThreadsPanel() { } if (clicked === "delete") { - await confirmAndDeleteThread(threadId); + await confirmAndDeleteThread(threadRef); } }, [confirmAndDeleteThread, unarchiveThread], @@ -1566,10 +1561,13 @@ export function ArchivedThreadsPanel() { className="flex items-center justify-between gap-3 border-t border-border px-4 py-3 first:border-t-0 sm:px-5" onContextMenu={(event) => { event.preventDefault(); - void handleArchivedThreadContextMenu(thread.id, { - x: event.clientX, - y: event.clientY, - }); + void handleArchivedThreadContextMenu( + scopeThreadRef(thread.environmentId, thread.id), + { + x: event.clientX, + y: event.clientY, + }, + ); }} >
@@ -1586,13 +1584,16 @@ export function ArchivedThreadsPanel() { size="sm" className="h-7 shrink-0 cursor-pointer gap-1.5 px-2.5" onClick={() => - void unarchiveThread(thread.id).catch((error) => { - toastManager.add({ - type: "error", - title: "Failed to unarchive thread", - description: error instanceof Error ? error.message : "An error occurred.", - }); - }) + void unarchiveThread(scopeThreadRef(thread.environmentId, thread.id)).catch( + (error) => { + toastManager.add({ + type: "error", + title: "Failed to unarchive thread", + description: + error instanceof Error ? error.message : "An error occurred.", + }); + }, + ) } > diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 797e27a6ed..8432680d42 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1,5 +1,12 @@ +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; import * as Schema from "effect/Schema"; import { + EnvironmentId, ProjectId, ThreadId, type ModelSelection, @@ -9,10 +16,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { COMPOSER_DRAFT_STORAGE_KEY, - clearPromotedDraftThread, - clearPromotedDraftThreads, + finalizePromotedDraftThreadByRef, + markPromotedDraftThread, + markPromotedDraftThreadByRef, + markPromotedDraftThreads, + markPromotedDraftThreadsByRef, type ComposerImageAttachment, useComposerDraftStore, + DraftId, } from "./composerDraftStore"; import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; import { @@ -71,9 +82,9 @@ function makeTerminalContext(input: { function resetComposerDraftStore() { useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); @@ -95,8 +106,32 @@ function providerModelOptions(options: ProviderModelOptions): ProviderModelOptio return options; } +const TEST_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); +const OTHER_TEST_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-remote"); +const LEGACY_TEST_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("__legacy__"); + +function threadKeyFor( + threadId: ThreadId, + environmentId: EnvironmentId = LEGACY_TEST_ENVIRONMENT_ID, +): string { + if (environmentId === LEGACY_TEST_ENVIRONMENT_ID) { + return threadId; + } + return scopedThreadKey(scopeThreadRef(environmentId, threadId)); +} + +function draftFor(threadId: ThreadId, environmentId: EnvironmentId = LEGACY_TEST_ENVIRONMENT_ID) { + const store = useComposerDraftStore.getState().draftsByThreadKey; + return store[threadKeyFor(threadId, environmentId)] ?? store[threadId] ?? undefined; +} + +function draftByKey(key: string) { + return useComposerDraftStore.getState().draftsByThreadKey[key] ?? undefined; +} + describe("composerDraftStore addImages", () => { const threadId = ThreadId.makeUnsafe("thread-dedupe"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; let revokeSpy: ReturnType void>>; @@ -129,9 +164,9 @@ describe("composerDraftStore addImages", () => { lastModified: 12345, }); - useComposerDraftStore.getState().addImages(threadId, [first, duplicate]); + useComposerDraftStore.getState().addImages(threadRef, [first, duplicate]); - const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.images.map((image) => image.id)).toEqual(["img-1"]); expect(revokeSpy).toHaveBeenCalledWith("blob:duplicate"); }); @@ -154,10 +189,10 @@ describe("composerDraftStore addImages", () => { lastModified: 999, }); - useComposerDraftStore.getState().addImage(threadId, first); - useComposerDraftStore.getState().addImage(threadId, duplicateLater); + useComposerDraftStore.getState().addImage(threadRef, first); + useComposerDraftStore.getState().addImage(threadRef, duplicateLater); - const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.images.map((image) => image.id)).toEqual(["img-a"]); expect(revokeSpy).toHaveBeenCalledWith("blob:b"); }); @@ -172,9 +207,9 @@ describe("composerDraftStore addImages", () => { previewUrl: "blob:shared", }); - useComposerDraftStore.getState().addImages(threadId, [first, duplicateSameUrl]); + useComposerDraftStore.getState().addImages(threadRef, [first, duplicateSameUrl]); - const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.images.map((image) => image.id)).toEqual(["img-shared"]); expect(revokeSpy).not.toHaveBeenCalledWith("blob:shared"); }); @@ -182,6 +217,7 @@ describe("composerDraftStore addImages", () => { describe("composerDraftStore clearComposerContent", () => { const threadId = ThreadId.makeUnsafe("thread-clear"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; let revokeSpy: ReturnType void>>; @@ -201,11 +237,11 @@ describe("composerDraftStore clearComposerContent", () => { id: "img-optimistic", previewUrl: "blob:optimistic", }); - useComposerDraftStore.getState().addImage(threadId, first); + useComposerDraftStore.getState().addImage(threadRef, first); - useComposerDraftStore.getState().clearComposerContent(threadId); + useComposerDraftStore.getState().clearComposerContent(threadRef); - const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft).toBeUndefined(); expect(revokeSpy).not.toHaveBeenCalledWith("blob:optimistic"); }); @@ -213,13 +249,14 @@ describe("composerDraftStore clearComposerContent", () => { describe("composerDraftStore syncPersistedAttachments", () => { const threadId = ThreadId.makeUnsafe("thread-sync-persisted"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); @@ -234,7 +271,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { id: "img-persisted", previewUrl: "blob:persisted", }); - useComposerDraftStore.getState().addImage(threadId, image); + useComposerDraftStore.getState().addImage(threadRef, image); setLocalStorageItem( COMPOSER_DRAFT_STORAGE_KEY, { @@ -250,7 +287,7 @@ describe("composerDraftStore syncPersistedAttachments", () => { Schema.Unknown, ); - useComposerDraftStore.getState().syncPersistedAttachments(threadId, [ + useComposerDraftStore.getState().syncPersistedAttachments(threadRef, [ { id: image.id, name: image.name, @@ -261,23 +298,20 @@ describe("composerDraftStore syncPersistedAttachments", () => { ]); await Promise.resolve(); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.persistedAttachments, - ).toEqual([]); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.nonPersistedImageIds, - ).toEqual([image.id]); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.persistedAttachments).toEqual([]); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.nonPersistedImageIds).toEqual([image.id]); }); }); describe("composerDraftStore terminal contexts", () => { const threadId = ThreadId.makeUnsafe("thread-dedupe"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); @@ -287,20 +321,20 @@ describe("composerDraftStore terminal contexts", () => { const first = makeTerminalContext({ id: "ctx-1" }); const duplicate = makeTerminalContext({ id: "ctx-2" }); - useComposerDraftStore.getState().addTerminalContexts(threadId, [first, duplicate]); + useComposerDraftStore.getState().addTerminalContexts(threadRef, [first, duplicate]); - const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.terminalContexts.map((context) => context.id)).toEqual(["ctx-1"]); }); it("clears terminal contexts when clearing composer content", () => { useComposerDraftStore .getState() - .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-1" })); + .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-1" })); - useComposerDraftStore.getState().clearComposerContent(threadId); + useComposerDraftStore.getState().clearComposerContent(threadRef); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); }); it("inserts terminal contexts at the requested inline prompt position", () => { @@ -311,7 +345,7 @@ describe("composerDraftStore terminal contexts", () => { useComposerDraftStore .getState() .insertTerminalContext( - threadId, + threadRef, firstInsertion.prompt, makeTerminalContext({ id: "ctx-1" }), firstInsertion.contextIndex, @@ -319,7 +353,7 @@ describe("composerDraftStore terminal contexts", () => { ).toBe(true); expect( useComposerDraftStore.getState().insertTerminalContext( - threadId, + threadRef, secondInsertion.prompt, makeTerminalContext({ id: "ctx-2", @@ -331,7 +365,7 @@ describe("composerDraftStore terminal contexts", () => { ), ).toBe(true); - const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.prompt).toBe( `${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} alpha ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} beta`, ); @@ -341,7 +375,7 @@ describe("composerDraftStore terminal contexts", () => { it("omits terminal context text from persisted drafts", () => { useComposerDraftStore .getState() - .addTerminalContext(threadId, makeTerminalContext({ id: "ctx-persist" })); + .addTerminalContext(threadRef, makeTerminalContext({ id: "ctx-persist" })); const persistApi = useComposerDraftStore.persist as unknown as { getOptions: () => { @@ -349,11 +383,12 @@ describe("composerDraftStore terminal contexts", () => { }; }; const persistedState = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { - draftsByThreadId?: Record> }>; + draftsByThreadKey?: Record> }>; }; expect( - persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0], + persistedState.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)] + ?.terminalContexts?.[0], "Expected terminal context metadata to be persisted.", ).toMatchObject({ id: "ctx-persist", @@ -363,7 +398,8 @@ describe("composerDraftStore terminal contexts", () => { lineEnd: 5, }); expect( - persistedState.draftsByThreadId?.[threadId]?.terminalContexts?.[0]?.text, + persistedState.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)] + ?.terminalContexts?.[0]?.text, ).toBeUndefined(); }); @@ -396,12 +432,12 @@ describe("composerDraftStore terminal contexts", () => { }, }, draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + projectDraftThreadIdByProjectKey: {}, }, useComposerDraftStore.getInitialState(), ); - expect(mergedState.draftsByThreadId[threadId]?.terminalContexts).toMatchObject([ + expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]?.terminalContexts).toMatchObject([ { id: "ctx-rehydrated", terminalId: "default", @@ -434,22 +470,30 @@ describe("composerDraftStore terminal contexts", () => { }, }, draftThreadsByThreadId: "not-an-object", - projectDraftThreadIdByProjectId: "not-an-object", + projectDraftThreadIdByProjectKey: "not-an-object", }, useComposerDraftStore.getInitialState(), ); - expect(mergedState.draftsByThreadId[threadId]).toBeUndefined(); - expect(mergedState.draftThreadsByThreadId).toEqual({}); - expect(mergedState.projectDraftThreadIdByProjectId).toEqual({}); + expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]).toBeUndefined(); + expect(mergedState.draftThreadsByThreadKey).toEqual({}); + expect(mergedState.logicalProjectDraftThreadKeyByLogicalProjectKey).toEqual({}); }); }); describe("composerDraftStore project draft thread mapping", () => { const projectId = ProjectId.makeUnsafe("project-a"); const otherProjectId = ProjectId.makeUnsafe("project-b"); + const projectRef = scopeProjectRef(TEST_ENVIRONMENT_ID, projectId); + const otherProjectRef = scopeProjectRef(TEST_ENVIRONMENT_ID, otherProjectId); + const remoteProjectRef = scopeProjectRef(OTHER_TEST_ENVIRONMENT_ID, projectId); const threadId = ThreadId.makeUnsafe("thread-a"); const otherThreadId = ThreadId.makeUnsafe("thread-b"); + const draftId = DraftId.makeUnsafe("draft-a"); + const otherDraftId = DraftId.makeUnsafe("draft-b"); + const sharedDraftId = DraftId.makeUnsafe("draft-shared"); + const localDraftId = DraftId.makeUnsafe("draft-local"); + const remoteDraftId = DraftId.makeUnsafe("draft-remote"); beforeEach(() => { resetComposerDraftStore(); @@ -457,17 +501,20 @@ describe("composerDraftStore project draft thread mapping", () => { it("stores and reads project draft thread ids via actions", () => { const store = useComposerDraftStore.getState(); - expect(store.getDraftThreadByProjectId(projectId)).toBeNull(); - expect(store.getDraftThread(threadId)).toBeNull(); + expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(store.getDraftThread(draftId)).toBeNull(); - store.setProjectDraftThreadId(projectId, threadId, { + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, branch: "feature/test", worktreePath: "/tmp/worktree-test", createdAt: "2026-01-01T00:00:00.000Z", }); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toEqual({ + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toMatchObject({ threadId, + environmentId: TEST_ENVIRONMENT_ID, projectId, + logicalProjectKey: scopedProjectKey(projectRef), branch: "feature/test", worktreePath: "/tmp/worktree-test", envMode: "worktree", @@ -475,8 +522,10 @@ describe("composerDraftStore project draft thread mapping", () => { interactionMode: "default", createdAt: "2026-01-01T00:00:00.000Z", }); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({ + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ + environmentId: TEST_ENVIRONMENT_ID, projectId, + logicalProjectKey: scopedProjectKey(projectRef), branch: "feature/test", worktreePath: "/tmp/worktree-test", envMode: "worktree", @@ -488,134 +537,210 @@ describe("composerDraftStore project draft thread mapping", () => { it("clears only matching project draft mapping entries", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId); - store.setPrompt(threadId, "hello"); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "hello"); - store.clearProjectDraftThreadById(projectId, otherThreadId); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( + store.clearProjectDraftThreadById(projectRef, otherDraftId); + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( threadId, ); - store.clearProjectDraftThreadById(projectId, threadId); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + store.clearProjectDraftThreadById(projectRef, draftId); + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); + expect(draftByKey(draftId)).toBeUndefined(); }); it("clears project draft mapping by project id", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId); - store.setPrompt(threadId, "hello"); - store.clearProjectDraftThreadId(projectId); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "hello"); + store.clearProjectDraftThreadId(projectRef); + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); + expect(draftByKey(draftId)).toBeUndefined(); }); it("clears orphaned composer drafts when remapping a project to a new draft thread", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId); - store.setPrompt(threadId, "orphan me"); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "orphan me"); - store.setProjectDraftThreadId(projectId, otherThreadId); + store.setProjectDraftThreadId(projectRef, otherDraftId, { threadId: otherThreadId }); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( otherThreadId, ); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); + expect(draftByKey(draftId)).toBeUndefined(); }); it("keeps composer drafts when the thread is still mapped by another project", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId); - store.setProjectDraftThreadId(otherProjectId, threadId); - store.setPrompt(threadId, "keep me"); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setProjectDraftThreadId(otherProjectRef, sharedDraftId, { threadId }); + store.setPrompt(sharedDraftId, "keep me"); - store.clearProjectDraftThreadId(projectId); + store.clearProjectDraftThreadId(projectRef); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); expect( - useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId, + useComposerDraftStore.getState().getDraftThreadByProjectRef(otherProjectRef)?.threadId, ).toBe(threadId); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me"); + expect(draftByKey(sharedDraftId)?.prompt).toBe("keep me"); }); it("clears draft registration independently", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId); - store.setPrompt(threadId, "remove me"); - store.clearDraftThread(threadId); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "remove me"); + store.clearDraftThread(draftId); + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); + expect(draftByKey(draftId)).toBeUndefined(); + }); + + it("marks a promoted draft by thread id without deleting composer state", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "promote me"); + + markPromotedDraftThread(threadId); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual( + scopeThreadRef(TEST_ENVIRONMENT_ID, threadId), + ); + expect(draftByKey(draftId)?.prompt).toBe("promote me"); }); - it("clears a promoted draft by thread id", () => { + it("reads local draft composer state through a scoped thread ref", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId); - store.setPrompt(threadId, "promote me"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); - clearPromotedDraftThread(threadId); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "scoped access"); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toBeNull(); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + expect(store.getComposerDraft(draftId)?.prompt).toBe("scoped access"); + expect(store.getComposerDraft(threadRef)?.prompt).toBe("scoped access"); }); it("does not clear composer drafts for existing server threads during promotion cleanup", () => { const store = useComposerDraftStore.getState(); - store.setPrompt(threadId, "keep me"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + store.setPrompt(threadRef, "keep me"); - clearPromotedDraftThread(threadId); + markPromotedDraftThread(threadId); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me"); + expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull(); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me"); }); - it("clears promoted drafts from an iterable of server thread ids", () => { + it("marks promoted drafts from an iterable of server thread ids", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId); - store.setPrompt(threadId, "promote me"); - store.setProjectDraftThreadId(otherProjectId, otherThreadId); - store.setPrompt(otherThreadId, "keep me"); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "promote me"); + store.setProjectDraftThreadId(otherProjectRef, otherDraftId, { threadId: otherThreadId }); + store.setPrompt(otherDraftId, "keep me"); - clearPromotedDraftThreads([threadId]); + markPromotedDraftThreads([threadId]); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.promotedTo).toEqual( + scopeThreadRef(TEST_ENVIRONMENT_ID, threadId), + ); + expect(draftByKey(draftId)?.prompt).toBe("promote me"); expect( - useComposerDraftStore.getState().getDraftThreadByProjectId(otherProjectId)?.threadId, + useComposerDraftStore.getState().getDraftThreadByProjectRef(otherProjectRef)?.threadId, ).toBe(otherThreadId); - expect(useComposerDraftStore.getState().draftsByThreadId[otherThreadId]?.prompt).toBe( - "keep me", + expect(draftByKey(otherDraftId)?.prompt).toBe("keep me"); + }); + + it("marks every matching scoped draft when multiple environments share a thread id", () => { + const store = useComposerDraftStore.getState(); + const localThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + const remoteThreadRef = scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId); + + store.setProjectDraftThreadId(projectRef, localDraftId, { threadId }); + store.setPrompt(localDraftId, "local draft"); + store.setProjectDraftThreadId(remoteProjectRef, remoteDraftId, { threadId }); + store.setPrompt(remoteDraftId, "remote draft"); + + markPromotedDraftThread(threadId); + + expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(store.getDraftThreadByProjectRef(remoteProjectRef)).toBeNull(); + expect(store.getDraftThreadByRef(localThreadRef)?.promotedTo).toEqual(localThreadRef); + expect(store.getDraftThreadByRef(remoteThreadRef)?.promotedTo).toEqual(remoteThreadRef); + expect(draftByKey(localDraftId)?.prompt).toBe("local draft"); + expect(draftByKey(remoteDraftId)?.prompt).toBe("remote draft"); + }); + + it("only marks promoted drafts for the matching environment ref", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "promote me"); + + markPromotedDraftThreadByRef(scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId)); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( + threadId, + ); + expect(draftByKey(draftId)?.prompt).toBe("promote me"); + }); + + it("only marks iterable promotion cleanup entries for the matching environment refs", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "promote me"); + + markPromotedDraftThreadsByRef([scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, threadId)]); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( + threadId, ); + expect(draftByKey(draftId)?.prompt).toBe("promote me"); }); it("keeps existing server-thread composer drafts during iterable promotion cleanup", () => { const store = useComposerDraftStore.getState(); - store.setPrompt(threadId, "keep me"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + store.setPrompt(threadRef, "keep me"); - clearPromotedDraftThreads([threadId]); + markPromotedDraftThreads([threadId]); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toBeNull(); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.prompt).toBe("keep me"); + expect(useComposerDraftStore.getState().getDraftThread(threadRef)).toBeNull(); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("keep me"); + }); + + it("finalizes a promoted draft after the canonical thread route is active", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "promote me"); + markPromotedDraftThread(threadId); + + finalizePromotedDraftThreadByRef(scopeThreadRef(TEST_ENVIRONMENT_ID, threadId)); + + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toBeNull(); + expect(draftByKey(draftId)).toBeUndefined(); }); it("updates branch context on an existing draft thread", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId, { + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, branch: "main", worktreePath: null, }); - store.setDraftThreadContext(threadId, { + store.setDraftThreadContext(draftId, { branch: "feature/next", worktreePath: "/tmp/feature-next", }); - expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)?.threadId).toBe( + expect(useComposerDraftStore.getState().getDraftThreadByProjectRef(projectRef)?.threadId).toBe( threadId, ); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ + environmentId: TEST_ENVIRONMENT_ID, projectId, branch: "feature/next", worktreePath: "/tmp/feature-next", @@ -625,7 +750,8 @@ describe("composerDraftStore project draft thread mapping", () => { it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId, { + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, branch: "main", worktreePath: "/tmp/main-worktree", }); @@ -636,9 +762,10 @@ describe("composerDraftStore project draft thread mapping", () => { branch?: string | null; worktreePath?: string | null; }; - store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions); + store.setProjectDraftThreadId(projectRef, draftId, runtimeUndefinedOptions); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ + environmentId: TEST_ENVIRONMENT_ID, projectId, branch: "main", worktreePath: "/tmp/main-worktree", @@ -648,7 +775,8 @@ describe("composerDraftStore project draft thread mapping", () => { it("preserves worktree env mode without a worktree path", () => { const store = useComposerDraftStore.getState(); - store.setProjectDraftThreadId(projectId, threadId, { + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, branch: "feature/base", worktreePath: null, envMode: "worktree", @@ -662,9 +790,10 @@ describe("composerDraftStore project draft thread mapping", () => { worktreePath?: string | null; envMode?: "local" | "worktree"; }; - store.setProjectDraftThreadId(projectId, threadId, runtimeUndefinedOptions); + store.setProjectDraftThreadId(projectRef, draftId, runtimeUndefinedOptions); - expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ + environmentId: TEST_ENVIRONMENT_ID, projectId, branch: "feature/base", worktreePath: null, @@ -675,6 +804,7 @@ describe("composerDraftStore project draft thread mapping", () => { describe("composerDraftStore modelSelection", () => { const threadId = ThreadId.makeUnsafe("thread-model-options"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { resetComposerDraftStore(); @@ -683,16 +813,14 @@ describe("composerDraftStore modelSelection", () => { it("stores a model selection in the draft", () => { const store = useComposerDraftStore.getState(); store.setModelSelection( - threadId, + threadRef, modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "xhigh", fastMode: true, }), ); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, - ).toEqual( + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "xhigh", fastMode: true, @@ -702,18 +830,18 @@ describe("composerDraftStore modelSelection", () => { it("keeps default-only model selections on the draft", () => { const store = useComposerDraftStore.getState(); - store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4")); + store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4")); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, - ).toEqual(modelSelection("codex", "gpt-5.4")); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( + modelSelection("codex", "gpt-5.4"), + ); }); it("replaces only the targeted provider options on the current model selection", () => { const store = useComposerDraftStore.getState(); store.setModelSelection( - threadId, + threadRef, modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max", fastMode: true, @@ -727,7 +855,7 @@ describe("composerDraftStore modelSelection", () => { ); store.setProviderModelOptions( - threadId, + threadRef, "claudeAgent", { thinking: false, @@ -735,10 +863,7 @@ describe("composerDraftStore modelSelection", () => { { persistSticky: true }, ); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider - .claudeAgent, - ).toEqual( + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, }), @@ -754,20 +879,17 @@ describe("composerDraftStore modelSelection", () => { const store = useComposerDraftStore.getState(); store.setModelSelection( - threadId, + threadRef, modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max", }), ); - store.setProviderModelOptions(threadId, "claudeAgent", { + store.setProviderModelOptions(threadRef, "claudeAgent", { thinking: true, }); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider - .claudeAgent, - ).toEqual( + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { thinking: true, }), @@ -778,16 +900,14 @@ describe("composerDraftStore modelSelection", () => { it("keeps explicit off/default codex overrides on the selection", () => { const store = useComposerDraftStore.getState(); - store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4", { fastMode: true })); + store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4", { fastMode: true })); - store.setProviderModelOptions(threadId, "codex", { + store.setProviderModelOptions(threadRef, "codex", { reasoningEffort: "high", fastMode: false, }); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, - ).toEqual( + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( modelSelection("codex", "gpt-5.4", { reasoningEffort: "high", fastMode: false, @@ -802,18 +922,15 @@ describe("composerDraftStore modelSelection", () => { modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); store.setModelSelection( - threadId, + threadRef, modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); - store.setProviderModelOptions(threadId, "claudeAgent", { + store.setProviderModelOptions(threadRef, "claudeAgent", { thinking: false, }); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider - .claudeAgent, - ).toEqual( + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, }), @@ -828,7 +945,7 @@ describe("composerDraftStore modelSelection", () => { // Set options for both providers store.setModelOptions( - threadId, + threadRef, providerModelOptions({ codex: { fastMode: true }, claudeAgent: { effort: "max" }, @@ -836,9 +953,9 @@ describe("composerDraftStore modelSelection", () => { ); // Now set options for only codex — claudeAgent should be untouched - store.setModelOptions(threadId, providerModelOptions({ codex: { reasoningEffort: "xhigh" } })); + store.setModelOptions(threadRef, providerModelOptions({ codex: { reasoningEffort: "xhigh" } })); - const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ reasoningEffort: "xhigh" }); expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); }); @@ -847,16 +964,16 @@ describe("composerDraftStore modelSelection", () => { const store = useComposerDraftStore.getState(); store.setModelOptions( - threadId, + threadRef, providerModelOptions({ codex: { fastMode: true }, claudeAgent: { effort: "max" }, }), ); - store.setModelSelection(threadId, modelSelection("claudeAgent", "claude-opus-4-6")); + store.setModelSelection(threadRef, modelSelection("claudeAgent", "claude-opus-4-6")); - const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); @@ -867,10 +984,10 @@ describe("composerDraftStore modelSelection", () => { it("creates the first sticky snapshot from provider option changes", () => { const store = useComposerDraftStore.getState(); - store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4")); + store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.4")); store.setProviderModelOptions( - threadId, + threadRef, "codex", { fastMode: true, @@ -892,12 +1009,12 @@ describe("composerDraftStore modelSelection", () => { modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); store.setModelSelection( - threadId, + threadRef, modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), ); store.setProviderModelOptions( - threadId, + threadRef, "claudeAgent", { thinking: false, @@ -905,10 +1022,7 @@ describe("composerDraftStore modelSelection", () => { { persistSticky: false }, ); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider - .claudeAgent, - ).toEqual( + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.claudeAgent).toEqual( modelSelection("claudeAgent", "claude-opus-4-6", { thinking: false, }), @@ -921,6 +1035,7 @@ describe("composerDraftStore modelSelection", () => { describe("composerDraftStore setModelSelection", () => { const threadId = ThreadId.makeUnsafe("thread-model"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { resetComposerDraftStore(); @@ -929,11 +1044,11 @@ describe("composerDraftStore setModelSelection", () => { it("keeps explicit model overrides instead of coercing to null", () => { const store = useComposerDraftStore.getState(); - store.setModelSelection(threadId, modelSelection("codex", "gpt-5.3-codex")); + store.setModelSelection(threadRef, modelSelection("codex", "gpt-5.3-codex")); - expect( - useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, - ).toEqual(modelSelection("codex", "gpt-5.3-codex")); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.modelSelectionByProvider.codex).toEqual( + modelSelection("codex", "gpt-5.3-codex"), + ); }); }); @@ -975,11 +1090,12 @@ describe("composerDraftStore sticky composer settings", () => { it("applies sticky activeProvider to new drafts", () => { const store = useComposerDraftStore.getState(); const threadId = ThreadId.makeUnsafe("thread-sticky-active-provider"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); store.setStickyModelSelection(modelSelection("claudeAgent", "claude-opus-4-6")); - store.applyStickyState(threadId); + store.applyStickyState(threadRef); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toMatchObject({ modelSelectionByProvider: { claudeAgent: modelSelection("claudeAgent", "claude-opus-4-6"), }, @@ -990,6 +1106,7 @@ describe("composerDraftStore sticky composer settings", () => { describe("composerDraftStore provider-scoped option updates", () => { const threadId = ThreadId.makeUnsafe("thread-provider"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { resetComposerDraftStore(); @@ -998,13 +1115,13 @@ describe("composerDraftStore provider-scoped option updates", () => { it("retains off-provider option memory without changing the active selection", () => { const store = useComposerDraftStore.getState(); store.setModelSelection( - threadId, + threadRef, modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium", }), ); - store.setProviderModelOptions(threadId, "claudeAgent", { effort: "max" }); - const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + store.setProviderModelOptions(threadRef, "claudeAgent", { effort: "max" }); + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); expect(draft?.modelSelectionByProvider.codex).toEqual( modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }), ); @@ -1015,6 +1132,7 @@ describe("composerDraftStore provider-scoped option updates", () => { describe("composerDraftStore runtime and interaction settings", () => { const threadId = ThreadId.makeUnsafe("thread-settings"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); beforeEach(() => { resetComposerDraftStore(); @@ -1023,32 +1141,28 @@ describe("composerDraftStore runtime and interaction settings", () => { it("stores runtime mode overrides in the composer draft", () => { const store = useComposerDraftStore.getState(); - store.setRuntimeMode(threadId, "approval-required"); + store.setRuntimeMode(threadRef, "approval-required"); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.runtimeMode).toBe( - "approval-required", - ); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.runtimeMode).toBe("approval-required"); }); it("stores interaction mode overrides in the composer draft", () => { const store = useComposerDraftStore.getState(); - store.setInteractionMode(threadId, "plan"); + store.setInteractionMode(threadRef, "plan"); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.interactionMode).toBe( - "plan", - ); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.interactionMode).toBe("plan"); }); it("removes empty settings-only drafts when overrides are cleared", () => { const store = useComposerDraftStore.getState(); - store.setRuntimeMode(threadId, "approval-required"); - store.setInteractionMode(threadId, "plan"); - store.setRuntimeMode(threadId, null); - store.setInteractionMode(threadId, null); + store.setRuntimeMode(threadRef, "approval-required"); + store.setInteractionMode(threadRef, "plan"); + store.setRuntimeMode(threadRef, null); + store.setInteractionMode(threadRef, null); - expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 8a93b7b0da..0df2332e0c 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -3,6 +3,7 @@ import { type ClaudeCodeEffort, type CodexReasoningEffort, DEFAULT_MODEL_BY_PROVIDER, + type EnvironmentId, ModelSelection, ProjectId, ProviderInteractionMode, @@ -10,8 +11,18 @@ import { ProviderModelOptions, RuntimeMode, type ServerProvider, + type ScopedProjectRef, + type ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; +import { + parseScopedProjectKey, + parseScopedThreadKey, + scopedProjectKey, + scopeProjectRef, + scopedThreadKey, + scopeThreadRef, +} from "@t3tools/client-runtime"; import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; @@ -32,10 +43,13 @@ import { getDefaultServerModel } from "./providerModels"; import { UnifiedSettings } from "@t3tools/contracts/settings"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; -const COMPOSER_DRAFT_STORAGE_VERSION = 3; +const COMPOSER_DRAFT_STORAGE_VERSION = 5; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; +export const DraftId = Schema.String.pipe(Schema.brand("DraftId")); +export type DraftId = typeof DraftId.Type; + const COMPOSER_PERSIST_DEBOUNCE_MS = 300; const composerDebouncedStorage = createDebouncedStorage( @@ -44,7 +58,7 @@ const composerDebouncedStorage = createDebouncedStorage( ); // Flush pending composer draft writes before page unload to prevent data loss. -if (typeof window !== "undefined") { +if (typeof window !== "undefined" && typeof window.addEventListener === "function") { window.addEventListener("beforeunload", () => { composerDebouncedStorage.flush(); }); @@ -122,6 +136,14 @@ type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type; type LegacyV2StoreFields = { stickyModelSelection?: ModelSelection | null; stickyModelOptions?: ProviderModelOptions | null; + projectDraftThreadIdByProjectId?: Record | null; + draftsByThreadId?: Record | null; + draftThreadsByThreadId?: Record | null; + projectDraftThreadIdByProjectKey?: Record | null; + draftsByThreadKey?: Record | null; + draftThreadsByThreadKey?: Record | null; + projectDraftThreadKeyByProjectKey?: Record | null; + logicalProjectDraftThreadKeyByLogicalProjectKey?: Record | null; }; type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & @@ -129,20 +151,31 @@ type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState & LegacyV2StoreFields; const PersistedDraftThreadState = Schema.Struct({ + threadId: ThreadId, + environmentId: Schema.String, projectId: ProjectId, + logicalProjectKey: Schema.optionalKey(Schema.String), createdAt: Schema.String, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, + promotedTo: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + environmentId: Schema.String, + threadId: Schema.String, + }), + ), + ), }); type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type; const PersistedComposerDraftStoreState = Schema.Struct({ - draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState), - draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState), - projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId), + draftsByThreadKey: Schema.Record(Schema.String, PersistedComposerThreadDraftState), + draftThreadsByThreadKey: Schema.Record(Schema.String, PersistedDraftThreadState), + logicalProjectDraftThreadKeyByLogicalProjectKey: Schema.Record(Schema.String, Schema.String), stickyModelSelectionByProvider: Schema.optionalKey( Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), ), @@ -155,6 +188,10 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ state: PersistedComposerDraftStoreState, }); +/** + * Composer content keyed by either a draft session (`DraftId`) or a real server + * thread (`ScopedThreadRef`). This is the editable payload shown in the composer. + */ export interface ComposerThreadDraftState { prompt: string; images: ComposerImageAttachment[]; @@ -167,32 +204,94 @@ export interface ComposerThreadDraftState { interactionMode: ProviderInteractionMode | null; } -export interface DraftThreadState { +/** + * Mutable routing and execution context for a pre-thread draft session. + * + * Unlike a real server thread, a draft session can still change target + * environment/worktree configuration before the first send. + */ +export interface DraftSessionState { + threadId: ThreadId; + environmentId: EnvironmentId; projectId: ProjectId; + logicalProjectKey: string; createdAt: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; + promotedTo?: ScopedThreadRef | null; } -interface ProjectDraftThread extends DraftThreadState { - threadId: ThreadId; +export type DraftThreadState = DraftSessionState; + +/** + * Draft session metadata paired with its stable draft-session identity. + */ +interface ProjectDraftSession extends DraftSessionState { + draftId: DraftId; } +/** + * App-facing composer identity: + * - `DraftId` for pre-thread draft sessions + * - `ScopedThreadRef` for server-backed threads + * + * Raw `ThreadId` is intentionally excluded so callers cannot drop environment + * identity for real threads. + */ +type ComposerThreadTarget = ScopedThreadRef | DraftId; + +/** + * Persisted store for composer content plus draft-session metadata. + * + * The store intentionally models two domains: + * - draft sessions keyed by `DraftId` + * - server thread composer state keyed by `ScopedThreadRef` + */ interface ComposerDraftStoreState { - draftsByThreadId: Record; - draftThreadsByThreadId: Record; - projectDraftThreadIdByProjectId: Record; + draftsByThreadKey: Record; + draftThreadsByThreadKey: Record; + logicalProjectDraftThreadKeyByLogicalProjectKey: Record; stickyModelSelectionByProvider: Partial>; stickyActiveProvider: ProviderKind | null; - getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; - getDraftThread: (threadId: ThreadId) => DraftThreadState | null; + /** Returns the editable composer content for a draft session or server thread. */ + getComposerDraft: (target: ComposerThreadTarget) => ComposerThreadDraftState | null; + /** Looks up the active draft session for a logical project identity. */ + getDraftThreadByLogicalProjectKey: (logicalProjectKey: string) => ProjectDraftSession | null; + getDraftSessionByLogicalProjectKey: (logicalProjectKey: string) => ProjectDraftSession | null; + getDraftThreadByProjectRef: (projectRef: ScopedProjectRef) => ProjectDraftSession | null; + getDraftSessionByProjectRef: (projectRef: ScopedProjectRef) => ProjectDraftSession | null; + /** Reads mutable draft-session metadata by `DraftId`. */ + getDraftSession: (draftId: DraftId) => DraftSessionState | null; + /** Resolves a server-thread ref back to a matching draft session when one exists. */ + getDraftSessionByRef: (threadRef: ScopedThreadRef) => DraftSessionState | null; + getDraftThreadByRef: (threadRef: ScopedThreadRef) => DraftThreadState | null; + getDraftThread: (threadRef: ComposerThreadTarget) => DraftThreadState | null; + listDraftThreadKeys: () => string[]; + hasDraftThreadsInEnvironment: (environmentId: EnvironmentId) => boolean; + /** Creates or updates the draft session tracked for a logical project. */ + setLogicalProjectDraftThreadId: ( + logicalProjectKey: string, + projectRef: ScopedProjectRef, + draftId: DraftId, + options?: { + threadId?: ThreadId; + branch?: string | null; + worktreePath?: string | null; + createdAt?: string; + envMode?: DraftThreadEnvMode; + runtimeMode?: RuntimeMode; + interactionMode?: ProviderInteractionMode; + }, + ) => void; + /** Creates or updates the draft session tracked for a concrete project ref. */ setProjectDraftThreadId: ( - projectId: ProjectId, - threadId: ThreadId, + projectRef: ScopedProjectRef, + draftId: DraftId, options?: { + threadId?: ThreadId; branch?: string | null; worktreePath?: string | null; createdAt?: string; @@ -201,65 +300,76 @@ interface ComposerDraftStoreState { interactionMode?: ProviderInteractionMode; }, ) => void; + /** Updates mutable draft-session metadata without touching composer content. */ setDraftThreadContext: ( - threadId: ThreadId, + threadRef: ComposerThreadTarget, options: { branch?: string | null; worktreePath?: string | null; - projectId?: ProjectId; + projectRef?: ScopedProjectRef; createdAt?: string; envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, ) => void; - clearProjectDraftThreadId: (projectId: ProjectId) => void; - clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; - clearDraftThread: (threadId: ThreadId) => void; + clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; + clearProjectDraftThreadById: ( + projectRef: ScopedProjectRef, + threadRef: ComposerThreadTarget, + ) => void; + /** Marks a draft session as being promoted to a real server thread. */ + markDraftThreadPromoting: (threadRef: ComposerThreadTarget, promotedTo?: ScopedThreadRef) => void; + /** Removes draft-session metadata after promotion is complete. */ + finalizePromotedDraftThread: (threadRef: ComposerThreadTarget) => void; + clearDraftThread: (threadRef: ComposerThreadTarget) => void; setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; - setPrompt: (threadId: ThreadId, prompt: string) => void; - setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; + setPrompt: (threadRef: ComposerThreadTarget, prompt: string) => void; + setTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; setModelSelection: ( - threadId: ThreadId, + threadRef: ComposerThreadTarget, modelSelection: ModelSelection | null | undefined, ) => void; setModelOptions: ( - threadId: ThreadId, + threadRef: ComposerThreadTarget, modelOptions: ProviderModelOptions | null | undefined, ) => void; - applyStickyState: (threadId: ThreadId) => void; + applyStickyState: (threadRef: ComposerThreadTarget) => void; setProviderModelOptions: ( - threadId: ThreadId, + threadRef: ComposerThreadTarget, provider: ProviderKind, nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, options?: { persistSticky?: boolean; }, ) => void; - setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void; + setRuntimeMode: ( + threadRef: ComposerThreadTarget, + runtimeMode: RuntimeMode | null | undefined, + ) => void; setInteractionMode: ( - threadId: ThreadId, + threadRef: ComposerThreadTarget, interactionMode: ProviderInteractionMode | null | undefined, ) => void; - addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void; - addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void; - removeImage: (threadId: ThreadId, imageId: string) => void; + addImage: (threadRef: ComposerThreadTarget, image: ComposerImageAttachment) => void; + addImages: (threadRef: ComposerThreadTarget, images: ComposerImageAttachment[]) => void; + removeImage: (threadRef: ComposerThreadTarget, imageId: string) => void; insertTerminalContext: ( - threadId: ThreadId, + threadRef: ComposerThreadTarget, prompt: string, context: TerminalContextDraft, index: number, ) => boolean; - addTerminalContext: (threadId: ThreadId, context: TerminalContextDraft) => void; - addTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; - removeTerminalContext: (threadId: ThreadId, contextId: string) => void; - clearTerminalContexts: (threadId: ThreadId) => void; - clearPersistedAttachments: (threadId: ThreadId) => void; + addTerminalContext: (threadRef: ComposerThreadTarget, context: TerminalContextDraft) => void; + addTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; + removeTerminalContext: (threadRef: ComposerThreadTarget, contextId: string) => void; + clearTerminalContexts: (threadRef: ComposerThreadTarget) => void; + clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void; syncPersistedAttachments: ( - threadId: ThreadId, + threadRef: ComposerThreadTarget, attachments: PersistedComposerImageAttachment[], ) => void; - clearComposerContent: (threadId: ThreadId) => void; + clearComposerContent: (threadRef: ComposerThreadTarget) => void; } export interface EffectiveComposerModelState { @@ -293,9 +403,9 @@ function modelSelectionByProviderToOptions( } const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, }); @@ -748,35 +858,323 @@ function normalizeDraftThreadEnvMode( return fallbackWorktreePath ? "worktree" : "local"; } +function projectDraftKey(projectRef: ScopedProjectRef): string { + return scopedProjectKey(projectRef); +} + +function logicalProjectDraftKey(logicalProjectKey: string): string { + return logicalProjectKey.trim(); +} + +/** + * Runtime composer storage key for app-facing identities only. + * + * Draft sessions are keyed by `DraftId`. Real threads are keyed by + * `ScopedThreadRef` so environment identity is always preserved. + */ +function composerTargetKey(target: ScopedThreadRef | DraftId): string { + if (typeof target === "string") { + return target.trim(); + } + return scopedThreadKey(target); +} + +/** + * Legacy persisted data may still be keyed by a raw `ThreadId`. This helper is + * intentionally migration-only so live code cannot accidentally accept that + * incomplete identity. + */ +function normalizeLegacyComposerStorageKey( + threadKeyOrId: string, + options?: { + environmentId?: EnvironmentId; + }, +): string { + const parsedThreadRef = parseScopedThreadKey(threadKeyOrId); + if (parsedThreadRef) { + return composerTargetKey(parsedThreadRef); + } + if (options?.environmentId) { + return composerTargetKey(scopeThreadRef(options.environmentId, threadKeyOrId as ThreadId)); + } + return threadKeyOrId; +} + +function composerThreadRefFromKey(threadKey: string): ScopedThreadRef | null { + return parseScopedThreadKey(threadKey); +} + +type ComposerThreadLookupState = Pick< + ComposerDraftStoreState, + "draftsByThreadKey" | "draftThreadsByThreadKey" +>; + +function normalizeComposerTarget( + state: ComposerThreadLookupState, + target: ComposerThreadTarget, +): ComposerThreadTarget | null { + if (typeof target === "string") { + const draftId = target.trim(); + return draftId.length > 0 ? DraftId.makeUnsafe(draftId) : null; + } + return target; +} + +function resolveComposerDraftKey( + state: ComposerThreadLookupState, + target: ComposerThreadTarget, +): string | null { + const normalizedTarget = normalizeComposerTarget(state, target); + if (!normalizedTarget) { + return null; + } + if (typeof normalizedTarget !== "string") { + const scopedKey = composerTargetKey(normalizedTarget); + if (state.draftsByThreadKey[scopedKey]) { + return scopedKey; + } + for (const [draftId, draftSession] of Object.entries(state.draftThreadsByThreadKey)) { + if ( + draftSession.environmentId === normalizedTarget.environmentId && + draftSession.threadId === normalizedTarget.threadId + ) { + return draftId; + } + } + return scopedKey; + } + const threadKey = composerTargetKey(normalizedTarget); + return threadKey.length > 0 ? threadKey : null; +} + +function resolveComposerThreadId( + state: ComposerThreadLookupState, + target: ComposerThreadTarget, +): ThreadId | null { + const normalizedTarget = normalizeComposerTarget(state, target); + if (!normalizedTarget) { + return null; + } + if (typeof normalizedTarget !== "string") { + return normalizedTarget.threadId; + } + return state.draftThreadsByThreadKey[normalizedTarget]?.threadId ?? null; +} + +function getComposerDraftState( + state: Pick, + target: ComposerThreadTarget, +): ComposerThreadDraftState | null { + const threadKey = resolveComposerDraftKey(state, target); + if (!threadKey) { + return null; + } + return state.draftsByThreadKey[threadKey] ?? null; +} + +function isComposerThreadKeyInUse(mappings: Record, threadKey: string): boolean { + return Object.values(mappings).includes(threadKey); +} + +function toProjectDraftSession( + draftId: DraftId, + draftSession: DraftSessionState, +): ProjectDraftSession { + return { + draftId, + ...draftSession, + }; +} + +function createDraftThreadState( + projectRef: ScopedProjectRef, + threadId: ThreadId, + logicalProjectKey: string, + existingThread: DraftThreadState | undefined, + options?: { + threadId?: ThreadId; + branch?: string | null; + worktreePath?: string | null; + createdAt?: string; + envMode?: DraftThreadEnvMode; + runtimeMode?: RuntimeMode; + interactionMode?: ProviderInteractionMode; + }, +): DraftThreadState { + const nextWorktreePath = + options?.worktreePath === undefined + ? (existingThread?.worktreePath ?? null) + : (options.worktreePath ?? null); + return { + threadId, + environmentId: projectRef.environmentId, + projectId: projectRef.projectId, + logicalProjectKey, + createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), + runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, + interactionMode: + options?.interactionMode ?? existingThread?.interactionMode ?? DEFAULT_INTERACTION_MODE, + branch: + options?.branch === undefined ? (existingThread?.branch ?? null) : (options.branch ?? null), + worktreePath: nextWorktreePath, + envMode: + options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), + promotedTo: null, + }; +} + +function scopedThreadRefsEqual( + left: ScopedThreadRef | null | undefined, + right: ScopedThreadRef | null | undefined, +): boolean { + if (!left || !right) { + return left === right; + } + return left.environmentId === right.environmentId && left.threadId === right.threadId; +} + +function isDraftThreadPromoting(draftThread: DraftThreadState | null | undefined): boolean { + return draftThread?.promotedTo !== null && draftThread?.promotedTo !== undefined; +} + +function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThreadState): boolean { + return ( + !!left && + left.threadId === right.threadId && + left.environmentId === right.environmentId && + left.projectId === right.projectId && + left.logicalProjectKey === right.logicalProjectKey && + left.createdAt === right.createdAt && + left.runtimeMode === right.runtimeMode && + left.interactionMode === right.interactionMode && + left.branch === right.branch && + left.worktreePath === right.worktreePath && + left.envMode === right.envMode && + scopedThreadRefsEqual(left.promotedTo, right.promotedTo) + ); +} + +function removeDraftThreadReferences( + state: Pick< + ComposerDraftStoreState, + | "draftThreadsByThreadKey" + | "draftsByThreadKey" + | "logicalProjectDraftThreadKeyByLogicalProjectKey" + >, + threadKey: string, +): Pick< + ComposerDraftStoreState, + | "draftThreadsByThreadKey" + | "draftsByThreadKey" + | "logicalProjectDraftThreadKeyByLogicalProjectKey" +> { + const nextLogicalMappings = Object.fromEntries( + Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter( + ([, draftThreadKey]) => draftThreadKey !== threadKey, + ), + ) as Record; + const { [threadKey]: _removedDraftThread, ...restDraftThreadsByThreadKey } = + state.draftThreadsByThreadKey; + const { [threadKey]: _removedComposerDraft, ...restDraftsByThreadKey } = state.draftsByThreadKey; + return { + draftsByThreadKey: restDraftsByThreadKey, + draftThreadsByThreadKey: restDraftThreadsByThreadKey, + logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings, + }; +} + function normalizePersistedDraftThreads( rawDraftThreadsByThreadId: unknown, - rawProjectDraftThreadIdByProjectId: unknown, + rawProjectDraftThreadIdByProjectKey: unknown, ): Pick< PersistedComposerDraftStoreState, - "draftThreadsByThreadId" | "projectDraftThreadIdByProjectId" + "draftThreadsByThreadKey" | "logicalProjectDraftThreadKeyByLogicalProjectKey" > { - const draftThreadsByThreadId: Record = {}; + const draftThreadsByThreadKey: Record = {}; + const environmentIdByThreadId = new Map(); + if ( + rawProjectDraftThreadIdByProjectKey && + typeof rawProjectDraftThreadIdByProjectKey === "object" + ) { + for (const [projectKey, threadId] of Object.entries( + rawProjectDraftThreadIdByProjectKey as Record, + )) { + if (typeof threadId !== "string" || threadId.length === 0) { + continue; + } + const projectRef = parseScopedProjectKey(projectKey); + if (!projectRef) { + continue; + } + const parsedThreadRef = parseScopedThreadKey(threadId); + if (parsedThreadRef) { + environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId); + continue; + } + environmentIdByThreadId.set(threadId as ThreadId, projectRef.environmentId); + } + } if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") { - for (const [threadId, rawDraftThread] of Object.entries( + for (const [threadKeyOrId, rawDraftThread] of Object.entries( rawDraftThreadsByThreadId as Record, )) { - if (typeof threadId !== "string" || threadId.length === 0) { + if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) { continue; } if (!rawDraftThread || typeof rawDraftThread !== "object") { continue; } const candidateDraftThread = rawDraftThread as Record; + const parsedThreadRef = parseScopedThreadKey(threadKeyOrId); + const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId); + const threadId = + parsedThreadRef?.threadId ?? + (typeof candidateDraftThread.threadId === "string" && + candidateDraftThread.threadId.length > 0 + ? (candidateDraftThread.threadId as ThreadId) + : (threadKeyOrId as ThreadId)); + const environmentId = + parsedThreadRef?.environmentId ?? + (typeof candidateDraftThread.environmentId === "string" && + candidateDraftThread.environmentId.length > 0 + ? (candidateDraftThread.environmentId as EnvironmentId) + : environmentIdByThreadId.get(threadKeyOrId as ThreadId)); const projectId = candidateDraftThread.projectId; const createdAt = candidateDraftThread.createdAt; const branch = candidateDraftThread.branch; const worktreePath = candidateDraftThread.worktreePath; const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; - if (typeof projectId !== "string" || projectId.length === 0) { + const promotedToCandidate = candidateDraftThread.promotedTo; + const promotedToRecord = + promotedToCandidate && typeof promotedToCandidate === "object" + ? (promotedToCandidate as Record) + : null; + const promotedTo = + promotedToRecord && + typeof promotedToRecord.environmentId === "string" && + promotedToRecord.environmentId.length > 0 && + typeof promotedToRecord.threadId === "string" && + promotedToRecord.threadId.length > 0 + ? scopeThreadRef( + promotedToRecord.environmentId as EnvironmentId, + promotedToRecord.threadId as ThreadId, + ) + : null; + if (typeof projectId !== "string" || projectId.length === 0 || environmentId === undefined) { continue; } - draftThreadsByThreadId[threadId as ThreadId] = { + const normalizedEnvironmentId = environmentId as EnvironmentId; + draftThreadsByThreadKey[threadKey] = { + threadId, + environmentId: normalizedEnvironmentId, projectId: projectId as ProjectId, + logicalProjectKey: + typeof candidateDraftThread.logicalProjectKey === "string" && + candidateDraftThread.logicalProjectKey.length > 0 + ? candidateDraftThread.logicalProjectKey + : parsedThreadRef + ? projectDraftKey(scopeProjectRef(normalizedEnvironmentId, projectId as ProjectId)) + : threadKeyOrId, createdAt: typeof createdAt === "string" && createdAt.length > 0 ? createdAt @@ -794,59 +1192,97 @@ function normalizePersistedDraftThreads( branch: typeof branch === "string" ? branch : null, worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), + promotedTo, }; } } - const projectDraftThreadIdByProjectId: Record = {}; + const logicalProjectDraftThreadKeyByLogicalProjectKey: Record = {}; if ( - rawProjectDraftThreadIdByProjectId && - typeof rawProjectDraftThreadIdByProjectId === "object" + rawProjectDraftThreadIdByProjectKey && + typeof rawProjectDraftThreadIdByProjectKey === "object" ) { - for (const [projectId, threadId] of Object.entries( - rawProjectDraftThreadIdByProjectId as Record, + for (const [logicalProjectKey, threadKeyOrId] of Object.entries( + rawProjectDraftThreadIdByProjectKey as Record, )) { - if ( - typeof projectId === "string" && - projectId.length > 0 && - typeof threadId === "string" && - threadId.length > 0 - ) { - projectDraftThreadIdByProjectId[projectId as ProjectId] = threadId as ThreadId; - if (!draftThreadsByThreadId[threadId as ThreadId]) { - draftThreadsByThreadId[threadId as ThreadId] = { - projectId: projectId as ProjectId, - createdAt: new Date().toISOString(), - runtimeMode: DEFAULT_RUNTIME_MODE, - interactionMode: DEFAULT_INTERACTION_MODE, - branch: null, - worktreePath: null, - envMode: "local", - }; - } else if (draftThreadsByThreadId[threadId as ThreadId]?.projectId !== projectId) { - draftThreadsByThreadId[threadId as ThreadId] = { - ...draftThreadsByThreadId[threadId as ThreadId]!, - projectId: projectId as ProjectId, + if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) { + continue; + } + const projectRef = parseScopedProjectKey(logicalProjectKey); + const parsedThreadRef = parseScopedThreadKey(threadKeyOrId); + const threadKey = normalizeLegacyComposerStorageKey(threadKeyOrId); + logicalProjectDraftThreadKeyByLogicalProjectKey[logicalProjectKey] = threadKey; + if (parsedThreadRef) { + environmentIdByThreadId.set(parsedThreadRef.threadId, parsedThreadRef.environmentId); + } + if (!projectRef) { + const existingDraftThread = draftThreadsByThreadKey[threadKey]; + if (existingDraftThread && !existingDraftThread.logicalProjectKey) { + draftThreadsByThreadKey[threadKey] = { + ...existingDraftThread, + logicalProjectKey, }; } + continue; + } + if (!draftThreadsByThreadKey[threadKey]) { + draftThreadsByThreadKey[threadKey] = { + threadId: parsedThreadRef?.threadId ?? (threadKey as ThreadId), + environmentId: projectRef.environmentId, + projectId: projectRef.projectId, + logicalProjectKey, + createdAt: new Date().toISOString(), + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + branch: null, + worktreePath: null, + envMode: "local", + promotedTo: null, + }; + } else if ( + draftThreadsByThreadKey[threadKey]?.projectId !== projectRef.projectId || + draftThreadsByThreadKey[threadKey]?.environmentId !== projectRef.environmentId + ) { + draftThreadsByThreadKey[threadKey] = { + ...draftThreadsByThreadKey[threadKey]!, + threadId: draftThreadsByThreadKey[threadKey]!.threadId, + environmentId: projectRef.environmentId, + projectId: projectRef.projectId, + logicalProjectKey, + }; } } } - return { draftThreadsByThreadId, projectDraftThreadIdByProjectId }; + return { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey }; } function normalizePersistedDraftsByThreadId( rawDraftMap: unknown, -): PersistedComposerDraftStoreState["draftsByThreadId"] { + draftThreadsByThreadKey: PersistedComposerDraftStoreState["draftThreadsByThreadKey"], +): PersistedComposerDraftStoreState["draftsByThreadKey"] { if (!rawDraftMap || typeof rawDraftMap !== "object") { return {}; } - const nextDraftsByThreadId: DeepMutable = + const environmentIdByThreadId = new Map(); + for (const [threadKey, draftThread] of Object.entries(draftThreadsByThreadKey)) { + const parsedThreadRef = composerThreadRefFromKey(threadKey); + if (!parsedThreadRef) { + continue; + } + environmentIdByThreadId.set( + parsedThreadRef.threadId, + draftThread.environmentId as EnvironmentId, + ); + } + + const nextDraftsByThreadKey: DeepMutable = {}; - for (const [threadId, draftValue] of Object.entries(rawDraftMap as Record)) { - if (typeof threadId !== "string" || threadId.length === 0) { + for (const [threadKeyOrId, draftValue] of Object.entries( + rawDraftMap as Record, + )) { + if (typeof threadKeyOrId !== "string" || threadKeyOrId.length === 0) { continue; } if (!draftValue || typeof draftValue !== "object") { @@ -937,7 +1373,19 @@ function normalizePersistedDraftsByThreadId( ) { continue; } - nextDraftsByThreadId[threadId as ThreadId] = { + const parsedThreadRef = parseScopedThreadKey(threadKeyOrId); + const normalizedThreadKey = + parsedThreadRef !== null + ? normalizeLegacyComposerStorageKey(threadKeyOrId) + : draftThreadsByThreadKey[threadKeyOrId] !== undefined + ? threadKeyOrId + : (() => { + const environmentId = environmentIdByThreadId.get(threadKeyOrId as ThreadId); + return environmentId + ? normalizeLegacyComposerStorageKey(threadKeyOrId, { environmentId }) + : threadKeyOrId; + })(); + nextDraftsByThreadKey[normalizedThreadKey] = { prompt, attachments, ...(terminalContexts.length > 0 ? { terminalContexts } : {}), @@ -947,7 +1395,7 @@ function normalizePersistedDraftsByThreadId( }; } - return nextDraftsByThreadId; + return nextDraftsByThreadKey; } function migratePersistedComposerDraftStoreState( @@ -957,9 +1405,14 @@ function migratePersistedComposerDraftStoreState( return EMPTY_PERSISTED_DRAFT_STORE_STATE; } const candidate = persistedState as LegacyPersistedComposerDraftStoreState; - const rawDraftMap = candidate.draftsByThreadId; - const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; - const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; + const rawDraftMap = candidate.draftsByThreadKey ?? candidate.draftsByThreadId; + const rawDraftThreadsByThreadId = + candidate.draftThreadsByThreadKey ?? candidate.draftThreadsByThreadId; + const rawProjectDraftThreadIdByProjectKey = + candidate.logicalProjectDraftThreadKeyByLogicalProjectKey ?? + candidate.projectDraftThreadKeyByProjectKey ?? + candidate.projectDraftThreadIdByProjectKey ?? + candidate.projectDraftThreadIdByProjectId; // Migrate sticky state from v2 (dual) to v3 (consolidated) const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {}; @@ -982,13 +1435,16 @@ function migratePersistedComposerDraftStoreState( ); const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null; - const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = - normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId); - const draftsByThreadId = normalizePersistedDraftsByThreadId(rawDraftMap); + const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } = + normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectKey); + const draftsByThreadKey = normalizePersistedDraftsByThreadId( + rawDraftMap, + draftThreadsByThreadKey, + ); return { - draftsByThreadId, - draftThreadsByThreadId, - projectDraftThreadIdByProjectId, + draftsByThreadKey, + draftThreadsByThreadKey, + logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider, stickyActiveProvider, }; @@ -997,11 +1453,11 @@ function migratePersistedComposerDraftStoreState( function partializeComposerDraftStoreState( state: ComposerDraftStoreState, ): PersistedComposerDraftStoreState { - const persistedDraftsByThreadId: DeepMutable< - PersistedComposerDraftStoreState["draftsByThreadId"] + const persistedDraftsByThreadKey: DeepMutable< + PersistedComposerDraftStoreState["draftsByThreadKey"] > = {}; - for (const [threadId, draft] of Object.entries(state.draftsByThreadId)) { - if (typeof threadId !== "string" || threadId.length === 0) { + for (const [threadKey, draft] of Object.entries(state.draftsByThreadKey)) { + if (typeof threadKey !== "string" || threadKey.length === 0) { continue; } const hasModelData = @@ -1041,12 +1497,13 @@ function partializeComposerDraftStoreState( ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}), }; - persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft; + persistedDraftsByThreadKey[threadKey] = persistedDraft; } return { - draftsByThreadId: persistedDraftsByThreadId, - draftThreadsByThreadId: state.draftThreadsByThreadId, - projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, + draftsByThreadKey: persistedDraftsByThreadKey, + draftThreadsByThreadKey: state.draftThreadsByThreadKey, + logicalProjectDraftThreadKeyByLogicalProjectKey: + state.logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider: state.stickyModelSelectionByProvider, stickyActiveProvider: state.stickyActiveProvider, }; @@ -1059,10 +1516,14 @@ function normalizeCurrentPersistedComposerDraftStoreState( return EMPTY_PERSISTED_DRAFT_STORE_STATE; } const normalizedPersistedState = persistedState as LegacyPersistedComposerDraftStoreState; - const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = + const { draftThreadsByThreadKey, logicalProjectDraftThreadKeyByLogicalProjectKey } = normalizePersistedDraftThreads( - normalizedPersistedState.draftThreadsByThreadId, - normalizedPersistedState.projectDraftThreadIdByProjectId, + normalizedPersistedState.draftThreadsByThreadKey ?? + normalizedPersistedState.draftThreadsByThreadId, + normalizedPersistedState.logicalProjectDraftThreadKeyByLogicalProjectKey ?? + normalizedPersistedState.projectDraftThreadKeyByProjectKey ?? + normalizedPersistedState.projectDraftThreadIdByProjectKey ?? + normalizedPersistedState.projectDraftThreadIdByProjectId, ); // Handle both v3 (modelSelectionByProvider) and v2/legacy formats @@ -1105,16 +1566,19 @@ function normalizeCurrentPersistedComposerDraftStoreState( } return { - draftsByThreadId: normalizePersistedDraftsByThreadId(normalizedPersistedState.draftsByThreadId), - draftThreadsByThreadId, - projectDraftThreadIdByProjectId, + draftsByThreadKey: normalizePersistedDraftsByThreadId( + normalizedPersistedState.draftsByThreadKey ?? normalizedPersistedState.draftsByThreadId, + draftThreadsByThreadKey, + ), + draftThreadsByThreadKey, + logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider, stickyActiveProvider, }; } -function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { - if (threadId.length === 0) { +function readPersistedAttachmentIdsFromStorage(threadKey: string): string[] { + if (threadKey.length === 0) { return []; } try { @@ -1125,7 +1589,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { if (!persisted || persisted.version !== COMPOSER_DRAFT_STORAGE_VERSION) { return []; } - return (persisted.state.draftsByThreadId[threadId]?.attachments ?? []).map( + return (persisted.state.draftsByThreadKey[threadKey]?.attachments ?? []).map( (attachment) => attachment.id, ); } catch { @@ -1134,7 +1598,7 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { } function verifyPersistedAttachments( - threadId: ThreadId, + threadKey: string, attachments: PersistedComposerImageAttachment[], set: ( partial: @@ -1149,12 +1613,12 @@ function verifyPersistedAttachments( let persistedIdSet = new Set(); try { composerDebouncedStorage.flush(); - persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadId)); + persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadKey)); } catch { persistedIdSet = new Set(); } set((state) => { - const current = state.draftsByThreadId[threadId]; + const current = state.draftsByThreadKey[threadKey]; if (!current) { return state; } @@ -1170,13 +1634,13 @@ function verifyPersistedAttachments( persistedAttachments, nonPersistedImageIds, }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; + delete nextDraftsByThreadKey[threadKey]; } else { - nextDraftsByThreadId[threadId] = nextDraft; + nextDraftsByThreadKey[threadKey] = nextDraft; } - return { draftsByThreadId: nextDraftsByThreadId }; + return { draftsByThreadKey: nextDraftsByThreadKey }; }); } @@ -1258,879 +1722,980 @@ function toHydratedThreadDraft( }; } -export const useComposerDraftStore = create()( +function toHydratedDraftThreadState( + persistedDraftThread: PersistedDraftThreadState, +): DraftThreadState { + return { + threadId: persistedDraftThread.threadId, + environmentId: persistedDraftThread.environmentId as EnvironmentId, + projectId: persistedDraftThread.projectId, + logicalProjectKey: + persistedDraftThread.logicalProjectKey ?? + projectDraftKey( + scopeProjectRef( + persistedDraftThread.environmentId as EnvironmentId, + persistedDraftThread.projectId, + ), + ), + createdAt: persistedDraftThread.createdAt, + runtimeMode: persistedDraftThread.runtimeMode, + interactionMode: persistedDraftThread.interactionMode, + branch: persistedDraftThread.branch, + worktreePath: persistedDraftThread.worktreePath, + envMode: persistedDraftThread.envMode, + promotedTo: persistedDraftThread.promotedTo + ? scopeThreadRef( + persistedDraftThread.promotedTo.environmentId as EnvironmentId, + persistedDraftThread.promotedTo.threadId as ThreadId, + ) + : null, + }; +} + +const composerDraftStore = create()( persist( - (set, get) => ({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - stickyModelSelectionByProvider: {}, - stickyActiveProvider: null, - getDraftThreadByProjectId: (projectId) => { - if (projectId.length === 0) { - return null; - } - const threadId = get().projectDraftThreadIdByProjectId[projectId]; - if (!threadId) { - return null; - } - const draftThread = get().draftThreadsByThreadId[threadId]; - if (!draftThread || draftThread.projectId !== projectId) { - return null; - } - return { - threadId, - ...draftThread, - }; - }, - getDraftThread: (threadId) => { - if (threadId.length === 0) { - return null; - } - return get().draftThreadsByThreadId[threadId] ?? null; - }, - setProjectDraftThreadId: (projectId, threadId, options) => { - if (projectId.length === 0 || threadId.length === 0) { - return; - } - set((state) => { - const existingThread = state.draftThreadsByThreadId[threadId]; - const previousThreadIdForProject = state.projectDraftThreadIdByProjectId[projectId]; - const nextWorktreePath = - options?.worktreePath === undefined - ? (existingThread?.worktreePath ?? null) - : (options.worktreePath ?? null); - const nextDraftThread: DraftThreadState = { - projectId, - createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), - runtimeMode: - options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, - interactionMode: - options?.interactionMode ?? - existingThread?.interactionMode ?? - DEFAULT_INTERACTION_MODE, - branch: - options?.branch === undefined - ? (existingThread?.branch ?? null) - : (options.branch ?? null), - worktreePath: nextWorktreePath, - envMode: - options?.envMode ?? - (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), - }; - const hasSameProjectMapping = previousThreadIdForProject === threadId; - const hasSameDraftThread = - existingThread && - existingThread.projectId === nextDraftThread.projectId && - existingThread.createdAt === nextDraftThread.createdAt && - existingThread.runtimeMode === nextDraftThread.runtimeMode && - existingThread.interactionMode === nextDraftThread.interactionMode && - existingThread.branch === nextDraftThread.branch && - existingThread.worktreePath === nextDraftThread.worktreePath && - existingThread.envMode === nextDraftThread.envMode; - if (hasSameProjectMapping && hasSameDraftThread) { - return state; - } - const nextProjectDraftThreadIdByProjectId: Record = { - ...state.projectDraftThreadIdByProjectId, - [projectId]: threadId, - }; - const nextDraftThreadsByThreadId: Record = { - ...state.draftThreadsByThreadId, - [threadId]: nextDraftThread, - }; - let nextDraftsByThreadId = state.draftsByThreadId; - if ( - previousThreadIdForProject && - previousThreadIdForProject !== threadId && - !Object.values(nextProjectDraftThreadIdByProjectId).includes(previousThreadIdForProject) - ) { - delete nextDraftThreadsByThreadId[previousThreadIdForProject]; - if (state.draftsByThreadId[previousThreadIdForProject] !== undefined) { - nextDraftsByThreadId = { ...state.draftsByThreadId }; - delete nextDraftsByThreadId[previousThreadIdForProject]; - } - } - return { - draftsByThreadId: nextDraftsByThreadId, - draftThreadsByThreadId: nextDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, - }; - }); - }, - setDraftThreadContext: (threadId, options) => { - if (threadId.length === 0) { - return; - } - set((state) => { - const existing = state.draftThreadsByThreadId[threadId]; - if (!existing) { - return state; + (setBase, get) => { + const set = setBase; + + return { + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, + getComposerDraft: (target) => getComposerDraftState(get(), target), + getDraftThreadByLogicalProjectKey: (logicalProjectKey) => { + return get().getDraftSessionByLogicalProjectKey(logicalProjectKey); + }, + getDraftSessionByLogicalProjectKey: (logicalProjectKey) => { + const normalizedLogicalProjectKey = logicalProjectDraftKey(logicalProjectKey); + if (normalizedLogicalProjectKey.length === 0) { + return null; } - const nextProjectId = options.projectId ?? existing.projectId; - if (nextProjectId.length === 0) { - return state; + const draftId = + get().logicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey]; + if (!draftId) { + return null; } - const nextWorktreePath = - options.worktreePath === undefined - ? existing.worktreePath - : (options.worktreePath ?? null); - const nextDraftThread: DraftThreadState = { - projectId: nextProjectId, - createdAt: - options.createdAt === undefined - ? existing.createdAt - : options.createdAt || existing.createdAt, - runtimeMode: options.runtimeMode ?? existing.runtimeMode, - interactionMode: options.interactionMode ?? existing.interactionMode, - branch: options.branch === undefined ? existing.branch : (options.branch ?? null), - worktreePath: nextWorktreePath, - envMode: - options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), - }; - const isUnchanged = - nextDraftThread.projectId === existing.projectId && - nextDraftThread.createdAt === existing.createdAt && - nextDraftThread.runtimeMode === existing.runtimeMode && - nextDraftThread.interactionMode === existing.interactionMode && - nextDraftThread.branch === existing.branch && - nextDraftThread.worktreePath === existing.worktreePath && - nextDraftThread.envMode === existing.envMode; - if (isUnchanged) { - return state; + const draftThread = get().draftThreadsByThreadKey[draftId]; + if (!draftThread || isDraftThreadPromoting(draftThread)) { + return null; } - const nextProjectDraftThreadIdByProjectId: Record = { - ...state.projectDraftThreadIdByProjectId, - [nextProjectId]: threadId, - }; - if (existing.projectId !== nextProjectId) { - if (nextProjectDraftThreadIdByProjectId[existing.projectId] === threadId) { - delete nextProjectDraftThreadIdByProjectId[existing.projectId]; + return toProjectDraftSession(DraftId.makeUnsafe(draftId), draftThread); + }, + getDraftThreadByProjectRef: (projectRef) => { + return get().getDraftSessionByProjectRef(projectRef); + }, + getDraftSessionByProjectRef: (projectRef) => { + for (const [draftId, draftThread] of Object.entries(get().draftThreadsByThreadKey)) { + if (isDraftThreadPromoting(draftThread)) { + continue; } - } - return { - draftThreadsByThreadId: { - ...state.draftThreadsByThreadId, - [threadId]: nextDraftThread, - }, - projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, - }; - }); - }, - clearProjectDraftThreadId: (projectId) => { - if (projectId.length === 0) { - return; - } - set((state) => { - const threadId = state.projectDraftThreadIdByProjectId[projectId]; - if (threadId === undefined) { - return state; - } - const { [projectId]: _removed, ...restProjectMappingsRaw } = - state.projectDraftThreadIdByProjectId; - const restProjectMappings = restProjectMappingsRaw as Record; - const nextDraftThreadsByThreadId: Record = { - ...state.draftThreadsByThreadId, - }; - let nextDraftsByThreadId = state.draftsByThreadId; - if (!Object.values(restProjectMappings).includes(threadId)) { - delete nextDraftThreadsByThreadId[threadId]; - if (state.draftsByThreadId[threadId] !== undefined) { - nextDraftsByThreadId = { ...state.draftsByThreadId }; - delete nextDraftsByThreadId[threadId]; + if ( + draftThread.projectId === projectRef.projectId && + draftThread.environmentId === projectRef.environmentId + ) { + return toProjectDraftSession(DraftId.makeUnsafe(draftId), draftThread); } } - return { - draftsByThreadId: nextDraftsByThreadId, - draftThreadsByThreadId: nextDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: restProjectMappings, - }; - }); - }, - clearProjectDraftThreadById: (projectId, threadId) => { - if (projectId.length === 0 || threadId.length === 0) { - return; - } - set((state) => { - if (state.projectDraftThreadIdByProjectId[projectId] !== threadId) { - return state; - } - const { [projectId]: _removed, ...restProjectMappingsRaw } = - state.projectDraftThreadIdByProjectId; - const restProjectMappings = restProjectMappingsRaw as Record; - const nextDraftThreadsByThreadId: Record = { - ...state.draftThreadsByThreadId, - }; - let nextDraftsByThreadId = state.draftsByThreadId; - if (!Object.values(restProjectMappings).includes(threadId)) { - delete nextDraftThreadsByThreadId[threadId]; - if (state.draftsByThreadId[threadId] !== undefined) { - nextDraftsByThreadId = { ...state.draftsByThreadId }; - delete nextDraftsByThreadId[threadId]; + return null; + }, + getDraftSession: (draftId) => get().draftThreadsByThreadKey[draftId] ?? null, + getDraftSessionByRef: (threadRef) => { + for (const draftSession of Object.values(get().draftThreadsByThreadKey)) { + if ( + draftSession.environmentId === threadRef.environmentId && + draftSession.threadId === threadRef.threadId + ) { + return draftSession; } } - return { - draftsByThreadId: nextDraftsByThreadId, - draftThreadsByThreadId: nextDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: restProjectMappings, - }; - }); - }, - clearDraftThread: (threadId) => { - if (threadId.length === 0) { - return; - } - const existing = get().draftsByThreadId[threadId]; - if (existing) { - for (const image of existing.images) { - revokeObjectPreviewUrl(image.previewUrl); - } - } - set((state) => { - const hasDraftThread = state.draftThreadsByThreadId[threadId] !== undefined; - const hasProjectMapping = Object.values(state.projectDraftThreadIdByProjectId).includes( - threadId, - ); - const hasComposerDraft = state.draftsByThreadId[threadId] !== undefined; - if (!hasDraftThread && !hasProjectMapping && !hasComposerDraft) { - return state; - } - const nextProjectDraftThreadIdByProjectId = Object.fromEntries( - Object.entries(state.projectDraftThreadIdByProjectId).filter( - ([, draftThreadId]) => draftThreadId !== threadId, - ), - ) as Record; - const { [threadId]: _removedDraftThread, ...restDraftThreadsByThreadId } = - state.draftThreadsByThreadId; - const { [threadId]: _removedComposerDraft, ...restDraftsByThreadId } = - state.draftsByThreadId; - return { - draftsByThreadId: restDraftsByThreadId, - draftThreadsByThreadId: restDraftThreadsByThreadId, - projectDraftThreadIdByProjectId: nextProjectDraftThreadIdByProjectId, - }; - }); - }, - setStickyModelSelection: (modelSelection) => { - const normalized = normalizeModelSelection(modelSelection); - set((state) => { - if (!normalized) { - return state; + return null; + }, + getDraftThread: (threadRef) => { + if (typeof threadRef === "string") { + return get().getDraftSession(DraftId.makeUnsafe(threadRef)); } - const nextMap: Partial> = { - ...state.stickyModelSelectionByProvider, - [normalized.provider]: normalized, - }; - if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { - return state.stickyActiveProvider === normalized.provider - ? state - : { stickyActiveProvider: normalized.provider }; + return get().getDraftSessionByRef(threadRef); + }, + getDraftThreadByRef: (threadRef) => { + return get().getDraftSessionByRef(threadRef); + }, + listDraftThreadKeys: () => + Object.values(get().draftThreadsByThreadKey).map((draftThread) => + scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), + ), + hasDraftThreadsInEnvironment: (environmentId) => + Object.values(get().draftThreadsByThreadKey).some( + (draftThread) => draftThread.environmentId === environmentId, + ), + setLogicalProjectDraftThreadId: (logicalProjectKey, projectRef, draftId, options) => { + const normalizedLogicalProjectKey = logicalProjectDraftKey(logicalProjectKey); + if (normalizedLogicalProjectKey.length === 0 || draftId.length === 0) { + return; } - return { - stickyModelSelectionByProvider: nextMap, - stickyActiveProvider: normalized.provider, - }; - }); - }, - applyStickyState: (threadId) => { - if (threadId.length === 0) { - return; - } - set((state) => { - const stickyMap = state.stickyModelSelectionByProvider; - const stickyActiveProvider = state.stickyActiveProvider; - if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) { - return state; + set((state) => { + const existingThread = state.draftThreadsByThreadKey[draftId]; + const previousThreadKeyForLogicalProject = + state.logicalProjectDraftThreadKeyByLogicalProjectKey[normalizedLogicalProjectKey]; + const nextDraftThread = createDraftThreadState( + projectRef, + options?.threadId ?? existingThread?.threadId ?? ThreadId.makeUnsafe(draftId), + normalizedLogicalProjectKey, + existingThread, + options, + ); + const hasSameLogicalMapping = previousThreadKeyForLogicalProject === draftId; + if (hasSameLogicalMapping && draftThreadsEqual(existingThread, nextDraftThread)) { + return state; + } + const nextLogicalProjectDraftThreadKeyByLogicalProjectKey: Record = { + ...state.logicalProjectDraftThreadKeyByLogicalProjectKey, + [normalizedLogicalProjectKey]: draftId, + }; + const nextDraftThreadsByThreadKey: Record = { + ...state.draftThreadsByThreadKey, + [draftId]: nextDraftThread, + }; + let nextDraftsByThreadKey = state.draftsByThreadKey; + if ( + previousThreadKeyForLogicalProject && + previousThreadKeyForLogicalProject !== draftId && + !isComposerThreadKeyInUse( + nextLogicalProjectDraftThreadKeyByLogicalProjectKey, + previousThreadKeyForLogicalProject, + ) + ) { + delete nextDraftThreadsByThreadKey[previousThreadKeyForLogicalProject]; + if (state.draftsByThreadKey[previousThreadKeyForLogicalProject] !== undefined) { + nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + delete nextDraftsByThreadKey[previousThreadKeyForLogicalProject]; + } + } + return { + draftsByThreadKey: nextDraftsByThreadKey, + draftThreadsByThreadKey: nextDraftThreadsByThreadKey, + logicalProjectDraftThreadKeyByLogicalProjectKey: + nextLogicalProjectDraftThreadKeyByLogicalProjectKey, + }; + }); + }, + setProjectDraftThreadId: (projectRef, draftId, options) => { + get().setLogicalProjectDraftThreadId( + projectDraftKey(projectRef), + projectRef, + draftId, + options, + ); + }, + setDraftThreadContext: (threadRef, options) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - const existing = state.draftsByThreadId[threadId]; - const base = existing ?? createEmptyThreadDraft(); - const nextMap = { ...base.modelSelectionByProvider }; - for (const [provider, selection] of Object.entries(stickyMap)) { - if (selection) { - const current = nextMap[provider as ProviderKind]; - nextMap[provider as ProviderKind] = { - ...selection, - model: current?.model ?? selection.model, - }; + set((state) => { + const existing = state.draftThreadsByThreadKey[threadKey]; + if (!existing) { + return state; } + const nextProjectRef = options.projectRef ?? { + environmentId: existing.environmentId, + projectId: existing.projectId, + }; + if ( + nextProjectRef.projectId.length === 0 || + nextProjectRef.environmentId.length === 0 + ) { + return state; + } + const nextWorktreePath = + options.worktreePath === undefined + ? existing.worktreePath + : (options.worktreePath ?? null); + const nextDraftThread: DraftThreadState = { + threadId: existing.threadId, + environmentId: nextProjectRef.environmentId, + projectId: nextProjectRef.projectId, + logicalProjectKey: existing.logicalProjectKey, + createdAt: + options.createdAt === undefined + ? existing.createdAt + : options.createdAt || existing.createdAt, + runtimeMode: options.runtimeMode ?? existing.runtimeMode, + interactionMode: options.interactionMode ?? existing.interactionMode, + branch: options.branch === undefined ? existing.branch : (options.branch ?? null), + worktreePath: nextWorktreePath, + envMode: + options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), + promotedTo: existing.promotedTo ?? null, + }; + const isUnchanged = + nextDraftThread.environmentId === existing.environmentId && + nextDraftThread.projectId === existing.projectId && + nextDraftThread.logicalProjectKey === existing.logicalProjectKey && + nextDraftThread.createdAt === existing.createdAt && + nextDraftThread.runtimeMode === existing.runtimeMode && + nextDraftThread.interactionMode === existing.interactionMode && + nextDraftThread.branch === existing.branch && + nextDraftThread.worktreePath === existing.worktreePath && + nextDraftThread.envMode === existing.envMode && + scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); + if (isUnchanged) { + return state; + } + return { + draftThreadsByThreadKey: { + ...state.draftThreadsByThreadKey, + [threadKey]: nextDraftThread, + }, + }; + }); + }, + clearProjectDraftThreadId: (projectRef) => { + set((state) => { + const matchingThreadEntry = Object.entries(state.draftThreadsByThreadKey).find( + ([, draftThread]) => + draftThread.projectId === projectRef.projectId && + draftThread.environmentId === projectRef.environmentId, + ); + if (!matchingThreadEntry) { + return state; + } + return removeDraftThreadReferences(state, matchingThreadEntry[0]); + }); + }, + clearProjectDraftThreadById: (projectRef, threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - if ( - Equal.equals(base.modelSelectionByProvider, nextMap) && - base.activeProvider === stickyActiveProvider - ) { - return state; + set((state) => { + const draftThread = state.draftThreadsByThreadKey[threadKey]; + if ( + !draftThread || + draftThread.projectId !== projectRef.projectId || + draftThread.environmentId !== projectRef.environmentId + ) { + return state; + } + return removeDraftThreadReferences(state, threadKey); + }); + }, + markDraftThreadPromoting: (threadRef, promotedTo) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey) { + return; } - const nextDraft: ComposerThreadDraftState = { - ...base, - modelSelectionByProvider: nextMap, - activeProvider: stickyActiveProvider, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + set((state) => { + const existing = state.draftThreadsByThreadKey[threadKey]; + if (!existing) { + return state; + } + const nextPromotedTo = + promotedTo ?? scopeThreadRef(existing.environmentId, existing.threadId); + if (scopedThreadRefsEqual(existing.promotedTo, nextPromotedTo)) { + return state; + } + return { + draftThreadsByThreadKey: { + ...state.draftThreadsByThreadKey, + [threadKey]: { + ...existing, + promotedTo: nextPromotedTo, + }, + }, + }; + }); + }, + finalizePromotedDraftThread: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - setPrompt: (threadId, prompt) => { - if (threadId.length === 0) { - return; - } - set((state) => { - const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); - const nextDraft: ComposerThreadDraftState = { - ...existing, - prompt, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + set((state) => { + const existing = state.draftThreadsByThreadKey[threadKey]; + if (!isDraftThreadPromoting(existing)) { + return state; + } + return removeDraftThreadReferences(state, threadKey); + }); + }, + clearDraftThread: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - setTerminalContexts: (threadId, contexts) => { - if (threadId.length === 0) { - return; - } - const normalizedContexts = normalizeTerminalContextsForThread(threadId, contexts); - set((state) => { - const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); - const nextDraft: ComposerThreadDraftState = { - ...existing, - prompt: ensureInlineTerminalContextPlaceholders( - existing.prompt, - normalizedContexts.length, - ), - terminalContexts: normalizedContexts, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + const existing = get().draftsByThreadKey[threadKey]; + if (existing) { + for (const image of existing.images) { + revokeObjectPreviewUrl(image.previewUrl); + } } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - setModelSelection: (threadId, modelSelection) => { - if (threadId.length === 0) { - return; - } - const normalized = normalizeModelSelection(modelSelection); - set((state) => { - const existing = state.draftsByThreadId[threadId]; - if (!existing && normalized === null) { - return state; + set((state) => { + const hasDraftThread = state.draftThreadsByThreadKey[threadKey] !== undefined; + const hasLogicalProjectMapping = Object.values( + state.logicalProjectDraftThreadKeyByLogicalProjectKey, + ).includes(threadKey); + const hasComposerDraft = state.draftsByThreadKey[threadKey] !== undefined; + if (!hasDraftThread && !hasLogicalProjectMapping && !hasComposerDraft) { + return state; + } + return removeDraftThreadReferences(state, threadKey); + }); + }, + setStickyModelSelection: (modelSelection) => { + const normalized = normalizeModelSelection(modelSelection); + set((state) => { + if (!normalized) { + return state; + } + const nextMap: Partial> = { + ...state.stickyModelSelectionByProvider, + [normalized.provider]: normalized, + }; + if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { + return state.stickyActiveProvider === normalized.provider + ? state + : { stickyActiveProvider: normalized.provider }; + } + return { + stickyModelSelectionByProvider: nextMap, + stickyActiveProvider: normalized.provider, + }; + }); + }, + applyStickyState: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - const base = existing ?? createEmptyThreadDraft(); - const nextMap = { ...base.modelSelectionByProvider }; - if (normalized) { - const current = nextMap[normalized.provider]; - if (normalized.options !== undefined) { - // Explicit options provided → use them - nextMap[normalized.provider] = normalized; + set((state) => { + const stickyMap = state.stickyModelSelectionByProvider; + const stickyActiveProvider = state.stickyActiveProvider; + if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) { + return state; + } + const existing = state.draftsByThreadKey[threadKey]; + const base = existing ?? createEmptyThreadDraft(); + const nextMap = { ...base.modelSelectionByProvider }; + for (const [provider, selection] of Object.entries(stickyMap)) { + if (selection) { + const current = nextMap[provider as ProviderKind]; + nextMap[provider as ProviderKind] = { + ...selection, + model: current?.model ?? selection.model, + }; + } + } + if ( + Equal.equals(base.modelSelectionByProvider, nextMap) && + base.activeProvider === stickyActiveProvider + ) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...base, + modelSelectionByProvider: nextMap, + activeProvider: stickyActiveProvider, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; } else { - // No options in selection → preserve existing options, update provider+model - nextMap[normalized.provider] = { - provider: normalized.provider, - model: normalized.model, - ...(current?.options ? { options: current.options } : {}), - }; + nextDraftsByThreadKey[threadKey] = nextDraft; } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + setPrompt: (threadRef, prompt) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - const nextActiveProvider = normalized?.provider ?? base.activeProvider; - if ( - Equal.equals(base.modelSelectionByProvider, nextMap) && - base.activeProvider === nextActiveProvider - ) { - return state; - } - const nextDraft: ComposerThreadDraftState = { - ...base, - modelSelectionByProvider: nextMap, - activeProvider: nextActiveProvider, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; - } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - setModelOptions: (threadId, modelOptions) => { - if (threadId.length === 0) { - return; - } - const normalizedOpts = normalizeProviderModelOptions(modelOptions); - set((state) => { - const existing = state.draftsByThreadId[threadId]; - if (!existing && normalizedOpts === null) { - return state; - } - const base = existing ?? createEmptyThreadDraft(); - const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of ["codex", "claudeAgent"] as const) { - // Only touch providers explicitly present in the input - if (!normalizedOpts || !(provider in normalizedOpts)) continue; - const opts = normalizedOpts[provider]; - const current = nextMap[provider]; - if (opts) { - nextMap[provider] = { - provider, - model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], - options: opts, - }; - } else if (current?.options) { - // Remove options but keep the selection - const { options: _, ...rest } = current; - nextMap[provider] = rest as ModelSelection; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const nextDraft: ComposerThreadDraftState = { + ...existing, + prompt, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + setTerminalContexts: (threadRef, contexts) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + const threadId = resolveComposerThreadId(get(), threadRef); + if (!threadKey || !threadId) { + return; } - if (Equal.equals(base.modelSelectionByProvider, nextMap)) { - return state; + const normalizedContexts = normalizeTerminalContextsForThread(threadId, contexts); + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const nextDraft: ComposerThreadDraftState = { + ...existing, + prompt: ensureInlineTerminalContextPlaceholders( + existing.prompt, + normalizedContexts.length, + ), + terminalContexts: normalizedContexts, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + setModelSelection: (threadRef, modelSelection) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - const nextDraft: ComposerThreadDraftState = { - ...base, - modelSelectionByProvider: nextMap, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + const normalized = normalizeModelSelection(modelSelection); + set((state) => { + const existing = state.draftsByThreadKey[threadKey]; + if (!existing && normalized === null) { + return state; + } + const base = existing ?? createEmptyThreadDraft(); + const nextMap = { ...base.modelSelectionByProvider }; + if (normalized) { + const current = nextMap[normalized.provider]; + if (normalized.options !== undefined) { + // Explicit options provided → use them + nextMap[normalized.provider] = normalized; + } else { + // No options in selection → preserve existing options, update provider+model + nextMap[normalized.provider] = { + provider: normalized.provider, + model: normalized.model, + ...(current?.options ? { options: current.options } : {}), + }; + } + } + const nextActiveProvider = normalized?.provider ?? base.activeProvider; + if ( + Equal.equals(base.modelSelectionByProvider, nextMap) && + base.activeProvider === nextActiveProvider + ) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...base, + modelSelectionByProvider: nextMap, + activeProvider: nextActiveProvider, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + setModelOptions: (threadRef, modelOptions) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - setProviderModelOptions: (threadId, provider, nextProviderOptions, options) => { - if (threadId.length === 0) { - return; - } - const normalizedProvider = normalizeProviderKind(provider); - if (normalizedProvider === null) { - return; - } - // Normalize just this provider's options - const normalizedOpts = normalizeProviderModelOptions( - { [normalizedProvider]: nextProviderOptions }, - normalizedProvider, - ); - const providerOpts = normalizedOpts?.[normalizedProvider]; - - set((state) => { - const existing = state.draftsByThreadId[threadId]; - const base = existing ?? createEmptyThreadDraft(); - - // Update the map entry for this provider - const nextMap = { ...base.modelSelectionByProvider }; - const currentForProvider = nextMap[normalizedProvider]; - if (providerOpts) { - nextMap[normalizedProvider] = { - provider: normalizedProvider, - model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], - options: providerOpts, + const normalizedOpts = normalizeProviderModelOptions(modelOptions); + set((state) => { + const existing = state.draftsByThreadKey[threadKey]; + if (!existing && normalizedOpts === null) { + return state; + } + const base = existing ?? createEmptyThreadDraft(); + const nextMap = { ...base.modelSelectionByProvider }; + for (const provider of ["codex", "claudeAgent"] as const) { + // Only touch providers explicitly present in the input + if (!normalizedOpts || !(provider in normalizedOpts)) continue; + const opts = normalizedOpts[provider]; + const current = nextMap[provider]; + if (opts) { + nextMap[provider] = { + provider, + model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], + options: opts, + }; + } else if (current?.options) { + // Remove options but keep the selection + const { options: _, ...rest } = current; + nextMap[provider] = rest as ModelSelection; + } + } + if (Equal.equals(base.modelSelectionByProvider, nextMap)) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...base, + modelSelectionByProvider: nextMap, }; - } else if (currentForProvider?.options) { - const { options: _, ...rest } = currentForProvider; - nextMap[normalizedProvider] = rest as ModelSelection; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + setProviderModelOptions: (threadRef, provider, nextProviderOptions, options) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } + const normalizedProvider = normalizeProviderKind(provider); + if (normalizedProvider === null) { + return; + } + // Normalize just this provider's options + const normalizedOpts = normalizeProviderModelOptions( + { [normalizedProvider]: nextProviderOptions }, + normalizedProvider, + ); + const providerOpts = normalizedOpts?.[normalizedProvider]; - // Handle sticky persistence - let nextStickyMap = state.stickyModelSelectionByProvider; - let nextStickyActiveProvider = state.stickyActiveProvider; - if (options?.persistSticky === true) { - nextStickyMap = { ...state.stickyModelSelectionByProvider }; - const stickyBase = - nextStickyMap[normalizedProvider] ?? - base.modelSelectionByProvider[normalizedProvider] ?? - ({ - provider: normalizedProvider, - model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], - } as ModelSelection); + set((state) => { + const existing = state.draftsByThreadKey[threadKey]; + const base = existing ?? createEmptyThreadDraft(); + + // Update the map entry for this provider + const nextMap = { ...base.modelSelectionByProvider }; + const currentForProvider = nextMap[normalizedProvider]; if (providerOpts) { - nextStickyMap[normalizedProvider] = { - ...stickyBase, + nextMap[normalizedProvider] = { provider: normalizedProvider, + model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], options: providerOpts, }; - } else if (stickyBase.options) { - const { options: _, ...rest } = stickyBase; - nextStickyMap[normalizedProvider] = rest as ModelSelection; + } else if (currentForProvider?.options) { + const { options: _, ...rest } = currentForProvider; + nextMap[normalizedProvider] = rest as ModelSelection; } - nextStickyActiveProvider = base.activeProvider ?? normalizedProvider; - } - if ( - Equal.equals(base.modelSelectionByProvider, nextMap) && - Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) && - state.stickyActiveProvider === nextStickyActiveProvider - ) { - return state; - } + // Handle sticky persistence + let nextStickyMap = state.stickyModelSelectionByProvider; + let nextStickyActiveProvider = state.stickyActiveProvider; + if (options?.persistSticky === true) { + nextStickyMap = { ...state.stickyModelSelectionByProvider }; + const stickyBase = + nextStickyMap[normalizedProvider] ?? + base.modelSelectionByProvider[normalizedProvider] ?? + ({ + provider: normalizedProvider, + model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], + } as ModelSelection); + if (providerOpts) { + nextStickyMap[normalizedProvider] = { + ...stickyBase, + provider: normalizedProvider, + options: providerOpts, + }; + } else if (stickyBase.options) { + const { options: _, ...rest } = stickyBase; + nextStickyMap[normalizedProvider] = rest as ModelSelection; + } + nextStickyActiveProvider = base.activeProvider ?? normalizedProvider; + } - const nextDraft: ComposerThreadDraftState = { - ...base, - modelSelectionByProvider: nextMap, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; - } + if ( + Equal.equals(base.modelSelectionByProvider, nextMap) && + Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) && + state.stickyActiveProvider === nextStickyActiveProvider + ) { + return state; + } - return { - draftsByThreadId: nextDraftsByThreadId, - ...(options?.persistSticky === true - ? { - stickyModelSelectionByProvider: nextStickyMap, - stickyActiveProvider: nextStickyActiveProvider, - } - : {}), - }; - }); - }, - setRuntimeMode: (threadId, runtimeMode) => { - if (threadId.length === 0) { - return; - } - const nextRuntimeMode = - runtimeMode === "approval-required" || runtimeMode === "full-access" ? runtimeMode : null; - set((state) => { - const existing = state.draftsByThreadId[threadId]; - if (!existing && nextRuntimeMode === null) { - return state; - } - const base = existing ?? createEmptyThreadDraft(); - if (base.runtimeMode === nextRuntimeMode) { - return state; - } - const nextDraft: ComposerThreadDraftState = { - ...base, - runtimeMode: nextRuntimeMode, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; - } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - setInteractionMode: (threadId, interactionMode) => { - if (threadId.length === 0) { - return; - } - const nextInteractionMode = - interactionMode === "plan" || interactionMode === "default" ? interactionMode : null; - set((state) => { - const existing = state.draftsByThreadId[threadId]; - if (!existing && nextInteractionMode === null) { - return state; + const nextDraft: ComposerThreadDraftState = { + ...base, + modelSelectionByProvider: nextMap, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + + return { + draftsByThreadKey: nextDraftsByThreadKey, + ...(options?.persistSticky === true + ? { + stickyModelSelectionByProvider: nextStickyMap, + stickyActiveProvider: nextStickyActiveProvider, + } + : {}), + }; + }); + }, + setRuntimeMode: (threadRef, runtimeMode) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - const base = existing ?? createEmptyThreadDraft(); - if (base.interactionMode === nextInteractionMode) { - return state; + const nextRuntimeMode = + runtimeMode === "approval-required" || runtimeMode === "full-access" + ? runtimeMode + : null; + set((state) => { + const existing = state.draftsByThreadKey[threadKey]; + if (!existing && nextRuntimeMode === null) { + return state; + } + const base = existing ?? createEmptyThreadDraft(); + if (base.runtimeMode === nextRuntimeMode) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...base, + runtimeMode: nextRuntimeMode, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + setInteractionMode: (threadRef, interactionMode) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - const nextDraft: ComposerThreadDraftState = { - ...base, - interactionMode: nextInteractionMode, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + const nextInteractionMode = + interactionMode === "plan" || interactionMode === "default" ? interactionMode : null; + set((state) => { + const existing = state.draftsByThreadKey[threadKey]; + if (!existing && nextInteractionMode === null) { + return state; + } + const base = existing ?? createEmptyThreadDraft(); + if (base.interactionMode === nextInteractionMode) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...base, + interactionMode: nextInteractionMode, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + addImage: (threadRef, image) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + const threadId = resolveComposerThreadId(get(), threadRef); + if (!threadKey || !threadId) { + return; } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - addImage: (threadId, image) => { - if (threadId.length === 0) { - return; - } - get().addImages(threadId, [image]); - }, - addImages: (threadId, images) => { - if (threadId.length === 0 || images.length === 0) { - return; - } - set((state) => { - const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); - const existingIds = new Set(existing.images.map((image) => image.id)); - const existingDedupKeys = new Set( - existing.images.map((image) => composerImageDedupKey(image)), + get().addImages( + typeof threadRef === "string" ? DraftId.makeUnsafe(threadKey) : threadRef, + [image], ); - const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl)); - const dedupedIncoming: ComposerImageAttachment[] = []; - for (const image of images) { - const dedupKey = composerImageDedupKey(image); - if (existingIds.has(image.id) || existingDedupKeys.has(dedupKey)) { - // Avoid revoking a blob URL that's still referenced by an accepted image. - if (!acceptedPreviewUrls.has(image.previewUrl)) { - revokeObjectPreviewUrl(image.previewUrl); + }, + addImages: (threadRef, images) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0 || images.length === 0) { + return; + } + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const existingIds = new Set(existing.images.map((image) => image.id)); + const existingDedupKeys = new Set( + existing.images.map((image) => composerImageDedupKey(image)), + ); + const acceptedPreviewUrls = new Set(existing.images.map((image) => image.previewUrl)); + const dedupedIncoming: ComposerImageAttachment[] = []; + for (const image of images) { + const dedupKey = composerImageDedupKey(image); + if (existingIds.has(image.id) || existingDedupKeys.has(dedupKey)) { + // Avoid revoking a blob URL that's still referenced by an accepted image. + if (!acceptedPreviewUrls.has(image.previewUrl)) { + revokeObjectPreviewUrl(image.previewUrl); + } + continue; } - continue; + dedupedIncoming.push(image); + existingIds.add(image.id); + existingDedupKeys.add(dedupKey); + acceptedPreviewUrls.add(image.previewUrl); } - dedupedIncoming.push(image); - existingIds.add(image.id); - existingDedupKeys.add(dedupKey); - acceptedPreviewUrls.add(image.previewUrl); - } - if (dedupedIncoming.length === 0) { - return state; - } - return { - draftsByThreadId: { - ...state.draftsByThreadId, - [threadId]: { - ...existing, - images: [...existing.images, ...dedupedIncoming], + if (dedupedIncoming.length === 0) { + return state; + } + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...existing, + images: [...existing.images, ...dedupedIncoming], + }, }, - }, - }; - }); - }, - removeImage: (threadId, imageId) => { - if (threadId.length === 0) { - return; - } - const existing = get().draftsByThreadId[threadId]; - if (!existing) { - return; - } - const removedImage = existing.images.find((image) => image.id === imageId); - if (removedImage) { - revokeObjectPreviewUrl(removedImage.previewUrl); - } - set((state) => { - const current = state.draftsByThreadId[threadId]; - if (!current) { - return state; - } - const nextDraft: ComposerThreadDraftState = { - ...current, - images: current.images.filter((image) => image.id !== imageId), - nonPersistedImageIds: current.nonPersistedImageIds.filter((id) => id !== imageId), - persistedAttachments: current.persistedAttachments.filter( - (attachment) => attachment.id !== imageId, - ), - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + }; + }); + }, + removeImage: (threadRef, imageId) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - insertTerminalContext: (threadId, prompt, context, index) => { - if (threadId.length === 0) { - return false; - } - let inserted = false; - set((state) => { - const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); - const normalizedContext = normalizeTerminalContextForThread(threadId, context); - if (!normalizedContext) { - return state; + const existing = get().draftsByThreadKey[threadKey]; + if (!existing) { + return; } - const dedupKey = terminalContextDedupKey(normalizedContext); - if ( - existing.terminalContexts.some((entry) => entry.id === normalizedContext.id) || - existing.terminalContexts.some((entry) => terminalContextDedupKey(entry) === dedupKey) - ) { - return state; + const removedImage = existing.images.find((image) => image.id === imageId); + if (removedImage) { + revokeObjectPreviewUrl(removedImage.previewUrl); } - inserted = true; - const boundedIndex = Math.max(0, Math.min(existing.terminalContexts.length, index)); - const nextDraft: ComposerThreadDraftState = { - ...existing, - prompt, - terminalContexts: [ - ...existing.terminalContexts.slice(0, boundedIndex), - normalizedContext, - ...existing.terminalContexts.slice(boundedIndex), - ], - }; - return { - draftsByThreadId: { - ...state.draftsByThreadId, - [threadId]: nextDraft, - }, - }; - }); - return inserted; - }, - addTerminalContext: (threadId, context) => { - if (threadId.length === 0) { - return; - } - get().addTerminalContexts(threadId, [context]); - }, - addTerminalContexts: (threadId, contexts) => { - if (threadId.length === 0 || contexts.length === 0) { - return; - } - set((state) => { - const existing = state.draftsByThreadId[threadId] ?? createEmptyThreadDraft(); - const acceptedContexts = normalizeTerminalContextsForThread(threadId, [ - ...existing.terminalContexts, - ...contexts, - ]).slice(existing.terminalContexts.length); - if (acceptedContexts.length === 0) { - return state; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...current, + images: current.images.filter((image) => image.id !== imageId), + nonPersistedImageIds: current.nonPersistedImageIds.filter((id) => id !== imageId), + persistedAttachments: current.persistedAttachments.filter( + (attachment) => attachment.id !== imageId, + ), + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + insertTerminalContext: (threadRef, prompt, context, index) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + const threadId = resolveComposerThreadId(get(), threadRef); + if (!threadKey || !threadId) { + return false; } - return { - draftsByThreadId: { - ...state.draftsByThreadId, - [threadId]: { - ...existing, - prompt: ensureInlineTerminalContextPlaceholders( - existing.prompt, - existing.terminalContexts.length + acceptedContexts.length, - ), - terminalContexts: [...existing.terminalContexts, ...acceptedContexts], + let inserted = false; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const normalizedContext = normalizeTerminalContextForThread(threadId, context); + if (!normalizedContext) { + return state; + } + const dedupKey = terminalContextDedupKey(normalizedContext); + if ( + existing.terminalContexts.some((entry) => entry.id === normalizedContext.id) || + existing.terminalContexts.some((entry) => terminalContextDedupKey(entry) === dedupKey) + ) { + return state; + } + inserted = true; + const boundedIndex = Math.max(0, Math.min(existing.terminalContexts.length, index)); + const nextDraft: ComposerThreadDraftState = { + ...existing, + prompt, + terminalContexts: [ + ...existing.terminalContexts.slice(0, boundedIndex), + normalizedContext, + ...existing.terminalContexts.slice(boundedIndex), + ], + }; + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: nextDraft, }, - }, - }; - }); - }, - removeTerminalContext: (threadId, contextId) => { - if (threadId.length === 0 || contextId.length === 0) { - return; - } - set((state) => { - const current = state.draftsByThreadId[threadId]; - if (!current) { - return state; - } - const nextDraft: ComposerThreadDraftState = { - ...current, - terminalContexts: current.terminalContexts.filter( - (context) => context.id !== contextId, - ), - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; - } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - clearTerminalContexts: (threadId) => { - if (threadId.length === 0) { - return; - } - set((state) => { - const current = state.draftsByThreadId[threadId]; - if (!current || current.terminalContexts.length === 0) { - return state; - } - const nextDraft: ComposerThreadDraftState = { - ...current, - terminalContexts: [], - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + }; + }); + return inserted; + }, + addTerminalContext: (threadRef, context) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + const threadId = resolveComposerThreadId(get(), threadRef); + if (!threadKey || !threadId) { + return; } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - clearPersistedAttachments: (threadId) => { - if (threadId.length === 0) { - return; - } - set((state) => { - const current = state.draftsByThreadId[threadId]; - if (!current) { - return state; + get().addTerminalContexts( + typeof threadRef === "string" ? DraftId.makeUnsafe(threadKey) : threadRef, + [context], + ); + }, + addTerminalContexts: (threadRef, contexts) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + const threadId = resolveComposerThreadId(get(), threadRef); + if (!threadKey || !threadId || contexts.length === 0) { + return; } - const nextDraft: ComposerThreadDraftState = { - ...current, - persistedAttachments: [], - nonPersistedImageIds: [], - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const acceptedContexts = normalizeTerminalContextsForThread(threadId, [ + ...existing.terminalContexts, + ...contexts, + ]).slice(existing.terminalContexts.length); + if (acceptedContexts.length === 0) { + return state; + } + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...existing, + prompt: ensureInlineTerminalContextPlaceholders( + existing.prompt, + existing.terminalContexts.length + acceptedContexts.length, + ), + terminalContexts: [...existing.terminalContexts, ...acceptedContexts], + }, + }, + }; + }); + }, + removeTerminalContext: (threadRef, contextId) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0 || contextId.length === 0) { + return; } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - syncPersistedAttachments: (threadId, attachments) => { - if (threadId.length === 0) { - return; - } - const attachmentIdSet = new Set(attachments.map((attachment) => attachment.id)); - set((state) => { - const current = state.draftsByThreadId[threadId]; - if (!current) { - return state; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...current, + terminalContexts: current.terminalContexts.filter( + (context) => context.id !== contextId, + ), + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + clearTerminalContexts: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - const nextDraft: ComposerThreadDraftState = { - ...current, - // Stage attempted attachments so persist middleware can try writing them. - persistedAttachments: attachments, - nonPersistedImageIds: current.nonPersistedImageIds.filter( - (id) => !attachmentIdSet.has(id), - ), - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current || current.terminalContexts.length === 0) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...current, + terminalContexts: [], + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + clearPersistedAttachments: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - Promise.resolve().then(() => { - verifyPersistedAttachments(threadId, attachments, set); - }); - }, - clearComposerContent: (threadId) => { - if (threadId.length === 0) { - return; - } - set((state) => { - const current = state.draftsByThreadId[threadId]; - if (!current) { - return state; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...current, + persistedAttachments: [], + nonPersistedImageIds: [], + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + syncPersistedAttachments: (threadRef, attachments) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey) { + return; } - const nextDraft: ComposerThreadDraftState = { - ...current, - prompt: "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; + const attachmentIdSet = new Set(attachments.map((attachment) => attachment.id)); + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...current, + // Stage attempted attachments so persist middleware can try writing them. + persistedAttachments: attachments, + nonPersistedImageIds: current.nonPersistedImageIds.filter( + (id) => !attachmentIdSet.has(id), + ), + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + Promise.resolve().then(() => { + verifyPersistedAttachments(threadKey, attachments, set); + }); + }, + clearComposerContent: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) { + return; } - return { draftsByThreadId: nextDraftsByThreadId }; - }); - }, - }), + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) { + return state; + } + const nextDraft: ComposerThreadDraftState = { + ...current, + prompt: "", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + }; + }, { name: COMPOSER_DRAFT_STORAGE_KEY, version: COMPOSER_DRAFT_STORAGE_VERSION, @@ -2140,17 +2705,23 @@ export const useComposerDraftStore = create()( merge: (persistedState, currentState) => { const normalizedPersisted = normalizeCurrentPersistedComposerDraftStoreState(persistedState); - const draftsByThreadId = Object.fromEntries( - Object.entries(normalizedPersisted.draftsByThreadId).map(([threadId, draft]) => [ - threadId, + const draftsByThreadKey = Object.fromEntries( + Object.entries(normalizedPersisted.draftsByThreadKey).map(([threadKey, draft]) => [ + threadKey, toHydratedThreadDraft(draft), ]), ); + const draftThreadsByThreadKey = Object.fromEntries( + Object.entries(normalizedPersisted.draftThreadsByThreadKey).map( + ([threadKey, draftThread]) => [threadKey, toHydratedDraftThreadState(draftThread)], + ), + ) as Record; return { ...currentState, - draftsByThreadId, - draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, - projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, + draftsByThreadKey, + draftThreadsByThreadKey, + logicalProjectDraftThreadKeyByLogicalProjectKey: + normalizedPersisted.logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {}, stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null, }; @@ -2159,19 +2730,24 @@ export const useComposerDraftStore = create()( ), ); -export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftState { - return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); +export const useComposerDraftStore = composerDraftStore; + +export function useComposerThreadDraft(threadRef: ComposerThreadTarget): ComposerThreadDraftState { + return useComposerDraftStore((state) => { + return getComposerDraftState(state, threadRef) ?? EMPTY_THREAD_DRAFT; + }); } export function useEffectiveComposerModelState(input: { - threadId: ThreadId; + threadRef?: ComposerThreadTarget; + draftId?: DraftId; providers: ReadonlyArray; selectedProvider: ProviderKind; threadModelSelection: ModelSelection | null | undefined; projectModelSelection: ModelSelection | null | undefined; settings: UnifiedSettings; }): EffectiveComposerModelState { - const draft = useComposerThreadDraft(input.threadId); + const draft = useComposerThreadDraft(input.threadRef ?? input.draftId ?? DraftId.makeUnsafe("")); return useMemo( () => @@ -2195,21 +2771,69 @@ export function useEffectiveComposerModelState(input: { } /** - * Clear a draft thread once the server has materialized the same thread id. + * Mark a draft thread as promoting once the server has materialized the same thread id. * * Use the single-thread helper for live `thread.created` events and the * iterable helper for bootstrap/recovery paths that discover multiple server * threads at once. */ -export function clearPromotedDraftThread(threadId: ThreadId): void { - if (!useComposerDraftStore.getState().getDraftThread(threadId)) { +export function markPromotedDraftThread(threadId: ThreadId): void { + const store = useComposerDraftStore.getState(); + const draftThreadTargets: ComposerThreadTarget[] = []; + for (const [draftId, draftThread] of Object.entries(store.draftThreadsByThreadKey)) { + if (draftThread.threadId === threadId) { + draftThreadTargets.push(DraftId.makeUnsafe(draftId)); + } + } + if (draftThreadTargets.length === 0) { return; } - useComposerDraftStore.getState().clearDraftThread(threadId); + for (const draftThreadTarget of draftThreadTargets) { + store.markDraftThreadPromoting(draftThreadTarget); + } +} + +export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { + const draftStore = useComposerDraftStore.getState(); + for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) { + if ( + draftThread.environmentId === threadRef.environmentId && + draftThread.threadId === threadRef.threadId + ) { + draftStore.markDraftThreadPromoting(DraftId.makeUnsafe(draftId), threadRef); + } + } } -export function clearPromotedDraftThreads(serverThreadIds: Iterable): void { +export function markPromotedDraftThreads(serverThreadIds: Iterable): void { for (const threadId of serverThreadIds) { - clearPromotedDraftThread(threadId); + markPromotedDraftThread(threadId); + } +} + +export function markPromotedDraftThreadsByRef(serverThreadRefs: Iterable): void { + for (const threadRef of serverThreadRefs) { + markPromotedDraftThreadByRef(threadRef); + } +} + +export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { + const draftStore = useComposerDraftStore.getState(); + for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) { + if ( + draftThread.promotedTo && + draftThread.promotedTo.environmentId === threadRef.environmentId && + draftThread.promotedTo.threadId === threadRef.threadId + ) { + draftStore.finalizePromotedDraftThread(DraftId.makeUnsafe(draftId)); + } + } +} + +export function finalizePromotedDraftThreadsByRef( + serverThreadRefs: Iterable, +): void { + for (const threadRef of serverThreadRefs) { + finalizePromotedDraftThreadByRef(threadRef); } } diff --git a/apps/web/src/editorPreferences.ts b/apps/web/src/editorPreferences.ts index ca43f3e5d8..38c59115a5 100644 --- a/apps/web/src/editorPreferences.ts +++ b/apps/web/src/editorPreferences.ts @@ -1,4 +1,4 @@ -import { EDITORS, EditorId, NativeApi } from "@t3tools/contracts"; +import { EDITORS, EditorId, LocalApi } from "@t3tools/contracts"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; import { useMemo } from "react"; @@ -26,7 +26,7 @@ export function resolveAndPersistPreferredEditor( return editor ?? null; } -export async function openInPreferredEditor(api: NativeApi, targetPath: string): Promise { +export async function openInPreferredEditor(api: LocalApi, targetPath: string): Promise { const { availableEditors } = await api.server.getConfig(); const editor = resolveAndPersistPreferredEditor(availableEditors); if (!editor) throw new Error("No available editors found."); diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts new file mode 100644 index 0000000000..a565940687 --- /dev/null +++ b/apps/web/src/environmentApi.ts @@ -0,0 +1,67 @@ +import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts"; + +import { readWsRpcClientEntryForEnvironment, WsRpcClient } from "./wsRpcClient"; + +export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { + return { + terminal: { + open: (input) => rpcClient.terminal.open(input as never), + write: (input) => rpcClient.terminal.write(input as never), + resize: (input) => rpcClient.terminal.resize(input as never), + clear: (input) => rpcClient.terminal.clear(input as never), + restart: (input) => rpcClient.terminal.restart(input as never), + close: (input) => rpcClient.terminal.close(input as never), + onEvent: (callback) => rpcClient.terminal.onEvent(callback), + }, + projects: { + searchEntries: rpcClient.projects.searchEntries, + writeFile: rpcClient.projects.writeFile, + }, + git: { + pull: rpcClient.git.pull, + refreshStatus: rpcClient.git.refreshStatus, + onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options), + listBranches: rpcClient.git.listBranches, + createWorktree: rpcClient.git.createWorktree, + removeWorktree: rpcClient.git.removeWorktree, + createBranch: rpcClient.git.createBranch, + checkout: rpcClient.git.checkout, + init: rpcClient.git.init, + resolvePullRequest: rpcClient.git.resolvePullRequest, + preparePullRequestThread: rpcClient.git.preparePullRequestThread, + }, + orchestration: { + getSnapshot: rpcClient.orchestration.getSnapshot, + dispatchCommand: rpcClient.orchestration.dispatchCommand, + getTurnDiff: rpcClient.orchestration.getTurnDiff, + getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, + replayEvents: (fromSequenceExclusive) => + rpcClient.orchestration + .replayEvents({ fromSequenceExclusive }) + .then((events) => [...events]), + onDomainEvent: (callback, options) => + rpcClient.orchestration.onDomainEvent(callback, options), + }, + }; +} + +export function readEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi | undefined { + if (typeof window === "undefined") { + return undefined; + } + + if (!environmentId) { + return undefined; + } + + const entry = readWsRpcClientEntryForEnvironment(environmentId); + return entry ? createEnvironmentApi(entry.client) : undefined; +} + +export function ensureEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi { + const api = readEnvironmentApi(environmentId); + if (!api) { + throw new Error(`Environment API not found for environment ${environmentId}`); + } + return api; +} diff --git a/apps/web/src/environmentBootstrap.ts b/apps/web/src/environmentBootstrap.ts new file mode 100644 index 0000000000..860c459edf --- /dev/null +++ b/apps/web/src/environmentBootstrap.ts @@ -0,0 +1,65 @@ +import { + createKnownEnvironmentFromWsUrl, + getKnownEnvironmentBaseUrl, + type KnownEnvironment, +} from "@t3tools/client-runtime"; +import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; + +function createKnownEnvironmentFromDesktopBootstrap( + bootstrap: DesktopEnvironmentBootstrap | null | undefined, +): KnownEnvironment | null { + if (!bootstrap?.wsUrl) { + return null; + } + + return createKnownEnvironmentFromWsUrl({ + id: `desktop:${bootstrap.label}`, + label: bootstrap.label, + source: "desktop-managed", + wsUrl: bootstrap.wsUrl, + }); +} + +export function getPrimaryKnownEnvironment(): KnownEnvironment | null { + const desktopEnvironment = createKnownEnvironmentFromDesktopBootstrap( + window.desktopBridge?.getLocalEnvironmentBootstrap(), + ); + if (desktopEnvironment) { + return desktopEnvironment; + } + + const legacyDesktopWsUrl = window.desktopBridge?.getWsUrl(); + if (typeof legacyDesktopWsUrl === "string" && legacyDesktopWsUrl.length > 0) { + return createKnownEnvironmentFromWsUrl({ + id: "desktop-legacy", + label: "Local environment", + source: "desktop-managed", + wsUrl: legacyDesktopWsUrl, + }); + } + + const configuredWsUrl = import.meta.env.VITE_WS_URL; + if (typeof configuredWsUrl === "string" && configuredWsUrl.length > 0) { + return createKnownEnvironmentFromWsUrl({ + id: "configured-primary", + label: "Primary environment", + source: "configured", + wsUrl: configuredWsUrl, + }); + } + + return createKnownEnvironmentFromWsUrl({ + id: "window-origin", + label: "Primary environment", + source: "window-origin", + wsUrl: window.location.origin, + }); +} + +export function resolvePrimaryEnvironmentBootstrapUrl(): string { + const baseUrl = getKnownEnvironmentBaseUrl(getPrimaryKnownEnvironment()); + if (!baseUrl) { + throw new Error("Unable to resolve a known environment bootstrap URL."); + } + return baseUrl; +} diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index f08b2c7a57..ed338e7f38 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,5 +1,6 @@ -import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; +import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts"; +import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { useShallow } from "zustand/react/shallow"; import { @@ -7,37 +8,49 @@ import { type DraftThreadState, useComposerDraftStore, } from "../composerDraftStore"; -import { newThreadId } from "../lib/utils"; +import { newDraftId, newThreadId } from "../lib/utils"; import { orderItemsByPreferredIds } from "../components/Sidebar.logic"; -import { useStore } from "../store"; -import { createThreadSelector } from "../storeSelectors"; +import { deriveLogicalProjectKey } from "../logicalProject"; +import { selectProjectsAcrossEnvironments, useStore } from "../store"; +import { createThreadSelectorByRef } from "../storeSelectors"; +import { resolveThreadRouteTarget } from "../threadRoutes"; import { useUiStateStore } from "../uiStateStore"; export function useHandleNewThread() { - const projectIds = useStore(useShallow((store) => store.projectIds)); + const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); const projectOrder = useUiStateStore((store) => store.projectOrder); - const navigate = useNavigate(); - const routeThreadId = useParams({ + const router = useRouter(); + const routeTarget = useParams({ strict: false, - select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), + select: (params) => resolveThreadRouteTarget(params), }); + const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; const activeThread = useStore( - useMemo(() => createThreadSelector(routeThreadId), [routeThreadId]), + useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), ); - const activeDraftThread = useComposerDraftStore((store) => - routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null, + const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); + const activeDraftThread = useComposerDraftStore(() => + routeTarget + ? routeTarget.kind === "server" + ? getDraftThread(routeTarget.threadRef) + : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId) + : null, ); const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ - items: projectIds, + items: projects, preferredIds: projectOrder, - getId: (projectId) => projectId, + getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), }); - }, [projectIds, projectOrder]); + }, [projectOrder, projects]); + const getCurrentRouteTarget = useCallback(() => { + const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; + return resolveThreadRouteTarget(currentRouteParams); + }, [router]); const handleNewThread = useCallback( ( - projectId: ProjectId, + projectRef: ScopedProjectRef, options?: { branch?: string | null; worktreePath?: string | null; @@ -45,84 +58,110 @@ export function useHandleNewThread() { }, ): Promise => { const { - clearProjectDraftThreadId, + getDraftSessionByLogicalProjectKey, + getDraftSession, getDraftThread, - getDraftThreadByProjectId, applyStickyState, setDraftThreadContext, - setProjectDraftThreadId, + setLogicalProjectDraftThreadId, } = useComposerDraftStore.getState(); + const currentRouteTarget = getCurrentRouteTarget(); + const project = projects.find( + (candidate) => + candidate.id === projectRef.projectId && + candidate.environmentId === projectRef.environmentId, + ); + const logicalProjectKey = project + ? deriveLogicalProjectKey(project) + : scopedProjectKey(projectRef); const hasBranchOption = options?.branch !== undefined; const hasWorktreePathOption = options?.worktreePath !== undefined; const hasEnvModeOption = options?.envMode !== undefined; - const storedDraftThread = getDraftThreadByProjectId(projectId); - const latestActiveDraftThread: DraftThreadState | null = routeThreadId - ? getDraftThread(routeThreadId) + const storedDraftThread = getDraftSessionByLogicalProjectKey(logicalProjectKey); + const latestActiveDraftThread: DraftThreadState | null = currentRouteTarget + ? currentRouteTarget.kind === "server" + ? getDraftThread(currentRouteTarget.threadRef) + : getDraftSession(currentRouteTarget.draftId) : null; if (storedDraftThread) { return (async () => { if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(storedDraftThread.threadId, { + setDraftThreadContext(storedDraftThread.draftId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), }); } - setProjectDraftThreadId(projectId, storedDraftThread.threadId); - if (routeThreadId === storedDraftThread.threadId) { + setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, storedDraftThread.draftId, { + threadId: storedDraftThread.threadId, + }); + if ( + currentRouteTarget?.kind === "draft" && + currentRouteTarget.draftId === storedDraftThread.draftId + ) { return; } - await navigate({ - to: "/$threadId", - params: { threadId: storedDraftThread.threadId }, + await router.navigate({ + to: "/draft/$draftId", + params: { draftId: storedDraftThread.draftId }, }); })(); } - clearProjectDraftThreadId(projectId); - if ( latestActiveDraftThread && - routeThreadId && - latestActiveDraftThread.projectId === projectId + currentRouteTarget?.kind === "draft" && + latestActiveDraftThread.logicalProjectKey === logicalProjectKey ) { if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(routeThreadId, { + setDraftThreadContext(currentRouteTarget.draftId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), }); } - setProjectDraftThreadId(projectId, routeThreadId); + setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, currentRouteTarget.draftId, { + threadId: latestActiveDraftThread.threadId, + createdAt: latestActiveDraftThread.createdAt, + runtimeMode: latestActiveDraftThread.runtimeMode, + interactionMode: latestActiveDraftThread.interactionMode, + ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), + ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), + ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + }); return Promise.resolve(); } + const draftId = newDraftId(); const threadId = newThreadId(); const createdAt = new Date().toISOString(); return (async () => { - setProjectDraftThreadId(projectId, threadId, { + setLogicalProjectDraftThreadId(logicalProjectKey, projectRef, draftId, { + threadId, createdAt, branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, envMode: options?.envMode ?? "local", runtimeMode: DEFAULT_RUNTIME_MODE, }); - applyStickyState(threadId); + applyStickyState(draftId); - await navigate({ - to: "/$threadId", - params: { threadId }, + await router.navigate({ + to: "/draft/$draftId", + params: { draftId }, }); })(); }, - [navigate, routeThreadId], + [getCurrentRouteTarget, router, projects], ); return { activeDraftThread, activeThread, - defaultProjectId: orderedProjects[0] ?? null, + defaultProjectRef: orderedProjects[0] + ? scopeProjectRef(orderedProjects[0].environmentId, orderedProjects[0].id) + : null, handleNewThread, - routeThreadId, + routeThreadRef, }; } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index f6b43f9a77..a953bc4656 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -26,7 +26,7 @@ import { TimestampFormat, UnifiedSettings, } from "@t3tools/contracts/settings"; -import { ensureNativeApi } from "~/nativeApi"; +import { ensureLocalApi } from "~/localApi"; import { useLocalStorage } from "./useLocalStorage"; import { normalizeCustomModelSlugs } from "~/modelSelection"; import { Predicate, Schema, Struct } from "effect"; @@ -67,9 +67,7 @@ function splitPatch(patch: Partial): { * only re-render when the slice they care about changes. */ -export function useSettings( - selector?: (s: UnifiedSettings) => T, -): T { +export function useSettings(selector?: (s: UnifiedSettings) => T): T { const serverSettings = useServerSettings(); const [clientSettings] = useLocalStorage( CLIENT_SETTINGS_STORAGE_KEY, @@ -111,7 +109,7 @@ export function useUpdateSettings() { applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch)); } // Fire-and-forget RPC — push will reconcile on success - void ensureNativeApi().server.updateSettings(serverPatch); + void ensureLocalApi().server.updateSettings(serverPatch); } if (Object.keys(clientPatch).length > 0) { @@ -239,7 +237,7 @@ export function migrateLocalSettingsToServer(): void { // Migrate server-relevant keys via RPC const serverPatch = buildLegacyServerSettingsMigrationPatch(old); if (Object.keys(serverPatch).length > 0) { - const api = ensureNativeApi(); + const api = ensureLocalApi(); void api.server.updateSettings(serverPatch); } diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index bc13b872cd..7087002bcd 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -1,42 +1,63 @@ -import { ThreadId } from "@t3tools/contracts"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "@tanstack/react-router"; import { useCallback } from "react"; import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "./useHandleNewThread"; -import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; +import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi"; +import { invalidateGitQueries } from "../lib/gitReactQuery"; import { newCommandId } from "../lib/utils"; -import { readNativeApi } from "../nativeApi"; -import { selectProjectById, selectThreadById, selectThreads, useStore } from "../store"; +import { readLocalApi } from "../localApi"; +import { + selectProjectByRef, + selectThreadByRef, + selectThreadsForEnvironment, + useStore, +} from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; +import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { toastManager } from "../components/ui/toast"; import { useSettings } from "./useSettings"; export function useThreadActions() { - const appSettings = useSettings(); + const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder); + const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, ); const clearTerminalState = useTerminalStateStore((state) => state.clearTerminalState); - const routeThreadId = useParams({ - strict: false, - select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null), - }); - const navigate = useNavigate(); + const router = useRouter(); const { handleNewThread } = useHandleNewThread(); const queryClient = useQueryClient(); - const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); + + const resolveThreadTarget = useCallback((target: ScopedThreadRef) => { + const state = useStore.getState(); + const thread = selectThreadByRef(state, target); + if (!thread) { + return null; + } + return { + thread, + threadRef: target, + }; + }, []); + const getCurrentRouteThreadRef = useCallback(() => { + const currentRouteParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; + return resolveThreadRouteRef(currentRouteParams); + }, [router]); const archiveThread = useCallback( - async (threadId: ThreadId) => { - const api = readNativeApi(); + async (target: ScopedThreadRef) => { + const api = readEnvironmentApi(target.environmentId); if (!api) return; - const thread = selectThreadById(threadId)(useStore.getState()); - if (!thread) return; + const resolved = resolveThreadTarget(target); + if (!resolved) return; + const { thread, threadRef } = resolved; if (thread.session?.status === "running" && thread.session.activeTurnId != null) { throw new Error("Cannot archive a running thread."); } @@ -44,48 +65,69 @@ export function useThreadActions() { await api.orchestration.dispatchCommand({ type: "thread.archive", commandId: newCommandId(), - threadId, + threadId: threadRef.threadId, }); + const currentRouteThreadRef = getCurrentRouteThreadRef(); - if (routeThreadId === threadId) { - await handleNewThread(thread.projectId); + if ( + currentRouteThreadRef?.threadId === threadRef.threadId && + currentRouteThreadRef.environmentId === threadRef.environmentId + ) { + await handleNewThread(scopeProjectRef(thread.environmentId, thread.projectId)); } }, - [handleNewThread, routeThreadId], + [getCurrentRouteThreadRef, handleNewThread, resolveThreadTarget], ); - const unarchiveThread = useCallback(async (threadId: ThreadId) => { - const api = readNativeApi(); + const unarchiveThread = useCallback(async (target: ScopedThreadRef) => { + const api = readEnvironmentApi(target.environmentId); if (!api) return; await api.orchestration.dispatchCommand({ type: "thread.unarchive", commandId: newCommandId(), - threadId, + threadId: target.threadId, }); }, []); const deleteThread = useCallback( - async (threadId: ThreadId, opts: { deletedThreadIds?: ReadonlySet } = {}) => { - const api = readNativeApi(); + async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet } = {}) => { + const api = readEnvironmentApi(target.environmentId); if (!api) return; + const resolved = resolveThreadTarget(target); + if (!resolved) return; + const { thread, threadRef } = resolved; const state = useStore.getState(); - const threads = selectThreads(state); - const thread = selectThreadById(threadId)(state); - if (!thread) return; - const threadProject = selectProjectById(thread.projectId)(state); - const deletedIds = opts.deletedThreadIds; + const threads = selectThreadsForEnvironment(state, threadRef.environmentId); + const threadProject = selectProjectByRef(state, { + environmentId: threadRef.environmentId, + projectId: thread.projectId, + }); + const deletedIds = + opts.deletedThreadKeys && opts.deletedThreadKeys.size > 0 + ? new Set( + [...opts.deletedThreadKeys].flatMap((threadKey) => { + const ref = parseScopedThreadKey(threadKey); + return ref && ref.environmentId === threadRef.environmentId ? [ref.threadId] : []; + }), + ) + : undefined; const survivingThreads = deletedIds && deletedIds.size > 0 - ? threads.filter((entry) => entry.id === threadId || !deletedIds.has(entry.id)) + ? threads.filter((entry) => entry.id === threadRef.threadId || !deletedIds.has(entry.id)) : threads; - const orphanedWorktreePath = getOrphanedWorktreePathForThread(survivingThreads, threadId); + const orphanedWorktreePath = getOrphanedWorktreePathForThread( + survivingThreads, + threadRef.threadId, + ); const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; + const localApi = readLocalApi(); const shouldDeleteWorktree = canDeleteWorktree && - (await api.dialogs.confirm( + localApi && + (await localApi.dialogs.confirm( [ "This thread is the only one linked to this worktree:", displayWorktreePath ?? orphanedWorktreePath, @@ -99,44 +141,60 @@ export function useThreadActions() { .dispatchCommand({ type: "thread.session.stop", commandId: newCommandId(), - threadId, + threadId: threadRef.threadId, createdAt: new Date().toISOString(), }) .catch(() => undefined); } try { - await api.terminal.close({ threadId, deleteHistory: true }); + await api.terminal.close({ threadId: threadRef.threadId, deleteHistory: true }); } catch { // Terminal may already be closed. } - const deletedThreadIds = opts.deletedThreadIds ?? new Set(); - const shouldNavigateToFallback = routeThreadId === threadId; + const deletedThreadIds = deletedIds ?? new Set(); + const currentRouteThreadRef = getCurrentRouteThreadRef(); + const shouldNavigateToFallback = + currentRouteThreadRef?.threadId === threadRef.threadId && + currentRouteThreadRef.environmentId === threadRef.environmentId; const fallbackThreadId = getFallbackThreadIdAfterDelete({ threads, - deletedThreadId: threadId, + deletedThreadId: threadRef.threadId, deletedThreadIds, - sortOrder: appSettings.sidebarThreadSortOrder, + sortOrder: sidebarThreadSortOrder, }); await api.orchestration.dispatchCommand({ type: "thread.delete", commandId: newCommandId(), - threadId, + threadId: threadRef.threadId, }); - clearComposerDraftForThread(threadId); - clearProjectDraftThreadById(thread.projectId, thread.id); - clearTerminalState(threadId); + clearComposerDraftForThread(threadRef); + clearProjectDraftThreadById( + scopeProjectRef(threadRef.environmentId, thread.projectId), + threadRef, + ); + clearTerminalState(threadRef); if (shouldNavigateToFallback) { if (fallbackThreadId) { - await navigate({ - to: "/$threadId", - params: { threadId: fallbackThreadId }, - replace: true, - }); + const fallbackThread = selectThreadByRef( + useStore.getState(), + scopeThreadRef(threadRef.environmentId, fallbackThreadId), + ); + if (fallbackThread) { + await router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(fallbackThread.environmentId, fallbackThread.id), + ), + replace: true, + }); + } else { + await router.navigate({ to: "/", replace: true }); + } } else { - await navigate({ to: "/", replace: true }); + await router.navigate({ to: "/", replace: true }); } } @@ -145,15 +203,18 @@ export function useThreadActions() { } try { - await removeWorktreeMutation.mutateAsync({ + await ensureEnvironmentApi(threadRef.environmentId).git.removeWorktree({ cwd: threadProject.cwd, path: orphanedWorktreePath, force: true, }); + await invalidateGitQueries(queryClient, { + environmentId: threadRef.environmentId, + }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error removing worktree."; console.error("Failed to remove orphaned worktree after thread deletion", { - threadId, + threadId: threadRef.threadId, projectCwd: threadProject.cwd, worktreePath: orphanedWorktreePath, error, @@ -169,22 +230,25 @@ export function useThreadActions() { clearComposerDraftForThread, clearProjectDraftThreadById, clearTerminalState, - appSettings.sidebarThreadSortOrder, - navigate, - removeWorktreeMutation, - routeThreadId, + getCurrentRouteThreadRef, + router, + queryClient, + resolveThreadTarget, + sidebarThreadSortOrder, ], ); const confirmAndDeleteThread = useCallback( - async (threadId: ThreadId) => { - const api = readNativeApi(); + async (target: ScopedThreadRef) => { + const api = readEnvironmentApi(target.environmentId); if (!api) return; - const thread = selectThreadById(threadId)(useStore.getState()); - if (!thread) return; + const localApi = readLocalApi(); + const resolved = resolveThreadTarget(target); + if (!resolved) return; + const { thread } = resolved; - if (appSettings.confirmThreadDelete) { - const confirmed = await api.dialogs.confirm( + if (confirmThreadDelete && localApi) { + const confirmed = await localApi.dialogs.confirm( [ `Delete thread "${thread.title}"?`, "This permanently clears conversation history for this thread.", @@ -195,9 +259,9 @@ export function useThreadActions() { } } - await deleteThread(threadId); + await deleteThread(target); }, - [appSettings.confirmThreadDelete, deleteThread], + [confirmThreadDelete, deleteThread, resolveThreadTarget], ); return { diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index 254b93eb6d..71788b5eac 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -1,16 +1,17 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it, vi } from "vitest"; -vi.mock("../nativeApi", () => ({ - ensureNativeApi: vi.fn(), +vi.mock("../environmentApi", () => ({ + ensureEnvironmentApi: vi.fn(), })); vi.mock("../wsRpcClient", () => ({ getWsRpcClient: vi.fn(), + getWsRpcClientForEnvironment: vi.fn(), })); import type { InfiniteData } from "@tanstack/react-query"; -import type { GitListBranchesResult } from "@t3tools/contracts"; +import { EnvironmentId, type GitListBranchesResult } from "@t3tools/contracts"; import { gitBranchSearchInfiniteQueryOptions, @@ -33,21 +34,25 @@ const BRANCH_SEARCH_RESULT: InfiniteData = { pages: [BRANCH_QUERY_RESULT], pageParams: [0], }; +const ENVIRONMENT_A = EnvironmentId.makeUnsafe("environment-a"); +const ENVIRONMENT_B = EnvironmentId.makeUnsafe("environment-b"); describe("gitMutationKeys", () => { it("scopes stacked action keys by cwd", () => { - expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual( - gitMutationKeys.runStackedAction("/repo/b"), + expect(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a")).not.toEqual( + gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/b"), ); }); it("scopes pull keys by cwd", () => { - expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b")); + expect(gitMutationKeys.pull(ENVIRONMENT_A, "/repo/a")).not.toEqual( + gitMutationKeys.pull(ENVIRONMENT_A, "/repo/b"), + ); }); it("scopes pull request thread preparation keys by cwd", () => { - expect(gitMutationKeys.preparePullRequestThread("/repo/a")).not.toEqual( - gitMutationKeys.preparePullRequestThread("/repo/b"), + expect(gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a")).not.toEqual( + gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/b"), ); }); }); @@ -57,23 +62,31 @@ describe("git mutation options", () => { it("attaches cwd-scoped mutation key for runStackedAction", () => { const options = gitRunStackedActionMutationOptions({ + environmentId: ENVIRONMENT_A, cwd: "/repo/a", queryClient, }); - expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a")); + expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a")); }); it("attaches cwd-scoped mutation key for pull", () => { - const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); - expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a")); + const options = gitPullMutationOptions({ + environmentId: ENVIRONMENT_A, + cwd: "/repo/a", + queryClient, + }); + expect(options.mutationKey).toEqual(gitMutationKeys.pull(ENVIRONMENT_A, "/repo/a")); }); it("attaches cwd-scoped mutation key for preparePullRequestThread", () => { const options = gitPreparePullRequestThreadMutationOptions({ + environmentId: ENVIRONMENT_A, cwd: "/repo/a", queryClient, }); - expect(options.mutationKey).toEqual(gitMutationKeys.preparePullRequestThread("/repo/a")); + expect(options.mutationKey).toEqual( + gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a"), + ); }); }); @@ -83,6 +96,7 @@ describe("invalidateGitQueries", () => { queryClient.setQueryData( gitBranchSearchInfiniteQueryOptions({ + environmentId: ENVIRONMENT_A, cwd: "/repo/a", query: "feature", }).queryKey, @@ -90,17 +104,19 @@ describe("invalidateGitQueries", () => { ); queryClient.setQueryData( gitBranchSearchInfiniteQueryOptions({ + environmentId: ENVIRONMENT_B, cwd: "/repo/b", query: "feature", }).queryKey, BRANCH_SEARCH_RESULT, ); - await invalidateGitQueries(queryClient, { cwd: "/repo/a" }); + await invalidateGitQueries(queryClient, { environmentId: ENVIRONMENT_A, cwd: "/repo/a" }); expect( queryClient.getQueryState( gitBranchSearchInfiniteQueryOptions({ + environmentId: ENVIRONMENT_A, cwd: "/repo/a", query: "feature", }).queryKey, @@ -109,6 +125,7 @@ describe("invalidateGitQueries", () => { expect( queryClient.getQueryState( gitBranchSearchInfiniteQueryOptions({ + environmentId: ENVIRONMENT_B, cwd: "/repo/b", query: "feature", }).queryKey, diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index a2611ebe25..5651a19b01 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -1,4 +1,5 @@ import { + type EnvironmentId, type GitActionProgressEvent, type GitStackedAction, type ThreadId, @@ -9,8 +10,8 @@ import { queryOptions, type QueryClient, } from "@tanstack/react-query"; -import { ensureNativeApi } from "../nativeApi"; -import { getWsRpcClient } from "../wsRpcClient"; +import { ensureEnvironmentApi } from "../environmentApi"; +import { getWsRpcClientForEnvironment } from "../wsRpcClient"; const GIT_BRANCHES_STALE_TIME_MS = 15_000; const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; @@ -18,38 +19,52 @@ const GIT_BRANCHES_PAGE_SIZE = 100; export const gitQueryKeys = { all: ["git"] as const, - branches: (cwd: string | null) => ["git", "branches", cwd] as const, - branchSearch: (cwd: string | null, query: string) => - ["git", "branches", cwd, "search", query] as const, + branches: (environmentId: EnvironmentId | null, cwd: string | null) => + ["git", "branches", environmentId ?? null, cwd] as const, + branchSearch: (environmentId: EnvironmentId | null, cwd: string | null, query: string) => + ["git", "branches", environmentId ?? null, cwd, "search", query] as const, }; export const gitMutationKeys = { - init: (cwd: string | null) => ["git", "mutation", "init", cwd] as const, - checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const, - runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const, - pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const, - preparePullRequestThread: (cwd: string | null) => - ["git", "mutation", "prepare-pull-request-thread", cwd] as const, + init: (environmentId: EnvironmentId | null, cwd: string | null) => + ["git", "mutation", "init", environmentId ?? null, cwd] as const, + checkout: (environmentId: EnvironmentId | null, cwd: string | null) => + ["git", "mutation", "checkout", environmentId ?? null, cwd] as const, + runStackedAction: (environmentId: EnvironmentId | null, cwd: string | null) => + ["git", "mutation", "run-stacked-action", environmentId ?? null, cwd] as const, + pull: (environmentId: EnvironmentId | null, cwd: string | null) => + ["git", "mutation", "pull", environmentId ?? null, cwd] as const, + preparePullRequestThread: (environmentId: EnvironmentId | null, cwd: string | null) => + ["git", "mutation", "prepare-pull-request-thread", environmentId ?? null, cwd] as const, }; -export function invalidateGitQueries(queryClient: QueryClient, input?: { cwd?: string | null }) { +export function invalidateGitQueries( + queryClient: QueryClient, + input?: { environmentId?: EnvironmentId | null; cwd?: string | null }, +) { + const environmentId = input?.environmentId ?? null; const cwd = input?.cwd ?? null; if (cwd !== null) { - return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }); + return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) }); } return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); } -function invalidateGitBranchQueries(queryClient: QueryClient, cwd: string | null) { +function invalidateGitBranchQueries( + queryClient: QueryClient, + environmentId: EnvironmentId | null, + cwd: string | null, +) { if (cwd === null) { return Promise.resolve(); } - return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }); + return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) }); } export function gitBranchSearchInfiniteQueryOptions(input: { + environmentId: EnvironmentId | null; cwd: string | null; query: string; enabled?: boolean; @@ -57,11 +72,12 @@ export function gitBranchSearchInfiniteQueryOptions(input: { const normalizedQuery = input.query.trim(); return infiniteQueryOptions({ - queryKey: gitQueryKeys.branchSearch(input.cwd, normalizedQuery), + queryKey: gitQueryKeys.branchSearch(input.environmentId, input.cwd, normalizedQuery), initialPageParam: 0, queryFn: async ({ pageParam }) => { - const api = ensureNativeApi(); if (!input.cwd) throw new Error("Git branches are unavailable."); + if (!input.environmentId) throw new Error("Git branches are unavailable."); + const api = ensureEnvironmentApi(input.environmentId); return api.git.listBranches({ cwd: input.cwd, ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}), @@ -79,62 +95,75 @@ export function gitBranchSearchInfiniteQueryOptions(input: { } export function gitResolvePullRequestQueryOptions(input: { + environmentId: EnvironmentId | null; cwd: string | null; reference: string | null; }) { return queryOptions({ - queryKey: ["git", "pull-request", input.cwd, input.reference] as const, + queryKey: [ + "git", + "pull-request", + input.environmentId ?? null, + input.cwd, + input.reference, + ] as const, queryFn: async () => { - const api = ensureNativeApi(); - if (!input.cwd || !input.reference) { + if (!input.cwd || !input.reference || !input.environmentId) { throw new Error("Pull request lookup is unavailable."); } + const api = ensureEnvironmentApi(input.environmentId); return api.git.resolvePullRequest({ cwd: input.cwd, reference: input.reference }); }, - enabled: input.cwd !== null && input.reference !== null, + enabled: input.environmentId !== null && input.cwd !== null && input.reference !== null, staleTime: 30_000, refetchOnWindowFocus: false, refetchOnReconnect: false, }); } -export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { +export function gitInitMutationOptions(input: { + environmentId: EnvironmentId | null; + cwd: string | null; + queryClient: QueryClient; +}) { return mutationOptions({ - mutationKey: gitMutationKeys.init(input.cwd), + mutationKey: gitMutationKeys.init(input.environmentId, input.cwd), mutationFn: async () => { - const api = ensureNativeApi(); - if (!input.cwd) throw new Error("Git init is unavailable."); + if (!input.cwd || !input.environmentId) throw new Error("Git init is unavailable."); + const api = ensureEnvironmentApi(input.environmentId); return api.git.init({ cwd: input.cwd }); }, onSettled: async () => { - await invalidateGitBranchQueries(input.queryClient, input.cwd); + await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); }, }); } export function gitCheckoutMutationOptions(input: { + environmentId: EnvironmentId | null; cwd: string | null; queryClient: QueryClient; }) { return mutationOptions({ - mutationKey: gitMutationKeys.checkout(input.cwd), + mutationKey: gitMutationKeys.checkout(input.environmentId, input.cwd), mutationFn: async (branch: string) => { - const api = ensureNativeApi(); - if (!input.cwd) throw new Error("Git checkout is unavailable."); + if (!input.cwd || !input.environmentId) throw new Error("Git checkout is unavailable."); + const api = ensureEnvironmentApi(input.environmentId); return api.git.checkout({ cwd: input.cwd, branch }); }, onSettled: async () => { - await invalidateGitBranchQueries(input.queryClient, input.cwd); + await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); }, }); } export function gitRunStackedActionMutationOptions(input: { + environmentId: EnvironmentId | null; cwd: string | null; queryClient: QueryClient; }) { return mutationOptions({ - mutationKey: gitMutationKeys.runStackedAction(input.cwd), + mutationKey: gitMutationKeys.runStackedAction(input.environmentId, input.cwd), mutationFn: async ({ actionId, action, @@ -150,8 +179,8 @@ export function gitRunStackedActionMutationOptions(input: { filePaths?: string[]; onProgress?: (event: GitActionProgressEvent) => void; }) => { - if (!input.cwd) throw new Error("Git action is unavailable."); - return getWsRpcClient().git.runStackedAction( + if (!input.cwd || !input.environmentId) throw new Error("Git action is unavailable."); + return getWsRpcClientForEnvironment(input.environmentId).git.runStackedAction( { action, actionId, @@ -164,62 +193,85 @@ export function gitRunStackedActionMutationOptions(input: { ); }, onSuccess: async () => { - await invalidateGitBranchQueries(input.queryClient, input.cwd); + await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); }, }); } -export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { +export function gitPullMutationOptions(input: { + environmentId: EnvironmentId | null; + cwd: string | null; + queryClient: QueryClient; +}) { return mutationOptions({ - mutationKey: gitMutationKeys.pull(input.cwd), + mutationKey: gitMutationKeys.pull(input.environmentId, input.cwd), mutationFn: async () => { - const api = ensureNativeApi(); - if (!input.cwd) throw new Error("Git pull is unavailable."); + if (!input.cwd || !input.environmentId) throw new Error("Git pull is unavailable."); + const api = ensureEnvironmentApi(input.environmentId); return api.git.pull({ cwd: input.cwd }); }, onSuccess: async () => { - await invalidateGitBranchQueries(input.queryClient, input.cwd); + await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); }, }); } -export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) { +export function gitCreateWorktreeMutationOptions(input: { + environmentId: EnvironmentId | null; + queryClient: QueryClient; +}) { return mutationOptions({ - mutationKey: ["git", "mutation", "create-worktree"] as const, + mutationKey: ["git", "mutation", "create-worktree", input.environmentId ?? null] as const, mutationFn: ( - args: Parameters["git"]["createWorktree"]>[0], - ) => ensureNativeApi().git.createWorktree(args), + args: Parameters["git"]["createWorktree"]>[0], + ) => { + if (!input.environmentId) { + throw new Error("Worktree creation is unavailable."); + } + return ensureEnvironmentApi(input.environmentId).git.createWorktree(args); + }, onSuccess: async () => { - await invalidateGitQueries(input.queryClient); + await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId }); }, }); } -export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) { +export function gitRemoveWorktreeMutationOptions(input: { + environmentId: EnvironmentId | null; + queryClient: QueryClient; +}) { return mutationOptions({ - mutationKey: ["git", "mutation", "remove-worktree"] as const, + mutationKey: ["git", "mutation", "remove-worktree", input.environmentId ?? null] as const, mutationFn: ( - args: Parameters["git"]["removeWorktree"]>[0], - ) => ensureNativeApi().git.removeWorktree(args), + args: Parameters["git"]["removeWorktree"]>[0], + ) => { + if (!input.environmentId) { + throw new Error("Worktree removal is unavailable."); + } + return ensureEnvironmentApi(input.environmentId).git.removeWorktree(args); + }, onSuccess: async () => { - await invalidateGitQueries(input.queryClient); + await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId }); }, }); } export function gitPreparePullRequestThreadMutationOptions(input: { + environmentId: EnvironmentId | null; cwd: string | null; queryClient: QueryClient; }) { return mutationOptions({ - mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), + mutationKey: gitMutationKeys.preparePullRequestThread(input.environmentId, input.cwd), mutationFn: async (args: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId; }) => { - const api = ensureNativeApi(); - if (!input.cwd) throw new Error("Pull request thread preparation is unavailable."); + if (!input.cwd || !input.environmentId) { + throw new Error("Pull request thread preparation is unavailable."); + } + const api = ensureEnvironmentApi(input.environmentId); return api.git.preparePullRequestThread({ cwd: input.cwd, reference: args.reference, @@ -228,7 +280,7 @@ export function gitPreparePullRequestThreadMutationOptions(input: { }); }, onSuccess: async () => { - await invalidateGitBranchQueries(input.queryClient, input.cwd); + await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); }, }); } diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts index 757130db9b..5ffed921bb 100644 --- a/apps/web/src/lib/gitStatusState.test.ts +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -1,4 +1,4 @@ -import type { GitStatusResult } from "@t3tools/contracts"; +import { EnvironmentId, type GitStatusResult } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -16,6 +16,10 @@ function registerListener(listeners: Set<(event: T) => void>, listener: (even } const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); +const ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-local"); +const OTHER_ENVIRONMENT_ID = EnvironmentId.makeUnsafe("environment-remote"); +const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const; +const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const; const BASE_STATUS: GitStatusResult = { isRepo: true, @@ -55,7 +59,7 @@ afterEach(() => { describe("gitStatusState", () => { it("starts fresh cwd state in a pending state", () => { - expect(getGitStatusSnapshot("/fresh")).toEqual({ + expect(getGitStatusSnapshot(FRESH_TARGET)).toEqual({ data: null, error: null, cause: null, @@ -64,11 +68,11 @@ describe("gitStatusState", () => { }); it("shares one live subscription per cwd and updates the per-cwd atom snapshot", () => { - const releaseA = watchGitStatus("/repo", gitClient); - const releaseB = watchGitStatus("/repo", gitClient); + const releaseA = watchGitStatus(TARGET, gitClient); + const releaseB = watchGitStatus(TARGET, gitClient); expect(gitClient.onStatus).toHaveBeenCalledOnce(); - expect(getGitStatusSnapshot("/repo")).toEqual({ + expect(getGitStatusSnapshot(TARGET)).toEqual({ data: null, error: null, cause: null, @@ -77,7 +81,7 @@ describe("gitStatusState", () => { emitGitStatus(BASE_STATUS); - expect(getGitStatusSnapshot("/repo")).toEqual({ + expect(getGitStatusSnapshot(TARGET)).toEqual({ data: BASE_STATUS, error: null, cause: null, @@ -92,15 +96,15 @@ describe("gitStatusState", () => { }); it("refreshes git status through the unary RPC without restarting the stream", async () => { - const release = watchGitStatus("/repo", gitClient); + const release = watchGitStatus(TARGET, gitClient); emitGitStatus(BASE_STATUS); - const refreshed = await refreshGitStatus("/repo", gitClient); + const refreshed = await refreshGitStatus(TARGET, gitClient); expect(gitClient.onStatus).toHaveBeenCalledOnce(); expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" }); - expect(getGitStatusSnapshot("/repo")).toEqual({ + expect(getGitStatusSnapshot(TARGET)).toEqual({ data: BASE_STATUS, error: null, cause: null, @@ -109,4 +113,38 @@ describe("gitStatusState", () => { release(); }); + + it("keeps git status subscriptions isolated by environment when cwds match", () => { + const localListeners = new Set<(event: GitStatusResult) => void>(); + const remoteListeners = new Set<(event: GitStatusResult) => void>(); + const localClient = { + refreshStatus: vi.fn(), + onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) => + registerListener(localListeners, listener), + ), + }; + const remoteClient = { + refreshStatus: vi.fn(), + onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) => + registerListener(remoteListeners, listener), + ), + }; + const remoteTarget = { environmentId: OTHER_ENVIRONMENT_ID, cwd: "/repo" } as const; + + const releaseLocal = watchGitStatus(TARGET, localClient); + const releaseRemote = watchGitStatus(remoteTarget, remoteClient); + + for (const listener of localListeners) { + listener(BASE_STATUS); + } + for (const listener of remoteListeners) { + listener({ ...BASE_STATUS, branch: "remote-branch" }); + } + + expect(getGitStatusSnapshot(TARGET).data?.branch).toBe("feature/push-status"); + expect(getGitStatusSnapshot(remoteTarget).data?.branch).toBe("remote-branch"); + + releaseLocal(); + releaseRemote(); + }); }); diff --git a/apps/web/src/lib/gitStatusState.ts b/apps/web/src/lib/gitStatusState.ts index 1c1cf00864..cabd2d9b94 100644 --- a/apps/web/src/lib/gitStatusState.ts +++ b/apps/web/src/lib/gitStatusState.ts @@ -1,11 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; -import { type GitManagerServiceError, type GitStatusResult } from "@t3tools/contracts"; +import { + type EnvironmentId, + type GitManagerServiceError, + type GitStatusResult, +} from "@t3tools/contracts"; import { Cause } from "effect"; import { Atom } from "effect/unstable/reactivity"; import { useEffect } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { getWsRpcClient, type WsRpcClient } from "../wsRpcClient"; +import { getWsRpcClient, getWsRpcClientForEnvironment, type WsRpcClient } from "../wsRpcClient"; export type GitStatusStreamError = GitManagerServiceError; @@ -23,6 +27,11 @@ interface WatchedGitStatus { unsubscribe: () => void; } +export interface GitStatusTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; +} + const EMPTY_GIT_STATUS_STATE = Object.freeze({ data: null, error: null, @@ -40,79 +49,91 @@ const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe( const NOOP: () => void = () => undefined; const watchedGitStatuses = new Map(); -const knownGitStatusCwds = new Set(); +const knownGitStatusKeys = new Set(); const gitStatusRefreshInFlight = new Map>(); -const gitStatusLastRefreshAtByCwd = new Map(); +const gitStatusLastRefreshAtByKey = new Map(); const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000; -let sharedGitStatusClient: GitStatusClient | null = null; - -const gitStatusStateAtom = Atom.family((cwd: string) => { - knownGitStatusCwds.add(cwd); +const gitStatusStateAtom = Atom.family((key: string) => { + knownGitStatusKeys.add(key); return Atom.make(INITIAL_GIT_STATUS_STATE).pipe( Atom.keepAlive, - Atom.withLabel(`git-status:${cwd}`), + Atom.withLabel(`git-status:${key}`), ); }); -export function getGitStatusSnapshot(cwd: string | null): GitStatusState { - if (cwd === null) { +function getGitStatusTargetKey(target: GitStatusTarget): string | null { + if (target.cwd === null) { + return null; + } + + return `${target.environmentId ?? "__default__"}:${target.cwd}`; +} + +function resolveGitStatusClient(target: GitStatusTarget): GitStatusClient { + if (target.environmentId) { + return getWsRpcClientForEnvironment(target.environmentId).git; + } + return getWsRpcClient().git; +} + +export function getGitStatusSnapshot(target: GitStatusTarget): GitStatusState { + const targetKey = getGitStatusTargetKey(target); + if (targetKey === null) { return EMPTY_GIT_STATUS_STATE; } - return appAtomRegistry.get(gitStatusStateAtom(cwd)); + return appAtomRegistry.get(gitStatusStateAtom(targetKey)); } export function watchGitStatus( - cwd: string | null, - client: GitStatusClient = getWsRpcClient().git, + target: GitStatusTarget, + client: GitStatusClient = resolveGitStatusClient(target), ): () => void { - if (cwd === null) { + const targetKey = getGitStatusTargetKey(target); + if (targetKey === null) { return NOOP; } - ensureGitStatusClient(client); - - const watched = watchedGitStatuses.get(cwd); + const watched = watchedGitStatuses.get(targetKey); if (watched) { watched.refCount += 1; - return () => unwatchGitStatus(cwd); + return () => unwatchGitStatus(targetKey); } - watchedGitStatuses.set(cwd, { + watchedGitStatuses.set(targetKey, { refCount: 1, - unsubscribe: subscribeToGitStatus(cwd), + unsubscribe: subscribeToGitStatus(targetKey, target.cwd!, client), }); - return () => unwatchGitStatus(cwd); + return () => unwatchGitStatus(targetKey); } export function refreshGitStatus( - cwd: string | null, - client: GitStatusClient = getWsRpcClient().git, + target: GitStatusTarget, + client: GitStatusClient = resolveGitStatusClient(target), ): Promise { - if (cwd === null) { + const targetKey = getGitStatusTargetKey(target); + if (targetKey === null || target.cwd === null) { return Promise.resolve(null); } - ensureGitStatusClient(client); - - const currentInFlight = gitStatusRefreshInFlight.get(cwd); + const currentInFlight = gitStatusRefreshInFlight.get(targetKey); if (currentInFlight) { return currentInFlight; } - const lastRequestedAt = gitStatusLastRefreshAtByCwd.get(cwd) ?? 0; + const lastRequestedAt = gitStatusLastRefreshAtByKey.get(targetKey) ?? 0; if (Date.now() - lastRequestedAt < GIT_STATUS_REFRESH_DEBOUNCE_MS) { - return Promise.resolve(getGitStatusSnapshot(cwd).data); + return Promise.resolve(getGitStatusSnapshot(target).data); } - gitStatusLastRefreshAtByCwd.set(cwd, Date.now()); - const refreshPromise = client.refreshStatus({ cwd }).finally(() => { - gitStatusRefreshInFlight.delete(cwd); + gitStatusLastRefreshAtByKey.set(targetKey, Date.now()); + const refreshPromise = client.refreshStatus({ cwd: target.cwd }).finally(() => { + gitStatusRefreshInFlight.delete(targetKey); }); - gitStatusRefreshInFlight.set(cwd, refreshPromise); + gitStatusRefreshInFlight.set(targetKey, refreshPromise); return refreshPromise; } @@ -122,43 +143,29 @@ export function resetGitStatusStateForTests(): void { } watchedGitStatuses.clear(); gitStatusRefreshInFlight.clear(); - gitStatusLastRefreshAtByCwd.clear(); - sharedGitStatusClient = null; + gitStatusLastRefreshAtByKey.clear(); - for (const cwd of knownGitStatusCwds) { - appAtomRegistry.set(gitStatusStateAtom(cwd), INITIAL_GIT_STATUS_STATE); + for (const key of knownGitStatusKeys) { + appAtomRegistry.set(gitStatusStateAtom(key), INITIAL_GIT_STATUS_STATE); } - knownGitStatusCwds.clear(); -} - -export function useGitStatus(cwd: string | null): GitStatusState { - useEffect(() => watchGitStatus(cwd), [cwd]); - - const state = useAtomValue(cwd !== null ? gitStatusStateAtom(cwd) : EMPTY_GIT_STATUS_ATOM); - return cwd === null ? EMPTY_GIT_STATUS_STATE : state; + knownGitStatusKeys.clear(); } -function ensureGitStatusClient(client: GitStatusClient): void { - if (sharedGitStatusClient === client) { - return; - } - - if (sharedGitStatusClient !== null) { - resetLiveGitStatusSubscriptions(); - } - - sharedGitStatusClient = client; -} +export function useGitStatus(target: GitStatusTarget): GitStatusState { + const targetKey = getGitStatusTargetKey(target); + useEffect( + () => watchGitStatus({ environmentId: target.environmentId, cwd: target.cwd }), + [target.environmentId, target.cwd], + ); -function resetLiveGitStatusSubscriptions(): void { - for (const watched of watchedGitStatuses.values()) { - watched.unsubscribe(); - } - watchedGitStatuses.clear(); + const state = useAtomValue( + targetKey !== null ? gitStatusStateAtom(targetKey) : EMPTY_GIT_STATUS_ATOM, + ); + return targetKey === null ? EMPTY_GIT_STATUS_STATE : state; } -function unwatchGitStatus(cwd: string): void { - const watched = watchedGitStatuses.get(cwd); +function unwatchGitStatus(targetKey: string): void { + const watched = watchedGitStatuses.get(targetKey); if (!watched) { return; } @@ -169,20 +176,15 @@ function unwatchGitStatus(cwd: string): void { } watched.unsubscribe(); - watchedGitStatuses.delete(cwd); + watchedGitStatuses.delete(targetKey); } -function subscribeToGitStatus(cwd: string): () => void { - const client = sharedGitStatusClient; - if (!client) { - return NOOP; - } - - markGitStatusPending(cwd); +function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusClient): () => void { + markGitStatusPending(targetKey); return client.onStatus( { cwd }, (status) => { - appAtomRegistry.set(gitStatusStateAtom(cwd), { + appAtomRegistry.set(gitStatusStateAtom(targetKey), { data: status, error: null, cause: null, @@ -191,14 +193,14 @@ function subscribeToGitStatus(cwd: string): () => void { }, { onResubscribe: () => { - markGitStatusPending(cwd); + markGitStatusPending(targetKey); }, }, ); } -function markGitStatusPending(cwd: string): void { - const atom = gitStatusStateAtom(cwd); +function markGitStatusPending(targetKey: string): void { + const atom = gitStatusStateAtom(targetKey); const current = appAtomRegistry.get(atom); const next = current.data === null diff --git a/apps/web/src/lib/projectReactQuery.ts b/apps/web/src/lib/projectReactQuery.ts index 20aa265b87..5977129809 100644 --- a/apps/web/src/lib/projectReactQuery.ts +++ b/apps/web/src/lib/projectReactQuery.ts @@ -1,11 +1,15 @@ -import type { ProjectSearchEntriesResult } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts"; import { queryOptions } from "@tanstack/react-query"; -import { ensureNativeApi } from "~/nativeApi"; +import { ensureEnvironmentApi } from "~/environmentApi"; export const projectQueryKeys = { all: ["projects"] as const, - searchEntries: (cwd: string | null, query: string, limit: number) => - ["projects", "search-entries", cwd, query, limit] as const, + searchEntries: ( + environmentId: EnvironmentId | null, + cwd: string | null, + query: string, + limit: number, + ) => ["projects", "search-entries", environmentId ?? null, cwd, query, limit] as const, }; const DEFAULT_SEARCH_ENTRIES_LIMIT = 80; @@ -16,6 +20,7 @@ const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = { }; export function projectSearchEntriesQueryOptions(input: { + environmentId: EnvironmentId | null; cwd: string | null; query: string; enabled?: boolean; @@ -24,19 +29,23 @@ export function projectSearchEntriesQueryOptions(input: { }) { const limit = input.limit ?? DEFAULT_SEARCH_ENTRIES_LIMIT; return queryOptions({ - queryKey: projectQueryKeys.searchEntries(input.cwd, input.query, limit), + queryKey: projectQueryKeys.searchEntries(input.environmentId, input.cwd, input.query, limit), queryFn: async () => { - const api = ensureNativeApi(); - if (!input.cwd) { + if (!input.cwd || !input.environmentId) { throw new Error("Workspace entry search is unavailable."); } + const api = ensureEnvironmentApi(input.environmentId); return api.projects.searchEntries({ cwd: input.cwd, query: input.query, limit, }); }, - enabled: (input.enabled ?? true) && input.cwd !== null && input.query.length > 0, + enabled: + (input.enabled ?? true) && + input.environmentId !== null && + input.cwd !== null && + input.query.length > 0, staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME, placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT, }); diff --git a/apps/web/src/lib/providerReactQuery.test.ts b/apps/web/src/lib/providerReactQuery.test.ts index b7e770799a..13361fc1de 100644 --- a/apps/web/src/lib/providerReactQuery.test.ts +++ b/apps/web/src/lib/providerReactQuery.test.ts @@ -1,21 +1,22 @@ -import { ThreadId, type NativeApi } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId, type EnvironmentApi } from "@t3tools/contracts"; import { QueryClient } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; import { checkpointDiffQueryOptions, providerQueryKeys } from "./providerReactQuery"; -import * as nativeApi from "../nativeApi"; +import * as environmentApi from "../environmentApi"; const threadId = ThreadId.makeUnsafe("thread-id"); +const environmentId = EnvironmentId.makeUnsafe("environment-local"); function mockNativeApi(input: { getTurnDiff: ReturnType; getFullThreadDiff: ReturnType; }) { - vi.spyOn(nativeApi, "ensureNativeApi").mockReturnValue({ + vi.spyOn(environmentApi, "ensureEnvironmentApi").mockReturnValue({ orchestration: { getTurnDiff: input.getTurnDiff, getFullThreadDiff: input.getFullThreadDiff, }, - } as unknown as NativeApi); + } as unknown as EnvironmentApi); } afterEach(() => { @@ -25,6 +26,7 @@ afterEach(() => { describe("providerQueryKeys.checkpointDiff", () => { it("includes cacheScope so reused turn counts do not collide", () => { const baseInput = { + environmentId, threadId, fromTurnCount: 1, toTurnCount: 2, @@ -51,6 +53,7 @@ describe("checkpointDiffQueryOptions", () => { mockNativeApi({ getTurnDiff, getFullThreadDiff }); const options = checkpointDiffQueryOptions({ + environmentId, threadId, fromTurnCount: 3, toTurnCount: 4, @@ -74,6 +77,7 @@ describe("checkpointDiffQueryOptions", () => { mockNativeApi({ getTurnDiff, getFullThreadDiff }); const options = checkpointDiffQueryOptions({ + environmentId, threadId, fromTurnCount: 0, toTurnCount: 2, @@ -96,6 +100,7 @@ describe("checkpointDiffQueryOptions", () => { mockNativeApi({ getTurnDiff, getFullThreadDiff }); const options = checkpointDiffQueryOptions({ + environmentId, threadId, fromTurnCount: 4, toTurnCount: 3, @@ -113,6 +118,7 @@ describe("checkpointDiffQueryOptions", () => { it("retries checkpoint-not-ready errors longer than generic failures", () => { const options = checkpointDiffQueryOptions({ + environmentId, threadId, fromTurnCount: 1, toTurnCount: 2, @@ -137,6 +143,7 @@ describe("checkpointDiffQueryOptions", () => { it("backs off longer for checkpoint-not-ready errors", () => { const options = checkpointDiffQueryOptions({ + environmentId, threadId, fromTurnCount: 1, toTurnCount: 2, diff --git a/apps/web/src/lib/providerReactQuery.ts b/apps/web/src/lib/providerReactQuery.ts index 0547aa9e5f..20007fc8fb 100644 --- a/apps/web/src/lib/providerReactQuery.ts +++ b/apps/web/src/lib/providerReactQuery.ts @@ -1,13 +1,15 @@ import { + type EnvironmentId, OrchestrationGetFullThreadDiffInput, OrchestrationGetTurnDiffInput, ThreadId, } from "@t3tools/contracts"; import { queryOptions } from "@tanstack/react-query"; import { Option, Schema } from "effect"; -import { ensureNativeApi } from "../nativeApi"; +import { ensureEnvironmentApi } from "../environmentApi"; interface CheckpointDiffQueryInput { + environmentId: EnvironmentId | null; threadId: ThreadId | null; fromTurnCount: number | null; toTurnCount: number | null; @@ -21,6 +23,7 @@ export const providerQueryKeys = { [ "providers", "checkpointDiff", + input.environmentId ?? null, input.threadId, input.fromTurnCount, input.toTurnCount, @@ -95,10 +98,10 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { return queryOptions({ queryKey: providerQueryKeys.checkpointDiff(input), queryFn: async () => { - const api = ensureNativeApi(); - if (!input.threadId || decodedRequest._tag === "None") { + if (!input.environmentId || !input.threadId || decodedRequest._tag === "None") { throw new Error("Checkpoint diff is unavailable."); } + const api = ensureEnvironmentApi(input.environmentId); try { if (decodedRequest.value.kind === "fullThreadDiff") { return await api.orchestration.getFullThreadDiff(decodedRequest.value.input); @@ -108,7 +111,11 @@ export function checkpointDiffQueryOptions(input: CheckpointDiffQueryInput) { throw new Error(normalizeCheckpointErrorMessage(error), { cause: error }); } }, - enabled: (input.enabled ?? true) && !!input.threadId && decodedRequest._tag === "Some", + enabled: + (input.enabled ?? true) && + !!input.environmentId && + !!input.threadId && + decodedRequest._tag === "Some", staleTime: Infinity, retry: (failureCount, error) => { if (isCheckpointTemporarilyUnavailable(error)) { diff --git a/apps/web/src/lib/terminalStateCleanup.test.ts b/apps/web/src/lib/terminalStateCleanup.test.ts index faf2c477cb..e3348d73e8 100644 --- a/apps/web/src/lib/terminalStateCleanup.test.ts +++ b/apps/web/src/lib/terminalStateCleanup.test.ts @@ -1,58 +1,65 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; import { ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { collectActiveTerminalThreadIds } from "./terminalStateCleanup"; const threadId = (id: string): ThreadId => ThreadId.makeUnsafe(id); +const threadKey = (environmentId: string, id: string): string => + scopedThreadKey(scopeThreadRef(environmentId as never, threadId(id))); describe("collectActiveTerminalThreadIds", () => { it("retains non-deleted server threads", () => { const activeThreadIds = collectActiveTerminalThreadIds({ snapshotThreads: [ - { id: threadId("server-1"), deletedAt: null, archivedAt: null }, - { id: threadId("server-2"), deletedAt: null, archivedAt: null }, + { key: threadKey("env-a", "server-1"), deletedAt: null, archivedAt: null }, + { key: threadKey("env-b", "server-2"), deletedAt: null, archivedAt: null }, ], - draftThreadIds: [], + draftThreadKeys: [], }); - expect(activeThreadIds).toEqual(new Set([threadId("server-1"), threadId("server-2")])); + expect(activeThreadIds).toEqual( + new Set([threadKey("env-a", "server-1"), threadKey("env-b", "server-2")]), + ); }); it("ignores deleted and archived server threads and keeps local draft threads", () => { const activeThreadIds = collectActiveTerminalThreadIds({ snapshotThreads: [ - { id: threadId("server-active"), deletedAt: null, archivedAt: null }, + { key: threadKey("env-a", "server-active"), deletedAt: null, archivedAt: null }, { - id: threadId("server-deleted"), + key: threadKey("env-a", "server-deleted"), deletedAt: "2026-03-05T08:00:00.000Z", archivedAt: null, }, { - id: threadId("server-archived"), + key: threadKey("env-a", "server-archived"), deletedAt: null, archivedAt: "2026-03-05T09:00:00.000Z", }, ], - draftThreadIds: [threadId("local-draft")], + draftThreadKeys: [threadKey("env-a", "local-draft")], }); - expect(activeThreadIds).toEqual(new Set([threadId("server-active"), threadId("local-draft")])); + expect(activeThreadIds).toEqual( + new Set([threadKey("env-a", "server-active"), threadKey("env-a", "local-draft")]), + ); }); it("does not keep draft-linked terminal state for archived server threads", () => { - const archivedThreadId = threadId("server-archived"); + const archivedThreadId = threadKey("env-a", "server-archived"); const activeThreadIds = collectActiveTerminalThreadIds({ snapshotThreads: [ { - id: archivedThreadId, + key: archivedThreadId, deletedAt: null, archivedAt: "2026-03-05T09:00:00.000Z", }, ], - draftThreadIds: [archivedThreadId, threadId("local-draft")], + draftThreadKeys: [archivedThreadId, threadKey("env-a", "local-draft")], }); - expect(activeThreadIds).toEqual(new Set([threadId("local-draft")])); + expect(activeThreadIds).toEqual(new Set([threadKey("env-a", "local-draft")])); }); }); diff --git a/apps/web/src/lib/terminalStateCleanup.ts b/apps/web/src/lib/terminalStateCleanup.ts index f11b30af92..78660708b5 100644 --- a/apps/web/src/lib/terminalStateCleanup.ts +++ b/apps/web/src/lib/terminalStateCleanup.ts @@ -1,35 +1,33 @@ -import type { ThreadId } from "@t3tools/contracts"; - interface TerminalRetentionThread { - id: ThreadId; + key: string; deletedAt: string | null; archivedAt: string | null; } interface CollectActiveTerminalThreadIdsInput { snapshotThreads: readonly TerminalRetentionThread[]; - draftThreadIds: Iterable; + draftThreadKeys: Iterable; } export function collectActiveTerminalThreadIds( input: CollectActiveTerminalThreadIdsInput, -): Set { - const activeThreadIds = new Set(); - const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.id, thread])); +): Set { + const activeThreadIds = new Set(); + const snapshotThreadById = new Map(input.snapshotThreads.map((thread) => [thread.key, thread])); for (const thread of input.snapshotThreads) { if (thread.deletedAt !== null) continue; if (thread.archivedAt !== null) continue; - activeThreadIds.add(thread.id); + activeThreadIds.add(thread.key); } - for (const draftThreadId of input.draftThreadIds) { - const snapshotThread = snapshotThreadById.get(draftThreadId); + for (const draftThreadKey of input.draftThreadKeys) { + const snapshotThread = snapshotThreadById.get(draftThreadKey); if ( snapshotThread && (snapshotThread.deletedAt !== null || snapshotThread.archivedAt !== null) ) { continue; } - activeThreadIds.add(draftThreadId); + activeThreadIds.add(draftThreadKey); } return activeThreadIds; } diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts index 017b6bee07..317933d677 100644 --- a/apps/web/src/lib/utils.test.ts +++ b/apps/web/src/lib/utils.test.ts @@ -1,6 +1,15 @@ -import { assert, describe, it } from "vitest"; +import { assert, describe, expect, it, vi } from "vitest"; + +const { resolvePrimaryEnvironmentBootstrapUrlMock } = vi.hoisted(() => ({ + resolvePrimaryEnvironmentBootstrapUrlMock: vi.fn(() => "http://bootstrap.test:4321"), +})); + +vi.mock("../environmentBootstrap", () => ({ + resolvePrimaryEnvironmentBootstrapUrl: resolvePrimaryEnvironmentBootstrapUrlMock, +})); import { isWindowsPlatform } from "./utils"; +import { resolveServerUrl } from "./utils"; describe("isWindowsPlatform", () => { it("matches Windows platform identifiers", () => { @@ -13,3 +22,34 @@ describe("isWindowsPlatform", () => { assert.isFalse(isWindowsPlatform("darwin")); }); }); + +describe("resolveServerUrl", () => { + it("falls back to the bootstrap environment URL when the explicit URL is empty", () => { + expect(resolveServerUrl({ url: "" })).toBe("http://bootstrap.test:4321/"); + }); + + it("uses the bootstrap environment URL when no explicit URL is provided", () => { + expect(resolveServerUrl()).toBe("http://bootstrap.test:4321/"); + }); + + it("prefers an explicit URL override", () => { + expect( + resolveServerUrl({ + url: "https://override.test:9999", + protocol: "wss", + pathname: "/rpc", + searchParams: { hello: "world" }, + }), + ).toBe("wss://override.test:9999/rpc?hello=world"); + }); + + it("does not evaluate the bootstrap resolver when an explicit URL is provided", () => { + resolvePrimaryEnvironmentBootstrapUrlMock.mockImplementationOnce(() => { + throw new Error("bootstrap unavailable"); + }); + + expect(resolveServerUrl({ url: "https://override.test:9999" })).toBe( + "https://override.test:9999/", + ); + }); +}); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index e48f815461..27800b3a61 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -4,6 +4,8 @@ import { type CxOptions, cx } from "class-variance-authority"; import { twMerge } from "tailwind-merge"; import * as Random from "effect/Random"; import * as Effect from "effect/Effect"; +import { resolvePrimaryEnvironmentBootstrapUrl } from "../environmentBootstrap"; +import { DraftId } from "../composerDraftStore"; export function cn(...inputs: CxOptions) { return twMerge(cx(inputs)); @@ -34,17 +36,11 @@ export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(randomUUID()); export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID()); +export const newDraftId = (): DraftId => DraftId.makeUnsafe(randomUUID()); + export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID()); const isNonEmptyString = Predicate.compose(Predicate.isString, String.isNonEmpty); -const firstNonEmptyString = (...values: unknown[]): string => { - for (const value of values) { - if (isNonEmptyString(value)) { - return value; - } - } - throw new Error("No non-empty string provided"); -}; export const resolveServerUrl = (options?: { url?: string | undefined; @@ -52,12 +48,9 @@ export const resolveServerUrl = (options?: { pathname?: string | undefined; searchParams?: Record | undefined; }): string => { - const rawUrl = firstNonEmptyString( - options?.url, - window.desktopBridge?.getWsUrl(), - import.meta.env.VITE_WS_URL, - window.location.origin, - ); + const rawUrl = isNonEmptyString(options?.url) + ? options.url + : resolvePrimaryEnvironmentBootstrapUrl(); const parsedUrl = new URL(rawUrl); if (options?.protocol) { diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/localApi.test.ts similarity index 84% rename from apps/web/src/wsNativeApi.test.ts rename to apps/web/src/localApi.test.ts index ae56f85991..f8d5e531f9 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -2,6 +2,7 @@ import { CommandId, DEFAULT_SERVER_SETTINGS, type DesktopBridge, + EnvironmentId, EventId, type GitStatusResult, ProjectId, @@ -94,6 +95,17 @@ const rpcClientMock = { vi.mock("./wsRpcClient", () => { return { getWsRpcClient: () => rpcClientMock, + getPrimaryWsRpcClientEntry: () => ({ + key: "primary", + knownEnvironment: { + id: "primary", + label: "Primary", + source: "manual", + target: { type: "ws", wsUrl: "ws://localhost:3000" }, + }, + client: rpcClientMock, + environmentId: null, + }), __resetWsRpcClientForTests: vi.fn(), }; }); @@ -121,6 +133,7 @@ function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unkn function makeDesktopBridge(overrides: Partial = {}): DesktopBridge { return { getWsUrl: () => null, + getLocalEnvironmentBootstrap: () => null, pickFolder: async () => null, confirm: async () => true, setTheme: async () => undefined, @@ -157,7 +170,21 @@ const defaultProviders: ReadonlyArray = [ }, ]; +const baseEnvironment = { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { + os: "darwin" as const, + arch: "arm64" as const, + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, +}; + const baseServerConfig: ServerConfig = { + environment: baseEnvironment, cwd: "/tmp/workspace", keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", keybindings: [], @@ -200,12 +227,12 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("wsNativeApi", () => { +describe("wsApi", () => { it("forwards server config fetches directly to the RPC client", async () => { rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig); - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createLocalApi } = await import("./localApi"); - const api = createWsNativeApi(); + const api = createLocalApi(); await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig); expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith(); @@ -214,9 +241,9 @@ describe("wsNativeApi", () => { }); it("forwards terminal and orchestration stream events", async () => { - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createEnvironmentApi } = await import("./environmentApi"); - const api = createWsNativeApi(); + const api = createEnvironmentApi(rpcClientMock as never); const onTerminalEvent = vi.fn(); const onDomainEvent = vi.fn(); @@ -263,9 +290,9 @@ describe("wsNativeApi", () => { }); it("forwards git status stream events", async () => { - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createEnvironmentApi } = await import("./environmentApi"); - const api = createWsNativeApi(); + const api = createEnvironmentApi(rpcClientMock as never); const onStatus = vi.fn(); api.git.onStatus({ cwd: "/repo" }, onStatus); @@ -279,9 +306,9 @@ describe("wsNativeApi", () => { it("forwards git status refreshes directly to the RPC client", async () => { rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus); - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createEnvironmentApi } = await import("./environmentApi"); - const api = createWsNativeApi(); + const api = createEnvironmentApi(rpcClientMock as never); await api.git.refreshStatus({ cwd: "/repo" }); @@ -289,9 +316,9 @@ describe("wsNativeApi", () => { }); it("forwards orchestration stream subscription options to the RPC client", async () => { - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createEnvironmentApi } = await import("./environmentApi"); - const api = createWsNativeApi(); + const api = createEnvironmentApi(rpcClientMock as never); const onDomainEvent = vi.fn(); const onResubscribe = vi.fn(); @@ -304,9 +331,9 @@ describe("wsNativeApi", () => { it("sends orchestration dispatch commands as the direct RPC payload", async () => { rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 }); - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createEnvironmentApi } = await import("./environmentApi"); - const api = createWsNativeApi(); + const api = createEnvironmentApi(rpcClientMock as never); const command = { type: "project.create", commandId: CommandId.makeUnsafe("cmd-1"), @@ -326,9 +353,9 @@ describe("wsNativeApi", () => { it("forwards workspace file writes to the project RPC", async () => { rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" }); - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createEnvironmentApi } = await import("./environmentApi"); - const api = createWsNativeApi(); + const api = createEnvironmentApi(rpcClientMock as never); await api.projects.writeFile({ cwd: "/tmp/project", relativePath: "plan.md", @@ -344,9 +371,9 @@ describe("wsNativeApi", () => { it("forwards full-thread diff requests to the orchestration RPC", async () => { rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" }); - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createEnvironmentApi } = await import("./environmentApi"); - const api = createWsNativeApi(); + const api = createEnvironmentApi(rpcClientMock as never); await api.orchestration.getFullThreadDiff({ threadId: ThreadId.makeUnsafe("thread-1"), toTurnCount: 1, @@ -366,9 +393,9 @@ describe("wsNativeApi", () => { }, ]; rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders }); - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createLocalApi } = await import("./localApi"); - const api = createWsNativeApi(); + const api = createLocalApi(); await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders }); expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); @@ -380,9 +407,9 @@ describe("wsNativeApi", () => { enableAssistantStreaming: true, }; rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings); - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createLocalApi } = await import("./localApi"); - const api = createWsNativeApi(); + const api = createLocalApi(); await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual( nextSettings, @@ -396,8 +423,8 @@ describe("wsNativeApi", () => { const showContextMenu = vi.fn().mockResolvedValue("delete"); getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu }); - const { createWsNativeApi } = await import("./wsNativeApi"); - const api = createWsNativeApi(); + const { createLocalApi } = await import("./localApi"); + const api = createLocalApi(); const items = [{ id: "delete", label: "Delete" }] as const; await expect(api.contextMenu.show(items)).resolves.toBe("delete"); @@ -406,9 +433,9 @@ describe("wsNativeApi", () => { it("falls back to the browser context menu helper when the desktop bridge is missing", async () => { showContextMenuFallbackMock.mockResolvedValue("rename"); - const { createWsNativeApi } = await import("./wsNativeApi"); + const { createLocalApi } = await import("./localApi"); - const api = createWsNativeApi(); + const api = createLocalApi(); const items = [{ id: "rename", label: "Rename" }] as const; await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts new file mode 100644 index 0000000000..3705c9f74a --- /dev/null +++ b/apps/web/src/localApi.ts @@ -0,0 +1,94 @@ +import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; + +import { resetGitStatusStateForTests } from "./lib/gitStatusState"; + +import { __resetWsRpcAtomClientForTests } from "./rpc/client"; +import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; +import { resetServerStateForTests } from "./rpc/serverState"; +import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; +import { getPrimaryWsRpcClientEntry, WsRpcClient, __resetWsRpcClientForTests } from "./wsRpcClient"; +import { showContextMenuFallback } from "./contextMenuFallback"; + +let cachedApi: LocalApi | undefined; + +export function createLocalApi( + rpcClient: WsRpcClient = getPrimaryWsRpcClientEntry().client, +): LocalApi { + return { + dialogs: { + pickFolder: async () => { + if (!window.desktopBridge) return null; + return window.desktopBridge.pickFolder(); + }, + confirm: async (message) => { + if (window.desktopBridge) { + return window.desktopBridge.confirm(message); + } + return window.confirm(message); + }, + }, + shell: { + openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }), + openExternal: async (url) => { + if (window.desktopBridge) { + const opened = await window.desktopBridge.openExternal(url); + if (!opened) { + throw new Error("Unable to open link."); + } + return; + } + + window.open(url, "_blank", "noopener,noreferrer"); + }, + }, + contextMenu: { + show: async ( + items: readonly ContextMenuItem[], + position?: { x: number; y: number }, + ): Promise => { + if (window.desktopBridge) { + return window.desktopBridge.showContextMenu(items, position) as Promise; + } + return showContextMenuFallback(items, position); + }, + }, + server: { + getConfig: rpcClient.server.getConfig, + refreshProviders: rpcClient.server.refreshProviders, + upsertKeybinding: rpcClient.server.upsertKeybinding, + getSettings: rpcClient.server.getSettings, + updateSettings: rpcClient.server.updateSettings, + }, + }; +} + +export function readLocalApi(): LocalApi | undefined { + if (typeof window === "undefined") return undefined; + if (cachedApi) return cachedApi; + + if (window.nativeApi) { + cachedApi = window.nativeApi; + return cachedApi; + } + + cachedApi = createLocalApi(); + return cachedApi; +} + +export function ensureLocalApi(): LocalApi { + const api = readLocalApi(); + if (!api) { + throw new Error("Local API not found"); + } + return api; +} + +export async function __resetLocalApiForTests() { + cachedApi = undefined; + await __resetWsRpcAtomClientForTests(); + await __resetWsRpcClientForTests(); + resetGitStatusStateForTests(); + resetRequestLatencyStateForTests(); + resetServerStateForTests(); + resetWsConnectionStateForTests(); +} diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts new file mode 100644 index 0000000000..789441877b --- /dev/null +++ b/apps/web/src/logicalProject.ts @@ -0,0 +1,19 @@ +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; +import type { ScopedProjectRef } from "@t3tools/contracts"; +import type { Project } from "./types"; + +export function deriveLogicalProjectKey( + project: Pick, +): string { + return ( + project.repositoryIdentity?.canonicalKey ?? + scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) + ); +} + +export function deriveLogicalProjectKeyFromRef( + projectRef: ScopedProjectRef, + project: Pick | null | undefined, +): string { + return project?.repositoryIdentity?.canonicalKey ?? scopedProjectKey(projectRef); +} diff --git a/apps/web/src/nativeApi.ts b/apps/web/src/nativeApi.ts deleted file mode 100644 index f9b0607347..0000000000 --- a/apps/web/src/nativeApi.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { NativeApi } from "@t3tools/contracts"; - -import { __resetWsNativeApiForTests, createWsNativeApi } from "./wsNativeApi"; - -let cachedApi: NativeApi | undefined; - -export function readNativeApi(): NativeApi | undefined { - if (typeof window === "undefined") return undefined; - if (cachedApi) return cachedApi; - - if (window.nativeApi) { - cachedApi = window.nativeApi; - return cachedApi; - } - - cachedApi = createWsNativeApi(); - return cachedApi; -} - -export function ensureNativeApi(): NativeApi { - const api = readNativeApi(); - if (!api) { - throw new Error("Native API not found"); - } - return api; -} - -export async function __resetNativeApiForTests() { - cachedApi = undefined; - await __resetWsNativeApiForTests(); -} diff --git a/apps/web/src/orchestrationEventEffects.test.ts b/apps/web/src/orchestrationEventEffects.test.ts index 9829ba9455..0e065288ae 100644 --- a/apps/web/src/orchestrationEventEffects.test.ts +++ b/apps/web/src/orchestrationEventEffects.test.ts @@ -68,7 +68,7 @@ describe("deriveOrchestrationBatchEffects", () => { }), ]); - expect(effects.clearPromotedDraftThreadIds).toEqual([createdThreadId]); + expect(effects.promoteDraftThreadIds).toEqual([createdThreadId]); expect(effects.clearDeletedThreadIds).toEqual([deletedThreadId]); expect(effects.removeTerminalStateThreadIds).toEqual([deletedThreadId, archivedThreadId]); expect(effects.needsProviderInvalidation).toBe(false); @@ -106,7 +106,7 @@ describe("deriveOrchestrationBatchEffects", () => { }), ]); - expect(effects.clearPromotedDraftThreadIds).toEqual([threadId]); + expect(effects.promoteDraftThreadIds).toEqual([threadId]); expect(effects.clearDeletedThreadIds).toEqual([]); expect(effects.removeTerminalStateThreadIds).toEqual([]); expect(effects.needsProviderInvalidation).toBe(true); @@ -127,7 +127,7 @@ describe("deriveOrchestrationBatchEffects", () => { }), ]); - expect(effects.clearPromotedDraftThreadIds).toEqual([]); + expect(effects.promoteDraftThreadIds).toEqual([]); expect(effects.clearDeletedThreadIds).toEqual([]); expect(effects.removeTerminalStateThreadIds).toEqual([]); }); diff --git a/apps/web/src/orchestrationEventEffects.ts b/apps/web/src/orchestrationEventEffects.ts index b19afa331f..216f07e3e2 100644 --- a/apps/web/src/orchestrationEventEffects.ts +++ b/apps/web/src/orchestrationEventEffects.ts @@ -1,7 +1,7 @@ import type { OrchestrationEvent, ThreadId } from "@t3tools/contracts"; export interface OrchestrationBatchEffects { - clearPromotedDraftThreadIds: ThreadId[]; + promoteDraftThreadIds: ThreadId[]; clearDeletedThreadIds: ThreadId[]; removeTerminalStateThreadIds: ThreadId[]; needsProviderInvalidation: boolean; @@ -70,12 +70,12 @@ export function deriveOrchestrationBatchEffects( } } - const clearPromotedDraftThreadIds: ThreadId[] = []; + const promoteDraftThreadIds: ThreadId[] = []; const clearDeletedThreadIds: ThreadId[] = []; const removeTerminalStateThreadIds: ThreadId[] = []; for (const [threadId, effect] of threadLifecycleEffects) { if (effect.clearPromotedDraft) { - clearPromotedDraftThreadIds.push(threadId); + promoteDraftThreadIds.push(threadId); } if (effect.clearDeletedThread) { clearDeletedThreadIds.push(threadId); @@ -86,7 +86,7 @@ export function deriveOrchestrationBatchEffects( } return { - clearPromotedDraftThreadIds, + promoteDraftThreadIds, clearDeletedThreadIds, removeTerminalStateThreadIds, needsProviderInvalidation, diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 77b1b15842..dd69738de3 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -14,7 +14,8 @@ import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' -import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' +import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' +import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' const SettingsRoute = SettingsRouteImport.update({ id: '/settings', @@ -40,58 +41,70 @@ const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ path: '/archived', getParentRoute: () => SettingsRoute, } as any) -const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ - id: '/$threadId', - path: '/$threadId', +const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ + id: '/draft/$draftId', + path: '/draft/$draftId', getParentRoute: () => ChatRoute, } as any) +const ChatEnvironmentIdThreadIdRoute = + ChatEnvironmentIdThreadIdRouteImport.update({ + id: '/$environmentId/$threadId', + path: '/$environmentId/$threadId', + getParentRoute: () => ChatRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/settings': typeof SettingsRouteWithChildren - '/$threadId': typeof ChatThreadIdRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute + '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute + '/draft/$draftId': typeof ChatDraftDraftIdRoute } export interface FileRoutesByTo { '/settings': typeof SettingsRouteWithChildren - '/$threadId': typeof ChatThreadIdRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute '/': typeof ChatIndexRoute + '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute + '/draft/$draftId': typeof ChatDraftDraftIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_chat': typeof ChatRouteWithChildren '/settings': typeof SettingsRouteWithChildren - '/_chat/$threadId': typeof ChatThreadIdRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/general': typeof SettingsGeneralRoute '/_chat/': typeof ChatIndexRoute + '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute + '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' | '/settings' - | '/$threadId' | '/settings/archived' | '/settings/general' + | '/$environmentId/$threadId' + | '/draft/$draftId' fileRoutesByTo: FileRoutesByTo to: | '/settings' - | '/$threadId' | '/settings/archived' | '/settings/general' | '/' + | '/$environmentId/$threadId' + | '/draft/$draftId' id: | '__root__' | '/_chat' | '/settings' - | '/_chat/$threadId' | '/settings/archived' | '/settings/general' | '/_chat/' + | '/_chat/$environmentId/$threadId' + | '/_chat/draft/$draftId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -136,24 +149,33 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } - '/_chat/$threadId': { - id: '/_chat/$threadId' - path: '/$threadId' - fullPath: '/$threadId' - preLoaderRoute: typeof ChatThreadIdRouteImport + '/_chat/draft/$draftId': { + id: '/_chat/draft/$draftId' + path: '/draft/$draftId' + fullPath: '/draft/$draftId' + preLoaderRoute: typeof ChatDraftDraftIdRouteImport + parentRoute: typeof ChatRoute + } + '/_chat/$environmentId/$threadId': { + id: '/_chat/$environmentId/$threadId' + path: '/$environmentId/$threadId' + fullPath: '/$environmentId/$threadId' + preLoaderRoute: typeof ChatEnvironmentIdThreadIdRouteImport parentRoute: typeof ChatRoute } } } interface ChatRouteChildren { - ChatThreadIdRoute: typeof ChatThreadIdRoute ChatIndexRoute: typeof ChatIndexRoute + ChatEnvironmentIdThreadIdRoute: typeof ChatEnvironmentIdThreadIdRoute + ChatDraftDraftIdRoute: typeof ChatDraftDraftIdRoute } const ChatRouteChildren: ChatRouteChildren = { - ChatThreadIdRoute: ChatThreadIdRoute, ChatIndexRoute: ChatIndexRoute, + ChatEnvironmentIdThreadIdRoute: ChatEnvironmentIdThreadIdRoute, + ChatDraftDraftIdRoute: ChatDraftDraftIdRoute, } const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 48c835ae79..8b24198527 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,8 +1,15 @@ import { + type EnvironmentId, OrchestrationEvent, type ServerLifecycleWelcomePayload, ThreadId, } from "@t3tools/contracts"; +import { + scopedProjectKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime"; import { Outlet, createRootRouteWithContext, @@ -10,7 +17,7 @@ import { useNavigate, useLocation, } from "@tanstack/react-router"; -import { useEffect, useEffectEvent, useRef } from "react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { Throttler } from "@tanstack/react-pacer"; @@ -24,7 +31,7 @@ import { import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { readNativeApi } from "../nativeApi"; +import { readLocalApi } from "../localApi"; import { getServerConfigUpdatedNotification, ServerConfigUpdatedNotification, @@ -34,11 +41,15 @@ import { useServerWelcomeSubscription, } from "../rpc/serverState"; import { - clearPromotedDraftThread, - clearPromotedDraftThreads, + markPromotedDraftThreadByRef, + markPromotedDraftThreadsByRef, useComposerDraftStore, } from "../composerDraftStore"; -import { selectProjects, selectThreadById, selectThreads, useStore } from "../store"; +import { + selectProjectsAcrossEnvironments, + selectThreadsAcrossEnvironments, + useStore, +} from "../store"; import { useUiStateStore } from "../uiStateStore"; import { useTerminalStateStore } from "../terminalStateStore"; import { migrateLocalSettingsToServer } from "../hooks/useSettings"; @@ -48,7 +59,15 @@ import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; import { deriveOrchestrationBatchEffects } from "../orchestrationEventEffects"; import { createOrchestrationRecoveryCoordinator } from "../orchestrationRecovery"; import { deriveReplayRetryDecision } from "../orchestrationRecovery"; -import { getWsRpcClient } from "~/wsRpcClient"; +import { selectThreadByRef } from "../store"; +import { + bindPrimaryWsRpcClientEnvironment, + bindWsRpcClientEntryEnvironment, + getPrimaryWsRpcClientEntry, + listWsRpcClientEntries, + subscribeWsRpcClientRegistry, + type WsRpcClientEntry, +} from "~/wsRpcClient"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -61,7 +80,7 @@ export const Route = createRootRouteWithContext<{ }); function RootRouteView() { - if (!readNativeApi()) { + if (!readLocalApi()) { return (
@@ -201,14 +220,24 @@ function coalesceOrchestrationUiEvents( const REPLAY_RECOVERY_RETRY_DELAY_MS = 100; const MAX_NO_PROGRESS_REPLAY_RETRIES = 3; +function useRegisteredWsRpcClientEntries(): ReadonlyArray { + const [, setRevision] = useState(0); + + useEffect(() => subscribeWsRpcClientRegistry(() => setRevision((value) => value + 1)), []); + + const entries = listWsRpcClientEntries(); + return entries.length > 0 ? entries : [getPrimaryWsRpcClientEntry()]; +} + function ServerStateBootstrap() { - useEffect(() => startServerStateSync(getWsRpcClient().server), []); + useEffect(() => startServerStateSync(getPrimaryWsRpcClientEntry().client.server), []); return null; } function EventRouter() { const applyOrchestrationEvents = useStore((store) => store.applyOrchestrationEvents); + const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded); const syncProjects = useUiStateStore((store) => store.syncProjects); @@ -226,15 +255,22 @@ function EventRouter() { const handledBootstrapThreadIdRef = useRef(null); const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); const disposedRef = useRef(false); - const bootstrapFromSnapshotRef = useRef<() => Promise>(async () => undefined); + const bootstrapFromSnapshotRef = useRef<(environmentId: EnvironmentId) => Promise>( + async () => undefined, + ); + const schedulePendingDomainEventFlushRef = useRef<() => void>(() => undefined); const serverConfig = useServerConfig(); + const clientEntries = useRegisteredWsRpcClientEntries(); const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { if (!payload) return; + bindPrimaryWsRpcClientEnvironment(payload.environment.environmentId); + setActiveEnvironmentId(payload.environment.environmentId); + schedulePendingDomainEventFlushRef.current(); migrateLocalSettingsToServer(); void (async () => { - await bootstrapFromSnapshotRef.current(); + await bootstrapFromSnapshotRef.current(payload.environment.environmentId); if (disposedRef.current) { return; } @@ -242,7 +278,12 @@ function EventRouter() { if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { return; } - setProjectExpanded(payload.bootstrapProjectId, true); + setProjectExpanded( + scopedProjectKey( + scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), + ), + true, + ); if (readPathname() !== "/") { return; @@ -251,8 +292,11 @@ function EventRouter() { return; } await navigate({ - to: "/$threadId", - params: { threadId: payload.bootstrapThreadId }, + to: "/$environmentId/$threadId", + params: { + environmentId: payload.environment.environmentId, + threadId: payload.bootstrapThreadId, + }, replace: true, }); handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; @@ -289,7 +333,7 @@ function EventRouter() { actionProps: { children: "Open keybindings.json", onClick: () => { - const api = readNativeApi(); + const api = readLocalApi(); if (!api) { return; } @@ -317,40 +361,49 @@ function EventRouter() { ); useEffect(() => { - const api = readNativeApi(); - if (!api) return; + if (!serverConfig) { + return; + } + + bindPrimaryWsRpcClientEnvironment(serverConfig.environment.environmentId); + setActiveEnvironmentId(serverConfig.environment.environmentId); + schedulePendingDomainEventFlushRef.current(); + }, [serverConfig, setActiveEnvironmentId]); + + useEffect(() => { let disposed = false; disposedRef.current = false; - const recovery = createOrchestrationRecoveryCoordinator(); - let replayRetryTracker: import("../orchestrationRecovery").ReplayRetryTracker | null = null; let needsProviderInvalidation = false; - const pendingDomainEvents: OrchestrationEvent[] = []; - let flushPendingDomainEventsScheduled = false; + const primaryClientKey = getPrimaryWsRpcClientEntry().key; const reconcileSnapshotDerivedState = () => { const storeState = useStore.getState(); - const threads = selectThreads(storeState); - const projects = selectProjects(storeState); - syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); + const threads = selectThreadsAcrossEnvironments(storeState); + const projects = selectProjectsAcrossEnvironments(storeState); + syncProjects( + projects.map((project) => ({ + key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + cwd: project.cwd, + })), + ); syncThreads( threads.map((thread) => ({ - id: thread.id, + key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), seedVisitedAt: thread.updatedAt ?? thread.createdAt, })), ); - clearPromotedDraftThreads(threads.map((thread) => thread.id)); - const draftThreadIds = Object.keys( - useComposerDraftStore.getState().draftThreadsByThreadId, - ) as ThreadId[]; - const activeThreadIds = collectActiveTerminalThreadIds({ + markPromotedDraftThreadsByRef( + threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), + ); + const activeThreadKeys = collectActiveTerminalThreadIds({ snapshotThreads: threads.map((thread) => ({ - id: thread.id, + key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), deletedAt: null, archivedAt: thread.archivedAt, })), - draftThreadIds, + draftThreadKeys: useComposerDraftStore.getState().listDraftThreadKeys(), }); - removeOrphanedTerminalStates(activeThreadIds); + removeOrphanedTerminalStates(activeThreadKeys); }; const queryInvalidationThrottler = new Throttler( @@ -371,7 +424,11 @@ function EventRouter() { }, ); - const applyEventBatch = (events: ReadonlyArray) => { + const applyEventBatch = ( + events: ReadonlyArray, + environmentId: EnvironmentId, + recovery: ReturnType, + ) => { const nextEvents = recovery.markEventBatchApplied(events); if (nextEvents.length === 0) { return; @@ -391,189 +448,284 @@ function EventRouter() { void queryInvalidationThrottler.maybeExecute(); } - applyOrchestrationEvents(uiEvents); + applyOrchestrationEvents(uiEvents, environmentId); if (needsProjectUiSync) { - const projects = selectProjects(useStore.getState()); - syncProjects(projects.map((project) => ({ id: project.id, cwd: project.cwd }))); + const projects = selectProjectsAcrossEnvironments(useStore.getState()); + syncProjects( + projects.map((project) => ({ + key: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), + cwd: project.cwd, + })), + ); } const needsThreadUiSync = nextEvents.some( (event) => event.type === "thread.created" || event.type === "thread.deleted", ); if (needsThreadUiSync) { - const threads = selectThreads(useStore.getState()); + const threads = selectThreadsAcrossEnvironments(useStore.getState()); syncThreads( threads.map((thread) => ({ - id: thread.id, + key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), seedVisitedAt: thread.updatedAt ?? thread.createdAt, })), ); } const draftStore = useComposerDraftStore.getState(); - for (const threadId of batchEffects.clearPromotedDraftThreadIds) { - clearPromotedDraftThread(threadId); + for (const threadId of batchEffects.promoteDraftThreadIds) { + markPromotedDraftThreadByRef(scopeThreadRef(environmentId, threadId)); } for (const threadId of batchEffects.clearDeletedThreadIds) { - draftStore.clearDraftThread(threadId); - clearThreadUi(threadId); + draftStore.clearDraftThread(scopeThreadRef(environmentId, threadId)); + clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId))); } for (const threadId of batchEffects.removeTerminalStateThreadIds) { - removeTerminalState(threadId); + removeTerminalState(scopeThreadRef(environmentId, threadId)); } }; - const flushPendingDomainEvents = () => { - flushPendingDomainEventsScheduled = false; - if (disposed || pendingDomainEvents.length === 0) { - return; - } + const clientContexts = clientEntries.map((entry) => { + const recovery = createOrchestrationRecoveryCoordinator(); + let replayRetryTracker: import("../orchestrationRecovery").ReplayRetryTracker | null = null; + const pendingDomainEvents: OrchestrationEvent[] = []; + let flushPendingDomainEventsScheduled = false; + let boundEnvironmentId = entry.environmentId; + + const bindEnvironmentId = (environmentId: EnvironmentId) => { + if (boundEnvironmentId === environmentId) { + return; + } + boundEnvironmentId = environmentId; + bindWsRpcClientEntryEnvironment(entry.key, environmentId); + schedulePendingDomainEventFlush(); + }; - const events = pendingDomainEvents.splice(0, pendingDomainEvents.length); - applyEventBatch(events); - }; - const schedulePendingDomainEventFlush = () => { - if (flushPendingDomainEventsScheduled) { - return; - } + const flushPendingDomainEvents = () => { + flushPendingDomainEventsScheduled = false; + if (disposed || pendingDomainEvents.length === 0 || boundEnvironmentId === null) { + return; + } - flushPendingDomainEventsScheduled = true; - queueMicrotask(flushPendingDomainEvents); - }; + const events = pendingDomainEvents.splice(0, pendingDomainEvents.length); + applyEventBatch(events, boundEnvironmentId, recovery); + }; - const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise => { - if (!recovery.beginReplayRecovery(reason)) { - return; - } + const schedulePendingDomainEventFlush = () => { + if (flushPendingDomainEventsScheduled) { + return; + } - const fromSequenceExclusive = recovery.getState().latestSequence; - try { - const events = await api.orchestration.replayEvents(fromSequenceExclusive); - if (!disposed) { - applyEventBatch(events); + flushPendingDomainEventsScheduled = true; + queueMicrotask(flushPendingDomainEvents); + }; + + const runSnapshotRecovery = async ( + reason: "bootstrap" | "replay-failed", + environmentId: EnvironmentId, + ): Promise => { + const started = recovery.beginSnapshotRecovery(reason); + if (import.meta.env.MODE !== "test") { + const state = recovery.getState(); + console.info("[orchestration-recovery]", "Snapshot recovery requested.", { + reason, + clientKey: entry.key, + environmentId, + skipped: !started, + ...(started + ? {} + : { + blockedBy: state.inFlight?.kind ?? null, + blockedByReason: state.inFlight?.reason ?? null, + }), + state, + }); + } + if (!started) { + return; } - } catch { - replayRetryTracker = null; - recovery.failReplayRecovery(); - void fallbackToSnapshotRecovery(); - return; - } - if (!disposed) { - const replayCompletion = recovery.completeReplayRecovery(); - const retryDecision = deriveReplayRetryDecision({ - previousTracker: replayRetryTracker, - completion: replayCompletion, - recoveryState: recovery.getState(), - baseDelayMs: REPLAY_RECOVERY_RETRY_DELAY_MS, - maxNoProgressRetries: MAX_NO_PROGRESS_REPLAY_RETRIES, - }); - replayRetryTracker = retryDecision.tracker; + try { + const snapshot = await entry.client.orchestration.getSnapshot(); + if (!disposed) { + bindEnvironmentId(environmentId); + syncServerReadModel(snapshot, environmentId); + reconcileSnapshotDerivedState(); + if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { + void runReplayRecovery("sequence-gap"); + } + } + } catch { + recovery.failSnapshotRecovery(); + } + }; - if (retryDecision.shouldRetry) { - if (retryDecision.delayMs > 0) { - await new Promise((resolve) => { - setTimeout(resolve, retryDecision.delayMs); - }); - if (disposed) { + const fallbackToSnapshotRecovery = async (): Promise => { + if (boundEnvironmentId === null) { + return; + } + await runSnapshotRecovery("replay-failed", boundEnvironmentId); + }; + + const runReplayRecovery = async (reason: "sequence-gap" | "resubscribe"): Promise => { + if (!recovery.beginReplayRecovery(reason)) { + return; + } + + const fromSequenceExclusive = recovery.getState().latestSequence; + try { + const events = await entry.client.orchestration.replayEvents({ fromSequenceExclusive }); + if (!disposed) { + if (boundEnvironmentId === null) { + replayRetryTracker = null; + recovery.failReplayRecovery(); return; } + applyEventBatch(events, boundEnvironmentId, recovery); } - void runReplayRecovery(reason); - } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") { - console.warn( - "[orchestration-recovery]", - "Stopping replay recovery after no-progress retries.", - { - state: recovery.getState(), - }, - ); + } catch { + replayRetryTracker = null; + recovery.failReplayRecovery(); + void fallbackToSnapshotRecovery(); + return; } - } - }; - - const runSnapshotRecovery = async (reason: "bootstrap" | "replay-failed"): Promise => { - const started = recovery.beginSnapshotRecovery(reason); - if (import.meta.env.MODE !== "test") { - const state = recovery.getState(); - console.info("[orchestration-recovery]", "Snapshot recovery requested.", { - reason, - skipped: !started, - ...(started - ? {} - : { - blockedBy: state.inFlight?.kind ?? null, - blockedByReason: state.inFlight?.reason ?? null, - }), - state, - }); - } - if (!started) { - return; - } - try { - const snapshot = await api.orchestration.getSnapshot(); if (!disposed) { - syncServerReadModel(snapshot); - reconcileSnapshotDerivedState(); - if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) { - void runReplayRecovery("sequence-gap"); + const replayCompletion = recovery.completeReplayRecovery(); + const retryDecision = deriveReplayRetryDecision({ + previousTracker: replayRetryTracker, + completion: replayCompletion, + recoveryState: recovery.getState(), + baseDelayMs: REPLAY_RECOVERY_RETRY_DELAY_MS, + maxNoProgressRetries: MAX_NO_PROGRESS_REPLAY_RETRIES, + }); + replayRetryTracker = retryDecision.tracker; + + if (retryDecision.shouldRetry) { + if (retryDecision.delayMs > 0) { + await new Promise((resolve) => { + setTimeout(resolve, retryDecision.delayMs); + }); + if (disposed) { + return; + } + } + void runReplayRecovery(reason); + } else if (replayCompletion.shouldReplay && import.meta.env.MODE !== "test") { + console.warn( + "[orchestration-recovery]", + "Stopping replay recovery after no-progress retries.", + { + clientKey: entry.key, + environmentId: boundEnvironmentId, + state: recovery.getState(), + }, + ); } } - } catch { - // Keep prior state and wait for welcome or a later replay attempt. - recovery.failSnapshotRecovery(); - } - }; - - const bootstrapFromSnapshot = async (): Promise => { - await runSnapshotRecovery("bootstrap"); - }; - bootstrapFromSnapshotRef.current = bootstrapFromSnapshot; + }; - const fallbackToSnapshotRecovery = async (): Promise => { - await runSnapshotRecovery("replay-failed"); - }; - const unsubDomainEvent = api.orchestration.onDomainEvent( - (event) => { - const action = recovery.classifyDomainEvent(event.sequence); - if (action === "apply") { - pendingDomainEvents.push(event); - schedulePendingDomainEventFlush(); - return; + const unsubLifecycle = entry.client.server.subscribeLifecycle((event) => { + if (event.type === "welcome") { + bindEnvironmentId(event.payload.environment.environmentId); } - if (action === "recover") { - flushPendingDomainEvents(); - void runReplayRecovery("sequence-gap"); + }); + const unsubConfig = entry.client.server.subscribeConfig((event) => { + if (event.type === "snapshot") { + bindEnvironmentId(event.config.environment.environmentId); } - }, - { - onResubscribe: () => { - if (disposed) { + }); + if (boundEnvironmentId === null) { + void entry.client.server + .getConfig() + .then((config) => { + if (!disposed) { + bindEnvironmentId(config.environment.environmentId); + } + }) + .catch(() => undefined); + } + const unsubDomainEvent = entry.client.orchestration.onDomainEvent( + (event) => { + const action = recovery.classifyDomainEvent(event.sequence); + if (action === "apply") { + pendingDomainEvents.push(event); + schedulePendingDomainEventFlush(); return; } - flushPendingDomainEvents(); - void runReplayRecovery("resubscribe"); + if (action === "recover") { + flushPendingDomainEvents(); + void runReplayRecovery("sequence-gap"); + } }, - }, - ); - const unsubTerminalEvent = api.terminal.onEvent((event) => { - const thread = selectThreadById(ThreadId.makeUnsafe(event.threadId))(useStore.getState()); - if (thread && thread.archivedAt !== null) { + { + onResubscribe: () => { + if (disposed) { + return; + } + flushPendingDomainEvents(); + void runReplayRecovery("resubscribe"); + }, + }, + ); + const unsubTerminalEvent = entry.client.terminal.onEvent((event) => { + if (boundEnvironmentId === null) { + return; + } + + const threadRef = scopeThreadRef(boundEnvironmentId, ThreadId.makeUnsafe(event.threadId)); + const thread = selectThreadByRef(useStore.getState(), threadRef); + if (!thread || thread.archivedAt !== null) { + return; + } + applyTerminalEvent(threadRef, event); + }); + + return { + key: entry.key, + bindEnvironmentId, + flushPendingDomainEvents, + schedulePendingDomainEventFlush, + runSnapshotRecovery, + cleanup: () => { + flushPendingDomainEventsScheduled = false; + pendingDomainEvents.length = 0; + unsubDomainEvent(); + unsubTerminalEvent(); + unsubLifecycle(); + unsubConfig(); + }, + }; + }); + + schedulePendingDomainEventFlushRef.current = () => { + for (const context of clientContexts) { + context.schedulePendingDomainEventFlush(); + } + }; + + const primaryClientContext = + clientContexts.find((context) => context.key === primaryClientKey) ?? + clientContexts[0] ?? + null; + bootstrapFromSnapshotRef.current = async (environmentId: EnvironmentId) => { + if (!primaryClientContext) { return; } - applyTerminalEvent(event); - }); + primaryClientContext.bindEnvironmentId(environmentId); + await primaryClientContext.runSnapshotRecovery("bootstrap", environmentId); + }; + return () => { disposed = true; disposedRef.current = true; needsProviderInvalidation = false; - flushPendingDomainEventsScheduled = false; - pendingDomainEvents.length = 0; + schedulePendingDomainEventFlushRef.current = () => undefined; queryInvalidationThrottler.cancel(); - unsubDomainEvent(); - unsubTerminalEvent(); + for (const context of clientContexts) { + context.cleanup(); + } }; }, [ applyOrchestrationEvents, + clientEntries, navigate, queryClient, removeTerminalState, @@ -581,6 +733,7 @@ function EventRouter() { applyTerminalEvent, clearThreadUi, setProjectExpanded, + setActiveEnvironmentId, syncProjects, syncServerReadModel, syncThreads, diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx similarity index 73% rename from apps/web/src/routes/_chat.$threadId.tsx rename to apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 99ecc05e7d..9e75ebe7f7 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -1,8 +1,8 @@ -import { ThreadId } from "@t3tools/contracts"; import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; -import { Suspense, lazy, type ReactNode, useCallback, useEffect, useState } from "react"; +import { Suspense, lazy, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import ChatView from "../components/ChatView"; +import { threadHasStarted } from "../components/ChatView.logic"; import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; import { DiffPanelHeaderSkeleton, @@ -10,14 +10,16 @@ import { DiffPanelShell, type DiffPanelMode, } from "../components/DiffPanelShell"; -import { useComposerDraftStore } from "../composerDraftStore"; +import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; import { type DiffRouteSearch, parseDiffRouteSearch, stripDiffSearchParams, } from "../diffRouteSearch"; import { useMediaQuery } from "../hooks/useMediaQuery"; -import { useStore } from "../store"; +import { selectEnvironmentState, selectThreadByRef, useStore } from "../store"; +import { createThreadSelectorByRef } from "../storeSelectors"; +import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; import { Sheet, SheetPopup } from "../components/ui/sheet"; import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; @@ -161,39 +163,60 @@ const DiffPanelInlineSidebar = (props: { }; function ChatThreadRouteView() { - const bootstrapComplete = useStore((store) => store.bootstrapComplete); const navigate = useNavigate(); - const threadId = Route.useParams({ - select: (params) => ThreadId.makeUnsafe(params.threadId), + const threadRef = Route.useParams({ + select: (params) => resolveThreadRouteRef(params), }); const search = Route.useSearch(); - const threadExists = useStore((store) => store.threadShellById[threadId] !== undefined); + const bootstrapComplete = useStore( + (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, + ); + const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); + const threadExists = useStore((store) => selectThreadByRef(store, threadRef) !== undefined); + const environmentHasServerThreads = useStore( + (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).threadIds.length > 0, + ); const draftThreadExists = useComposerDraftStore((store) => - Object.hasOwn(store.draftThreadsByThreadId, threadId), + threadRef ? store.getDraftThreadByRef(threadRef) !== null : false, ); + const draftThread = useComposerDraftStore((store) => + threadRef ? store.getDraftThreadByRef(threadRef) : null, + ); + const environmentHasDraftThreads = useComposerDraftStore((store) => { + if (!threadRef) { + return false; + } + return store.hasDraftThreadsInEnvironment(threadRef.environmentId); + }); const routeThreadExists = threadExists || draftThreadExists; + const serverThreadStarted = threadHasStarted(serverThread); + const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; const diffOpen = search.diff === "1"; const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); - // TanStack Router keeps active route components mounted across param-only navigations - // unless remountDeps are configured, so this stays warm across thread switches. const [hasOpenedDiff, setHasOpenedDiff] = useState(diffOpen); const closeDiff = useCallback(() => { + if (!threadRef) { + return; + } void navigate({ - to: "/$threadId", - params: { threadId }, + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), search: { diff: undefined }, }); - }, [navigate, threadId]); + }, [navigate, threadRef]); const openDiff = useCallback(() => { + if (!threadRef) { + return; + } void navigate({ - to: "/$threadId", - params: { threadId }, + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), search: (previous) => { const rest = stripDiffSearchParams(previous); return { ...rest, diff: "1" }; }, }); - }, [navigate, threadId]); + }, [navigate, threadRef]); useEffect(() => { if (diffOpen) { @@ -202,17 +225,23 @@ function ChatThreadRouteView() { }, [diffOpen]); useEffect(() => { - if (!bootstrapComplete) { + if (!threadRef || !bootstrapComplete) { return; } - if (!routeThreadExists) { + if (!routeThreadExists && environmentHasAnyThreads) { void navigate({ to: "/", replace: true }); + } + }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]); + + useEffect(() => { + if (!threadRef || !serverThreadStarted || !draftThread?.promotedTo) { return; } - }, [bootstrapComplete, navigate, routeThreadExists, threadId]); + finalizePromotedDraftThreadByRef(threadRef); + }, [draftThread?.promotedTo, serverThreadStarted, threadRef]); - if (!bootstrapComplete || !routeThreadExists) { + if (!threadRef || !bootstrapComplete || !routeThreadExists) { return null; } @@ -222,7 +251,11 @@ function ChatThreadRouteView() { return ( <> - + - + {shouldRenderDiffContent ? : null} @@ -246,7 +283,7 @@ function ChatThreadRouteView() { ); } -export const Route = createFileRoute("/_chat/$threadId")({ +export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ validateSearch: (search) => parseDiffRouteSearch(search), search: { middlewares: [retainSearchParams(["diff"])], diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx new file mode 100644 index 0000000000..6ddd78c6bb --- /dev/null +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -0,0 +1,86 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect, useMemo } from "react"; +import ChatView from "../components/ChatView"; +import { threadHasStarted } from "../components/ChatView.logic"; +import { useComposerDraftStore, DraftId } from "../composerDraftStore"; +import { SidebarInset } from "../components/ui/sidebar"; +import { createThreadSelectorAcrossEnvironments } from "../storeSelectors"; +import { useStore } from "../store"; +import { buildThreadRouteParams } from "../threadRoutes"; + +function DraftChatThreadRouteView() { + const navigate = useNavigate(); + const { draftId: rawDraftId } = Route.useParams(); + const draftId = DraftId.makeUnsafe(rawDraftId); + const draftSession = useComposerDraftStore((store) => store.getDraftSession(draftId)); + const serverThread = useStore( + useMemo( + () => createThreadSelectorAcrossEnvironments(draftSession?.threadId ?? null), + [draftSession?.threadId], + ), + ); + const serverThreadStarted = threadHasStarted(serverThread); + const canonicalThreadRef = useMemo( + () => + draftSession?.promotedTo + ? serverThreadStarted + ? draftSession.promotedTo + : null + : serverThread + ? { + environmentId: serverThread.environmentId, + threadId: serverThread.id, + } + : null, + [draftSession?.promotedTo, serverThread, serverThreadStarted], + ); + + useEffect(() => { + if (!canonicalThreadRef) { + return; + } + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(canonicalThreadRef), + replace: true, + }); + }, [canonicalThreadRef, navigate]); + + useEffect(() => { + if (draftSession || canonicalThreadRef) { + return; + } + void navigate({ to: "/", replace: true }); + }, [canonicalThreadRef, draftSession, navigate]); + + if (canonicalThreadRef) { + return ( + + + + ); + } + + if (!draftSession) { + return null; + } + + return ( + + + + ); +} + +export const Route = createFileRoute("/_chat/draft/$draftId")({ + component: DraftChatThreadRouteView, +}); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 1ce840a01a..7491fce005 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,3 +1,4 @@ +import { scopeProjectRef } from "@t3tools/client-runtime"; import { Outlet, createFileRoute } from "@tanstack/react-router"; import { useEffect } from "react"; @@ -12,13 +13,13 @@ import { useServerKeybindings } from "~/rpc/serverState"; function ChatRouteGlobalShortcuts() { const clearSelection = useThreadSelectionStore((state) => state.clearSelection); - const selectedThreadIdsSize = useThreadSelectionStore((state) => state.selectedThreadIds.size); - const { activeDraftThread, activeThread, defaultProjectId, handleNewThread, routeThreadId } = + const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size); + const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); const keybindings = useServerKeybindings(); const terminalOpen = useTerminalStateStore((state) => - routeThreadId - ? selectThreadTerminalState(state.terminalStateByThreadId, routeThreadId).terminalOpen + routeThreadRef + ? selectThreadTerminalState(state.terminalStateByThreadKey, routeThreadRef).terminalOpen : false, ); const appSettings = useSettings(); @@ -27,14 +28,18 @@ function ChatRouteGlobalShortcuts() { const onWindowKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; - if (event.key === "Escape" && selectedThreadIdsSize > 0) { + if (event.key === "Escape" && selectedThreadKeysSize > 0) { event.preventDefault(); clearSelection(); return; } - const projectId = activeThread?.projectId ?? activeDraftThread?.projectId ?? defaultProjectId; - if (!projectId) return; + const projectRef = activeThread + ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) + : activeDraftThread && routeThreadRef + ? scopeProjectRef(routeThreadRef.environmentId, activeDraftThread.projectId) + : defaultProjectRef; + if (!projectRef) return; const command = resolveShortcutCommand(event, keybindings, { context: { @@ -46,7 +51,7 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.newLocal") { event.preventDefault(); event.stopPropagation(); - void handleNewThread(projectId, { + void handleNewThread(projectRef, { envMode: resolveSidebarNewThreadEnvMode({ defaultEnvMode: appSettings.defaultThreadEnvMode, }), @@ -57,7 +62,7 @@ function ChatRouteGlobalShortcuts() { if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); - void handleNewThread(projectId, { + void handleNewThread(projectRef, { branch: activeThread?.branch ?? activeDraftThread?.branch ?? null, worktreePath: activeThread?.worktreePath ?? activeDraftThread?.worktreePath ?? null, envMode: @@ -77,8 +82,9 @@ function ChatRouteGlobalShortcuts() { clearSelection, handleNewThread, keybindings, - defaultProjectId, - selectedThreadIdsSize, + defaultProjectRef, + routeThreadRef, + selectedThreadKeysSize, terminalOpen, appSettings.defaultThreadEnvMode, ]); diff --git a/apps/web/src/rpc/serverState.test.ts b/apps/web/src/rpc/serverState.test.ts index 721ce25fb5..4eb198324d 100644 --- a/apps/web/src/rpc/serverState.test.ts +++ b/apps/web/src/rpc/serverState.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_SERVER_SETTINGS, + EnvironmentId, ProjectId, ThreadId, type ServerConfig, @@ -50,7 +51,21 @@ const defaultProviders: ReadonlyArray = [ }, ]; +const baseEnvironment = { + environmentId: EnvironmentId.makeUnsafe("environment-local"), + label: "Local environment", + platform: { + os: "darwin" as const, + arch: "arm64" as const, + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, +}; + const baseServerConfig: ServerConfig = { + environment: baseEnvironment, cwd: "/tmp/workspace", keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", keybindings: [], @@ -193,6 +208,7 @@ describe("serverState", () => { sequence: 1, type: "welcome", payload: { + environment: baseEnvironment, cwd: "/tmp/workspace", projectName: "t3-code", bootstrapProjectId: ProjectId.makeUnsafe("project-1"), @@ -201,6 +217,7 @@ describe("serverState", () => { }); expect(listener).toHaveBeenCalledWith({ + environment: baseEnvironment, cwd: "/tmp/workspace", projectName: "t3-code", bootstrapProjectId: ProjectId.makeUnsafe("project-1"), @@ -210,6 +227,7 @@ describe("serverState", () => { const lateListener = vi.fn(); const unsubscribeLate = onWelcome(lateListener); expect(lateListener).toHaveBeenCalledWith({ + environment: baseEnvironment, cwd: "/tmp/workspace", projectName: "t3-code", bootstrapProjectId: ProjectId.makeUnsafe("project-1"), diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 063f148f9c..ccde42d82b 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1,6 +1,7 @@ import { CheckpointRef, DEFAULT_MODEL_BY_PROVIDER, + EnvironmentId, EventId, MessageId, ProjectId, @@ -14,16 +15,49 @@ import { describe, expect, it } from "vitest"; import { applyOrchestrationEvent, applyOrchestrationEvents, - selectProjects, - selectThreads, + selectEnvironmentState, + selectProjectsAcrossEnvironments, + selectThreadsAcrossEnvironments, syncServerReadModel, type AppState, + type EnvironmentState, } from "./store"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); + +function withActiveEnvironmentState( + environmentState: EnvironmentState, + overrides: Partial = {}, +): AppState { + const { + activeEnvironmentId: overrideActiveEnvironmentId, + environmentStateById: overrideEnvironmentStateById, + ...environmentOverrides + } = overrides; + const activeEnvironmentId = overrideActiveEnvironmentId ?? localEnvironmentId; + const mergedEnvironmentState = { + ...environmentState, + ...environmentOverrides, + }; + const environmentStateById = + overrideEnvironmentStateById ?? + (activeEnvironmentId + ? { + [activeEnvironmentId]: mergedEnvironmentState, + } + : {}); + + return { + activeEnvironmentId, + environmentStateById, + }; +} + function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", @@ -52,6 +86,7 @@ function makeState(thread: Thread): AppState { const projectId = ProjectId.makeUnsafe("project-1"); const project = { id: projectId, + environmentId: localEnvironmentId, name: "Project", cwd: "/tmp/project", defaultModelSelection: { @@ -62,10 +97,10 @@ function makeState(thread: Thread): AppState { updatedAt: "2026-02-13T00:00:00.000Z", scripts: [], }; - const threadIdsByProjectId: AppState["threadIdsByProjectId"] = { + const threadIdsByProjectId: EnvironmentState["threadIdsByProjectId"] = { [thread.projectId]: [thread.id], }; - return { + const environmentState = { projectIds: [projectId], projectById: { [projectId]: project, @@ -75,6 +110,7 @@ function makeState(thread: Thread): AppState { threadShellById: { [thread.id]: { id: thread.id, + environmentId: thread.environmentId, codexThreadId: thread.codexThreadId, projectId: thread.projectId, title: thread.title, @@ -106,7 +142,7 @@ function makeState(thread: Thread): AppState { messageByThreadId: { [thread.id]: Object.fromEntries( thread.messages.map((message) => [message.id, message] as const), - ) as AppState["messageByThreadId"][ThreadId], + ) as EnvironmentState["messageByThreadId"][ThreadId], }, activityIdsByThreadId: { [thread.id]: thread.activities.map((activity) => activity.id), @@ -114,7 +150,7 @@ function makeState(thread: Thread): AppState { activityByThreadId: { [thread.id]: Object.fromEntries( thread.activities.map((activity) => [activity.id, activity] as const), - ) as AppState["activityByThreadId"][ThreadId], + ) as EnvironmentState["activityByThreadId"][ThreadId], }, proposedPlanIdsByThreadId: { [thread.id]: thread.proposedPlans.map((plan) => plan.id), @@ -122,7 +158,7 @@ function makeState(thread: Thread): AppState { proposedPlanByThreadId: { [thread.id]: Object.fromEntries( thread.proposedPlans.map((plan) => [plan.id, plan] as const), - ) as AppState["proposedPlanByThreadId"][ThreadId], + ) as EnvironmentState["proposedPlanByThreadId"][ThreadId], }, turnDiffIdsByThreadId: { [thread.id]: thread.turnDiffSummaries.map((summary) => summary.turnId), @@ -130,15 +166,16 @@ function makeState(thread: Thread): AppState { turnDiffSummaryByThreadId: { [thread.id]: Object.fromEntries( thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), - ) as AppState["turnDiffSummaryByThreadId"][ThreadId], + ) as EnvironmentState["turnDiffSummaryByThreadId"][ThreadId], }, sidebarThreadSummaryById: {}, bootstrapComplete: true, }; + return withActiveEnvironmentState(environmentState); } -function makeEmptyState(overrides: Partial = {}): AppState { - return { +function makeEmptyState(overrides: Partial = {}): AppState { + const environmentState: EnvironmentState = { projectIds: [], projectById: {}, threadIds: [], @@ -156,16 +193,20 @@ function makeEmptyState(overrides: Partial = {}): AppState { turnDiffSummaryByThreadId: {}, sidebarThreadSummaryById: {}, bootstrapComplete: true, - ...overrides, }; + return withActiveEnvironmentState(environmentState, overrides); +} + +function localEnvironmentStateOf(state: AppState): EnvironmentState { + return selectEnvironmentState(state, localEnvironmentId); } function projectsOf(state: AppState) { - return selectProjects(state); + return selectProjectsAcrossEnvironments(state); } function threadsOf(state: AppState) { - return selectThreads(state); + return selectThreadsAcrossEnvironments(state); } function makeEvent( @@ -266,14 +307,20 @@ function makeReadModelProject( describe("store read model sync", () => { it("marks bootstrap complete after snapshot sync", () => { - const initialState: AppState = { - ...makeState(makeThread()), - bootstrapComplete: false, - }; + const initialState = withActiveEnvironmentState( + localEnvironmentStateOf(makeState(makeThread())), + { + bootstrapComplete: false, + }, + ); - const next = syncServerReadModel(initialState, makeReadModel(makeReadModelThread({}))); + const next = syncServerReadModel( + initialState, + makeReadModel(makeReadModelThread({})), + localEnvironmentId, + ); - expect(next.bootstrapComplete).toBe(true); + expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(true); }); it("preserves claude model slugs without an active session", () => { @@ -287,7 +334,7 @@ describe("store read model sync", () => { }), ); - const next = syncServerReadModel(initialState, readModel); + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-opus-4-6"); }); @@ -312,7 +359,7 @@ describe("store read model sync", () => { }), ); - const next = syncServerReadModel(initialState, readModel); + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); expect(threadsOf(next)[0]?.modelSelection.model).toBe("claude-sonnet-4-6"); }); @@ -325,7 +372,7 @@ describe("store read model sync", () => { }), ); - const next = syncServerReadModel(initialState, readModel); + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); expect(projectsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:00:00.000Z"); expect(threadsOf(next)[0]?.updatedAt).toBe("2026-02-27T00:05:00.000Z"); @@ -341,6 +388,7 @@ describe("store read model sync", () => { archivedAt, }), ), + localEnvironmentId, ); expect(threadsOf(next)[0]?.archivedAt).toBe(archivedAt); @@ -355,6 +403,7 @@ describe("store read model sync", () => { projectById: { [project2]: { id: project2, + environmentId: localEnvironmentId, name: "Project 2", cwd: "/tmp/project-2", defaultModelSelection: { @@ -367,6 +416,7 @@ describe("store read model sync", () => { }, [project1]: { id: project1, + environmentId: localEnvironmentId, name: "Project 1", cwd: "/tmp/project-1", defaultModelSelection: { @@ -402,7 +452,7 @@ describe("store read model sync", () => { threads: [], }; - const next = syncServerReadModel(initialState, readModel); + const next = syncServerReadModel(initialState, readModel, localEnvironmentId); expect(projectsOf(next).map((project) => project.id)).toEqual([project1, project2, project3]); }); @@ -410,10 +460,9 @@ describe("store read model sync", () => { describe("incremental orchestration updates", () => { it("does not mark bootstrap complete for incremental events", () => { - const state: AppState = { - ...makeState(makeThread()), + const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(makeThread())), { bootstrapComplete: false, - }; + }); const next = applyOrchestrationEvent( state, @@ -422,30 +471,10 @@ describe("incremental orchestration updates", () => { title: "Updated title", updatedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); - expect(next.bootstrapComplete).toBe(false); - }); - - it("updates the existing project title when project.meta-updated arrives", () => { - const projectId = ProjectId.makeUnsafe("project-1"); - const state = makeState( - makeThread({ - projectId, - }), - ); - - const next = applyOrchestrationEvent( - state, - makeEvent("project.meta-updated", { - projectId, - title: "Renamed Project", - updatedAt: "2026-02-27T00:00:01.000Z", - }), - ); - - expect(next.projectById[projectId]?.name).toBe("Renamed Project"); - expect(next.projectById[projectId]?.updatedAt).toBe("2026-02-27T00:00:01.000Z"); + expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(false); }); it("preserves state identity for no-op project and thread deletes", () => { @@ -458,6 +487,7 @@ describe("incremental orchestration updates", () => { projectId: ProjectId.makeUnsafe("project-missing"), deletedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); const nextAfterThreadDelete = applyOrchestrationEvent( state, @@ -465,6 +495,7 @@ describe("incremental orchestration updates", () => { threadId: ThreadId.makeUnsafe("thread-missing"), deletedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); expect(nextAfterProjectDelete).toBe(state); @@ -479,6 +510,7 @@ describe("incremental orchestration updates", () => { projectById: { [originalProjectId]: { id: originalProjectId, + environmentId: localEnvironmentId, name: "Project", cwd: "/tmp/project", defaultModelSelection: { @@ -506,15 +538,18 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:01.000Z", updatedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); expect(projectsOf(next)).toHaveLength(1); expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId); expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project"); expect(projectsOf(next)[0]?.name).toBe("Project Recreated"); - expect(next.projectIds).toEqual([recreatedProjectId]); - expect(next.projectById[originalProjectId]).toBeUndefined(); - expect(next.projectById[recreatedProjectId]?.id).toBe(recreatedProjectId); + expect(localEnvironmentStateOf(next).projectIds).toEqual([recreatedProjectId]); + expect(localEnvironmentStateOf(next).projectById[originalProjectId]).toBeUndefined(); + expect(localEnvironmentStateOf(next).projectById[recreatedProjectId]?.id).toBe( + recreatedProjectId, + ); }); it("removes stale project index entries when thread.created recreates a thread under a new project", () => { @@ -525,12 +560,12 @@ describe("incremental orchestration updates", () => { id: threadId, projectId: originalProjectId, }); - const state: AppState = { - ...makeState(thread), + const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(thread)), { projectIds: [originalProjectId, recreatedProjectId], projectById: { [originalProjectId]: { id: originalProjectId, + environmentId: localEnvironmentId, name: "Project 1", cwd: "/tmp/project-1", defaultModelSelection: { @@ -543,6 +578,7 @@ describe("incremental orchestration updates", () => { }, [recreatedProjectId]: { id: recreatedProjectId, + environmentId: localEnvironmentId, name: "Project 2", cwd: "/tmp/project-2", defaultModelSelection: { @@ -554,7 +590,7 @@ describe("incremental orchestration updates", () => { scripts: [], }, }, - }; + }); const next = applyOrchestrationEvent( state, @@ -573,12 +609,15 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:01.000Z", updatedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); expect(threadsOf(next)).toHaveLength(1); expect(threadsOf(next)[0]?.projectId).toBe(recreatedProjectId); - expect(next.threadIdsByProjectId[originalProjectId]).toBeUndefined(); - expect(next.threadIdsByProjectId[recreatedProjectId]).toEqual([threadId]); + expect(localEnvironmentStateOf(next).threadIdsByProjectId[originalProjectId]).toBeUndefined(); + expect(localEnvironmentStateOf(next).threadIdsByProjectId[recreatedProjectId]).toEqual([ + threadId, + ]); }); it("updates only the affected thread for message events", () => { @@ -597,13 +636,15 @@ describe("incremental orchestration updates", () => { ], }); const thread2 = makeThread({ id: ThreadId.makeUnsafe("thread-2") }); - const state: AppState = { - ...makeState(thread1), + const baseState = makeState(thread1); + const baseEnvironmentState = localEnvironmentStateOf(baseState); + const state = withActiveEnvironmentState(baseEnvironmentState, { threadIds: [thread1.id, thread2.id], threadShellById: { - ...makeState(thread1).threadShellById, + ...baseEnvironmentState.threadShellById, [thread2.id]: { id: thread2.id, + environmentId: thread2.environmentId, codexThreadId: thread2.codexThreadId, projectId: thread2.projectId, title: thread2.title, @@ -619,54 +660,54 @@ describe("incremental orchestration updates", () => { }, }, threadSessionById: { - ...makeState(thread1).threadSessionById, + ...baseEnvironmentState.threadSessionById, [thread2.id]: thread2.session, }, threadTurnStateById: { - ...makeState(thread1).threadTurnStateById, + ...baseEnvironmentState.threadTurnStateById, [thread2.id]: { latestTurn: thread2.latestTurn, }, }, messageIdsByThreadId: { - ...makeState(thread1).messageIdsByThreadId, + ...baseEnvironmentState.messageIdsByThreadId, [thread2.id]: [], }, messageByThreadId: { - ...makeState(thread1).messageByThreadId, + ...baseEnvironmentState.messageByThreadId, [thread2.id]: {}, }, activityIdsByThreadId: { - ...makeState(thread1).activityIdsByThreadId, + ...baseEnvironmentState.activityIdsByThreadId, [thread2.id]: [], }, activityByThreadId: { - ...makeState(thread1).activityByThreadId, + ...baseEnvironmentState.activityByThreadId, [thread2.id]: {}, }, proposedPlanIdsByThreadId: { - ...makeState(thread1).proposedPlanIdsByThreadId, + ...baseEnvironmentState.proposedPlanIdsByThreadId, [thread2.id]: [], }, proposedPlanByThreadId: { - ...makeState(thread1).proposedPlanByThreadId, + ...baseEnvironmentState.proposedPlanByThreadId, [thread2.id]: {}, }, turnDiffIdsByThreadId: { - ...makeState(thread1).turnDiffIdsByThreadId, + ...baseEnvironmentState.turnDiffIdsByThreadId, [thread2.id]: [], }, turnDiffSummaryByThreadId: { - ...makeState(thread1).turnDiffSummaryByThreadId, + ...baseEnvironmentState.turnDiffSummaryByThreadId, [thread2.id]: {}, }, sidebarThreadSummaryById: { - ...makeState(thread1).sidebarThreadSummaryById, + ...baseEnvironmentState.sidebarThreadSummaryById, }, threadIdsByProjectId: { [thread1.projectId]: [thread1.id, thread2.id], }, - }; + }); const next = applyOrchestrationEvent( state, @@ -680,14 +721,25 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:01.000Z", updatedAt: "2026-02-27T00:00:01.000Z", }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world"); expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); - expect(next.threadShellById[thread2.id]).toBe(state.threadShellById[thread2.id]); - expect(next.threadSessionById[thread2.id]).toBe(state.threadSessionById[thread2.id]); - expect(next.messageIdsByThreadId[thread2.id]).toBe(state.messageIdsByThreadId[thread2.id]); - expect(next.messageByThreadId[thread2.id]).toBe(state.messageByThreadId[thread2.id]); + const nextEnvironmentState = next.environmentStateById[localEnvironmentId]; + const previousEnvironmentState = state.environmentStateById[localEnvironmentId]; + expect(nextEnvironmentState?.threadShellById[thread2.id]).toBe( + previousEnvironmentState?.threadShellById[thread2.id], + ); + expect(nextEnvironmentState?.threadSessionById[thread2.id]).toBe( + previousEnvironmentState?.threadSessionById[thread2.id], + ); + expect(nextEnvironmentState?.messageIdsByThreadId[thread2.id]).toBe( + previousEnvironmentState?.messageIdsByThreadId[thread2.id], + ); + expect(nextEnvironmentState?.messageByThreadId[thread2.id]).toBe( + previousEnvironmentState?.messageByThreadId[thread2.id], + ); }); it("applies replay batches in sequence and updates session state", () => { @@ -703,38 +755,42 @@ describe("incremental orchestration updates", () => { }); const state = makeState(thread); - const next = applyOrchestrationEvents(state, [ - makeEvent( - "thread.session-set", - { - threadId: thread.id, - session: { + const next = applyOrchestrationEvents( + state, + [ + makeEvent( + "thread.session-set", + { threadId: thread.id, - status: "running", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: TurnId.makeUnsafe("turn-1"), - lastError: null, - updatedAt: "2026-02-27T00:00:02.000Z", + session: { + threadId: thread.id, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: TurnId.makeUnsafe("turn-1"), + lastError: null, + updatedAt: "2026-02-27T00:00:02.000Z", + }, }, - }, - { sequence: 2 }, - ), - makeEvent( - "thread.message-sent", - { - threadId: thread.id, - messageId: MessageId.makeUnsafe("assistant-1"), - role: "assistant", - text: "done", - turnId: TurnId.makeUnsafe("turn-1"), - streaming: false, - createdAt: "2026-02-27T00:00:03.000Z", - updatedAt: "2026-02-27T00:00:03.000Z", - }, - { sequence: 3 }, - ), - ]); + { sequence: 2 }, + ), + makeEvent( + "thread.message-sent", + { + threadId: thread.id, + messageId: MessageId.makeUnsafe("assistant-1"), + role: "assistant", + text: "done", + turnId: TurnId.makeUnsafe("turn-1"), + streaming: false, + createdAt: "2026-02-27T00:00:03.000Z", + updatedAt: "2026-02-27T00:00:03.000Z", + }, + { sequence: 3 }, + ), + ], + localEnvironmentId, + ); expect(threadsOf(next)[0]?.session?.status).toBe("running"); expect(threadsOf(next)[0]?.latestTurn?.state).toBe("completed"); @@ -767,6 +823,7 @@ describe("incremental orchestration updates", () => { assistantMessageId: MessageId.makeUnsafe("assistant-1"), completedAt: "2026-02-27T00:00:04.000Z", }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1); @@ -811,6 +868,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:03.000Z", updatedAt: "2026-02-27T00:00:03.000Z", }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( @@ -920,6 +978,7 @@ describe("incremental orchestration updates", () => { threadId: ThreadId.makeUnsafe("thread-1"), turnCount: 1, }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([ @@ -978,6 +1037,7 @@ describe("incremental orchestration updates", () => { threadId: thread.id, turnCount: 1, }), + localEnvironmentId, ); expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined(); @@ -996,6 +1056,7 @@ describe("incremental orchestration updates", () => { updatedAt: "2026-02-27T00:00:04.000Z", }, }), + localEnvironmentId, ); expect(threadsOf(next)[0]?.latestTurn).toMatchObject({ diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 4fbb11942c..d5d7d436df 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,4 +1,5 @@ import { + type EnvironmentId, type MessageId, type OrchestrationCheckpointSummary, type OrchestrationEvent, @@ -11,6 +12,8 @@ import { type OrchestrationThreadActivity, type ProjectId, type ProviderKind, + type ScopedProjectRef, + type ScopedThreadRef, ThreadId, type TurnId, } from "@t3tools/contracts"; @@ -35,7 +38,7 @@ import { } from "./types"; import { sanitizeThreadErrorMessage } from "./rpc/transportError"; -export interface AppState { +export interface EnvironmentState { projectIds: ProjectId[]; projectById: Record; threadIds: ThreadId[]; @@ -55,7 +58,12 @@ export interface AppState { bootstrapComplete: boolean; } -const initialState: AppState = { +export interface AppState { + activeEnvironmentId: EnvironmentId | null; + environmentStateById: Record; +} + +const initialEnvironmentState: EnvironmentState = { projectIds: [], projectById: {}, threadIds: [], @@ -75,6 +83,11 @@ const initialState: AppState = { bootstrapComplete: false, }; +const initialState: AppState = { + activeEnvironmentId: null, + environmentStateById: {}, +}; + const MAX_THREAD_MESSAGES = 2_000; const MAX_THREAD_CHECKPOINTS = 500; const MAX_THREAD_PROPOSED_PLANS = 200; @@ -169,11 +182,16 @@ function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDif }; } -function mapProject(project: OrchestrationReadModel["projects"][number]): Project { +function mapProject( + project: OrchestrationReadModel["projects"][number], + environmentId: EnvironmentId, +): Project { return { id: project.id, + environmentId, name: project.title, cwd: project.workspaceRoot, + repositoryIdentity: project.repositoryIdentity ?? null, defaultModelSelection: project.defaultModelSelection ? normalizeModelSelection(project.defaultModelSelection) : null, @@ -183,9 +201,10 @@ function mapProject(project: OrchestrationReadModel["projects"][number]): Projec }; } -function mapThread(thread: OrchestrationThread): Thread { +function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): Thread { return { id: thread.id, + environmentId, codexThreadId: null, projectId: thread.projectId, title: thread.title, @@ -211,6 +230,7 @@ function mapThread(thread: OrchestrationThread): Thread { function toThreadShell(thread: Thread): ThreadShell { return { id: thread.id, + environmentId: thread.environmentId, codexThreadId: thread.codexThreadId, projectId: thread.projectId, title: thread.title, @@ -251,6 +271,7 @@ function getLatestUserMessageAt(messages: ReadonlyArray): string | function buildSidebarThreadSummary(thread: Thread): SidebarThreadSummary { return { id: thread.id, + environmentId: thread.environmentId, projectId: thread.projectId, title: thread.title, interactionMode: thread.interactionMode, @@ -298,6 +319,7 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b return ( left !== undefined && left.id === right.id && + left.environmentId === right.environmentId && left.codexThreadId === right.codexThreadId && left.projectId === right.projectId && left.title === right.title && @@ -377,7 +399,7 @@ function buildTurnDiffSlice(thread: Thread): { }; } -function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[] { +function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): ChatMessage[] { const ids = state.messageIdsByThreadId[threadId] ?? EMPTY_MESSAGE_IDS; const byId = state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP; if (ids.length === 0) { @@ -390,7 +412,7 @@ function selectThreadMessages(state: AppState, threadId: ThreadId): ChatMessage[ } function selectThreadActivities( - state: AppState, + state: EnvironmentState, threadId: ThreadId, ): OrchestrationThreadActivity[] { const ids = state.activityIdsByThreadId[threadId] ?? EMPTY_ACTIVITY_IDS; @@ -404,7 +426,7 @@ function selectThreadActivities( }); } -function selectThreadProposedPlans(state: AppState, threadId: ThreadId): ProposedPlan[] { +function selectThreadProposedPlans(state: EnvironmentState, threadId: ThreadId): ProposedPlan[] { const ids = state.proposedPlanIdsByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_IDS; const byId = state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP; if (ids.length === 0) { @@ -416,7 +438,10 @@ function selectThreadProposedPlans(state: AppState, threadId: ThreadId): Propose }); } -function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): TurnDiffSummary[] { +function selectThreadTurnDiffSummaries( + state: EnvironmentState, + threadId: ThreadId, +): TurnDiffSummary[] { const ids = state.turnDiffIdsByThreadId[threadId] ?? EMPTY_TURN_IDS; const byId = state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP; if (ids.length === 0) { @@ -428,7 +453,7 @@ function selectThreadTurnDiffSummaries(state: AppState, threadId: ThreadId): Tur }); } -function getThread(state: AppState, threadId: ThreadId): Thread | undefined { +function getThread(state: EnvironmentState, threadId: ThreadId): Thread | undefined { const shell = state.threadShellById[threadId]; if (!shell) { return undefined; @@ -446,21 +471,25 @@ function getThread(state: AppState, threadId: ThreadId): Thread | undefined { }; } -function getProjects(state: AppState): Project[] { +function getProjects(state: EnvironmentState): Project[] { return state.projectIds.flatMap((projectId) => { const project = state.projectById[projectId]; return project ? [project] : []; }); } -function getThreads(state: AppState): Thread[] { +function getThreads(state: EnvironmentState): Thread[] { return state.threadIds.flatMap((threadId) => { const thread = getThread(state, threadId); return thread ? [thread] : []; }); } -function writeThreadState(state: AppState, nextThread: Thread, previousThread?: Thread): AppState { +function writeThreadState( + state: EnvironmentState, + nextThread: Thread, + previousThread?: Thread, +): EnvironmentState { const nextShell = toThreadShell(nextThread); const nextTurnState = toThreadTurnState(nextThread); const previousShell = state.threadShellById[nextThread.id]; @@ -613,7 +642,7 @@ function writeThreadState(state: AppState, nextThread: Thread, previousThread?: return nextState; } -function removeThreadState(state: AppState, threadId: ThreadId): AppState { +function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState { const shell = state.threadShellById[threadId]; if (!shell) { return state; @@ -887,10 +916,10 @@ function attachmentPreviewRoutePath(attachmentId: string): string { } function updateThreadState( - state: AppState, + state: EnvironmentState, threadId: ThreadId, updater: (thread: Thread) => Thread, -): AppState { +): EnvironmentState { const currentThread = getThread(state, threadId); if (!currentThread) { return state; @@ -904,7 +933,7 @@ function updateThreadState( function buildProjectState( projects: ReadonlyArray, -): Pick { +): Pick { return { projectIds: projects.map((project) => project.id), projectById: Object.fromEntries( @@ -916,7 +945,7 @@ function buildProjectState( function buildThreadState( threads: ReadonlyArray, ): Pick< - AppState, + EnvironmentState, | "threadIds" | "threadIdsByProjectId" | "threadShellById" @@ -989,11 +1018,48 @@ function buildThreadState( }; } -export function syncServerReadModel(state: AppState, readModel: OrchestrationReadModel): AppState { +function getStoredEnvironmentState( + state: AppState, + environmentId: EnvironmentId, +): EnvironmentState { + return state.environmentStateById[environmentId] ?? initialEnvironmentState; +} + +function commitEnvironmentState( + state: AppState, + environmentId: EnvironmentId, + nextEnvironmentState: EnvironmentState, +): AppState { + const currentEnvironmentState = state.environmentStateById[environmentId]; + const environmentStateById = + currentEnvironmentState === nextEnvironmentState + ? state.environmentStateById + : { + ...state.environmentStateById, + [environmentId]: nextEnvironmentState, + }; + + if (environmentStateById === state.environmentStateById) { + return state; + } + + return { + ...state, + environmentStateById, + }; +} + +function syncEnvironmentReadModel( + state: EnvironmentState, + readModel: OrchestrationReadModel, + environmentId: EnvironmentId, +): EnvironmentState { const projects = readModel.projects .filter((project) => project.deletedAt === null) - .map(mapProject); - const threads = readModel.threads.filter((thread) => thread.deletedAt === null).map(mapThread); + .map((project) => mapProject(project, environmentId)); + const threads = readModel.threads + .filter((thread) => thread.deletedAt === null) + .map((thread) => mapThread(thread, environmentId)); return { ...state, ...buildProjectState(projects), @@ -1002,19 +1068,43 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea }; } -export function applyOrchestrationEvent(state: AppState, event: OrchestrationEvent): AppState { +export function syncServerReadModel( + state: AppState, + readModel: OrchestrationReadModel, + environmentId: EnvironmentId, +): AppState { + return commitEnvironmentState( + state, + environmentId, + syncEnvironmentReadModel( + getStoredEnvironmentState(state, environmentId), + readModel, + environmentId, + ), + ); +} + +function applyEnvironmentOrchestrationEvent( + state: EnvironmentState, + event: OrchestrationEvent, + environmentId: EnvironmentId, +): EnvironmentState { switch (event.type) { case "project.created": { - const nextProject = mapProject({ - id: event.payload.projectId, - title: event.payload.title, - workspaceRoot: event.payload.workspaceRoot, - defaultModelSelection: event.payload.defaultModelSelection, - scripts: event.payload.scripts, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - deletedAt: null, - }); + const nextProject = mapProject( + { + id: event.payload.projectId, + title: event.payload.title, + workspaceRoot: event.payload.workspaceRoot, + repositoryIdentity: event.payload.repositoryIdentity ?? null, + defaultModelSelection: event.payload.defaultModelSelection, + scripts: event.payload.scripts, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + deletedAt: null, + }, + environmentId, + ); const existingProjectId = state.projectIds.find( (projectId) => @@ -1060,6 +1150,9 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve ...project, ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), + ...(event.payload.repositoryIdentity !== undefined + ? { repositoryIdentity: event.payload.repositoryIdentity ?? null } + : {}), ...(event.payload.defaultModelSelection !== undefined ? { defaultModelSelection: event.payload.defaultModelSelection @@ -1095,26 +1188,29 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve case "thread.created": { const previousThread = getThread(state, event.payload.threadId); - const nextThread = mapThread({ - id: event.payload.threadId, - projectId: event.payload.projectId, - title: event.payload.title, - modelSelection: event.payload.modelSelection, - runtimeMode: event.payload.runtimeMode, - interactionMode: event.payload.interactionMode, - branch: event.payload.branch, - worktreePath: event.payload.worktreePath, - latestTurn: null, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - archivedAt: null, - deletedAt: null, - messages: [], - proposedPlans: [], - activities: [], - checkpoints: [], - session: null, - }); + const nextThread = mapThread( + { + id: event.payload.threadId, + projectId: event.payload.projectId, + title: event.payload.title, + modelSelection: event.payload.modelSelection, + runtimeMode: event.payload.runtimeMode, + interactionMode: event.payload.interactionMode, + branch: event.payload.branch, + worktreePath: event.payload.worktreePath, + latestTurn: null, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + archivedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + environmentId, + ); return writeThreadState(state, nextThread, previousThread); } @@ -1481,70 +1577,218 @@ export function applyOrchestrationEvent(state: AppState, event: OrchestrationEve export function applyOrchestrationEvents( state: AppState, events: ReadonlyArray, + environmentId: EnvironmentId, ): AppState { if (events.length === 0) { return state; } - return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state); -} - -export const selectProjects = (state: AppState): Project[] => getProjects(state); -export const selectThreads = (state: AppState): Thread[] => getThreads(state); -export const selectProjectById = - (projectId: Project["id"] | null | undefined) => - (state: AppState): Project | undefined => - projectId ? state.projectById[projectId] : undefined; -export const selectThreadById = - (threadId: ThreadId | null | undefined) => - (state: AppState): Thread | undefined => - threadId ? getThread(state, threadId) : undefined; -export const selectSidebarThreadSummaryById = - (threadId: ThreadId | null | undefined) => - (state: AppState): SidebarThreadSummary | undefined => - threadId ? state.sidebarThreadSummaryById[threadId] : undefined; -export const selectThreadIdsByProjectId = - (projectId: ProjectId | null | undefined) => - (state: AppState): ThreadId[] => - projectId ? (state.threadIdsByProjectId[projectId] ?? EMPTY_THREAD_IDS) : EMPTY_THREAD_IDS; + const currentEnvironmentState = getStoredEnvironmentState(state, environmentId); + const nextEnvironmentState = events.reduce( + (nextState, event) => applyEnvironmentOrchestrationEvent(nextState, event, environmentId), + currentEnvironmentState, + ); + return commitEnvironmentState(state, environmentId, nextEnvironmentState); +} -export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { - return updateThreadState(state, threadId, (thread) => { - if (thread.error === error) return thread; - return { ...thread, error }; +function getEnvironmentEntries( + state: AppState, +): ReadonlyArray { + return Object.entries(state.environmentStateById) as unknown as ReadonlyArray< + readonly [EnvironmentId, EnvironmentState] + >; +} + +export function selectEnvironmentState( + state: AppState, + environmentId: EnvironmentId | null | undefined, +): EnvironmentState { + return environmentId ? getStoredEnvironmentState(state, environmentId) : initialEnvironmentState; +} + +export function selectProjectsForEnvironment( + state: AppState, + environmentId: EnvironmentId | null | undefined, +): Project[] { + return getProjects(selectEnvironmentState(state, environmentId)); +} + +export function selectThreadsForEnvironment( + state: AppState, + environmentId: EnvironmentId | null | undefined, +): Thread[] { + return getThreads(selectEnvironmentState(state, environmentId)); +} + +export function selectProjectsAcrossEnvironments(state: AppState): Project[] { + return getEnvironmentEntries(state).flatMap(([, environmentState]) => + getProjects(environmentState), + ); +} + +export function selectThreadsAcrossEnvironments(state: AppState): Thread[] { + return getEnvironmentEntries(state).flatMap(([, environmentState]) => + getThreads(environmentState), + ); +} + +export function selectSidebarThreadsAcrossEnvironments(state: AppState): SidebarThreadSummary[] { + return getEnvironmentEntries(state).flatMap(([environmentId, environmentState]) => + environmentState.threadIds.flatMap((threadId) => { + const thread = environmentState.sidebarThreadSummaryById[threadId]; + return thread && thread.environmentId === environmentId ? [thread] : []; + }), + ); +} + +export function selectSidebarThreadsForProjectRef( + state: AppState, + ref: ScopedProjectRef | null | undefined, +): SidebarThreadSummary[] { + if (!ref) { + return []; + } + + const environmentState = selectEnvironmentState(state, ref.environmentId); + const threadIds = environmentState.threadIdsByProjectId[ref.projectId] ?? EMPTY_THREAD_IDS; + return threadIds.flatMap((threadId) => { + const thread = environmentState.sidebarThreadSummaryById[threadId]; + return thread ? [thread] : []; }); } +export function selectBootstrapCompleteForActiveEnvironment(state: AppState): boolean { + return selectEnvironmentState(state, state.activeEnvironmentId).bootstrapComplete; +} + +export function selectProjectByRef( + state: AppState, + ref: ScopedProjectRef | null | undefined, +): Project | undefined { + return ref + ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] + : undefined; +} + +export function selectThreadByRef( + state: AppState, + ref: ScopedThreadRef | null | undefined, +): Thread | undefined { + return ref + ? getThread(selectEnvironmentState(state, ref.environmentId), ref.threadId) + : undefined; +} + +export function selectSidebarThreadSummaryByRef( + state: AppState, + ref: ScopedThreadRef | null | undefined, +): SidebarThreadSummary | undefined { + return ref + ? selectEnvironmentState(state, ref.environmentId).sidebarThreadSummaryById[ref.threadId] + : undefined; +} + +export function selectThreadIdsByProjectRef( + state: AppState, + ref: ScopedProjectRef | null | undefined, +): ThreadId[] { + return ref + ? (selectEnvironmentState(state, ref.environmentId).threadIdsByProjectId[ref.projectId] ?? + EMPTY_THREAD_IDS) + : EMPTY_THREAD_IDS; +} + +export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { + if (state.activeEnvironmentId === null) { + return state; + } + + const nextEnvironmentState = updateThreadState( + getStoredEnvironmentState(state, state.activeEnvironmentId), + threadId, + (thread) => { + if (thread.error === error) return thread; + return { ...thread, error }; + }, + ); + return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState); +} + +export function applyOrchestrationEvent( + state: AppState, + event: OrchestrationEvent, + environmentId: EnvironmentId, +): AppState { + return commitEnvironmentState( + state, + environmentId, + applyEnvironmentOrchestrationEvent( + getStoredEnvironmentState(state, environmentId), + event, + environmentId, + ), + ); +} + +export function setActiveEnvironmentId(state: AppState, environmentId: EnvironmentId): AppState { + if (state.activeEnvironmentId === environmentId) { + return state; + } + + return { + ...state, + activeEnvironmentId: environmentId, + }; +} + export function setThreadBranch( state: AppState, threadId: ThreadId, branch: string | null, worktreePath: string | null, ): AppState { - return updateThreadState(state, threadId, (thread) => { - if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; - const cwdChanged = thread.worktreePath !== worktreePath; - return { - ...thread, - branch, - worktreePath, - ...(cwdChanged ? { session: null } : {}), - }; - }); + if (state.activeEnvironmentId === null) { + return state; + } + + const nextEnvironmentState = updateThreadState( + getStoredEnvironmentState(state, state.activeEnvironmentId), + threadId, + (thread) => { + if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; + const cwdChanged = thread.worktreePath !== worktreePath; + return { + ...thread, + branch, + worktreePath, + ...(cwdChanged ? { session: null } : {}), + }; + }, + ); + return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState); } interface AppStore extends AppState { - syncServerReadModel: (readModel: OrchestrationReadModel) => void; - applyOrchestrationEvent: (event: OrchestrationEvent) => void; - applyOrchestrationEvents: (events: ReadonlyArray) => void; + setActiveEnvironmentId: (environmentId: EnvironmentId) => void; + syncServerReadModel: (readModel: OrchestrationReadModel, environmentId: EnvironmentId) => void; + applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void; + applyOrchestrationEvents: ( + events: ReadonlyArray, + environmentId: EnvironmentId, + ) => void; setError: (threadId: ThreadId, error: string | null) => void; setThreadBranch: (threadId: ThreadId, branch: string | null, worktreePath: string | null) => void; } export const useStore = create((set) => ({ ...initialState, - syncServerReadModel: (readModel) => set((state) => syncServerReadModel(state, readModel)), - applyOrchestrationEvent: (event) => set((state) => applyOrchestrationEvent(state, event)), - applyOrchestrationEvents: (events) => set((state) => applyOrchestrationEvents(state, events)), + setActiveEnvironmentId: (environmentId) => + set((state) => setActiveEnvironmentId(state, environmentId)), + syncServerReadModel: (readModel, environmentId) => + set((state) => syncServerReadModel(state, readModel, environmentId)), + applyOrchestrationEvent: (event, environmentId) => + set((state) => applyOrchestrationEvent(state, event, environmentId)), + applyOrchestrationEvents: (events, environmentId) => + set((state) => applyOrchestrationEvents(state, events, environmentId)), setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadBranch: (threadId, branch, worktreePath) => set((state) => setThreadBranch(state, threadId, branch, worktreePath)), diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts index a7a7440eb2..84802ae6d5 100644 --- a/apps/web/src/storeSelectors.ts +++ b/apps/web/src/storeSelectors.ts @@ -1,5 +1,11 @@ -import { type MessageId, type ProjectId, type ThreadId, type TurnId } from "@t3tools/contracts"; -import { type AppState } from "./store"; +import { + type MessageId, + type ScopedProjectRef, + type ScopedThreadRef, + type ThreadId, + type TurnId, +} from "@t3tools/contracts"; +import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store"; import { type ChatMessage, type Project, @@ -30,54 +36,61 @@ function collectByIds( }); } -export function createProjectSelector( - projectId: ProjectId | null | undefined, +export function createProjectSelectorByRef( + ref: ScopedProjectRef | null | undefined, ): (state: AppState) => Project | undefined { - return (state) => (projectId ? state.projectById[projectId] : undefined); + return (state) => + ref ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] : undefined; } -export function createSidebarThreadSummarySelector( - threadId: ThreadId | null | undefined, +export function createSidebarThreadSummarySelectorByRef( + ref: ScopedThreadRef | null | undefined, ): (state: AppState) => SidebarThreadSummary | undefined { - return (state) => (threadId ? state.sidebarThreadSummaryById[threadId] : undefined); + return (state) => + ref + ? selectEnvironmentState(state, ref.environmentId).sidebarThreadSummaryById[ref.threadId] + : undefined; } -export function createThreadSelector( - threadId: ThreadId | null | undefined, +function createScopedThreadSelector( + resolveRef: (state: AppState) => ScopedThreadRef | null | undefined, ): (state: AppState) => Thread | undefined { - let previousShell: AppState["threadShellById"][ThreadId] | undefined; + let previousShell: EnvironmentState["threadShellById"][ThreadId] | undefined; let previousSession: ThreadSession | null | undefined; let previousTurnState: ThreadTurnState | undefined; let previousMessageIds: MessageId[] | undefined; - let previousMessagesById: AppState["messageByThreadId"][ThreadId] | undefined; + let previousMessagesById: EnvironmentState["messageByThreadId"][ThreadId] | undefined; let previousActivityIds: string[] | undefined; - let previousActivitiesById: AppState["activityByThreadId"][ThreadId] | undefined; + let previousActivitiesById: EnvironmentState["activityByThreadId"][ThreadId] | undefined; let previousProposedPlanIds: string[] | undefined; - let previousProposedPlansById: AppState["proposedPlanByThreadId"][ThreadId] | undefined; + let previousProposedPlansById: EnvironmentState["proposedPlanByThreadId"][ThreadId] | undefined; let previousTurnDiffIds: TurnId[] | undefined; - let previousTurnDiffsById: AppState["turnDiffSummaryByThreadId"][ThreadId] | undefined; + let previousTurnDiffsById: EnvironmentState["turnDiffSummaryByThreadId"][ThreadId] | undefined; let previousThread: Thread | undefined; return (state) => { - if (!threadId) { + const ref = resolveRef(state); + if (!ref) { return undefined; } - const shell = state.threadShellById[threadId]; + const environmentState = selectEnvironmentState(state, ref.environmentId); + const threadId = ref.threadId; + const shell = environmentState.threadShellById[threadId]; if (!shell) { return undefined; } - const session = state.threadSessionById[threadId] ?? null; - const turnState = state.threadTurnStateById[threadId]; - const messageIds = state.messageIdsByThreadId[threadId]; - const messageById = state.messageByThreadId[threadId]; - const activityIds = state.activityIdsByThreadId[threadId]; - const activityById = state.activityByThreadId[threadId]; - const proposedPlanIds = state.proposedPlanIdsByThreadId[threadId]; - const proposedPlanById = state.proposedPlanByThreadId[threadId]; - const turnDiffIds = state.turnDiffIdsByThreadId[threadId]; - const turnDiffById = state.turnDiffSummaryByThreadId[threadId]; + const session = environmentState.threadSessionById[threadId] ?? null; + const turnState = environmentState.threadTurnStateById[threadId]; + const messageIds = environmentState.messageIdsByThreadId[threadId]; + const messageById = environmentState.messageByThreadId[threadId]; + const activityIds = environmentState.activityIdsByThreadId[threadId]; + const activityById = environmentState.activityByThreadId[threadId]; + const proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId]; + const proposedPlanById = environmentState.proposedPlanByThreadId[threadId]; + const turnDiffIds = environmentState.turnDiffIdsByThreadId[threadId]; + const turnDiffById = environmentState.turnDiffSummaryByThreadId[threadId]; if ( previousThread && @@ -144,3 +157,31 @@ export function createThreadSelector( return previousThread; }; } + +export function createThreadSelectorByRef( + ref: ScopedThreadRef | null | undefined, +): (state: AppState) => Thread | undefined { + return createScopedThreadSelector(() => ref); +} + +export function createThreadSelectorAcrossEnvironments( + threadId: ThreadId | null | undefined, +): (state: AppState) => Thread | undefined { + return createScopedThreadSelector((state) => { + if (!threadId) { + return undefined; + } + + for (const [environmentId, environmentState] of Object.entries( + state.environmentStateById, + ) as Array<[ScopedThreadRef["environmentId"], EnvironmentState]>) { + if (environmentState.threadShellById[threadId]) { + return { + environmentId, + threadId, + }; + } + } + return undefined; + }); +} diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index 4ded76fba1..37d5ba5d0f 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -1,13 +1,17 @@ +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime"; import { ThreadId, type TerminalEvent } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vitest"; import { + migratePersistedTerminalStateStoreState, selectTerminalEventEntries, selectThreadTerminalState, useTerminalStateStore, } from "./terminalStateStore"; const THREAD_ID = ThreadId.makeUnsafe("thread-1"); +const THREAD_REF = scopeThreadRef("environment-a" as never, THREAD_ID); +const OTHER_THREAD_REF = scopeThreadRef("environment-b" as never, THREAD_ID); function makeTerminalEvent( type: TerminalEvent["type"], @@ -56,8 +60,8 @@ describe("terminalStateStore actions", () => { beforeEach(() => { useTerminalStateStore.persist.clearStorage(); useTerminalStateStore.setState({ - terminalStateByThreadId: {}, - terminalLaunchContextByThreadId: {}, + terminalStateByThreadKey: {}, + terminalLaunchContextByThreadKey: {}, terminalEventEntriesByKey: {}, nextTerminalEventId: 1, }); @@ -65,8 +69,8 @@ describe("terminalStateStore actions", () => { it("returns a closed default terminal state for unknown threads", () => { const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - THREAD_ID, + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, ); expect(terminalState).toEqual({ terminalOpen: false, @@ -81,12 +85,12 @@ describe("terminalStateStore actions", () => { it("opens and splits terminals into the active group", () => { const store = useTerminalStateStore.getState(); - store.setTerminalOpen(THREAD_ID, true); - store.splitTerminal(THREAD_ID, "terminal-2"); + store.setTerminalOpen(THREAD_REF, true); + store.splitTerminal(THREAD_REF, "terminal-2"); const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - THREAD_ID, + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, ); expect(terminalState.terminalOpen).toBe(true); expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); @@ -98,14 +102,14 @@ describe("terminalStateStore actions", () => { it("caps splits at four terminals per group", () => { const store = useTerminalStateStore.getState(); - store.splitTerminal(THREAD_ID, "terminal-2"); - store.splitTerminal(THREAD_ID, "terminal-3"); - store.splitTerminal(THREAD_ID, "terminal-4"); - store.splitTerminal(THREAD_ID, "terminal-5"); + store.splitTerminal(THREAD_REF, "terminal-2"); + store.splitTerminal(THREAD_REF, "terminal-3"); + store.splitTerminal(THREAD_REF, "terminal-4"); + store.splitTerminal(THREAD_REF, "terminal-5"); const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - THREAD_ID, + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, ); expect(terminalState.terminalIds).toEqual([ "default", @@ -119,11 +123,11 @@ describe("terminalStateStore actions", () => { }); it("creates new terminals in a separate group", () => { - useTerminalStateStore.getState().newTerminal(THREAD_ID, "terminal-2"); + useTerminalStateStore.getState().newTerminal(THREAD_REF, "terminal-2"); const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - THREAD_ID, + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, ); expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); expect(terminalState.activeTerminalId).toBe("terminal-2"); @@ -136,11 +140,11 @@ describe("terminalStateStore actions", () => { it("ensures unknown server terminals are registered, opened, and activated", () => { const store = useTerminalStateStore.getState(); - store.ensureTerminal(THREAD_ID, "setup-setup", { open: true, active: true }); + store.ensureTerminal(THREAD_REF, "setup-setup", { open: true, active: true }); const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - THREAD_ID, + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, ); expect(terminalState.terminalOpen).toBe(true); expect(terminalState.terminalIds).toEqual(["default", "setup-setup"]); @@ -151,69 +155,111 @@ describe("terminalStateStore actions", () => { ]); }); - it("allows unlimited groups while keeping each group capped at four terminals", () => { + it("keeps state isolated per environment when raw thread ids collide", () => { const store = useTerminalStateStore.getState(); - store.splitTerminal(THREAD_ID, "terminal-2"); - store.splitTerminal(THREAD_ID, "terminal-3"); - store.splitTerminal(THREAD_ID, "terminal-4"); - store.newTerminal(THREAD_ID, "terminal-5"); - store.newTerminal(THREAD_ID, "terminal-6"); + store.setTerminalOpen(THREAD_REF, true); + store.newTerminal(OTHER_THREAD_REF, "env-b-terminal"); - const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - THREAD_ID, + expect( + selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, + ).terminalOpen, + ).toBe(true); + expect( + selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + OTHER_THREAD_REF, + ).terminalIds, + ).toEqual(["default", "env-b-terminal"]); + }); + + it("migrates v1 persisted terminal state using the stored version", () => { + const migrated = migratePersistedTerminalStateStoreState( + { + terminalStateByThreadKey: { + [scopedThreadKey(THREAD_REF)]: { + terminalOpen: true, + terminalHeight: 320, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + }, + "legacy-thread-id": { + terminalOpen: true, + terminalHeight: 320, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + }, + }, + }, + 1, ); - expect(terminalState.terminalIds).toEqual([ - "default", - "terminal-2", - "terminal-3", - "terminal-4", - "terminal-5", - "terminal-6", - ]); - expect(terminalState.terminalGroups).toEqual([ - { id: "group-default", terminalIds: ["default", "terminal-2", "terminal-3", "terminal-4"] }, - { id: "group-terminal-5", terminalIds: ["terminal-5"] }, - { id: "group-terminal-6", terminalIds: ["terminal-6"] }, - ]); + + expect(migrated).toEqual({ + terminalStateByThreadKey: { + [scopedThreadKey(THREAD_REF)]: { + terminalOpen: true, + terminalHeight: 320, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + }, + }, + }); }); it("tracks and clears terminal subprocess activity", () => { const store = useTerminalStateStore.getState(); - store.splitTerminal(THREAD_ID, "terminal-2"); - store.setTerminalActivity(THREAD_ID, "terminal-2", true); + store.splitTerminal(THREAD_REF, "terminal-2"); + store.setTerminalActivity(THREAD_REF, "terminal-2", true); expect( - selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID) - .runningTerminalIds, + selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, + ).runningTerminalIds, ).toEqual(["terminal-2"]); - store.setTerminalActivity(THREAD_ID, "terminal-2", false); + store.setTerminalActivity(THREAD_REF, "terminal-2", false); expect( - selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID) - .runningTerminalIds, + selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, + ).runningTerminalIds, ).toEqual([]); }); it("resets to default and clears persisted entry when closing the last terminal", () => { const store = useTerminalStateStore.getState(); - store.closeTerminal(THREAD_ID, "default"); + store.closeTerminal(THREAD_REF, "default"); - expect(useTerminalStateStore.getState().terminalStateByThreadId[THREAD_ID]).toBeUndefined(); expect( - selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID) - .terminalIds, + useTerminalStateStore.getState().terminalStateByThreadKey[scopedThreadKey(THREAD_REF)], + ).toBeUndefined(); + expect( + selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, + ).terminalIds, ).toEqual(["default"]); }); it("keeps a valid active terminal after closing an active split terminal", () => { const store = useTerminalStateStore.getState(); - store.splitTerminal(THREAD_ID, "terminal-2"); - store.splitTerminal(THREAD_ID, "terminal-3"); - store.closeTerminal(THREAD_ID, "terminal-3"); + store.splitTerminal(THREAD_REF, "terminal-2"); + store.splitTerminal(THREAD_REF, "terminal-3"); + store.closeTerminal(THREAD_REF, "terminal-3"); const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - THREAD_ID, + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, ); expect(terminalState.activeTerminalId).toBe("terminal-2"); expect(terminalState.terminalIds).toEqual(["default", "terminal-2"]); @@ -224,12 +270,12 @@ describe("terminalStateStore actions", () => { it("buffers terminal events outside persisted terminal UI state", () => { const store = useTerminalStateStore.getState(); - store.recordTerminalEvent(makeTerminalEvent("output")); - store.recordTerminalEvent(makeTerminalEvent("activity")); + store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("output")); + store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("activity")); const entries = selectTerminalEventEntries( useTerminalStateStore.getState().terminalEventEntriesByKey, - THREAD_ID, + THREAD_REF, "default", ); @@ -241,6 +287,7 @@ describe("terminalStateStore actions", () => { it("applies started terminal events to terminal state, launch context, and event buffer", () => { const store = useTerminalStateStore.getState(); store.applyTerminalEvent( + THREAD_REF, makeTerminalEvent("started", { terminalId: "setup-bootstrap", snapshot: { @@ -259,19 +306,23 @@ describe("terminalStateStore actions", () => { ); const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - THREAD_ID, + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, ); const entries = selectTerminalEventEntries( useTerminalStateStore.getState().terminalEventEntriesByKey, - THREAD_ID, + THREAD_REF, "setup-bootstrap", ); expect(terminalState.terminalOpen).toBe(true); expect(terminalState.activeTerminalId).toBe("setup-bootstrap"); expect(terminalState.terminalIds).toEqual(["default", "setup-bootstrap"]); - expect(useTerminalStateStore.getState().terminalLaunchContextByThreadId[THREAD_ID]).toEqual({ + expect( + useTerminalStateStore.getState().terminalLaunchContextByThreadKey[ + scopedThreadKey(THREAD_REF) + ], + ).toEqual({ cwd: "/tmp/worktree", worktreePath: "/tmp/worktree", }); @@ -281,20 +332,24 @@ describe("terminalStateStore actions", () => { it("applies activity and exited terminal events to subprocess state while buffering events", () => { const store = useTerminalStateStore.getState(); - store.ensureTerminal(THREAD_ID, "terminal-2", { open: true, active: true }); + store.ensureTerminal(THREAD_REF, "terminal-2", { open: true, active: true }); store.applyTerminalEvent( + THREAD_REF, makeTerminalEvent("activity", { terminalId: "terminal-2", hasRunningSubprocess: true, }), ); expect( - selectThreadTerminalState(useTerminalStateStore.getState().terminalStateByThreadId, THREAD_ID) - .runningTerminalIds, + selectThreadTerminalState( + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, + ).runningTerminalIds, ).toEqual(["terminal-2"]); store.applyTerminalEvent( + THREAD_REF, makeTerminalEvent("exited", { terminalId: "terminal-2", exitCode: 0, @@ -303,12 +358,12 @@ describe("terminalStateStore actions", () => { ); const terminalState = selectThreadTerminalState( - useTerminalStateStore.getState().terminalStateByThreadId, - THREAD_ID, + useTerminalStateStore.getState().terminalStateByThreadKey, + THREAD_REF, ); const entries = selectTerminalEventEntries( useTerminalStateStore.getState().terminalEventEntriesByKey, - THREAD_ID, + THREAD_REF, "terminal-2", ); @@ -318,12 +373,12 @@ describe("terminalStateStore actions", () => { it("clears buffered terminal events when a thread terminal state is removed", () => { const store = useTerminalStateStore.getState(); - store.recordTerminalEvent(makeTerminalEvent("output")); - store.removeTerminalState(THREAD_ID); + store.recordTerminalEvent(THREAD_REF, makeTerminalEvent("output")); + store.removeTerminalState(THREAD_REF); const entries = selectTerminalEventEntries( useTerminalStateStore.getState().terminalEventEntriesByKey, - THREAD_ID, + THREAD_REF, "default", ); @@ -334,7 +389,7 @@ describe("terminalStateStore actions", () => { const store = useTerminalStateStore.getState(); const before = useTerminalStateStore.getState(); - store.clearTerminalState(THREAD_ID); + store.clearTerminalState(THREAD_REF); expect(useTerminalStateStore.getState()).toBe(before); }); diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index 7189e715a4..962c92f180 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -1,11 +1,12 @@ /** - * Single Zustand store for terminal UI state keyed by threadId. + * Single Zustand store for terminal UI state keyed by scoped thread identity. * * Terminal transition helpers are intentionally private to keep the public * API constrained to store actions/selectors. */ -import { ThreadId, type TerminalEvent } from "@t3tools/contracts"; +import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime"; +import { type ScopedThreadRef, type TerminalEvent } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; @@ -41,6 +42,26 @@ const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; const EMPTY_TERMINAL_EVENT_ENTRIES: ReadonlyArray = []; const MAX_TERMINAL_EVENT_BUFFER = 200; +interface PersistedTerminalStateStoreState { + terminalStateByThreadKey?: Record; +} + +export function migratePersistedTerminalStateStoreState( + persistedState: unknown, + version: number, +): PersistedTerminalStateStoreState { + if (version === 1 && persistedState && typeof persistedState === "object") { + const candidate = persistedState as PersistedTerminalStateStoreState; + const nextTerminalStateByThreadKey = Object.fromEntries( + Object.entries(candidate.terminalStateByThreadKey ?? {}).filter(([threadKey]) => + parseScopedThreadKey(threadKey), + ), + ); + return { terminalStateByThreadKey: nextTerminalStateByThreadKey }; + } + return { terminalStateByThreadKey: {} }; +} + function createTerminalStateStorage() { return resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined); } @@ -240,8 +261,12 @@ function isValidTerminalId(terminalId: string): boolean { return terminalId.trim().length > 0; } -function terminalEventBufferKey(threadId: ThreadId, terminalId: string): string { - return `${threadId}\u0000${terminalId}`; +function terminalThreadKey(threadRef: ScopedThreadRef): string { + return scopedThreadKey(threadRef); +} + +function terminalEventBufferKey(threadRef: ScopedThreadRef, terminalId: string): string { + return `${terminalThreadKey(threadRef)}\u0000${terminalId}`; } function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[] { @@ -254,9 +279,10 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[ function appendTerminalEventEntry( terminalEventEntriesByKey: Record>, nextTerminalEventId: number, + threadRef: ScopedThreadRef, event: TerminalEvent, ) { - const key = terminalEventBufferKey(ThreadId.makeUnsafe(event.threadId), event.terminalId); + const key = terminalEventBufferKey(threadRef, event.terminalId); const currentEntries = terminalEventEntriesByKey[key] ?? EMPTY_TERMINAL_EVENT_ENTRIES; const nextEntry: TerminalEventEntry = { id: nextTerminalEventId, @@ -484,125 +510,129 @@ function setThreadTerminalActivity( } export function selectThreadTerminalState( - terminalStateByThreadId: Record, - threadId: ThreadId, + terminalStateByThreadKey: Record, + threadRef: ScopedThreadRef | null | undefined, ): ThreadTerminalState { - if (threadId.length === 0) { + if (!threadRef || threadRef.threadId.length === 0) { return getDefaultThreadTerminalState(); } - return terminalStateByThreadId[threadId] ?? getDefaultThreadTerminalState(); + return terminalStateByThreadKey[terminalThreadKey(threadRef)] ?? getDefaultThreadTerminalState(); } -function updateTerminalStateByThreadId( - terminalStateByThreadId: Record, - threadId: ThreadId, +function updateTerminalStateByThreadKey( + terminalStateByThreadKey: Record, + threadRef: ScopedThreadRef, updater: (state: ThreadTerminalState) => ThreadTerminalState, -): Record { - if (threadId.length === 0) { - return terminalStateByThreadId; +): Record { + if (threadRef.threadId.length === 0) { + return terminalStateByThreadKey; } - const current = selectThreadTerminalState(terminalStateByThreadId, threadId); + const threadKey = terminalThreadKey(threadRef); + const current = selectThreadTerminalState(terminalStateByThreadKey, threadRef); const next = updater(current); if (next === current) { - return terminalStateByThreadId; + return terminalStateByThreadKey; } if (isDefaultThreadTerminalState(next)) { - if (terminalStateByThreadId[threadId] === undefined) { - return terminalStateByThreadId; + if (terminalStateByThreadKey[threadKey] === undefined) { + return terminalStateByThreadKey; } - const { [threadId]: _removed, ...rest } = terminalStateByThreadId; - return rest as Record; + const { [threadKey]: _removed, ...rest } = terminalStateByThreadKey; + return rest; } return { - ...terminalStateByThreadId, - [threadId]: next, + ...terminalStateByThreadKey, + [threadKey]: next, }; } export function selectTerminalEventEntries( terminalEventEntriesByKey: Record>, - threadId: ThreadId, + threadRef: ScopedThreadRef | null | undefined, terminalId: string, ): ReadonlyArray { - if (threadId.length === 0 || terminalId.trim().length === 0) { + if (!threadRef || threadRef.threadId.length === 0 || terminalId.trim().length === 0) { return EMPTY_TERMINAL_EVENT_ENTRIES; } return ( - terminalEventEntriesByKey[terminalEventBufferKey(threadId, terminalId)] ?? + terminalEventEntriesByKey[terminalEventBufferKey(threadRef, terminalId)] ?? EMPTY_TERMINAL_EVENT_ENTRIES ); } interface TerminalStateStoreState { - terminalStateByThreadId: Record; - terminalLaunchContextByThreadId: Record; + terminalStateByThreadKey: Record; + terminalLaunchContextByThreadKey: Record; terminalEventEntriesByKey: Record>; nextTerminalEventId: number; - setTerminalOpen: (threadId: ThreadId, open: boolean) => void; - setTerminalHeight: (threadId: ThreadId, height: number) => void; - splitTerminal: (threadId: ThreadId, terminalId: string) => void; - newTerminal: (threadId: ThreadId, terminalId: string) => void; + setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; + setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void; + splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; + newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; ensureTerminal: ( - threadId: ThreadId, + threadRef: ScopedThreadRef, terminalId: string, options?: { open?: boolean; active?: boolean }, ) => void; - setActiveTerminal: (threadId: ThreadId, terminalId: string) => void; - closeTerminal: (threadId: ThreadId, terminalId: string) => void; - setTerminalLaunchContext: (threadId: ThreadId, context: ThreadTerminalLaunchContext) => void; - clearTerminalLaunchContext: (threadId: ThreadId) => void; + setActiveTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; + closeTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; + setTerminalLaunchContext: ( + threadRef: ScopedThreadRef, + context: ThreadTerminalLaunchContext, + ) => void; + clearTerminalLaunchContext: (threadRef: ScopedThreadRef) => void; setTerminalActivity: ( - threadId: ThreadId, + threadRef: ScopedThreadRef, terminalId: string, hasRunningSubprocess: boolean, ) => void; - recordTerminalEvent: (event: TerminalEvent) => void; - applyTerminalEvent: (event: TerminalEvent) => void; - clearTerminalState: (threadId: ThreadId) => void; - removeTerminalState: (threadId: ThreadId) => void; - removeOrphanedTerminalStates: (activeThreadIds: Set) => void; + recordTerminalEvent: (threadRef: ScopedThreadRef, event: TerminalEvent) => void; + applyTerminalEvent: (threadRef: ScopedThreadRef, event: TerminalEvent) => void; + clearTerminalState: (threadRef: ScopedThreadRef) => void; + removeTerminalState: (threadRef: ScopedThreadRef) => void; + removeOrphanedTerminalStates: (activeThreadKeys: Set) => void; } export const useTerminalStateStore = create()( persist( (set) => { const updateTerminal = ( - threadId: ThreadId, + threadRef: ScopedThreadRef, updater: (state: ThreadTerminalState) => ThreadTerminalState, ) => { set((state) => { - const nextTerminalStateByThreadId = updateTerminalStateByThreadId( - state.terminalStateByThreadId, - threadId, + const nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( + state.terminalStateByThreadKey, + threadRef, updater, ); - if (nextTerminalStateByThreadId === state.terminalStateByThreadId) { + if (nextTerminalStateByThreadKey === state.terminalStateByThreadKey) { return state; } return { - terminalStateByThreadId: nextTerminalStateByThreadId, + terminalStateByThreadKey: nextTerminalStateByThreadKey, }; }); }; return { - terminalStateByThreadId: {}, - terminalLaunchContextByThreadId: {}, + terminalStateByThreadKey: {}, + terminalLaunchContextByThreadKey: {}, terminalEventEntriesByKey: {}, nextTerminalEventId: 1, - setTerminalOpen: (threadId, open) => - updateTerminal(threadId, (state) => setThreadTerminalOpen(state, open)), - setTerminalHeight: (threadId, height) => - updateTerminal(threadId, (state) => setThreadTerminalHeight(state, height)), - splitTerminal: (threadId, terminalId) => - updateTerminal(threadId, (state) => splitThreadTerminal(state, terminalId)), - newTerminal: (threadId, terminalId) => - updateTerminal(threadId, (state) => newThreadTerminal(state, terminalId)), - ensureTerminal: (threadId, terminalId, options) => - updateTerminal(threadId, (state) => { + setTerminalOpen: (threadRef, open) => + updateTerminal(threadRef, (state) => setThreadTerminalOpen(state, open)), + setTerminalHeight: (threadRef, height) => + updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)), + splitTerminal: (threadRef, terminalId) => + updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)), + newTerminal: (threadRef, terminalId) => + updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)), + ensureTerminal: (threadRef, terminalId, options) => + updateTerminal(threadRef, (state) => { let nextState = state; if (!state.terminalIds.includes(terminalId)) { nextState = newThreadTerminal(nextState, terminalId); @@ -622,47 +652,49 @@ export const useTerminalStateStore = create()( } return normalizeThreadTerminalState(nextState); }), - setActiveTerminal: (threadId, terminalId) => - updateTerminal(threadId, (state) => setThreadActiveTerminal(state, terminalId)), - closeTerminal: (threadId, terminalId) => - updateTerminal(threadId, (state) => closeThreadTerminal(state, terminalId)), - setTerminalLaunchContext: (threadId, context) => + setActiveTerminal: (threadRef, terminalId) => + updateTerminal(threadRef, (state) => setThreadActiveTerminal(state, terminalId)), + closeTerminal: (threadRef, terminalId) => + updateTerminal(threadRef, (state) => closeThreadTerminal(state, terminalId)), + setTerminalLaunchContext: (threadRef, context) => set((state) => ({ - terminalLaunchContextByThreadId: { - ...state.terminalLaunchContextByThreadId, - [threadId]: context, + terminalLaunchContextByThreadKey: { + ...state.terminalLaunchContextByThreadKey, + [terminalThreadKey(threadRef)]: context, }, })), - clearTerminalLaunchContext: (threadId) => + clearTerminalLaunchContext: (threadRef) => set((state) => { - if (!state.terminalLaunchContextByThreadId[threadId]) { + const threadKey = terminalThreadKey(threadRef); + if (!state.terminalLaunchContextByThreadKey[threadKey]) { return state; } - const { [threadId]: _removed, ...rest } = state.terminalLaunchContextByThreadId; - return { terminalLaunchContextByThreadId: rest }; + const { [threadKey]: _removed, ...rest } = state.terminalLaunchContextByThreadKey; + return { terminalLaunchContextByThreadKey: rest }; }), - setTerminalActivity: (threadId, terminalId, hasRunningSubprocess) => - updateTerminal(threadId, (state) => + setTerminalActivity: (threadRef, terminalId, hasRunningSubprocess) => + updateTerminal(threadRef, (state) => setThreadTerminalActivity(state, terminalId, hasRunningSubprocess), ), - recordTerminalEvent: (event) => + recordTerminalEvent: (threadRef, event) => set((state) => appendTerminalEventEntry( state.terminalEventEntriesByKey, state.nextTerminalEventId, + threadRef, event, ), ), - applyTerminalEvent: (event) => + applyTerminalEvent: (threadRef, event) => set((state) => { - const threadId = ThreadId.makeUnsafe(event.threadId); - let nextTerminalStateByThreadId = state.terminalStateByThreadId; - let nextTerminalLaunchContextByThreadId = state.terminalLaunchContextByThreadId; + const threadKey = terminalThreadKey(threadRef); + let nextTerminalStateByThreadKey = state.terminalStateByThreadKey; + let nextTerminalLaunchContextByThreadKey = state.terminalLaunchContextByThreadKey; if (event.type === "started" || event.type === "restarted") { - nextTerminalStateByThreadId = updateTerminalStateByThreadId( - nextTerminalStateByThreadId, - threadId, + nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( + nextTerminalStateByThreadKey, + threadRef, (current) => { let nextState = current; if (!current.terminalIds.includes(event.terminalId)) { @@ -673,17 +705,17 @@ export const useTerminalStateStore = create()( return normalizeThreadTerminalState(nextState); }, ); - nextTerminalLaunchContextByThreadId = { - ...nextTerminalLaunchContextByThreadId, - [threadId]: launchContextFromStartEvent(event), + nextTerminalLaunchContextByThreadKey = { + ...nextTerminalLaunchContextByThreadKey, + [threadKey]: launchContextFromStartEvent(event), }; } const hasRunningSubprocess = terminalRunningSubprocessFromEvent(event); if (hasRunningSubprocess !== null) { - nextTerminalStateByThreadId = updateTerminalStateByThreadId( - nextTerminalStateByThreadId, - threadId, + nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( + nextTerminalStateByThreadKey, + threadRef, (current) => setThreadTerminalActivity(current, event.terminalId, hasRunningSubprocess), ); @@ -692,54 +724,59 @@ export const useTerminalStateStore = create()( const nextEventState = appendTerminalEventEntry( state.terminalEventEntriesByKey, state.nextTerminalEventId, + threadRef, event, ); return { - terminalStateByThreadId: nextTerminalStateByThreadId, - terminalLaunchContextByThreadId: nextTerminalLaunchContextByThreadId, + terminalStateByThreadKey: nextTerminalStateByThreadKey, + terminalLaunchContextByThreadKey: nextTerminalLaunchContextByThreadKey, ...nextEventState, }; }), - clearTerminalState: (threadId) => + clearTerminalState: (threadRef) => set((state) => { - const nextTerminalStateByThreadId = updateTerminalStateByThreadId( - state.terminalStateByThreadId, - threadId, + const threadKey = terminalThreadKey(threadRef); + const nextTerminalStateByThreadKey = updateTerminalStateByThreadKey( + state.terminalStateByThreadKey, + threadRef, () => createDefaultThreadTerminalState(), ); - const hadLaunchContext = state.terminalLaunchContextByThreadId[threadId] !== undefined; - const { [threadId]: _removed, ...remainingLaunchContexts } = - state.terminalLaunchContextByThreadId; + const hadLaunchContext = + state.terminalLaunchContextByThreadKey[threadKey] !== undefined; + const { [threadKey]: _removed, ...remainingLaunchContexts } = + state.terminalLaunchContextByThreadKey; const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; let removedEventEntries = false; for (const key of Object.keys(nextTerminalEventEntriesByKey)) { - if (key.startsWith(`${threadId}\u0000`)) { + if (key.startsWith(`${threadKey}\u0000`)) { delete nextTerminalEventEntriesByKey[key]; removedEventEntries = true; } } if ( - nextTerminalStateByThreadId === state.terminalStateByThreadId && + nextTerminalStateByThreadKey === state.terminalStateByThreadKey && !hadLaunchContext && !removedEventEntries ) { return state; } return { - terminalStateByThreadId: nextTerminalStateByThreadId, - terminalLaunchContextByThreadId: remainingLaunchContexts, + terminalStateByThreadKey: nextTerminalStateByThreadKey, + terminalLaunchContextByThreadKey: remainingLaunchContexts, terminalEventEntriesByKey: nextTerminalEventEntriesByKey, }; }), - removeTerminalState: (threadId) => + removeTerminalState: (threadRef) => set((state) => { - const hadTerminalState = state.terminalStateByThreadId[threadId] !== undefined; - const hadLaunchContext = state.terminalLaunchContextByThreadId[threadId] !== undefined; + const threadKey = terminalThreadKey(threadRef); + const hadTerminalState = state.terminalStateByThreadKey[threadKey] !== undefined; + const hadLaunchContext = + state.terminalLaunchContextByThreadKey[threadKey] !== undefined; const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; let removedEventEntries = false; for (const key of Object.keys(nextTerminalEventEntriesByKey)) { - if (key.startsWith(`${threadId}\u0000`)) { + if (key.startsWith(`${threadKey}\u0000`)) { delete nextTerminalEventEntriesByKey[key]; removedEventEntries = true; } @@ -747,29 +784,29 @@ export const useTerminalStateStore = create()( if (!hadTerminalState && !hadLaunchContext && !removedEventEntries) { return state; } - const nextTerminalStateByThreadId = { ...state.terminalStateByThreadId }; - delete nextTerminalStateByThreadId[threadId]; - const nextLaunchContexts = { ...state.terminalLaunchContextByThreadId }; - delete nextLaunchContexts[threadId]; + const nextTerminalStateByThreadKey = { ...state.terminalStateByThreadKey }; + delete nextTerminalStateByThreadKey[threadKey]; + const nextLaunchContexts = { ...state.terminalLaunchContextByThreadKey }; + delete nextLaunchContexts[threadKey]; return { - terminalStateByThreadId: nextTerminalStateByThreadId, - terminalLaunchContextByThreadId: nextLaunchContexts, + terminalStateByThreadKey: nextTerminalStateByThreadKey, + terminalLaunchContextByThreadKey: nextLaunchContexts, terminalEventEntriesByKey: nextTerminalEventEntriesByKey, }; }), - removeOrphanedTerminalStates: (activeThreadIds) => + removeOrphanedTerminalStates: (activeThreadKeys) => set((state) => { - const orphanedIds = Object.keys(state.terminalStateByThreadId).filter( - (id) => !activeThreadIds.has(id as ThreadId), + const orphanedIds = Object.keys(state.terminalStateByThreadKey).filter( + (key) => !activeThreadKeys.has(key), ); const orphanedLaunchContextIds = Object.keys( - state.terminalLaunchContextByThreadId, - ).filter((id) => !activeThreadIds.has(id as ThreadId)); + state.terminalLaunchContextByThreadKey, + ).filter((key) => !activeThreadKeys.has(key)); const nextTerminalEventEntriesByKey = { ...state.terminalEventEntriesByKey }; let removedEventEntries = false; for (const key of Object.keys(nextTerminalEventEntriesByKey)) { - const [threadId] = key.split("\u0000"); - if (threadId && !activeThreadIds.has(threadId as ThreadId)) { + const [threadKey] = key.split("\u0000"); + if (threadKey && !activeThreadKeys.has(threadKey)) { delete nextTerminalEventEntriesByKey[key]; removedEventEntries = true; } @@ -781,17 +818,17 @@ export const useTerminalStateStore = create()( ) { return state; } - const next = { ...state.terminalStateByThreadId }; + const next = { ...state.terminalStateByThreadKey }; for (const id of orphanedIds) { - delete next[id as ThreadId]; + delete next[id]; } - const nextLaunchContexts = { ...state.terminalLaunchContextByThreadId }; + const nextLaunchContexts = { ...state.terminalLaunchContextByThreadKey }; for (const id of orphanedLaunchContextIds) { - delete nextLaunchContexts[id as ThreadId]; + delete nextLaunchContexts[id]; } return { - terminalStateByThreadId: next, - terminalLaunchContextByThreadId: nextLaunchContexts, + terminalStateByThreadKey: next, + terminalLaunchContextByThreadKey: nextLaunchContexts, terminalEventEntriesByKey: nextTerminalEventEntriesByKey, }; }), @@ -799,10 +836,11 @@ export const useTerminalStateStore = create()( }, { name: TERMINAL_STATE_STORAGE_KEY, - version: 1, + version: 2, storage: createJSONStorage(createTerminalStateStorage), + migrate: migratePersistedTerminalStateStoreState, partialize: (state) => ({ - terminalStateByThreadId: state.terminalStateByThreadId, + terminalStateByThreadKey: state.terminalStateByThreadKey, }), }, ), diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts new file mode 100644 index 0000000000..b2010dd16d --- /dev/null +++ b/apps/web/src/threadRoutes.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { scopeThreadRef } from "@t3tools/client-runtime"; +import { ThreadId } from "@t3tools/contracts"; +import { DraftId } from "./composerDraftStore"; + +import { + buildDraftThreadRouteParams, + buildThreadRouteParams, + resolveThreadRouteRef, + resolveThreadRouteTarget, +} from "./threadRoutes"; + +describe("threadRoutes", () => { + it("builds canonical thread route params from a scoped ref", () => { + const ref = scopeThreadRef("env-1" as never, ThreadId.makeUnsafe("thread-1")); + + expect(buildThreadRouteParams(ref)).toEqual({ + environmentId: "env-1", + threadId: "thread-1", + }); + }); + + it("resolves a scoped ref only when both params are present", () => { + expect( + resolveThreadRouteRef({ + environmentId: "env-1", + threadId: "thread-1", + }), + ).toEqual({ + environmentId: "env-1", + threadId: "thread-1", + }); + + expect(resolveThreadRouteRef({ environmentId: "env-1" })).toBeNull(); + expect(resolveThreadRouteRef({ threadId: "thread-1" })).toBeNull(); + }); + + it("builds canonical draft route params from a draft id", () => { + expect(buildDraftThreadRouteParams(DraftId.makeUnsafe("draft-1"))).toEqual({ + draftId: "draft-1", + }); + }); + + it("resolves draft and server route targets", () => { + expect( + resolveThreadRouteTarget({ + environmentId: "env-1", + threadId: "thread-1", + }), + ).toEqual({ + kind: "server", + threadRef: { + environmentId: "env-1", + threadId: "thread-1", + }, + }); + + expect( + resolveThreadRouteTarget({ + draftId: "draft-1", + }), + ).toEqual({ + kind: "draft", + draftId: "draft-1", + }); + }); +}); diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts new file mode 100644 index 0000000000..3fda9eb423 --- /dev/null +++ b/apps/web/src/threadRoutes.ts @@ -0,0 +1,59 @@ +import { scopeThreadRef } from "@t3tools/client-runtime"; +import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import type { DraftId } from "./composerDraftStore"; + +export type ThreadRouteTarget = + | { + kind: "server"; + threadRef: ScopedThreadRef; + } + | { + kind: "draft"; + draftId: DraftId; + }; + +export function buildThreadRouteParams(ref: ScopedThreadRef): { + environmentId: EnvironmentId; + threadId: ThreadId; +} { + return { + environmentId: ref.environmentId, + threadId: ref.threadId, + }; +} + +export function buildDraftThreadRouteParams(draftId: DraftId): { + draftId: DraftId; +} { + return { draftId }; +} + +export function resolveThreadRouteRef( + params: Partial>, +): ScopedThreadRef | null { + if (!params.environmentId || !params.threadId) { + return null; + } + + return scopeThreadRef(params.environmentId as EnvironmentId, params.threadId as ThreadId); +} + +export function resolveThreadRouteTarget( + params: Partial>, +): ThreadRouteTarget | null { + if (params.environmentId && params.threadId) { + return { + kind: "server", + threadRef: scopeThreadRef(params.environmentId as EnvironmentId, params.threadId as ThreadId), + }; + } + + if (!params.draftId) { + return null; + } + + return { + kind: "draft", + draftId: params.draftId as DraftId, + }; +} diff --git a/apps/web/src/threadSelectionStore.test.ts b/apps/web/src/threadSelectionStore.test.ts index b142c5c7d2..818cebc72d 100644 --- a/apps/web/src/threadSelectionStore.test.ts +++ b/apps/web/src/threadSelectionStore.test.ts @@ -21,9 +21,9 @@ describe("threadSelectionStore", () => { useThreadSelectionStore.getState().toggleThread(THREAD_A); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_A)).toBe(true); - expect(state.selectedThreadIds.size).toBe(1); - expect(state.anchorThreadId).toBe(THREAD_A); + expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true); + expect(state.selectedThreadKeys.size).toBe(1); + expect(state.anchorThreadKey).toBe(THREAD_A); }); it("removes a thread that is already selected", () => { @@ -32,8 +32,8 @@ describe("threadSelectionStore", () => { store.toggleThread(THREAD_A); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_A)).toBe(false); - expect(state.selectedThreadIds.size).toBe(0); + expect(state.selectedThreadKeys.has(THREAD_A)).toBe(false); + expect(state.selectedThreadKeys.size).toBe(0); }); it("preserves existing selections when toggling a new thread", () => { @@ -42,9 +42,9 @@ describe("threadSelectionStore", () => { store.toggleThread(THREAD_B); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_A)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); - expect(state.selectedThreadIds.size).toBe(2); + expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); + expect(state.selectedThreadKeys.size).toBe(2); }); it("sets anchor to the newly added thread", () => { @@ -52,7 +52,7 @@ describe("threadSelectionStore", () => { store.toggleThread(THREAD_A); store.toggleThread(THREAD_B); - expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B); + expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B); }); it("preserves anchor when deselecting a non-anchor thread", () => { @@ -61,7 +61,7 @@ describe("threadSelectionStore", () => { store.toggleThread(THREAD_B); store.toggleThread(THREAD_A); // deselect A, anchor should stay B - expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B); + expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B); }); }); @@ -70,8 +70,8 @@ describe("threadSelectionStore", () => { useThreadSelectionStore.getState().setAnchor(THREAD_B); const state = useThreadSelectionStore.getState(); - expect(state.anchorThreadId).toBe(THREAD_B); - expect(state.selectedThreadIds.size).toBe(0); + expect(state.anchorThreadKey).toBe(THREAD_B); + expect(state.selectedThreadKeys.size).toBe(0); }); it("enables range select from a plain-click anchor", () => { @@ -80,10 +80,10 @@ describe("threadSelectionStore", () => { store.rangeSelectTo(THREAD_D, ORDERED); // shift-click D const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); - expect(state.selectedThreadIds.size).toBe(3); + expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); + expect(state.selectedThreadKeys.size).toBe(3); }); it("is a no-op when anchor is already set to the same thread", () => { @@ -105,8 +105,8 @@ describe("threadSelectionStore", () => { store.setAnchor(THREAD_C); const state = useThreadSelectionStore.getState(); - expect(state.anchorThreadId).toBe(THREAD_C); - expect(state.selectedThreadIds.size).toBe(0); + expect(state.anchorThreadKey).toBe(THREAD_C); + expect(state.selectedThreadKeys.size).toBe(0); }); }); @@ -115,9 +115,9 @@ describe("threadSelectionStore", () => { useThreadSelectionStore.getState().rangeSelectTo(THREAD_C, ORDERED); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); - expect(state.selectedThreadIds.size).toBe(1); - expect(state.anchorThreadId).toBe(THREAD_C); + expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); + expect(state.selectedThreadKeys.size).toBe(1); + expect(state.anchorThreadKey).toBe(THREAD_C); }); it("selects range from anchor to target (forward)", () => { @@ -126,10 +126,10 @@ describe("threadSelectionStore", () => { store.rangeSelectTo(THREAD_D, ORDERED); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); - expect(state.selectedThreadIds.size).toBe(3); + expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); + expect(state.selectedThreadKeys.size).toBe(3); }); it("selects range from anchor to target (backward)", () => { @@ -138,10 +138,10 @@ describe("threadSelectionStore", () => { store.rangeSelectTo(THREAD_B, ORDERED); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); - expect(state.selectedThreadIds.size).toBe(3); + expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); + expect(state.selectedThreadKeys.size).toBe(3); }); it("keeps anchor stable across multiple range selects", () => { @@ -151,11 +151,11 @@ describe("threadSelectionStore", () => { store.rangeSelectTo(THREAD_E, ORDERED); // extends B-E (anchor stays B) const state = useThreadSelectionStore.getState(); - expect(state.anchorThreadId).toBe(THREAD_B); - expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_E)).toBe(true); + expect(state.anchorThreadKey).toBe(THREAD_B); + expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_E)).toBe(true); }); it("falls back to toggle when anchor is not in the ordered list", () => { @@ -166,8 +166,8 @@ describe("threadSelectionStore", () => { const state = useThreadSelectionStore.getState(); // Should have added C and reset anchor to C - expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); - expect(state.anchorThreadId).toBe(THREAD_C); + expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); + expect(state.anchorThreadKey).toBe(THREAD_C); }); it("falls back to toggle when target is not in the ordered list", () => { @@ -177,8 +177,8 @@ describe("threadSelectionStore", () => { store.rangeSelectTo(unknownThread, ORDERED); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(unknownThread)).toBe(true); - expect(state.anchorThreadId).toBe(unknownThread); + expect(state.selectedThreadKeys.has(unknownThread)).toBe(true); + expect(state.anchorThreadKey).toBe(unknownThread); }); it("selects the single thread when anchor equals target", () => { @@ -187,8 +187,8 @@ describe("threadSelectionStore", () => { store.rangeSelectTo(THREAD_C, ORDERED); // range from C to C const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); - expect(state.selectedThreadIds.size).toBe(1); + expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); + expect(state.selectedThreadKeys.size).toBe(1); }); it("preserves previously selected threads outside the range", () => { @@ -200,11 +200,11 @@ describe("threadSelectionStore", () => { store.rangeSelectTo(THREAD_D, ORDERED); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_A)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_C)).toBe(true); - expect(state.selectedThreadIds.has(THREAD_D)).toBe(true); - expect(state.selectedThreadIds.size).toBe(4); + expect(state.selectedThreadKeys.has(THREAD_A)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_C)).toBe(true); + expect(state.selectedThreadKeys.has(THREAD_D)).toBe(true); + expect(state.selectedThreadKeys.size).toBe(4); }); }); @@ -216,8 +216,8 @@ describe("threadSelectionStore", () => { store.clearSelection(); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.size).toBe(0); - expect(state.anchorThreadId).toBeNull(); + expect(state.selectedThreadKeys.size).toBe(0); + expect(state.anchorThreadKey).toBeNull(); }); it("is a no-op when already empty", () => { @@ -226,7 +226,7 @@ describe("threadSelectionStore", () => { const stateAfter = useThreadSelectionStore.getState(); // Should be referentially the same (no unnecessary re-render) - expect(stateAfter.selectedThreadIds).toBe(stateBefore.selectedThreadIds); + expect(stateAfter.selectedThreadKeys).toBe(stateBefore.selectedThreadKeys); }); }); @@ -239,8 +239,8 @@ describe("threadSelectionStore", () => { store.removeFromSelection([THREAD_A, THREAD_C]); const state = useThreadSelectionStore.getState(); - expect(state.selectedThreadIds.has(THREAD_B)).toBe(true); - expect(state.selectedThreadIds.size).toBe(1); + expect(state.selectedThreadKeys.has(THREAD_B)).toBe(true); + expect(state.selectedThreadKeys.size).toBe(1); }); it("clears anchor when the anchor thread is removed", () => { @@ -249,7 +249,7 @@ describe("threadSelectionStore", () => { store.toggleThread(THREAD_B); // anchor = B store.removeFromSelection([THREAD_B]); - expect(useThreadSelectionStore.getState().anchorThreadId).toBeNull(); + expect(useThreadSelectionStore.getState().anchorThreadKey).toBeNull(); }); it("preserves anchor when the anchor thread is not removed", () => { @@ -258,7 +258,7 @@ describe("threadSelectionStore", () => { store.toggleThread(THREAD_B); // anchor = B store.removeFromSelection([THREAD_A]); - expect(useThreadSelectionStore.getState().anchorThreadId).toBe(THREAD_B); + expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_B); }); it("is a no-op when none of the specified threads are selected", () => { @@ -268,7 +268,7 @@ describe("threadSelectionStore", () => { store.removeFromSelection([THREAD_B, THREAD_C]); const stateAfter = useThreadSelectionStore.getState(); - expect(stateAfter.selectedThreadIds).toBe(stateBefore.selectedThreadIds); + expect(stateAfter.selectedThreadKeys).toBe(stateBefore.selectedThreadKeys); }); }); diff --git a/apps/web/src/threadSelectionStore.ts b/apps/web/src/threadSelectionStore.ts index 8360bc5c6f..2b4022a68f 100644 --- a/apps/web/src/threadSelectionStore.ts +++ b/apps/web/src/threadSelectionStore.ts @@ -4,121 +4,119 @@ * Supports Cmd/Ctrl+Click (toggle individual), Shift+Click (range select), * and bulk actions on the selected set. */ - -import type { ThreadId } from "@t3tools/contracts"; import { create } from "zustand"; export interface ThreadSelectionState { - /** Currently selected thread IDs. */ - selectedThreadIds: ReadonlySet; - /** The thread ID that anchors shift-click range selection. */ - anchorThreadId: ThreadId | null; + /** Currently selected scoped thread keys. */ + selectedThreadKeys: ReadonlySet; + /** The scoped thread key that anchors shift-click range selection. */ + anchorThreadKey: string | null; } interface ThreadSelectionStore extends ThreadSelectionState { - /** Toggle a single thread in the selection (Cmd/Ctrl+Click). */ - toggleThread: (threadId: ThreadId) => void; + /** Toggle a single scoped thread key in the selection (Cmd/Ctrl+Click). */ + toggleThread: (threadKey: string) => void; /** * Select a range of threads (Shift+Click). - * Requires the ordered list of thread IDs within the same project + * Requires the ordered list of scoped thread keys within the same project * so the store can compute which threads fall between anchor and target. */ - rangeSelectTo: (threadId: ThreadId, orderedThreadIds: readonly ThreadId[]) => void; + rangeSelectTo: (threadKey: string, orderedThreadKeys: readonly string[]) => void; /** Clear all selection state. */ clearSelection: () => void; - /** Remove specific thread IDs from the selection (e.g. after deletion). */ - removeFromSelection: (threadIds: readonly ThreadId[]) => void; + /** Remove specific scoped thread keys from the selection (e.g. after deletion). */ + removeFromSelection: (threadKeys: readonly string[]) => void; /** Set the anchor thread without adding it to the selection (e.g. on plain-click navigate). */ - setAnchor: (threadId: ThreadId) => void; + setAnchor: (threadKey: string) => void; /** Check if any threads are selected. */ hasSelection: () => boolean; } -const EMPTY_SET = new Set(); +const EMPTY_SET = new Set(); export const useThreadSelectionStore = create((set, get) => ({ - selectedThreadIds: EMPTY_SET, - anchorThreadId: null, + selectedThreadKeys: EMPTY_SET, + anchorThreadKey: null, - toggleThread: (threadId) => { + toggleThread: (threadKey) => { set((state) => { - const next = new Set(state.selectedThreadIds); - if (next.has(threadId)) { - next.delete(threadId); + const next = new Set(state.selectedThreadKeys); + if (next.has(threadKey)) { + next.delete(threadKey); } else { - next.add(threadId); + next.add(threadKey); } return { - selectedThreadIds: next, - anchorThreadId: next.has(threadId) ? threadId : state.anchorThreadId, + selectedThreadKeys: next, + anchorThreadKey: next.has(threadKey) ? threadKey : state.anchorThreadKey, }; }); }, - rangeSelectTo: (threadId, orderedThreadIds) => { + rangeSelectTo: (threadKey, orderedThreadKeys) => { set((state) => { - const anchor = state.anchorThreadId; + const anchor = state.anchorThreadKey; if (anchor === null) { // No anchor yet — treat as a single toggle - const next = new Set(state.selectedThreadIds); - next.add(threadId); - return { selectedThreadIds: next, anchorThreadId: threadId }; + const next = new Set(state.selectedThreadKeys); + next.add(threadKey); + return { selectedThreadKeys: next, anchorThreadKey: threadKey }; } - const anchorIndex = orderedThreadIds.indexOf(anchor); - const targetIndex = orderedThreadIds.indexOf(threadId); + const anchorIndex = orderedThreadKeys.indexOf(anchor); + const targetIndex = orderedThreadKeys.indexOf(threadKey); if (anchorIndex === -1 || targetIndex === -1) { // Anchor or target not in this list (different project?) — fallback to toggle - const next = new Set(state.selectedThreadIds); - next.add(threadId); - return { selectedThreadIds: next, anchorThreadId: threadId }; + const next = new Set(state.selectedThreadKeys); + next.add(threadKey); + return { selectedThreadKeys: next, anchorThreadKey: threadKey }; } const start = Math.min(anchorIndex, targetIndex); const end = Math.max(anchorIndex, targetIndex); - const next = new Set(state.selectedThreadIds); + const next = new Set(state.selectedThreadKeys); for (let i = start; i <= end; i++) { - const id = orderedThreadIds[i]; - if (id !== undefined) { - next.add(id); + const key = orderedThreadKeys[i]; + if (key !== undefined) { + next.add(key); } } // Keep anchor stable so subsequent shift-clicks extend from the same point - return { selectedThreadIds: next, anchorThreadId: anchor }; + return { selectedThreadKeys: next, anchorThreadKey: anchor }; }); }, clearSelection: () => { const state = get(); - if (state.selectedThreadIds.size === 0 && state.anchorThreadId === null) return; - set({ selectedThreadIds: EMPTY_SET, anchorThreadId: null }); + if (state.selectedThreadKeys.size === 0 && state.anchorThreadKey === null) return; + set({ selectedThreadKeys: EMPTY_SET, anchorThreadKey: null }); }, - setAnchor: (threadId) => { - if (get().anchorThreadId === threadId) return; - set({ anchorThreadId: threadId }); + setAnchor: (threadKey) => { + if (get().anchorThreadKey === threadKey) return; + set({ anchorThreadKey: threadKey }); }, - removeFromSelection: (threadIds) => { + removeFromSelection: (threadKeys) => { set((state) => { - const toRemove = new Set(threadIds); + const toRemove = new Set(threadKeys); let changed = false; - const next = new Set(); - for (const id of state.selectedThreadIds) { - if (toRemove.has(id)) { + const next = new Set(); + for (const key of state.selectedThreadKeys) { + if (toRemove.has(key)) { changed = true; } else { - next.add(id); + next.add(key); } } if (!changed) return state; const newAnchor = - state.anchorThreadId !== null && toRemove.has(state.anchorThreadId) + state.anchorThreadKey !== null && toRemove.has(state.anchorThreadKey) ? null - : state.anchorThreadId; - return { selectedThreadIds: next, anchorThreadId: newAnchor }; + : state.anchorThreadKey; + return { selectedThreadKeys: next, anchorThreadKey: newAnchor }; }); }, - hasSelection: () => get().selectedThreadIds.size > 0, + hasSelection: () => get().selectedThreadKeys.size > 0, })); diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 972cf42bab..a544975731 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,7 +1,9 @@ import type { + EnvironmentId, ModelSelection, OrchestrationLatestTurn, OrchestrationProposedPlanId, + RepositoryIdentity, OrchestrationSessionStatus, OrchestrationThreadActivity, ProjectScript as ContractProjectScript, @@ -80,8 +82,10 @@ export interface TurnDiffSummary { export interface Project { id: ProjectId; + environmentId: EnvironmentId; name: string; cwd: string; + repositoryIdentity?: RepositoryIdentity | null; defaultModelSelection: ModelSelection | null; createdAt?: string | undefined; updatedAt?: string | undefined; @@ -90,6 +94,7 @@ export interface Project { export interface Thread { id: ThreadId; + environmentId: EnvironmentId; codexThreadId: string | null; projectId: ProjectId; title: string; @@ -113,6 +118,7 @@ export interface Thread { export interface ThreadShell { id: ThreadId; + environmentId: EnvironmentId; codexThreadId: string | null; projectId: ProjectId; title: string; @@ -134,6 +140,7 @@ export interface ThreadTurnState { export interface SidebarThreadSummary { id: ThreadId; + environmentId: EnvironmentId; projectId: ProjectId; title: string; interactionMode: ProviderInteractionMode; diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index b0b19f763a..950a7e11ff 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -74,9 +74,9 @@ describe("uiStateStore pure functions", () => { }); const next = syncProjects(initialState, [ - { id: project1, cwd: "/tmp/project-1" }, - { id: project2, cwd: "/tmp/project-2" }, - { id: project3, cwd: "/tmp/project-3" }, + { key: project1, cwd: "/tmp/project-1" }, + { key: project2, cwd: "/tmp/project-2" }, + { key: project3, cwd: "/tmp/project-3" }, ]); expect(next.projectOrder).toEqual([project2, project1, project3]); @@ -96,14 +96,14 @@ describe("uiStateStore pure functions", () => { projectOrder: [oldProject2, oldProject1], }), [ - { id: oldProject1, cwd: "/tmp/project-1" }, - { id: oldProject2, cwd: "/tmp/project-2" }, + { key: oldProject1, cwd: "/tmp/project-1" }, + { key: oldProject2, cwd: "/tmp/project-2" }, ], ); const next = syncProjects(initialState, [ - { id: oldProject1, cwd: "/tmp/project-1" }, - { id: recreatedProject2, cwd: "/tmp/project-2" }, + { key: oldProject1, cwd: "/tmp/project-1" }, + { key: recreatedProject2, cwd: "/tmp/project-2" }, ]); expect(next.projectOrder).toEqual([recreatedProject2, oldProject1]); @@ -119,10 +119,10 @@ describe("uiStateStore pure functions", () => { }, projectOrder: [project1], }), - [{ id: project1, cwd: "/tmp/project-1" }], + [{ key: project1, cwd: "/tmp/project-1" }], ); - const next = syncProjects(initialState, [{ id: project1, cwd: "/tmp/project-1-renamed" }]); + const next = syncProjects(initialState, [{ key: project1, cwd: "/tmp/project-1-renamed" }]); expect(next).not.toBe(initialState); expect(next.projectOrder).toEqual([project1]); @@ -139,7 +139,7 @@ describe("uiStateStore pure functions", () => { }, }); - const next = syncThreads(initialState, [{ id: thread1 }]); + const next = syncThreads(initialState, [{ key: thread1 }]); expect(next.threadLastVisitedAtById).toEqual({ [thread1]: "2026-02-25T12:35:00.000Z", @@ -152,7 +152,7 @@ describe("uiStateStore pure functions", () => { const next = syncThreads(initialState, [ { - id: thread1, + key: thread1, seedVisitedAt: "2026-02-25T12:35:00.000Z", }, ]); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 342f2db18f..936afc42f5 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,5 +1,4 @@ import { Debouncer } from "@tanstack/react-pacer"; -import { type ProjectId, type ThreadId } from "@t3tools/contracts"; import { create } from "zustand"; const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; @@ -23,7 +22,7 @@ interface PersistedUiState { export interface UiProjectState { projectExpandedById: Record; - projectOrder: ProjectId[]; + projectOrder: string[]; } export interface UiThreadState { @@ -33,12 +32,12 @@ export interface UiThreadState { export interface UiState extends UiProjectState, UiThreadState {} export interface SyncProjectInput { - id: ProjectId; + key: string; cwd: string; } export interface SyncThreadInput { - id: ThreadId; + key: string; seedVisitedAt?: string | undefined; } @@ -50,7 +49,7 @@ const initialState: UiState = { const persistedExpandedProjectCwds = new Set(); const persistedProjectOrderCwds: string[] = []; -const currentProjectCwdById = new Map(); +const currentProjectCwdById = new Map(); let legacyKeysCleanedUp = false; function readPersistedState(): UiState { @@ -100,7 +99,7 @@ function persistState(state: UiState): void { const expandedProjectCwds = Object.entries(state.projectExpandedById) .filter(([, expanded]) => expanded) .flatMap(([projectId]) => { - const cwd = currentProjectCwdById.get(projectId as ProjectId); + const cwd = currentProjectCwdById.get(projectId); return cwd ? [cwd] : []; }); const projectOrderCwds = state.projectOrder.flatMap((projectId) => { @@ -141,7 +140,7 @@ function recordsEqual(left: Record, right: Record): boo return true; } -function projectOrdersEqual(left: readonly ProjectId[], right: readonly ProjectId[]): boolean { +function projectOrdersEqual(left: readonly string[], right: readonly string[]): boolean { return ( left.length === right.length && left.every((projectId, index) => projectId === right[index]) ); @@ -154,11 +153,11 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput ); currentProjectCwdById.clear(); for (const project of projects) { - currentProjectCwdById.set(project.id, project.cwd); + currentProjectCwdById.set(project.key, project.cwd); } const cwdMappingChanged = previousProjectCwdById.size !== currentProjectCwdById.size || - projects.some((project) => previousProjectCwdById.get(project.id) !== project.cwd); + projects.some((project) => previousProjectCwdById.get(project.key) !== project.cwd); const nextExpandedById: Record = {}; const previousExpandedById = state.projectExpandedById; @@ -168,14 +167,14 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput const mappedProjects = projects.map((project, index) => { const previousProjectIdForCwd = previousProjectIdByCwd.get(project.cwd); const expanded = - previousExpandedById[project.id] ?? + previousExpandedById[project.key] ?? (previousProjectIdForCwd ? previousExpandedById[previousProjectIdForCwd] : undefined) ?? (persistedExpandedProjectCwds.size > 0 ? persistedExpandedProjectCwds.has(project.cwd) : true); - nextExpandedById[project.id] = expanded; + nextExpandedById[project.key] = expanded; return { - id: project.id, + id: project.key, cwd: project.cwd, incomingIndex: index, }; @@ -187,8 +186,8 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput const nextProjectIdByCwd = new Map( mappedProjects.map((project) => [project.cwd, project.id] as const), ); - const usedProjectIds = new Set(); - const orderedProjectIds: ProjectId[] = []; + const usedProjectIds = new Set(); + const orderedProjectIds: string[] = []; for (const projectId of state.projectOrder) { const matchedProjectId = @@ -246,19 +245,19 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput } export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]): UiState { - const retainedThreadIds = new Set(threads.map((thread) => thread.id)); + const retainedThreadIds = new Set(threads.map((thread) => thread.key)); const nextThreadLastVisitedAtById = Object.fromEntries( Object.entries(state.threadLastVisitedAtById).filter(([threadId]) => - retainedThreadIds.has(threadId as ThreadId), + retainedThreadIds.has(threadId), ), ); for (const thread of threads) { if ( - nextThreadLastVisitedAtById[thread.id] === undefined && + nextThreadLastVisitedAtById[thread.key] === undefined && thread.seedVisitedAt !== undefined && thread.seedVisitedAt.length > 0 ) { - nextThreadLastVisitedAtById[thread.id] = thread.seedVisitedAt; + nextThreadLastVisitedAtById[thread.key] = thread.seedVisitedAt; } } if (recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById)) { @@ -270,7 +269,7 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]) }; } -export function markThreadVisited(state: UiState, threadId: ThreadId, visitedAt?: string): UiState { +export function markThreadVisited(state: UiState, threadId: string, visitedAt?: string): UiState { const at = visitedAt ?? new Date().toISOString(); const visitedAtMs = Date.parse(at); const previousVisitedAt = state.threadLastVisitedAtById[threadId]; @@ -293,7 +292,7 @@ export function markThreadVisited(state: UiState, threadId: ThreadId, visitedAt? export function markThreadUnread( state: UiState, - threadId: ThreadId, + threadId: string, latestTurnCompletedAt: string | null | undefined, ): UiState { if (!latestTurnCompletedAt) { @@ -316,7 +315,7 @@ export function markThreadUnread( }; } -export function clearThreadUi(state: UiState, threadId: ThreadId): UiState { +export function clearThreadUi(state: UiState, threadId: string): UiState { if (!(threadId in state.threadLastVisitedAtById)) { return state; } @@ -328,7 +327,7 @@ export function clearThreadUi(state: UiState, threadId: ThreadId): UiState { }; } -export function toggleProject(state: UiState, projectId: ProjectId): UiState { +export function toggleProject(state: UiState, projectId: string): UiState { const expanded = state.projectExpandedById[projectId] ?? true; return { ...state, @@ -339,11 +338,7 @@ export function toggleProject(state: UiState, projectId: ProjectId): UiState { }; } -export function setProjectExpanded( - state: UiState, - projectId: ProjectId, - expanded: boolean, -): UiState { +export function setProjectExpanded(state: UiState, projectId: string, expanded: boolean): UiState { if ((state.projectExpandedById[projectId] ?? true) === expanded) { return state; } @@ -358,8 +353,8 @@ export function setProjectExpanded( export function reorderProjects( state: UiState, - draggedProjectId: ProjectId, - targetProjectId: ProjectId, + draggedProjectId: string, + targetProjectId: string, ): UiState { if (draggedProjectId === targetProjectId) { return state; @@ -384,12 +379,12 @@ export function reorderProjects( interface UiStateStore extends UiState { syncProjects: (projects: readonly SyncProjectInput[]) => void; syncThreads: (threads: readonly SyncThreadInput[]) => void; - markThreadVisited: (threadId: ThreadId, visitedAt?: string) => void; - markThreadUnread: (threadId: ThreadId, latestTurnCompletedAt: string | null | undefined) => void; - clearThreadUi: (threadId: ThreadId) => void; - toggleProject: (projectId: ProjectId) => void; - setProjectExpanded: (projectId: ProjectId, expanded: boolean) => void; - reorderProjects: (draggedProjectId: ProjectId, targetProjectId: ProjectId) => void; + markThreadVisited: (threadId: string, visitedAt?: string) => void; + markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; + clearThreadUi: (threadId: string) => void; + toggleProject: (projectId: string) => void; + setProjectExpanded: (projectId: string, expanded: boolean) => void; + reorderProjects: (draggedProjectId: string, targetProjectId: string) => void; } export const useUiStateStore = create((set) => ({ diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index c3a23ad405..1d7d41db1b 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -1,6 +1,6 @@ /// -import type { NativeApi, DesktopBridge } from "@t3tools/contracts"; +import type { DesktopBridge, LocalApi } from "@t3tools/contracts"; interface ImportMetaEnv { readonly APP_VERSION: string; @@ -12,7 +12,7 @@ interface ImportMeta { declare global { interface Window { - nativeApi?: NativeApi; + nativeApi?: LocalApi; desktopBridge?: DesktopBridge; } } diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 723661ccbb..1af904495c 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -1,12 +1,15 @@ -import { ProjectId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "./worktreeCleanup"; +const localEnvironmentId = EnvironmentId.makeUnsafe("environment-local"); + function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.makeUnsafe("thread-1"), + environmentId: localEnvironmentId, codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts deleted file mode 100644 index 3cfb976e09..0000000000 --- a/apps/web/src/wsNativeApi.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { type ContextMenuItem, type NativeApi } from "@t3tools/contracts"; - -import { resetGitStatusStateForTests } from "./lib/gitStatusState"; -import { showContextMenuFallback } from "./contextMenuFallback"; -import { __resetWsRpcAtomClientForTests } from "./rpc/client"; -import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; -import { resetServerStateForTests } from "./rpc/serverState"; -import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; -import { __resetWsRpcClientForTests, getWsRpcClient } from "./wsRpcClient"; - -let instance: { api: NativeApi } | null = null; - -export async function __resetWsNativeApiForTests() { - instance = null; - await __resetWsRpcAtomClientForTests(); - await __resetWsRpcClientForTests(); - resetGitStatusStateForTests(); - resetRequestLatencyStateForTests(); - resetServerStateForTests(); - resetWsConnectionStateForTests(); -} - -export function createWsNativeApi(): NativeApi { - if (instance) { - return instance.api; - } - - const rpcClient = getWsRpcClient(); - - const api: NativeApi = { - dialogs: { - pickFolder: async () => { - if (!window.desktopBridge) return null; - return window.desktopBridge.pickFolder(); - }, - confirm: async (message) => { - if (window.desktopBridge) { - return window.desktopBridge.confirm(message); - } - return window.confirm(message); - }, - }, - terminal: { - open: (input) => rpcClient.terminal.open(input as never), - write: (input) => rpcClient.terminal.write(input as never), - resize: (input) => rpcClient.terminal.resize(input as never), - clear: (input) => rpcClient.terminal.clear(input as never), - restart: (input) => rpcClient.terminal.restart(input as never), - close: (input) => rpcClient.terminal.close(input as never), - onEvent: (callback) => rpcClient.terminal.onEvent(callback), - }, - projects: { - searchEntries: rpcClient.projects.searchEntries, - writeFile: rpcClient.projects.writeFile, - }, - shell: { - openInEditor: (cwd, editor) => rpcClient.shell.openInEditor({ cwd, editor }), - openExternal: async (url) => { - if (window.desktopBridge) { - const opened = await window.desktopBridge.openExternal(url); - if (!opened) { - throw new Error("Unable to open link."); - } - return; - } - - window.open(url, "_blank", "noopener,noreferrer"); - }, - }, - git: { - pull: rpcClient.git.pull, - refreshStatus: rpcClient.git.refreshStatus, - onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options), - listBranches: rpcClient.git.listBranches, - createWorktree: rpcClient.git.createWorktree, - removeWorktree: rpcClient.git.removeWorktree, - createBranch: rpcClient.git.createBranch, - checkout: rpcClient.git.checkout, - init: rpcClient.git.init, - resolvePullRequest: rpcClient.git.resolvePullRequest, - preparePullRequestThread: rpcClient.git.preparePullRequestThread, - }, - contextMenu: { - show: async ( - items: readonly ContextMenuItem[], - position?: { x: number; y: number }, - ): Promise => { - if (window.desktopBridge) { - return window.desktopBridge.showContextMenu(items, position) as Promise; - } - return showContextMenuFallback(items, position); - }, - }, - server: { - getConfig: rpcClient.server.getConfig, - refreshProviders: rpcClient.server.refreshProviders, - upsertKeybinding: rpcClient.server.upsertKeybinding, - getSettings: rpcClient.server.getSettings, - updateSettings: rpcClient.server.updateSettings, - }, - orchestration: { - getSnapshot: rpcClient.orchestration.getSnapshot, - dispatchCommand: rpcClient.orchestration.dispatchCommand, - getTurnDiff: rpcClient.orchestration.getTurnDiff, - getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, - replayEvents: (fromSequenceExclusive) => - rpcClient.orchestration - .replayEvents({ fromSequenceExclusive }) - .then((events) => [...events]), - onDomainEvent: (callback, options) => - rpcClient.orchestration.onDomainEvent(callback, options), - }, - }; - - instance = { api }; - return api; -} diff --git a/apps/web/src/wsRpcClient.test.ts b/apps/web/src/wsRpcClient.test.ts index 36467eed9a..8d1cf6849f 100644 --- a/apps/web/src/wsRpcClient.test.ts +++ b/apps/web/src/wsRpcClient.test.ts @@ -3,9 +3,25 @@ import type { GitStatusRemoteResult, GitStatusStreamEvent, } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { createWsRpcClient } from "./wsRpcClient"; +vi.mock("./wsTransport", () => ({ + WsTransport: class WsTransport { + dispose = vi.fn(async () => undefined); + reconnect = vi.fn(async () => undefined); + request = vi.fn(); + requestStream = vi.fn(); + subscribe = vi.fn(() => () => undefined); + }, +})); + +import { + __resetWsRpcClientForTests, + createWsRpcClient, + ensureWsRpcClientEntryForKnownEnvironment, + readWsRpcClientEntryForEnvironment, +} from "./wsRpcClient"; import { type WsTransport } from "./wsTransport"; const baseLocalStatus: GitStatusLocalResult = { @@ -25,6 +41,10 @@ const baseRemoteStatus: GitStatusRemoteResult = { }; describe("wsRpcClient", () => { + afterEach(async () => { + await __resetWsRpcClientForTests(); + }); + it("reduces git status stream events into flat status snapshots", () => { const subscribe = vi.fn((_connect: unknown, listener: (value: TValue) => void) => { for (const event of [ @@ -91,4 +111,20 @@ describe("wsRpcClient", () => { ], ]); }); + + it("does not fall back to the only registered client for an unbound environment", () => { + ensureWsRpcClientEntryForKnownEnvironment({ + id: "known-env-a", + label: "Environment A", + source: "manual", + target: { + type: "ws", + wsUrl: "ws://localhost:3000", + }, + }); + + expect( + readWsRpcClientEntryForEnvironment(EnvironmentId.makeUnsafe("environment-b")), + ).toBeNull(); + }); }); diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index 997b83d2d7..7251b91fab 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -4,14 +4,20 @@ import { type GitRunStackedActionResult, type GitStatusResult, type GitStatusStreamEvent, - type NativeApi, + type LocalApi, ORCHESTRATION_WS_METHODS, + type EnvironmentId, type ServerSettingsPatch, WS_METHODS, } from "@t3tools/contracts"; +import { getKnownEnvironmentBaseUrl, type KnownEnvironment } from "@t3tools/client-runtime"; import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; import { Effect, Stream } from "effect"; +import { + getPrimaryKnownEnvironment, + resolvePrimaryEnvironmentBootstrapUrl, +} from "./environmentBootstrap"; import { type WsRpcProtocolClient } from "./rpc/protocol"; import { resetWsReconnectBackoff } from "./rpc/wsConnectionState"; import { WsTransport } from "./wsTransport"; @@ -61,9 +67,9 @@ export interface WsRpcClient { }; readonly shell: { readonly openInEditor: (input: { - readonly cwd: Parameters[0]; - readonly editor: Parameters[1]; - }) => ReturnType; + readonly cwd: Parameters[0]; + readonly editor: Parameters[1]; + }) => ReturnType; }; readonly git: { readonly pull: RpcUnaryMethod; @@ -109,22 +115,157 @@ export interface WsRpcClient { }; } -let sharedWsRpcClient: WsRpcClient | null = null; +export interface WsRpcClientEntry { + readonly key: string; + readonly knownEnvironment: KnownEnvironment; + readonly client: WsRpcClient; + readonly environmentId: EnvironmentId | null; +} + +type MutableWsRpcClientEntry = { + key: string; + knownEnvironment: KnownEnvironment; + client: WsRpcClient; + environmentId: EnvironmentId | null; +}; + +const wsRpcClientEntriesByKey = new Map(); +const wsRpcClientKeyByEnvironmentId = new Map(); +const wsRpcClientRegistryListeners = new Set<() => void>(); + +function emitWsRpcClientRegistryChange() { + for (const listener of wsRpcClientRegistryListeners) { + listener(); + } +} + +function toReadonlyEntry(entry: MutableWsRpcClientEntry): WsRpcClientEntry { + return entry; +} + +function createWsRpcClientEntry(knownEnvironment: KnownEnvironment): MutableWsRpcClientEntry { + const baseUrl = getKnownEnvironmentBaseUrl(knownEnvironment); + if (!baseUrl) { + throw new Error(`Unable to resolve websocket bootstrap URL for ${knownEnvironment.label}.`); + } + + return { + key: knownEnvironment.id, + knownEnvironment, + client: createWsRpcClient(new WsTransport(baseUrl)), + environmentId: knownEnvironment.environmentId ?? null, + }; +} + +export function subscribeWsRpcClientRegistry(listener: () => void): () => void { + wsRpcClientRegistryListeners.add(listener); + return () => { + wsRpcClientRegistryListeners.delete(listener); + }; +} + +export function listWsRpcClientEntries(): ReadonlyArray { + return [...wsRpcClientEntriesByKey.values()].map(toReadonlyEntry); +} + +export function ensureWsRpcClientEntryForKnownEnvironment( + knownEnvironment: KnownEnvironment, +): WsRpcClientEntry { + const existingEntry = wsRpcClientEntriesByKey.get(knownEnvironment.id); + if (existingEntry) { + return toReadonlyEntry(existingEntry); + } + + const entry = createWsRpcClientEntry(knownEnvironment); + wsRpcClientEntriesByKey.set(entry.key, entry); + if (entry.environmentId) { + wsRpcClientKeyByEnvironmentId.set(entry.environmentId, entry.key); + } + emitWsRpcClientRegistryChange(); + return toReadonlyEntry(entry); +} + +export function getPrimaryWsRpcClientEntry(): WsRpcClientEntry { + const primaryKnownEnvironment = getPrimaryKnownEnvironment(); + if (!primaryKnownEnvironment) { + throw new Error("Unable to resolve the primary websocket environment."); + } + + return ensureWsRpcClientEntryForKnownEnvironment(primaryKnownEnvironment); +} export function getWsRpcClient(): WsRpcClient { - if (sharedWsRpcClient) { - return sharedWsRpcClient; + return getPrimaryWsRpcClientEntry().client; +} + +export function bindWsRpcClientEntryEnvironment( + clientKey: string, + environmentId: EnvironmentId, +): void { + const entry = wsRpcClientEntriesByKey.get(clientKey); + if (!entry) { + throw new Error(`No websocket client registered for key ${clientKey}.`); + } + + const previousBoundEnvironmentId = entry.environmentId; + const previousKeyForEnvironment = wsRpcClientKeyByEnvironmentId.get(environmentId); + + if (previousBoundEnvironmentId === environmentId && previousKeyForEnvironment === clientKey) { + return; + } + + if (previousBoundEnvironmentId) { + wsRpcClientKeyByEnvironmentId.delete(previousBoundEnvironmentId); + } + + if (previousKeyForEnvironment && previousKeyForEnvironment !== clientKey) { + const previousEntry = wsRpcClientEntriesByKey.get(previousKeyForEnvironment); + if (previousEntry) { + previousEntry.environmentId = null; + } + } + + entry.environmentId = environmentId; + wsRpcClientKeyByEnvironmentId.set(environmentId, clientKey); + emitWsRpcClientRegistryChange(); +} + +export function bindPrimaryWsRpcClientEnvironment(environmentId: EnvironmentId): void { + bindWsRpcClientEntryEnvironment(getPrimaryWsRpcClientEntry().key, environmentId); +} + +export function readWsRpcClientEntryForEnvironment( + environmentId: EnvironmentId, +): WsRpcClientEntry | null { + const clientKey = wsRpcClientKeyByEnvironmentId.get(environmentId); + if (clientKey) { + const entry = wsRpcClientEntriesByKey.get(clientKey); + return entry ? toReadonlyEntry(entry) : null; + } + + return null; +} + +export function getWsRpcClientForEnvironment(environmentId: EnvironmentId): WsRpcClient { + const entry = readWsRpcClientEntryForEnvironment(environmentId); + if (!entry) { + throw new Error(`No websocket client registered for environment ${environmentId}.`); } - sharedWsRpcClient = createWsRpcClient(); - return sharedWsRpcClient; + return entry.client; } export async function __resetWsRpcClientForTests() { - await sharedWsRpcClient?.dispose(); - sharedWsRpcClient = null; + for (const entry of wsRpcClientEntriesByKey.values()) { + await entry.client.dispose(); + } + wsRpcClientEntriesByKey.clear(); + wsRpcClientKeyByEnvironmentId.clear(); + wsRpcClientRegistryListeners.clear(); } -export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { +export function createWsRpcClient( + transport = new WsTransport(resolvePrimaryEnvironmentBootstrapUrl()), +): WsRpcClient { return { dispose: () => transport.dispose(), reconnect: async () => { diff --git a/apps/web/src/wsTransport.test.ts b/apps/web/src/wsTransport.test.ts index da5404b239..58453d9913 100644 --- a/apps/web/src/wsTransport.test.ts +++ b/apps/web/src/wsTransport.test.ts @@ -436,6 +436,13 @@ describe("WsTransport", () => { sequence: 1, type: "welcome", payload: { + environment: { + environmentId: "environment-local", + label: "Local environment", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/tmp/workspace", projectName: "workspace", }, @@ -489,6 +496,13 @@ describe("WsTransport", () => { sequence: 1, type: "welcome", payload: { + environment: { + environmentId: "environment-local", + label: "Local environment", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/tmp/one", projectName: "one", }, @@ -532,6 +546,13 @@ describe("WsTransport", () => { sequence: 2, type: "welcome", payload: { + environment: { + environmentId: "environment-local", + label: "Local environment", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, cwd: "/tmp/two", projectName: "two", }, diff --git a/bun.lock b/bun.lock index af243cf4eb..74c5badbe6 100644 --- a/bun.lock +++ b/bun.lock @@ -82,6 +82,7 @@ "@formkit/auto-animate": "^0.9.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", + "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", @@ -121,6 +122,18 @@ "vitest-browser-react": "^2.0.5", }, }, + "packages/client-runtime": { + "name": "@t3tools/client-runtime", + "version": "0.0.0-alpha.1", + "dependencies": { + "@t3tools/contracts": "workspace:*", + }, + "devDependencies": { + "@effect/language-service": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + }, + }, "packages/contracts": { "name": "@t3tools/contracts", "version": "0.0.15", @@ -659,6 +672,8 @@ "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], + "@t3tools/client-runtime": ["@t3tools/client-runtime@workspace:packages/client-runtime"], + "@t3tools/contracts": ["@t3tools/contracts@workspace:packages/contracts"], "@t3tools/desktop": ["@t3tools/desktop@workspace:apps/desktop"], diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json new file mode 100644 index 0000000000..bfe2d7828e --- /dev/null +++ b/packages/client-runtime/package.json @@ -0,0 +1,25 @@ +{ + "name": "@t3tools/client-runtime", + "version": "0.0.0-alpha.1", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "prepare": "effect-language-service patch", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@t3tools/contracts": "workspace:*" + }, + "devDependencies": { + "@effect/language-service": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/client-runtime/src/index.ts b/packages/client-runtime/src/index.ts new file mode 100644 index 0000000000..5dd6b9afa5 --- /dev/null +++ b/packages/client-runtime/src/index.ts @@ -0,0 +1,2 @@ +export * from "./knownEnvironment"; +export * from "./scoped"; diff --git a/packages/client-runtime/src/knownEnvironment.test.ts b/packages/client-runtime/src/knownEnvironment.test.ts new file mode 100644 index 0000000000..0856a253a2 --- /dev/null +++ b/packages/client-runtime/src/knownEnvironment.test.ts @@ -0,0 +1,63 @@ +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { createKnownEnvironmentFromWsUrl } from "./knownEnvironment"; +import { + parseScopedProjectKey, + parseScopedThreadKey, + scopedProjectKey, + scopedRefKey, + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "./scoped"; + +describe("known environment bootstrap helpers", () => { + it("creates known environments from explicit ws urls", () => { + expect( + createKnownEnvironmentFromWsUrl({ + label: "Remote environment", + wsUrl: "wss://remote.example.com/ws", + }), + ).toEqual({ + id: "ws:Remote environment", + label: "Remote environment", + source: "manual", + target: { + type: "ws", + wsUrl: "wss://remote.example.com/ws", + }, + }); + }); +}); + +describe("scoped refs", () => { + const environmentId = EnvironmentId.makeUnsafe("environment-test"); + const projectRef = scopeProjectRef(environmentId, ProjectId.makeUnsafe("project-1")); + const threadRef = scopeThreadRef(environmentId, ThreadId.makeUnsafe("thread-1")); + + it("builds stable scoped project and thread keys", () => { + expect(scopedRefKey(projectRef)).toBe("environment-test:project-1"); + expect(scopedRefKey(threadRef)).toBe("environment-test:thread-1"); + expect(scopedProjectKey(projectRef)).toBe("environment-test:project-1"); + expect(scopedThreadKey(threadRef)).toBe("environment-test:thread-1"); + }); + + it("returns typed scoped refs", () => { + expect(projectRef).toEqual({ + environmentId, + projectId: ProjectId.makeUnsafe("project-1"), + }); + expect(threadRef).toEqual({ + environmentId, + threadId: ThreadId.makeUnsafe("thread-1"), + }); + }); + + it("parses scoped project and thread keys back into refs", () => { + expect(parseScopedProjectKey("environment-test:project-1")).toEqual(projectRef); + expect(parseScopedThreadKey("environment-test:thread-1")).toEqual(threadRef); + expect(parseScopedProjectKey("bad-key")).toBeNull(); + expect(parseScopedThreadKey("bad-key")).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/knownEnvironment.ts b/packages/client-runtime/src/knownEnvironment.ts new file mode 100644 index 0000000000..3a5e0d0e7d --- /dev/null +++ b/packages/client-runtime/src/knownEnvironment.ts @@ -0,0 +1,39 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +export interface KnownEnvironmentConnectionTarget { + readonly type: "ws"; + readonly wsUrl: string; +} + +export type KnownEnvironmentSource = "configured" | "desktop-managed" | "manual" | "window-origin"; + +export interface KnownEnvironment { + readonly id: string; + readonly label: string; + readonly source: KnownEnvironmentSource; + readonly environmentId?: EnvironmentId; + readonly target: KnownEnvironmentConnectionTarget; +} + +export function createKnownEnvironmentFromWsUrl(input: { + readonly id?: string; + readonly label: string; + readonly source?: KnownEnvironmentSource; + readonly wsUrl: string; +}): KnownEnvironment { + return { + id: input.id ?? `ws:${input.label}`, + label: input.label, + source: input.source ?? "manual", + target: { + type: "ws", + wsUrl: input.wsUrl, + }, + }; +} + +export function getKnownEnvironmentBaseUrl( + environment: KnownEnvironment | null | undefined, +): string | null { + return environment?.target.wsUrl ?? null; +} diff --git a/packages/client-runtime/src/scoped.ts b/packages/client-runtime/src/scoped.ts new file mode 100644 index 0000000000..c729f34d22 --- /dev/null +++ b/packages/client-runtime/src/scoped.ts @@ -0,0 +1,64 @@ +import type { + EnvironmentId, + ProjectId, + ScopedProjectRef, + ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; + +export function scopeProjectRef( + environmentId: EnvironmentId, + projectId: ProjectId, +): ScopedProjectRef { + return { environmentId, projectId }; +} + +export function scopeThreadRef(environmentId: EnvironmentId, threadId: ThreadId): ScopedThreadRef { + return { environmentId, threadId }; +} + +export function scopedRefKey(ref: ScopedProjectRef | ScopedThreadRef): string { + const localId = "projectId" in ref ? ref.projectId : ref.threadId; + return `${ref.environmentId}:${localId}`; +} + +export function scopedProjectKey(ref: ScopedProjectRef): string { + return scopedRefKey(ref); +} + +export function scopedThreadKey(ref: ScopedThreadRef): string { + return scopedRefKey(ref); +} + +function parseScopedKey(key: string): { environmentId: EnvironmentId; localId: string } | null { + const separatorIndex = key.indexOf(":"); + if (separatorIndex <= 0 || separatorIndex >= key.length - 1) { + return null; + } + return { + environmentId: key.slice(0, separatorIndex) as EnvironmentId, + localId: key.slice(separatorIndex + 1), + }; +} + +export function parseScopedProjectKey(key: string): ScopedProjectRef | null { + const parsed = parseScopedKey(key); + if (!parsed) { + return null; + } + return { + environmentId: parsed.environmentId, + projectId: parsed.localId as ProjectId, + }; +} + +export function parseScopedThreadKey(key: string): ScopedThreadRef | null { + const parsed = parseScopedKey(key); + if (!parsed) { + return null; + } + return { + environmentId: parsed.environmentId, + threadId: parsed.localId as ThreadId, + }; +} diff --git a/packages/client-runtime/tsconfig.json b/packages/client-runtime/tsconfig.json new file mode 100644 index 0000000000..564a599005 --- /dev/null +++ b/packages/client-runtime/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 24962aed69..5a199e9a67 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -19,6 +19,8 @@ export const ThreadId = makeEntityId("ThreadId"); export type ThreadId = typeof ThreadId.Type; export const ProjectId = makeEntityId("ProjectId"); export type ProjectId = typeof ProjectId.Type; +export const EnvironmentId = makeEntityId("EnvironmentId"); +export type EnvironmentId = typeof EnvironmentId.Type; export const CommandId = makeEntityId("CommandId"); export type CommandId = typeof CommandId.Type; export const EventId = makeEntityId("EventId"); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts new file mode 100644 index 0000000000..9e97be83ea --- /dev/null +++ b/packages/contracts/src/environment.ts @@ -0,0 +1,77 @@ +import { Schema } from "effect"; + +import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; + +export const ExecutionEnvironmentPlatformOs = Schema.Literals([ + "darwin", + "linux", + "windows", + "unknown", +]); +export type ExecutionEnvironmentPlatformOs = typeof ExecutionEnvironmentPlatformOs.Type; + +export const ExecutionEnvironmentPlatformArch = Schema.Literals(["arm64", "x64", "other"]); +export type ExecutionEnvironmentPlatformArch = typeof ExecutionEnvironmentPlatformArch.Type; + +export const ExecutionEnvironmentPlatform = Schema.Struct({ + os: ExecutionEnvironmentPlatformOs, + arch: ExecutionEnvironmentPlatformArch, +}); +export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.Type; + +export const ExecutionEnvironmentCapabilities = Schema.Struct({ + repositoryIdentity: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), +}); +export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; + +export const ExecutionEnvironmentDescriptor = Schema.Struct({ + environmentId: EnvironmentId, + label: TrimmedNonEmptyString, + platform: ExecutionEnvironmentPlatform, + serverVersion: TrimmedNonEmptyString, + capabilities: ExecutionEnvironmentCapabilities, +}); +export type ExecutionEnvironmentDescriptor = typeof ExecutionEnvironmentDescriptor.Type; + +export const EnvironmentConnectionState = Schema.Literals([ + "connecting", + "connected", + "disconnected", + "error", +]); +export type EnvironmentConnectionState = typeof EnvironmentConnectionState.Type; + +export const RepositoryIdentityLocator = Schema.Struct({ + source: Schema.Literal("git-remote"), + remoteName: TrimmedNonEmptyString, + remoteUrl: TrimmedNonEmptyString, +}); +export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type; + +export const RepositoryIdentity = Schema.Struct({ + canonicalKey: TrimmedNonEmptyString, + locator: RepositoryIdentityLocator, + displayName: Schema.optionalKey(TrimmedNonEmptyString), + provider: Schema.optionalKey(TrimmedNonEmptyString), + owner: Schema.optionalKey(TrimmedNonEmptyString), + name: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type RepositoryIdentity = typeof RepositoryIdentity.Type; + +export const ScopedProjectRef = Schema.Struct({ + environmentId: EnvironmentId, + projectId: ProjectId, +}); +export type ScopedProjectRef = typeof ScopedProjectRef.Type; + +export const ScopedThreadRef = Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, +}); +export type ScopedThreadRef = typeof ScopedThreadRef.Type; + +export const ScopedThreadSessionRef = Schema.Struct({ + environmentId: EnvironmentId, + threadId: ThreadId, +}); +export type ScopedThreadSessionRef = typeof ScopedThreadSessionRef.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c60856bbe5..d2f84eda9d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,5 @@ export * from "./baseSchemas"; +export * from "./environment"; export * from "./ipc"; export * from "./terminal"; export * from "./provider"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 630ccd8249..1a08d5208c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -105,8 +105,14 @@ export interface DesktopUpdateCheckResult { state: DesktopUpdateState; } +export interface DesktopEnvironmentBootstrap { + label: string; + wsUrl: string | null; +} + export interface DesktopBridge { getWsUrl: () => string | null; + getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; pickFolder: () => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; @@ -123,11 +129,50 @@ export interface DesktopBridge { onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; } -export interface NativeApi { +/** + * APIs bound to the local app shell, not to any particular backend environment. + * + * These capabilities describe the desktop/browser host that the user is + * currently running: dialogs, editor/external-link opening, context menus, and + * app-level settings/config access. They must not be used as a proxy for + * "whatever environment the user is targeting", because in a multi-environment + * world the local shell and a selected backend environment are distinct + * concepts. + */ +export interface LocalApi { dialogs: { pickFolder: () => Promise; confirm: (message: string) => Promise; }; + shell: { + openInEditor: (cwd: string, editor: EditorId) => Promise; + openExternal: (url: string) => Promise; + }; + contextMenu: { + show: ( + items: readonly ContextMenuItem[], + position?: { x: number; y: number }, + ) => Promise; + }; + server: { + getConfig: () => Promise; + refreshProviders: () => Promise; + upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; + getSettings: () => Promise; + updateSettings: (patch: ServerSettingsPatch) => Promise; + }; +} + +/** + * APIs bound to a specific backend environment connection. + * + * These operations must always be routed with explicit environment context. + * They represent remote stateful capabilities such as orchestration, terminal, + * project, and git operations. In multi-environment mode, each environment gets + * its own instance of this surface, and callers should resolve it by + * `environmentId` rather than reaching through the local desktop bridge. + */ +export interface EnvironmentApi { terminal: { open: (input: typeof TerminalOpenInput.Encoded) => Promise; write: (input: typeof TerminalWriteInput.Encoded) => Promise; @@ -141,12 +186,7 @@ export interface NativeApi { searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; }; - shell: { - openInEditor: (cwd: string, editor: EditorId) => Promise; - openExternal: (url: string) => Promise; - }; git: { - // Existing branch/worktree API listBranches: (input: GitListBranchesInput) => Promise; createWorktree: (input: GitCreateWorktreeInput) => Promise; removeWorktree: (input: GitRemoveWorktreeInput) => Promise; @@ -157,7 +197,6 @@ export interface NativeApi { preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Promise; - // Stacked action API pull: (input: GitPullInput) => Promise; refreshStatus: (input: GitStatusInput) => Promise; onStatus: ( @@ -168,19 +207,6 @@ export interface NativeApi { }, ) => () => void; }; - contextMenu: { - show: ( - items: readonly ContextMenuItem[], - position?: { x: number; y: number }, - ) => Promise; - }; - server: { - getConfig: () => Promise; - refreshProviders: () => Promise; - upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; - getSettings: () => Promise; - updateSettings: (patch: ServerSettingsPatch) => Promise; - }; orchestration: { getSnapshot: () => Promise; dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 6c7f073612..e6e4a52106 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,5 +1,6 @@ import { Option, Schema, SchemaIssue, Struct } from "effect"; import { ClaudeModelOptions, CodexModelOptions } from "./model"; +import { RepositoryIdentity } from "./environment"; import { ApprovalRequestId, CheckpointRef, @@ -141,6 +142,7 @@ export const OrchestrationProject = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, @@ -647,6 +649,7 @@ export const ProjectCreatedPayload = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, @@ -657,6 +660,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ projectId: ProjectId, title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), + repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), scripts: Schema.optional(Schema.Array(ProjectScript)), updatedAt: IsoDateTime, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 776a0a89e9..9227f4d8c9 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,4 +1,5 @@ import { Schema } from "effect"; +import { ExecutionEnvironmentDescriptor } from "./environment"; import { IsoDateTime, NonNegativeInt, @@ -83,6 +84,7 @@ export const ServerObservability = Schema.Struct({ export type ServerObservability = typeof ServerObservability.Type; export const ServerConfig = Schema.Struct({ + environment: ExecutionEnvironmentDescriptor, cwd: TrimmedNonEmptyString, keybindingsConfigPath: TrimmedNonEmptyString, keybindings: ResolvedKeybindingsConfig, @@ -167,10 +169,12 @@ export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type; export const ServerLifecycleReadyPayload = Schema.Struct({ at: IsoDateTime, + environment: ExecutionEnvironmentDescriptor, }); export type ServerLifecycleReadyPayload = typeof ServerLifecycleReadyPayload.Type; export const ServerLifecycleWelcomePayload = Schema.Struct({ + environment: ExecutionEnvironmentDescriptor, cwd: TrimmedNonEmptyString, projectName: TrimmedNonEmptyString, bootstrapProjectId: Schema.optional(ProjectId), diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 7beb7a75de..dac644e83b 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -1,7 +1,54 @@ import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { applyGitStatusStreamEvent } from "./git"; +import { + applyGitStatusStreamEvent, + normalizeGitRemoteUrl, + parseGitHubRepositoryNameWithOwnerFromRemoteUrl, +} from "./git"; + +describe("normalizeGitRemoteUrl", () => { + it("canonicalizes equivalent GitHub remotes across protocol variants", () => { + expect(normalizeGitRemoteUrl("git@github.com:T3Tools/T3Code.git")).toBe( + "github.com/t3tools/t3code", + ); + expect(normalizeGitRemoteUrl("https://github.com/T3Tools/T3Code.git")).toBe( + "github.com/t3tools/t3code", + ); + expect(normalizeGitRemoteUrl("ssh://git@github.com/T3Tools/T3Code")).toBe( + "github.com/t3tools/t3code", + ); + }); + + it("preserves nested group paths for providers like GitLab", () => { + expect(normalizeGitRemoteUrl("git@gitlab.com:T3Tools/platform/T3Code.git")).toBe( + "gitlab.com/t3tools/platform/t3code", + ); + expect(normalizeGitRemoteUrl("https://gitlab.com/T3Tools/platform/T3Code.git")).toBe( + "gitlab.com/t3tools/platform/t3code", + ); + }); + + it("drops explicit ports from URL-shaped remotes", () => { + expect(normalizeGitRemoteUrl("https://gitlab.company.com:8443/team/project.git")).toBe( + "gitlab.company.com/team/project", + ); + expect(normalizeGitRemoteUrl("ssh://git@gitlab.company.com:2222/team/project.git")).toBe( + "gitlab.company.com/team/project", + ); + }); +}); + +describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { + it("extracts the owner and repository from common GitHub remote shapes", () => { + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com:T3Tools/T3Code.git"), + ).toBe("T3Tools/T3Code"); + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("https://github.com/T3Tools/T3Code.git"), + ).toBe("T3Tools/T3Code"); + }); +}); describe("applyGitStatusStreamEvent", () => { it("treats a remote-only update as a repository when local state is missing", () => { diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 16171315b7..a39c924447 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -80,6 +80,56 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { return branchName.slice(firstSeparatorIndex + 1); } +/** + * Normalize a git remote URL into a stable comparison key. + */ +export function normalizeGitRemoteUrl(value: string): string { + const normalized = value + .trim() + .replace(/\/+$/g, "") + .replace(/\.git$/i, "") + .toLowerCase(); + + if (/^(?:ssh|https?|git):\/\//i.test(normalized)) { + try { + const url = new URL(normalized); + const repositoryPath = url.pathname + .split("/") + .filter((segment) => segment.length > 0) + .join("/"); + if (url.hostname && repositoryPath.includes("/")) { + return `${url.hostname}/${repositoryPath}`; + } + } catch { + return normalized; + } + } + + const scpStyleHostAndPath = /^git@([^:/\s]+)[:/]([^/\s]+(?:\/[^/\s]+)+)$/i.exec(normalized); + if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) { + return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`; + } + + return normalized; +} + +/** + * Best-effort parse of a GitHub `owner/repo` identifier from common remote URL shapes. + */ +export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { + const trimmed = url?.trim() ?? ""; + if (trimmed.length === 0) { + return null; + } + + const match = + /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( + trimmed, + ); + const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; + return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; +} + function deriveLocalBranchNameCandidatesFromRemoteRef( branchName: string, remoteName?: string, diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index bf9d9f5c6a..98f7da5789 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -13,6 +13,7 @@ const workspaceFiles = [ "apps/desktop/package.json", "apps/web/package.json", "apps/marketing/package.json", + "packages/client-runtime/package.json", "packages/contracts/package.json", "packages/shared/package.json", "scripts/package.json", From b7559c467803f923e59a287c112b4c92b96e15c7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Apr 2026 10:18:30 -0700 Subject: [PATCH 16/26] Implement server auth bootstrap and pairing flow (#1768) Co-authored-by: codex Co-authored-by: Julius Marminge Co-authored-by: Cursor Agent Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com> --- .docs/remote-architecture.md | 302 ++++ .github/workflows/ci.yml | 2 +- .oxfmtrc.json | 3 +- .plans/18-server-auth-model.md | 823 +++++++++ apps/desktop/scripts/dev-electron.mjs | 19 +- apps/desktop/src/backendReadiness.test.ts | 83 + apps/desktop/src/backendReadiness.ts | 103 ++ apps/desktop/src/desktopSettings.test.ts | 64 + apps/desktop/src/desktopSettings.ts | 51 + apps/desktop/src/main.ts | 322 +++- apps/desktop/src/preload.ts | 9 +- apps/desktop/src/serverExposure.test.ts | 139 ++ apps/desktop/src/serverExposure.ts | 80 + .../src/auth/Layers/AuthControlPlane.test.ts | 111 ++ .../src/auth/Layers/AuthControlPlane.ts | 176 ++ .../Layers/BootstrapCredentialService.test.ts | 151 ++ .../auth/Layers/BootstrapCredentialService.ts | 296 ++++ .../server/src/auth/Layers/ServerAuth.test.ts | 182 ++ apps/server/src/auth/Layers/ServerAuth.ts | 385 ++++ .../src/auth/Layers/ServerAuthPolicy.test.ts | 111 ++ .../src/auth/Layers/ServerAuthPolicy.ts | 57 + .../src/auth/Layers/ServerSecretStore.test.ts | 263 +++ .../src/auth/Layers/ServerSecretStore.ts | 151 ++ .../Layers/SessionCredentialService.test.ts | 191 ++ .../auth/Layers/SessionCredentialService.ts | 493 ++++++ .../src/auth/Services/AuthControlPlane.ts | 69 + .../Services/BootstrapCredentialService.ts | 57 + apps/server/src/auth/Services/ServerAuth.ts | 84 + .../src/auth/Services/ServerAuthPolicy.ts | 11 + .../src/auth/Services/ServerSecretStore.ts | 22 + .../auth/Services/SessionCredentialService.ts | 87 + apps/server/src/auth/http.ts | 254 +++ apps/server/src/auth/utils.test.ts | 26 + apps/server/src/auth/utils.ts | 121 ++ apps/server/src/cli-config.test.ts | 20 +- apps/server/src/cli.test.ts | 110 +- apps/server/src/cli.ts | 376 +++- apps/server/src/cliAuthFormat.test.ts | 88 + apps/server/src/cliAuthFormat.ts | 190 ++ apps/server/src/config.ts | 6 +- .../Layers/ServerEnvironment.test.ts | 2 +- .../environment/Layers/ServerEnvironment.ts | 10 +- .../Layers/ServerEnvironmentLabel.test.ts | 150 ++ .../Layers/ServerEnvironmentLabel.ts | 104 ++ apps/server/src/http.test.ts | 27 + apps/server/src/http.ts | 75 +- apps/server/src/keybindings.test.ts | 19 +- apps/server/src/keybindings.ts | 4 +- apps/server/src/persistence/Errors.ts | 2 + .../persistence/Layers/AuthPairingLinks.ts | 209 +++ .../src/persistence/Layers/AuthSessions.ts | 279 +++ apps/server/src/persistence/Migrations.ts | 6 + .../Migrations/020_AuthAccessManagement.ts | 42 + .../021_AuthSessionClientMetadata.ts | 62 + .../022_AuthSessionLastConnectedAt.ts | 17 + .../persistence/Services/AuthPairingLinks.ts | 76 + .../src/persistence/Services/AuthSessions.ts | 93 + apps/server/src/server.test.ts | 868 ++++++++- apps/server/src/server.ts | 35 +- apps/server/src/serverRuntimeStartup.test.ts | 9 + apps/server/src/serverRuntimeStartup.ts | 61 +- apps/server/src/ws.ts | 1561 +++++++++-------- apps/web/index.html | 86 +- apps/web/src/authBootstrap.test.ts | 460 +++++ .../web/src/components/BranchToolbar.logic.ts | 9 +- apps/web/src/components/BranchToolbar.tsx | 164 +- .../BranchToolbarBranchSelector.tsx | 160 +- .../BranchToolbarEnvModeSelector.tsx | 73 + .../BranchToolbarEnvironmentSelector.tsx | 65 + apps/web/src/components/ChatView.browser.tsx | 26 +- apps/web/src/components/ChatView.tsx | 277 ++- .../components/GitActionsControl.browser.tsx | 19 +- .../components/KeybindingsToast.browser.tsx | 44 + .../src/components/NoActiveThreadState.tsx | 41 + apps/web/src/components/ProjectFavicon.tsx | 21 +- apps/web/src/components/Sidebar.tsx | 366 +++- apps/web/src/components/SplashScreen.tsx | 9 + .../ThreadTerminalDrawer.browser.tsx | 48 +- .../src/components/ThreadTerminalDrawer.tsx | 42 +- .../components/WebSocketConnectionSurface.tsx | 176 +- .../components/auth/PairingRouteSurface.tsx | 195 ++ .../settings/ConnectionsSettings.tsx | 1441 +++++++++++++++ .../settings/SettingsPanels.browser.tsx | 592 ++++++- .../components/settings/SettingsPanels.tsx | 127 +- .../settings/SettingsSidebarNav.tsx | 14 +- .../components/settings/settingsLayout.tsx | 122 ++ apps/web/src/components/ui/qr-code.test.tsx | 27 + apps/web/src/components/ui/qr-code.tsx | 81 + apps/web/src/composerDraftStore.test.ts | 44 + apps/web/src/composerDraftStore.ts | 46 +- apps/web/src/environmentApi.ts | 7 +- apps/web/src/environmentBootstrap.ts | 65 - apps/web/src/environmentGrouping.test.ts | 453 +++++ apps/web/src/environments/primary/auth.ts | 361 ++++ .../environments/primary/bootstrap.test.ts | 121 ++ apps/web/src/environments/primary/context.ts | 120 ++ apps/web/src/environments/primary/index.ts | 35 + apps/web/src/environments/primary/target.ts | 103 ++ apps/web/src/environments/remote/api.test.ts | 235 +++ apps/web/src/environments/remote/api.ts | 143 ++ apps/web/src/environments/remote/target.ts | 86 + .../src/environments/runtime/catalog.test.ts | 51 + apps/web/src/environments/runtime/catalog.ts | 251 +++ .../environments/runtime/connection.test.ts | 216 +++ .../src/environments/runtime/connection.ts | 296 ++++ apps/web/src/environments/runtime/index.ts | 29 + apps/web/src/environments/runtime/service.ts | 659 +++++++ apps/web/src/hooks/useTheme.ts | 53 + apps/web/src/index.css | 12 +- apps/web/src/lib/gitReactQuery.ts | 4 +- apps/web/src/lib/gitStatusState.test.ts | 178 +- apps/web/src/lib/gitStatusState.ts | 104 +- apps/web/src/lib/utils.test.ts | 43 +- apps/web/src/lib/utils.ts | 29 - apps/web/src/lib/vendor/qrcodegen.ts | 996 +++++++++++ apps/web/src/localApi.test.ts | 66 +- apps/web/src/localApi.ts | 23 +- apps/web/src/observability/clientTracing.ts | 7 +- apps/web/src/pairingUrl.ts | 33 + apps/web/src/routeTree.gen.ts | 42 + apps/web/src/routes/__root.tsx | 556 +----- apps/web/src/routes/_chat.index.tsx | 28 +- apps/web/src/routes/_chat.tsx | 15 +- apps/web/src/routes/pair.tsx | 47 + apps/web/src/routes/settings.connections.tsx | 7 + apps/web/src/routes/settings.tsx | 14 +- apps/web/src/rpc/client.test.ts | 279 --- apps/web/src/rpc/client.ts | 42 - apps/web/src/rpc/protocol.ts | 79 +- apps/web/src/rpc/serverState.test.ts | 6 + apps/web/src/rpc/serverState.ts | 2 +- apps/web/src/{ => rpc}/wsRpcClient.test.ts | 30 +- apps/web/src/{ => rpc}/wsRpcClient.ts | 169 +- apps/web/src/{ => rpc}/wsTransport.test.ts | 110 +- apps/web/src/{ => rpc}/wsTransport.ts | 35 +- apps/web/src/store.ts | 93 +- apps/web/src/timestampFormat.test.ts | 88 +- apps/web/src/timestampFormat.ts | 87 + apps/web/src/uiStateStore.ts | 2 +- apps/web/test/authHttpHandlers.ts | 41 + apps/web/test/wsRpcHarness.ts | 9 +- apps/web/vite.config.ts | 44 +- .../src/knownEnvironment.test.ts | 41 +- .../client-runtime/src/knownEnvironment.ts | 36 +- packages/contracts/src/auth.ts | 266 +++ packages/contracts/src/baseSchemas.ts | 2 + packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 15 +- packages/contracts/src/model.ts | 1 + packages/contracts/src/rpc.ts | 9 + packages/contracts/src/server.ts | 2 + packages/shared/src/model.test.ts | 1 + scripts/dev-runner.test.ts | 18 +- scripts/dev-runner.ts | 27 +- turbo.json | 2 +- 155 files changed, 18679 insertions(+), 2920 deletions(-) create mode 100644 .docs/remote-architecture.md create mode 100644 .plans/18-server-auth-model.md create mode 100644 apps/desktop/src/backendReadiness.test.ts create mode 100644 apps/desktop/src/backendReadiness.ts create mode 100644 apps/desktop/src/desktopSettings.test.ts create mode 100644 apps/desktop/src/desktopSettings.ts create mode 100644 apps/desktop/src/serverExposure.test.ts create mode 100644 apps/desktop/src/serverExposure.ts create mode 100644 apps/server/src/auth/Layers/AuthControlPlane.test.ts create mode 100644 apps/server/src/auth/Layers/AuthControlPlane.ts create mode 100644 apps/server/src/auth/Layers/BootstrapCredentialService.test.ts create mode 100644 apps/server/src/auth/Layers/BootstrapCredentialService.ts create mode 100644 apps/server/src/auth/Layers/ServerAuth.test.ts create mode 100644 apps/server/src/auth/Layers/ServerAuth.ts create mode 100644 apps/server/src/auth/Layers/ServerAuthPolicy.test.ts create mode 100644 apps/server/src/auth/Layers/ServerAuthPolicy.ts create mode 100644 apps/server/src/auth/Layers/ServerSecretStore.test.ts create mode 100644 apps/server/src/auth/Layers/ServerSecretStore.ts create mode 100644 apps/server/src/auth/Layers/SessionCredentialService.test.ts create mode 100644 apps/server/src/auth/Layers/SessionCredentialService.ts create mode 100644 apps/server/src/auth/Services/AuthControlPlane.ts create mode 100644 apps/server/src/auth/Services/BootstrapCredentialService.ts create mode 100644 apps/server/src/auth/Services/ServerAuth.ts create mode 100644 apps/server/src/auth/Services/ServerAuthPolicy.ts create mode 100644 apps/server/src/auth/Services/ServerSecretStore.ts create mode 100644 apps/server/src/auth/Services/SessionCredentialService.ts create mode 100644 apps/server/src/auth/http.ts create mode 100644 apps/server/src/auth/utils.test.ts create mode 100644 apps/server/src/auth/utils.ts create mode 100644 apps/server/src/cliAuthFormat.test.ts create mode 100644 apps/server/src/cliAuthFormat.ts create mode 100644 apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts create mode 100644 apps/server/src/environment/Layers/ServerEnvironmentLabel.ts create mode 100644 apps/server/src/http.test.ts create mode 100644 apps/server/src/persistence/Layers/AuthPairingLinks.ts create mode 100644 apps/server/src/persistence/Layers/AuthSessions.ts create mode 100644 apps/server/src/persistence/Migrations/020_AuthAccessManagement.ts create mode 100644 apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts create mode 100644 apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts create mode 100644 apps/server/src/persistence/Services/AuthPairingLinks.ts create mode 100644 apps/server/src/persistence/Services/AuthSessions.ts create mode 100644 apps/web/src/authBootstrap.test.ts create mode 100644 apps/web/src/components/BranchToolbarEnvModeSelector.tsx create mode 100644 apps/web/src/components/BranchToolbarEnvironmentSelector.tsx create mode 100644 apps/web/src/components/NoActiveThreadState.tsx create mode 100644 apps/web/src/components/SplashScreen.tsx create mode 100644 apps/web/src/components/auth/PairingRouteSurface.tsx create mode 100644 apps/web/src/components/settings/ConnectionsSettings.tsx create mode 100644 apps/web/src/components/settings/settingsLayout.tsx create mode 100644 apps/web/src/components/ui/qr-code.test.tsx create mode 100644 apps/web/src/components/ui/qr-code.tsx delete mode 100644 apps/web/src/environmentBootstrap.ts create mode 100644 apps/web/src/environmentGrouping.test.ts create mode 100644 apps/web/src/environments/primary/auth.ts create mode 100644 apps/web/src/environments/primary/bootstrap.test.ts create mode 100644 apps/web/src/environments/primary/context.ts create mode 100644 apps/web/src/environments/primary/index.ts create mode 100644 apps/web/src/environments/primary/target.ts create mode 100644 apps/web/src/environments/remote/api.test.ts create mode 100644 apps/web/src/environments/remote/api.ts create mode 100644 apps/web/src/environments/remote/target.ts create mode 100644 apps/web/src/environments/runtime/catalog.test.ts create mode 100644 apps/web/src/environments/runtime/catalog.ts create mode 100644 apps/web/src/environments/runtime/connection.test.ts create mode 100644 apps/web/src/environments/runtime/connection.ts create mode 100644 apps/web/src/environments/runtime/index.ts create mode 100644 apps/web/src/environments/runtime/service.ts create mode 100644 apps/web/src/lib/vendor/qrcodegen.ts create mode 100644 apps/web/src/pairingUrl.ts create mode 100644 apps/web/src/routes/pair.tsx create mode 100644 apps/web/src/routes/settings.connections.tsx delete mode 100644 apps/web/src/rpc/client.test.ts delete mode 100644 apps/web/src/rpc/client.ts rename apps/web/src/{ => rpc}/wsRpcClient.test.ts (75%) rename apps/web/src/{ => rpc}/wsRpcClient.ts (66%) rename apps/web/src/{ => rpc}/wsTransport.test.ts (87%) rename apps/web/src/{ => rpc}/wsTransport.ts (86%) create mode 100644 apps/web/test/authHttpHandlers.ts create mode 100644 packages/contracts/src/auth.ts diff --git a/.docs/remote-architecture.md b/.docs/remote-architecture.md new file mode 100644 index 0000000000..32e35d7caf --- /dev/null +++ b/.docs/remote-architecture.md @@ -0,0 +1,302 @@ +# Remote Architecture + +This document describes the target architecture for first-class remote environments in T3 Code. + +It is intentionally architecture-first. It does not define a complete implementation plan or user-facing rollout checklist. The goal is to establish the core model so remote support can be added without another broad rewrite. + +## Goals + +- Treat remote environments as first-class product primitives, not special cases. +- Support multiple ways to reach the same environment. +- Keep the T3 server as the execution boundary. +- Let desktop, mobile, and web all share the same conceptual model. +- Avoid introducing a local control plane unless product pressure proves it is necessary. + +## Non-goals + +- Replacing the existing WebSocket server boundary with a custom transport protocol. +- Making SSH the only remote story. +- Syncing provider auth across machines. +- Shipping every access method in the first iteration. + +## High-level architecture + +T3 already has a clean runtime boundary: the client talks to a T3 server over HTTP/WebSocket, and the server owns orchestration, providers, terminals, git, and filesystem operations. + +Remote support should preserve that boundary. + +```text +┌──────────────────────────────────────────────┐ +│ Client (desktop / mobile / web) │ +│ │ +│ - known environments │ +│ - connection manager │ +│ - environment-aware routing │ +└───────────────┬──────────────────────────────┘ + │ + │ resolves one access endpoint + │ +┌───────────────▼──────────────────────────────┐ +│ Access method │ +│ │ +│ - direct ws / wss │ +│ - tunneled ws / wss │ +│ - desktop-managed ssh bootstrap + forward │ +└───────────────┬──────────────────────────────┘ + │ + │ connects to one T3 server + │ +┌───────────────▼──────────────────────────────┐ +│ Execution environment = one T3 server │ +│ │ +│ - environment identity │ +│ - provider state │ +│ - projects / threads / terminals │ +│ - git / filesystem / process runtime │ +└──────────────────────────────────────────────┘ +``` + +The important decision is that remoteness is expressed at the environment connection layer, not by splitting the T3 runtime itself. + +## Domain model + +### ExecutionEnvironment + +An `ExecutionEnvironment` is one running T3 server instance. + +It is the unit that owns: + +- provider availability and auth state +- model availability +- projects and threads +- terminal processes +- filesystem access +- git operations +- server settings + +It is identified by a stable `environmentId`. + +This is the shared cross-client primitive. Desktop, mobile, and web should all reason about the same concept here. + +### KnownEnvironment + +A `KnownEnvironment` is a client-side saved entry for an environment the client knows how to reach. + +It is not server-authored. It is local to a device or client profile. + +Examples: + +- a saved LAN URL +- a saved public `wss://` endpoint +- a desktop-managed SSH host entry +- a saved tunneled environment + +A known environment may or may not know the target `environmentId` before first successful connect. + +### AccessEndpoint + +An `AccessEndpoint` is one concrete way to reach a known environment. + +This is the key abstraction that keeps SSH from taking over the model. + +A single environment may have many endpoints: + +- `wss://t3.example.com` +- `ws://10.0.0.25:3773` +- a tunneled relay URL +- a desktop-managed SSH tunnel that resolves to a local forwarded WebSocket URL + +The environment stays the same. Only the access path changes. + +### RepositoryIdentity + +`RepositoryIdentity` remains a best-effort logical repo grouping mechanism across environments. + +It is not used for routing. It is only used for UI grouping and correlation between local and remote clones of the same repository. + +### Workspace / Project + +The current `Project` model remains environment-local. + +That means: + +- a local clone and a remote clone are different projects +- they may share a `RepositoryIdentity` +- threads still bind to one project in one environment + +## Access methods + +Access methods answer one question: + +How does the client speak WebSocket to a T3 server? + +They do not answer: + +- how the server got started +- who manages the server process +- whether the environment is local or remote + +### 1. Direct WebSocket access + +Examples: + +- `ws://10.0.0.15:3773` +- `wss://t3.example.com` + +This is the base model and should be the first-class default. + +Benefits: + +- works for desktop, mobile, and web +- no client-specific process management required +- best fit for hosted or self-managed remote T3 deployments + +### 2. Tunneled WebSocket access + +Examples: + +- public relay URLs +- private network relay URLs +- local tunnel products such as pipenet + +This is still direct WebSocket access from the client's perspective. The difference is that the route is mediated by a tunnel or relay. + +For T3, tunnels are best modeled as another `AccessEndpoint`, not as a different kind of environment. + +This is especially useful when: + +- the host is behind NAT +- inbound ports are unavailable +- mobile must reach a desktop-hosted environment +- a machine should be reachable without exposing raw LAN or public ports + +### 3. Desktop-managed SSH access + +SSH is an access and launch helper, not a separate environment type. + +The desktop main process can use SSH to: + +- reach a machine +- probe it +- launch or reuse a remote T3 server +- establish a local port forward + +After that, the renderer should still connect using an ordinary WebSocket URL against the forwarded local port. + +This keeps the renderer transport model consistent with every other access method. + +## Launch methods + +Launch methods answer a different question: + +How does a T3 server come to exist on the target machine? + +Launch and access should stay separate in the design. + +### 1. Pre-existing server + +The simplest launch method is no launch at all. + +The user or operator already runs T3 on the target machine, and the client connects through a direct or tunneled WebSocket endpoint. + +This should be the first remote mode shipped because it validates the environment model with minimal extra machinery. + +### 2. Desktop-managed remote launch over SSH + +This is the main place where Zed is a useful reference. + +Useful ideas to borrow from Zed: + +- remote probing +- platform detection +- session directories with pid/log metadata +- reconnect-friendly launcher behavior +- desktop-owned connection UX + +What should be different in T3: + +- no custom stdio/socket proxy protocol between renderer and remote runtime +- no attempt to make the remote runtime look like an editor transport +- keep the final client-to-server connection as WebSocket + +The recommended T3 flow is: + +1. Desktop connects over SSH. +2. Desktop probes the remote machine and verifies T3 availability. +3. Desktop launches or reuses a remote T3 server. +4. Desktop establishes local port forwarding. +5. Renderer connects to the forwarded WebSocket endpoint as a normal environment. + +### 3. Client-managed local publish + +This is the inverse of remote launch: a local T3 server is already running, and the client publishes it through a tunnel. + +This is useful for: + +- exposing a desktop-hosted environment to mobile +- temporary remote access without changing router or firewall settings + +This is still a launch concern, not a new environment kind. + +## Why access and launch must stay separate + +These concerns are easy to conflate, but separating them prevents architectural drift. + +Examples: + +- A manually hosted T3 server might be reached through direct `wss`. +- The same server might also be reachable through a tunnel. +- An SSH-managed server might be launched over SSH but then reached through forwarded WebSocket. +- A local desktop server might be published through a tunnel for mobile. + +In all of those cases, the `ExecutionEnvironment` is the same kind of thing. + +Only the launch and access paths differ. + +## Security model + +Remote support must assume that some environments will be reachable over untrusted networks. + +That means: + +- remote-capable environments should require explicit authentication +- tunnel exposure should not rely on obscurity +- client-saved endpoints should carry enough auth metadata to reconnect safely + +T3 already supports a WebSocket auth token on the server. That should become a first-class part of environment access rather than remaining an incidental query parameter convention. + +For publicly reachable environments, authenticated access should be treated as required. + +## Relationship to Zed + +Zed is a useful reference implementation for managed remote launch and reconnect behavior. + +The relevant lessons are: + +- remote bootstrap should be explicit +- reconnect should be first-class +- connection UX belongs in the client shell +- runtime ownership should stay clearly on the remote host + +The important mismatch is transport shape. + +Zed needs a custom proxy/server protocol because its remote boundary sits below the editor and project runtime. + +T3 should not copy that part. + +T3 already has the right runtime boundary: + +- one T3 server per environment +- ordinary HTTP/WebSocket between client and environment + +So T3 should borrow Zed's launch discipline, not its transport protocol. + +## Recommended rollout + +1. First-class known environments and access endpoints. +2. Direct `ws` / `wss` remote environments. +3. Authenticated tunnel-backed environments. +4. Desktop-managed SSH launch and forwarding. +5. Multi-environment UI improvements after the base runtime path is proven. + +This ordering keeps the architecture network-first and transport-agnostic while still leaving room for richer managed remote flows. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f521ced14c..5c2d2c8381 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,7 +72,7 @@ jobs: - name: Verify preload bundle output run: | test -f apps/desktop/dist-electron/preload.js - grep -nE "desktopBridge|getWsUrl|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js + grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js release_smoke: name: Release Smoke diff --git a/.oxfmtrc.json b/.oxfmtrc.json index a3e32c9797..776d11b803 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -9,7 +9,8 @@ "bun.lock", "*.tsbuildinfo", "**/routeTree.gen.ts", - "apps/web/public/mockServiceWorker.js" + "apps/web/public/mockServiceWorker.js", + "apps/web/src/lib/vendor/qrcodegen.ts" ], "sortPackageJson": {} } diff --git a/.plans/18-server-auth-model.md b/.plans/18-server-auth-model.md new file mode 100644 index 0000000000..9f8ba8a05d --- /dev/null +++ b/.plans/18-server-auth-model.md @@ -0,0 +1,823 @@ +# Server Auth Model Plan + +## Purpose + +Define the long-term server auth architecture for T3 Code before first-class remote environments ship. + +This plan is deliberately broader than the current WebSocket token check and narrower than a complete remote collaboration system. The goal is to make the server secure by default, keep local desktop UX frictionless, and leave clean integration points for future remote access methods. + +This document is written in terms of Effect-native services and layers because auth needs to be a core runtime concern, not route-local glue code. + +## Primary goals + +- Make auth server-wide, not WebSocket-only. +- Make insecure exposure hard to do accidentally. +- Preserve zero-login local desktop UX for desktop-managed environments. +- Support browser-native pairing and session auth. +- Leave room for native/mobile credentials later without rewriting the server boundary. +- Keep auth separate from transport and launch method. + +## Non-goals + +- Full multi-user authorization and RBAC. +- OAuth / SSO / enterprise identity. +- Passkeys or biometric UX in v1. +- Syncing auth state across environments. +- Designing the full remote environment product in this document. + +## Core decisions + +### 1. Auth is a server concern + +Every privileged surface of the T3 server must go through the same auth policy engine: + +- HTTP routes +- WebSocket upgrades +- RPC methods reached through WebSocket + +The current split where [`/ws`](../apps/server/src/ws.ts) checks `authToken` but routes in [`http.ts`](../apps/server/src/http.ts) do not is not sufficient for a remote-capable product. + +### 2. Pairing and session are different things + +The system should distinguish: + +- bootstrap credentials +- session credentials + +Bootstrap credentials are short-lived and high-trust. They allow a client to become authenticated. + +Session credentials are the durable credentials used after pairing. + +Bootstrap should never become the long-lived request credential. + +### 3. Auth and transport are separate + +Auth must not be defined by how the client reached the server. + +Examples: + +- local desktop-managed server +- LAN `ws://` +- public `wss://` +- tunneled `wss://` +- SSH-forwarded `ws://127.0.0.1:` + +All of these should feed into the same auth model. + +### 4. Exposure level changes defaults + +The more exposed an environment is, the narrower the safe default should be. + +Safe default expectations: + +- local desktop-managed: auto-pair allowed +- loopback browser access: explicit bootstrap allowed +- non-loopback bind: auth required +- tunnel/public endpoint: auth required, explicit enablement required + +### 5. Browser and native clients may use different session credentials + +The auth model should support more than one session credential type even if only one ships first. + +Examples: + +- browser session cookie +- native bearer/device token + +This should be represented in the model now, even if browser cookies are the first implementation. + +## Target auth domain + +### Route classes + +Every route or transport entrypoint should be classified as one of: + +1. `public` +2. `bootstrap` +3. `authenticated` + +#### `public` + +Unauthenticated by definition. + +Should be extremely small. Examples: + +- static shell needed to render the pairing/login UI +- favicon/assets required for the pairing screen +- a minimal server health/version endpoint if needed + +#### `bootstrap` + +Used only to exchange a bootstrap credential for a session. + +Examples: + +- Initial bootstrap envelope over file descriptor at startup +- `POST /api/auth/bootstrap` +- `GET /api/auth/session` if unauthenticated checks are part of bootstrap UX + +#### `authenticated` + +Everything that reveals machine state or mutates it. + +Examples: + +- WebSocket upgrade +- orchestration snapshot and events +- terminal open/write/close +- project search and file writes +- git routes +- attachments +- project favicon lookup +- server settings + +The default stance should be: if it touches the machine, it is authenticated. + +## Credential model + +### Bootstrap credentials + +Initial credential types to model: + +- `desktop-bootstrap` +- `one-time-token` + +Possible future credential types: + +- `device-code` +- `passkey-assertion` +- `external-identity` + +#### `desktop-bootstrap` + +Used when the desktop shell manages the server and should be the only default pairing method for desktop-local environments. + +Properties: + +- launcher-provided +- short-lived +- one-time or bounded-use +- never shown to the user as a reusable password + +#### `one-time-token` + +Used for explicit browser/mobile pairing flows. + +Properties: + +- short TTL +- one-time use +- safe to embed in a pairing URL fragment +- exchanged for a session credential + +### Session credentials + +Initial credential types to model: + +- `browser-session-cookie` +- `bearer-session-token` + +#### `browser-session-cookie` + +Primary browser credential. + +Properties: + +- signed +- `HttpOnly` +- bounded lifetime +- revocable by server key rotation or session invalidation + +#### `bearer-session-token` + +Reserved for native/mobile or non-browser clients. + +Properties: + +- opaque token, not a bootstrap secret +- long enough lifetime to survive reconnects +- stored in secure client storage when available + +## Auth policy model + +Auth behavior should be driven by an explicit environment auth policy, not route-local heuristics. + +### Policy examples + +#### `DesktopManagedLocalPolicy` + +Default for desktop-managed local server. + +Allowed bootstrap methods: + +- `desktop-bootstrap` + +Allowed session methods: + +- `browser-session-cookie` + +Disabled by default: + +- `one-time-token` +- `bearer-session-token` +- password login +- public pairing + +#### `LoopbackBrowserPolicy` + +Used for browser access on localhost without desktop-managed bootstrap. + +Allowed bootstrap methods: + +- `one-time-token` + +Allowed session methods: + +- `browser-session-cookie` + +#### `RemoteReachablePolicy` + +Used when binding non-loopback or using an explicit remote/tunnel workflow. + +Allowed bootstrap methods: + +- `one-time-token` +- possibly `desktop-bootstrap` when a desktop shell is brokering access + +Allowed session methods: + +- `browser-session-cookie` +- `bearer-session-token` + +#### `UnsafeNoAuthPolicy` + +Should exist only as an explicit escape hatch. + +Requirements: + +- explicit opt-in flag +- loud startup warnings +- never defaulted automatically + +## Effect-native service model + +### `ServerAuth` + +The main auth facade used by HTTP routes and WebSocket upgrade handling. + +Responsibilities: + +- classify requests +- authenticate requests +- authorize bootstrap attempts +- create sessions from bootstrap credentials +- enforce policy by environment mode + +Sketch: + +```ts +export interface ServerAuthShape { + readonly getCapabilities: Effect.Effect; + readonly authenticateHttpRequest: ( + request: HttpServerRequest.HttpServerRequest, + routeClass: RouteAuthClass, + ) => Effect.Effect; + readonly authenticateWebSocketUpgrade: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly exchangeBootstrapCredential: ( + input: BootstrapExchangeInput, + ) => Effect.Effect; +} + +export class ServerAuth extends ServiceMap.Service()( + "t3/ServerAuth", +) {} +``` + +### `BootstrapCredentialService` + +Owns issuance, storage, validation, and consumption of bootstrap credentials. + +Responsibilities: + +- issue desktop bootstrap grants +- issue one-time pairing tokens +- validate TTL and single-use semantics +- consume bootstrap grants atomically + +Sketch: + +```ts +export interface BootstrapCredentialServiceShape { + readonly issueDesktopBootstrap: ( + input: IssueDesktopBootstrapInput, + ) => Effect.Effect; + readonly issueOneTimeToken: ( + input: IssueOneTimeTokenInput, + ) => Effect.Effect; + readonly consume: ( + presented: PresentedBootstrapCredential, + ) => Effect.Effect; +} +``` + +### `SessionCredentialService` + +Owns creation and validation of authenticated sessions. + +Responsibilities: + +- mint cookie sessions +- mint bearer sessions +- validate active session credentials +- revoke sessions if needed later + +Sketch: + +```ts +export interface SessionCredentialServiceShape { + readonly createBrowserSession: ( + input: CreateSessionFromBootstrapInput, + ) => Effect.Effect; + readonly createBearerSession: ( + input: CreateSessionFromBootstrapInput, + ) => Effect.Effect; + readonly authenticateCookie: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly authenticateBearer: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; +} +``` + +### `ServerAuthPolicy` + +Pure policy/config service that decides which credential types are allowed. + +Responsibilities: + +- map runtime mode and bind/exposure settings to allowed auth methods +- answer whether a route can be public +- answer whether remote exposure requires auth + +This should stay mostly pure and cheap to test. + +### `ServerSecretStore` + +Owns long-lived server signing keys and secrets. + +Responsibilities: + +- get or create signing key +- rotate signing key +- abstract secure OS-backed storage vs filesystem fallback + +Important: + +- prefer platform secure storage when available +- support hardened filesystem fallback for headless/server-only environments + +### `BrowserSessionCookieCodec` + +Focused utility service for cookie encode/decode/signing behavior. + +This should not own policy. It should only own the cookie format. + +### `AuthRouteGuards` + +Thin helper layer used by routes to enforce auth consistently. + +Responsibilities: + +- require auth for HTTP route handlers +- classify route auth mode +- convert auth failures into `401` / `403` + +This prevents every route from re-implementing the same pattern. + +Integrates with `HttpRouter.middleware` to enforce auth consistently. + +## Suggested layer graph + +```text +ServerSecretStore + ├─> BootstrapCredentialService + ├─> BrowserSessionCookieCodec + └─> SessionCredentialService + +ServerAuthPolicy + ├─> BootstrapCredentialService + ├─> SessionCredentialService + └─> ServerAuth + +ServerAuth + └─> AuthRouteGuards +``` + +Layer naming should follow existing repo style: + +- `ServerSecretStoreLive` +- `BootstrapCredentialServiceLive` +- `SessionCredentialServiceLive` +- `ServerAuthPolicyLive` +- `ServerAuthLive` +- `AuthRouteGuardsLive` + +## High-level implementation examples + +### Example: WebSocket upgrade auth + +Current state: + +- `authToken` query param is checked in [`ws.ts`](../apps/server/src/ws.ts) + +Target shape: + +```ts +const websocketUpgradeAuth = HttpMiddleware.make((httpApp) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + yield* serverAuth.authenticateWebSocketUpgrade(request); + return yield* httpApp; + }), +); +``` + +Then the `/ws` route becomes: + +```ts +export const websocketRpcRouteLayer = HttpRouter.add( + "GET", + "/ws", + rpcWebSocketHttpEffect.pipe( + websocketUpgradeAuth, + Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)), + ), +); +``` + +This keeps the route itself declarative and makes auth compose like normal HTTP middleware. + +### Example: authenticated HTTP route + +For routes like attachments or project favicon: + +```ts +const authenticatedRoute = (routeClass: RouteAuthClass) => + HttpMiddleware.make((httpApp) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + yield* serverAuth.authenticateHttpRequest(request, routeClass); + return yield* httpApp; + }), + ); +``` + +Then: + +```ts +export const attachmentsRouteLayer = HttpRouter.add( + "GET", + `${ATTACHMENTS_ROUTE_PREFIX}/*`, + serveAttachment.pipe( + authenticatedRoute(RouteAuthClass.Authenticated), + Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)), + ), +); +``` + +### Example: desktop bootstrap exchange + +The desktop shell launches the local server and gets a short-lived bootstrap grant through a trusted side channel. + +That grant is then exchanged for a browser cookie session when the renderer loads. + +Sketch: + +```ts +const pairDesktopRenderer = Effect.gen(function* () { + const bootstrapService = yield* BootstrapCredentialService; + const credential = yield* bootstrapService.issueDesktopBootstrap({ + audience: "desktop-renderer", + ttlMs: 30_000, + }); + return credential; +}); +``` + +The renderer then calls a bootstrap endpoint and receives a cookie session. The bootstrap credential is consumed and becomes invalid. + +### Example: one-time pairing URL + +For browser-driven pairing: + +```ts +const createPairingToken = Effect.gen(function* () { + const bootstrapService = yield* BootstrapCredentialService; + return yield* bootstrapService.issueOneTimeToken({ + ttlMs: 5 * 60_000, + audience: "browser", + }); +}); +``` + +The server can emit a pairing URL where the token lives in the URL fragment so it is not automatically sent to the server before the client explicitly exchanges it. + +## Sequence diagrams + +These flows are meant to anchor the auth model in concrete user journeys. + +The important invariant across all of them is: + +- access method is not the auth method +- launch method is not the auth method +- bootstrap credential is not the session credential + +### Normal desktop user + +This is the default desktop-managed local flow. + +The desktop shell is trusted to bootstrap the local renderer, but the renderer should still exchange that one-time bootstrap grant for a normal browser session cookie. + +```text +Participants: + DesktopMain = Electron main + SecretStore = secure local secret backend + T3Server = local backend child process + Frontend = desktop renderer + +DesktopMain -> SecretStore : getOrCreate("server-signing-key") +SecretStore --> DesktopMain : signing key available + +DesktopMain -> T3Server : spawn server (--bootstrap-fd ...) +DesktopMain -> T3Server : send desktop bootstrap envelope +note over T3Server : policy = DesktopManagedLocalPolicy +note over T3Server : allowed pairing = desktop-bootstrap only + +Frontend -> DesktopMain : request local bootstrap grant +DesktopMain --> Frontend : short-lived desktop bootstrap grant + +Frontend -> T3Server : POST /api/auth/bootstrap +T3Server -> T3Server : validate desktop bootstrap grant +T3Server -> T3Server : create browser session +T3Server --> Frontend : Set-Cookie: session=... + +Frontend -> T3Server : GET /ws + authenticated cookie +T3Server -> T3Server : validate cookie session +T3Server --> Frontend : websocket accepted +``` + +### `npx t3` user + +This is the standalone local server flow. + +There is no trusted desktop shell here, so pairing should be explicit. + +```text +Participants: + UserShell = npx t3 launcher + T3Server = standalone local server + Browser = browser tab + +UserShell -> T3Server : start server +T3Server -> T3Server : getOrCreate("server-signing-key") +note over T3Server : policy = LoopbackBrowserPolicy + +UserShell -> T3Server : issue one-time pairing token +T3Server --> UserShell : pairing URL or pairing token + +UserShell --> Browser : open /pair?token=... + +Browser -> T3Server : GET /pair?token=... +T3Server -> T3Server : validate one-time token +T3Server -> T3Server : create browser session +T3Server --> Browser : Set-Cookie: session=... +T3Server --> Browser : redirect to app + +Browser -> T3Server : GET /ws + authenticated cookie +T3Server --> Browser : websocket accepted +``` + +### Phone user with tunneled host + +This is the explicit remote access flow for a browser on another device. + +The tunnel only provides reachability. It must not imply trust. + +Recommended UX: + +- desktop shows a QR code +- desktop also shows a copyable pairing URL +- if the phone opens the host URL without a valid token, the server should render a login or pairing screen rather than granting access + +```text +Participants: + DesktopUser = user at the host machine + DesktopMain = desktop app + Tunnel = tunnel provider + T3Server = T3 server + PhoneBrowser = mobile browser + +DesktopUser -> DesktopMain : enable remote access via tunnel +DesktopMain -> T3Server : switch policy to RemoteReachablePolicy +DesktopMain -> Tunnel : publish local T3 endpoint +Tunnel --> DesktopMain : public https/wss URL + +DesktopMain -> T3Server : issue one-time pairing token +T3Server --> DesktopMain : pairing token +DesktopMain -> DesktopUser : show QR code / shareable URL + +DesktopUser -> PhoneBrowser : scan QR / open URL +PhoneBrowser -> Tunnel : GET https://public-host/pair?token=... +Tunnel -> T3Server : forward request +T3Server -> T3Server : validate one-time token +T3Server -> T3Server : create mobile browser session +T3Server --> PhoneBrowser : Set-Cookie: session=... +T3Server --> PhoneBrowser : redirect to app + +PhoneBrowser -> Tunnel : GET /ws + authenticated cookie +Tunnel -> T3Server : forward websocket upgrade +T3Server --> PhoneBrowser : websocket accepted +``` + +### Phone user with private network + +This is operationally similar to the tunnel flow, but the access endpoint is on a private network such as Tailscale. + +The auth flow should stay the same. + +```text +Participants: + DesktopUser = user at the host machine + T3Server = T3 server + PrivateNet = tailscale / private LAN + PhoneBrowser = mobile browser + +DesktopUser -> T3Server : enable private-network access +T3Server -> T3Server : switch policy to RemoteReachablePolicy +DesktopUser -> T3Server : issue one-time pairing token +T3Server --> DesktopUser : pairing URL / QR + +DesktopUser -> PhoneBrowser : open private-network URL +PhoneBrowser -> PrivateNet : GET http(s)://private-host/pair?token=... +PrivateNet -> T3Server : route request +T3Server -> T3Server : validate one-time token +T3Server -> T3Server : create mobile browser session +T3Server --> PhoneBrowser : Set-Cookie: session=... +T3Server --> PhoneBrowser : redirect to app + +PhoneBrowser -> PrivateNet : GET /ws + authenticated cookie +PrivateNet -> T3Server : websocket upgrade +T3Server --> PhoneBrowser : websocket accepted +``` + +### Desktop user adding new SSH hosts + +SSH should be treated as launch and reachability plumbing, not as the long-term auth model. + +The desktop app uses SSH to start or reach the remote server, then the renderer pairs against that server using the same bootstrap/session split as every other environment. + +```text +Participants: + DesktopUser = local desktop user + DesktopMain = desktop app + SSH = ssh transport/session + RemoteHost = remote machine + RemoteT3 = remote T3 server + Frontend = desktop renderer + +DesktopUser -> DesktopMain : add SSH host +DesktopMain -> SSH : connect to remote host +SSH -> RemoteHost : probe environment / verify t3 availability +DesktopMain -> SSH : run remote launch command +SSH -> RemoteHost : t3 remote launch --json +RemoteHost -> RemoteT3 : start or reuse server +RemoteT3 --> RemoteHost : port + environment metadata +RemoteHost --> SSH : launch result JSON +SSH --> DesktopMain : remote server details + +DesktopMain -> SSH : establish local port forward +SSH --> DesktopMain : localhost:FORWARDED_PORT ready + +note over RemoteT3 : policy = RemoteReachablePolicy +note over DesktopMain,RemoteT3 : desktop may use a trusted bootstrap flow here + +Frontend -> DesktopMain : request bootstrap for selected environment +DesktopMain --> Frontend : short-lived bootstrap grant + +Frontend -> RemoteT3 : POST /api/auth/bootstrap via forwarded port +RemoteT3 -> RemoteT3 : validate bootstrap grant +RemoteT3 -> RemoteT3 : create browser session +RemoteT3 --> Frontend : Set-Cookie: session=... + +Frontend -> RemoteT3 : GET /ws + authenticated cookie +RemoteT3 --> Frontend : websocket accepted +``` + +## Storage decisions + +### Server secrets + +Use a `ServerSecretStore` abstraction. + +Preferred order (use a layer for each, resolve on startup): + +1. OS secure storage if available +2. hardened filesystem fallback if not + +The filesystem fallback should store only opaque signing material with strict file permissions. It should not store user passwords or reusable third-party credentials. + +### Client credentials + +Client-side credential persistence should prefer secure storage when available: + +- desktop: OS keychain / secure store +- mobile: platform secure storage +- browser: cookie session for browser auth + +This concern should stay in the client shell/runtime layer, not the server auth layer. + +## What to build now + +These are the parts worth building before remote environments ship: + +1. `ServerAuth` service boundary. +2. route classification and route guards. +3. `ServerSecretStore` abstraction. +4. bootstrap vs session credential split. +5. browser session cookie codec as one session method. +6. explicit auth capabilities/config surfaced in contracts. + +Even if only one pairing flow is used initially, these seams will keep future remote and mobile work contained. + +## What to add as part of first remote-capable auth + +1. Browser pairing flow using one-time bootstrap token and cookie session. +2. Desktop-managed auto-bootstrap for the local desktop-managed environment. +3. Auth-required defaults for any non-loopback or explicitly published server. +4. Explicit environment auth policy selection instead of scattered `if (host !== localhost)` checks. + +## What to defer + +- passkeys / WebAuthn +- iCloud Keychain / Face ID-specific UX +- multi-user permissions +- collaboration roles +- OAuth / SSO +- polished session management UI +- complex device approval flows + +These can all sit on top of the same bootstrap/session/service split. + +## Relationship to future remote environments + +Remote access is one reason this auth model matters, but the auth model should not be remote-shaped. + +Keep the design focused on: + +- one T3 server +- one auth policy +- multiple credential types +- multiple future access methods + +That keeps the server auth model stable even as access methods expand later. + +## Recommended implementation order + +### Phase 1 + +- Introduce route auth classes. +- Add `ServerAuth` and `AuthRouteGuards`. +- Move existing `authToken` check behind `ServerAuth`. +- Require auth for all privileged HTTP routes as well as WebSocket. + +### Phase 2 + +- Add `ServerSecretStore` service with platform-specific layer implementations. + - `layerOSXKeychain`, `layer +- Add bootstrap/session split. +- Add browser session cookie support. +- Add one-time bootstrap exchange endpoint. + +### Phase 3 + +- Add desktop bootstrap flow on top of the same services. +- Make desktop-managed local environments default to bootstrap-only pairing. +- Surface auth capabilities in shared contracts and renderer bootstrap. + +### Phase 4 + +- Add non-browser bearer session support if mobile/native needs it. +- Add richer policy modes for remote-reachable environments. + +## Acceptance criteria + +- No privileged HTTP or WebSocket path bypasses auth policy. +- Local desktop-managed flows still avoid a visible login screen. +- Non-loopback or published environments require explicit authenticated pairing by default. +- Bootstrap and session credentials are distinct in code and in behavior. +- Auth logic is centralized in Effect services/layers rather than route-local branching. diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 5244d51dbf..7c0d55ac9a 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -5,8 +5,17 @@ import { join } from "node:path"; import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; import { waitForResources } from "./wait-for-resources.mjs"; -const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733); -const devServerUrl = `http://localhost:${port}`; +const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); +if (!devServerUrl) { + throw new Error("VITE_DEV_SERVER_URL is required for desktop development."); +} + +const devServer = new URL(devServerUrl); +const port = Number.parseInt(devServer.port, 10); +if (!Number.isInteger(port) || port <= 0) { + throw new Error(`VITE_DEV_SERVER_URL must include an explicit port: ${devServerUrl}`); +} + const requiredFiles = [ "dist-electron/main.js", "dist-electron/preload.js", @@ -23,6 +32,7 @@ const childTreeGracePeriodMs = 1_200; await waitForResources({ baseDir: desktopDir, files: requiredFiles, + tcpHost: devServer.hostname, tcpPort: port, }); @@ -62,10 +72,7 @@ function startApp() { [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"], { cwd: desktopDir, - env: { - ...childEnv, - VITE_DEV_SERVER_URL: devServerUrl, - }, + env: childEnv, stdio: "inherit", }, ); diff --git a/apps/desktop/src/backendReadiness.test.ts b/apps/desktop/src/backendReadiness.test.ts new file mode 100644 index 0000000000..fd6180b5da --- /dev/null +++ b/apps/desktop/src/backendReadiness.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + BackendReadinessAbortedError, + isBackendReadinessAborted, + waitForHttpReady, +} from "./backendReadiness"; + +describe("waitForHttpReady", () => { + it("returns once the backend reports a successful session endpoint", async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 503 })) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + + await waitForHttpReady("http://127.0.0.1:3773", { + fetchImpl, + timeoutMs: 1_000, + intervalMs: 0, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("retries after a readiness request stalls past the per-request timeout", async () => { + const fetchImpl = vi + .fn() + .mockImplementationOnce( + (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => { + reject(new Error("request timed out")); + }, + { once: true }, + ); + }) as ReturnType, + ) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + + await waitForHttpReady("http://127.0.0.1:3773", { + fetchImpl, + timeoutMs: 100, + intervalMs: 0, + requestTimeoutMs: 1, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("aborts an in-flight readiness wait", async () => { + const controller = new AbortController(); + const fetchImpl = vi.fn().mockImplementation( + () => + new Promise((_resolve, reject) => { + controller.signal.addEventListener( + "abort", + () => { + reject(new BackendReadinessAbortedError()); + }, + { once: true }, + ); + }) as ReturnType, + ); + + const waitPromise = waitForHttpReady("http://127.0.0.1:3773", { + fetchImpl, + timeoutMs: 1_000, + intervalMs: 0, + signal: controller.signal, + }); + + controller.abort(); + + await expect(waitPromise).rejects.toBeInstanceOf(BackendReadinessAbortedError); + }); + + it("recognizes aborted readiness errors", () => { + expect(isBackendReadinessAborted(new BackendReadinessAbortedError())).toBe(true); + expect(isBackendReadinessAborted(new Error("nope"))).toBe(false); + }); +}); diff --git a/apps/desktop/src/backendReadiness.ts b/apps/desktop/src/backendReadiness.ts new file mode 100644 index 0000000000..cd5a3c023e --- /dev/null +++ b/apps/desktop/src/backendReadiness.ts @@ -0,0 +1,103 @@ +export interface WaitForHttpReadyOptions { + readonly timeoutMs?: number; + readonly intervalMs?: number; + readonly requestTimeoutMs?: number; + readonly fetchImpl?: typeof fetch; + readonly signal?: AbortSignal; +} + +const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_INTERVAL_MS = 100; +const DEFAULT_REQUEST_TIMEOUT_MS = 1_000; + +export class BackendReadinessAbortedError extends Error { + constructor() { + super("Backend readiness wait was aborted."); + this.name = "BackendReadinessAbortedError"; + } +} + +function delay(ms: number, signal: AbortSignal | undefined): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + + const onAbort = () => { + cleanup(); + reject(new BackendReadinessAbortedError()); + }; + + const cleanup = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + }; + + if (signal?.aborted) { + cleanup(); + reject(new BackendReadinessAbortedError()); + return; + } + + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +export function isBackendReadinessAborted(error: unknown): error is BackendReadinessAbortedError { + return error instanceof BackendReadinessAbortedError; +} + +export async function waitForHttpReady( + baseUrl: string, + options?: WaitForHttpReadyOptions, +): Promise { + const fetchImpl = options?.fetchImpl ?? fetch; + const signal = options?.signal; + const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS; + const requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; + + for (;;) { + if (signal?.aborted) { + throw new BackendReadinessAbortedError(); + } + + const requestController = new AbortController(); + const requestTimeout = setTimeout(() => { + requestController.abort(); + }, requestTimeoutMs); + const abortRequest = () => { + requestController.abort(); + }; + signal?.addEventListener("abort", abortRequest, { once: true }); + + try { + const response = await fetchImpl(`${baseUrl}/api/auth/session`, { + redirect: "manual", + signal: requestController.signal, + }); + if (response.ok) { + return; + } + } catch (error) { + if (isBackendReadinessAborted(error)) { + throw error; + } + if (signal?.aborted) { + throw new BackendReadinessAbortedError(); + } + // Retry until the backend becomes reachable or the deadline expires. + } finally { + clearTimeout(requestTimeout); + signal?.removeEventListener("abort", abortRequest); + } + + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for backend readiness at ${baseUrl}.`); + } + + await delay(intervalMs, signal); + } +} diff --git a/apps/desktop/src/desktopSettings.test.ts b/apps/desktop/src/desktopSettings.test.ts new file mode 100644 index 0000000000..e687bf544e --- /dev/null +++ b/apps/desktop/src/desktopSettings.test.ts @@ -0,0 +1,64 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + DEFAULT_DESKTOP_SETTINGS, + readDesktopSettings, + setDesktopServerExposurePreference, + writeDesktopSettings, +} from "./desktopSettings"; + +const tempDirectories: string[] = []; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +function makeSettingsPath() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-desktop-settings-test-")); + tempDirectories.push(directory); + return path.join(directory, "desktop-settings.json"); +} + +describe("desktopSettings", () => { + it("returns defaults when no settings file exists", () => { + expect(readDesktopSettings(makeSettingsPath())).toEqual(DEFAULT_DESKTOP_SETTINGS); + }); + + it("persists and reloads the configured server exposure mode", () => { + const settingsPath = makeSettingsPath(); + + writeDesktopSettings(settingsPath, { + serverExposureMode: "network-accessible", + }); + + expect(readDesktopSettings(settingsPath)).toEqual({ + serverExposureMode: "network-accessible", + }); + }); + + it("preserves the requested network-accessible preference across temporary fallback", () => { + expect( + setDesktopServerExposurePreference( + { + serverExposureMode: "local-only", + }, + "network-accessible", + ), + ).toEqual({ + serverExposureMode: "network-accessible", + }); + }); + + it("falls back to defaults when the settings file is malformed", () => { + const settingsPath = makeSettingsPath(); + fs.writeFileSync(settingsPath, "{not-json", "utf8"); + + expect(readDesktopSettings(settingsPath)).toEqual(DEFAULT_DESKTOP_SETTINGS); + }); +}); diff --git a/apps/desktop/src/desktopSettings.ts b/apps/desktop/src/desktopSettings.ts new file mode 100644 index 0000000000..80ef229ea2 --- /dev/null +++ b/apps/desktop/src/desktopSettings.ts @@ -0,0 +1,51 @@ +import * as FS from "node:fs"; +import * as Path from "node:path"; +import type { DesktopServerExposureMode } from "@t3tools/contracts"; + +export interface DesktopSettings { + readonly serverExposureMode: DesktopServerExposureMode; +} + +export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + serverExposureMode: "local-only", +}; + +export function setDesktopServerExposurePreference( + settings: DesktopSettings, + requestedMode: DesktopServerExposureMode, +): DesktopSettings { + return settings.serverExposureMode === requestedMode + ? settings + : { + ...settings, + serverExposureMode: requestedMode, + }; +} + +export function readDesktopSettings(settingsPath: string): DesktopSettings { + try { + if (!FS.existsSync(settingsPath)) { + return DEFAULT_DESKTOP_SETTINGS; + } + + const raw = FS.readFileSync(settingsPath, "utf8"); + const parsed = JSON.parse(raw) as { + readonly serverExposureMode?: unknown; + }; + + return { + serverExposureMode: + parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", + }; + } catch { + return DEFAULT_DESKTOP_SETTINGS; + } +} + +export function writeDesktopSettings(settingsPath: string, settings: DesktopSettings): void { + const directory = Path.dirname(settingsPath); + const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; + FS.mkdirSync(directory, { recursive: true }); + FS.writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); + FS.renameSync(tempPath, settingsPath); +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index de327d0ff8..9377752e6c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -19,6 +19,8 @@ import type { MenuItemConstructorOptions } from "electron"; import * as Effect from "effect/Effect"; import type { DesktopTheme, + DesktopServerExposureMode, + DesktopServerExposureState, DesktopUpdateActionResult, DesktopUpdateCheckResult, DesktopUpdateState, @@ -29,7 +31,15 @@ import type { ContextMenuItem } from "@t3tools/contracts"; import { NetService } from "@t3tools/shared/Net"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import { + DEFAULT_DESKTOP_SETTINGS, + readDesktopSettings, + setDesktopServerExposurePreference, + writeDesktopSettings, +} from "./desktopSettings"; +import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness"; import { showDesktopConfirmDialog } from "./confirmDialog"; +import { resolveDesktopServerExposure } from "./serverExposure"; import { syncShellEnvironment } from "./syncShellEnvironment"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; import { @@ -59,10 +69,12 @@ const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const UPDATE_CHECK_CHANNEL = "desktop:update-check"; -const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; +const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); +const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); const DESKTOP_SCHEME = "t3"; const ROOT_DIR = Path.resolve(__dirname, "../../.."); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); @@ -83,6 +95,7 @@ const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; const DESKTOP_UPDATE_CHANNEL = "latest"; const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; +const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; type LinuxDesktopNamedApp = Electron.App & { @@ -92,8 +105,13 @@ type LinuxDesktopNamedApp = Electron.App & { let mainWindow: BrowserWindow | null = null; let backendProcess: ChildProcess.ChildProcess | null = null; let backendPort = 0; -let backendAuthToken = ""; +let backendBindHost = DESKTOP_LOOPBACK_HOST; +let backendBootstrapToken = ""; +let backendHttpUrl = ""; let backendWsUrl = ""; +let backendEndpointUrl: string | null = null; +let backendAdvertisedHost: string | null = null; +let backendReadinessAbortController: AbortController | null = null; let restartAttempt = 0; let restartTimer: ReturnType | null = null; let isQuitting = false; @@ -103,6 +121,8 @@ let desktopLogSink: RotatingFileSink | null = null; let backendLogSink: RotatingFileSink | null = null; let restoreStdIoCapture: (() => void) | null = null; let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); +let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH); +let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; let destructiveMenuIconCache: Electron.NativeImage | null | undefined; const expectedBackendExitChildren = new WeakSet(); @@ -141,17 +161,120 @@ function readPersistedBackendObservabilitySettings(): { } } +function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): number | undefined { + if (!rawPort) { + return undefined; + } + + const parsedPort = Number.parseInt(rawPort, 10); + if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65_535) { + return undefined; + } + + return parsedPort; +} + +function resolveDesktopDevServerUrl(): string { + const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); + if (!devServerUrl) { + throw new Error("VITE_DEV_SERVER_URL is required in desktop development."); + } + + return devServerUrl; +} + function backendChildEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; delete env.T3CODE_PORT; - delete env.T3CODE_AUTH_TOKEN; delete env.T3CODE_MODE; delete env.T3CODE_NO_BROWSER; delete env.T3CODE_HOST; delete env.T3CODE_DESKTOP_WS_URL; + delete env.T3CODE_DESKTOP_LAN_ACCESS; + delete env.T3CODE_DESKTOP_LAN_HOST; return env; } +function getDesktopServerExposureState(): DesktopServerExposureState { + return { + mode: desktopServerExposureMode, + endpointUrl: backendEndpointUrl, + advertisedHost: backendAdvertisedHost, + }; +} + +function resolveAdvertisedHostOverride(): string | undefined { + const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim(); + return override && override.length > 0 ? override : undefined; +} + +async function applyDesktopServerExposureMode( + mode: DesktopServerExposureMode, + options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean }, +): Promise { + const advertisedHostOverride = resolveAdvertisedHostOverride(); + const requestedMode = mode; + let exposure = resolveDesktopServerExposure({ + mode, + port: backendPort, + networkInterfaces: OS.networkInterfaces(), + ...(advertisedHostOverride ? { advertisedHostOverride } : {}), + }); + + if (requestedMode === "network-accessible" && exposure.endpointUrl === null) { + if (options?.rejectIfUnavailable) { + throw new Error("No reachable network address is available for this desktop right now."); + } + exposure = resolveDesktopServerExposure({ + mode: "local-only", + port: backendPort, + networkInterfaces: OS.networkInterfaces(), + ...(advertisedHostOverride ? { advertisedHostOverride } : {}), + }); + } + + desktopServerExposureMode = exposure.mode; + desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode); + backendBindHost = exposure.bindHost; + backendHttpUrl = exposure.localHttpUrl; + backendWsUrl = exposure.localWsUrl; + backendEndpointUrl = exposure.endpointUrl; + backendAdvertisedHost = exposure.advertisedHost; + + if (options?.persist) { + writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); + } + + return getDesktopServerExposureState(); +} + +function relaunchDesktopApp(reason: string): void { + writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`); + setImmediate(() => { + isQuitting = true; + clearUpdatePollTimer(); + cancelBackendReadinessWait(); + void stopBackendAndWaitForExit() + .catch((error) => { + writeDesktopLogHeader( + `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`, + ); + }) + .finally(() => { + restoreStdIoCapture?.(); + if (isDevelopment) { + app.exit(75); + return; + } + app.relaunch({ + execPath: process.execPath, + args: process.argv.slice(1), + }); + app.exit(0); + }); + }); +} + function writeDesktopLogHeader(message: string): void { if (!desktopLogSink) return; desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); @@ -199,6 +322,27 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { return null; } +async function waitForBackendHttpReady(baseUrl: string): Promise { + cancelBackendReadinessWait(); + const controller = new AbortController(); + backendReadinessAbortController = controller; + + try { + await waitForHttpReady(baseUrl, { + signal: controller.signal, + }); + } finally { + if (backendReadinessAbortController === controller) { + backendReadinessAbortController = null; + } + } +} + +function cancelBackendReadinessWait(): void { + backendReadinessAbortController?.abort(); + backendReadinessAbortController = null; +} + function writeDesktopStreamChunk( streamName: "stdout" | "stderr", chunk: unknown, @@ -543,10 +687,7 @@ function dispatchMenuAction(action: string): void { const send = () => { if (targetWindow.isDestroyed()) return; targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); - if (!targetWindow.isVisible()) { - targetWindow.show(); - } - targetWindow.focus(); + revealWindow(targetWindow); }; if (targetWindow.webContents.isLoadingMainFrame()) { @@ -767,6 +908,26 @@ function clearUpdatePollTimer(): void { } } +function revealWindow(window: BrowserWindow): void { + if (window.isDestroyed()) { + return; + } + + if (window.isMinimized()) { + window.restore(); + } + + if (!window.isVisible()) { + window.show(); + } + + if (process.platform === "darwin") { + app.focus({ steal: true }); + } + + window.focus(); +} + function emitUpdateState(): void { for (const window of BrowserWindow.getAllWindows()) { if (window.isDestroyed()) continue; @@ -1036,7 +1197,8 @@ function startBackend(): void { noBrowser: true, port: backendPort, t3Home: BASE_DIR, - authToken: backendAuthToken, + host: backendBindHost, + desktopBootstrapToken: backendBootstrapToken, ...(backendObservabilitySettings.otlpTracesUrl ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl } : {}), @@ -1095,6 +1257,7 @@ function startBackend(): void { } function stopBackend(): void { + cancelBackendReadinessWait(); if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; @@ -1116,6 +1279,7 @@ function stopBackend(): void { } async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { + cancelBackendReadinessWait(); if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; @@ -1168,19 +1332,38 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { } function registerIpcHandlers(): void { - ipcMain.removeAllListeners(GET_WS_URL_CHANNEL); - ipcMain.on(GET_WS_URL_CHANNEL, (event) => { - event.returnValue = backendWsUrl; - }); - ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => { event.returnValue = { label: "Local environment", - wsUrl: backendWsUrl || null, + httpBaseUrl: backendHttpUrl || null, + wsBaseUrl: backendWsUrl || null, + bootstrapToken: backendBootstrapToken || undefined, } as const; }); + ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); + ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); + + ipcMain.removeHandler(SET_SERVER_EXPOSURE_MODE_CHANNEL); + ipcMain.handle(SET_SERVER_EXPOSURE_MODE_CHANNEL, async (_event, rawMode: unknown) => { + if (rawMode !== "local-only" && rawMode !== "network-accessible") { + throw new Error("Invalid desktop server exposure input."); + } + + const nextMode = rawMode as DesktopServerExposureMode; + if (nextMode === desktopServerExposureMode) { + return getDesktopServerExposureState(); + } + + const nextState = await applyDesktopServerExposureMode(nextMode, { + persist: true, + rejectIfUnavailable: true, + }); + relaunchDesktopApp(`serverExposureMode=${nextMode}`); + return nextState; + }); + ipcMain.removeHandler(PICK_FOLDER_CHANNEL); ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; @@ -1346,14 +1529,19 @@ function getIconOption(): { icon: string } | Record { return iconPath ? { icon: iconPath } : {}; } +function getInitialWindowBackgroundColor(): string { + return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; +} + function createWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1100, height: 780, minWidth: 840, minHeight: 620, - show: false, + show: isDevelopment, autoHideMenuBar: true, + backgroundColor: getInitialWindowBackgroundColor(), ...getIconOption(), title: APP_DISPLAY_NAME, titleBarStyle: "hiddenInset", @@ -1410,15 +1598,20 @@ function createWindow(): BrowserWindow { window.setTitle(APP_DISPLAY_NAME); emitUpdateState(); }); - window.once("ready-to-show", () => { - window.show(); - }); + if (!isDevelopment) { + window.once("ready-to-show", () => { + revealWindow(window); + }); + } if (isDevelopment) { - void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); + void window.loadURL(resolveDesktopDevServerUrl()); window.webContents.openDevTools({ mode: "detach" }); + setImmediate(() => { + revealWindow(window); + }); } else { - void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`); + void window.loadURL(resolveDesktopWindowUrl()); } window.on("closed", () => { @@ -1430,6 +1623,14 @@ function createWindow(): BrowserWindow { return window; } +function resolveDesktopWindowUrl(): string { + if (backendHttpUrl) { + return backendHttpUrl; + } + + return `${DESKTOP_SCHEME}://app`; +} + // Override Electron's userData path before the `ready` event so that // Chromium session data uses a filesystem-friendly directory name. // Must be called synchronously at the top level — before `app.whenReady()`. @@ -1439,21 +1640,72 @@ configureAppIdentity(); async function bootstrap(): Promise { writeDesktopLogHeader("bootstrap start"); - backendPort = await Effect.service(NetService).pipe( - Effect.flatMap((net) => net.reserveLoopbackPort()), - Effect.provide(NetService.layer), - Effect.runPromise, + const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT); + if (isDevelopment && configuredBackendPort === undefined) { + throw new Error("T3CODE_PORT is required in desktop development."); + } + + backendPort = + configuredBackendPort ?? + (await Effect.service(NetService).pipe( + Effect.flatMap((net) => net.reserveLoopbackPort(DESKTOP_LOOPBACK_HOST)), + Effect.provide(NetService.layer), + Effect.runPromise, + )); + writeDesktopLogHeader( + configuredBackendPort === undefined + ? `reserved backend port via NetService port=${backendPort}` + : `using configured backend port port=${backendPort}`, ); - writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`); - backendAuthToken = Crypto.randomBytes(24).toString("hex"); - const baseUrl = `ws://127.0.0.1:${backendPort}`; - backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`; - writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`); + backendBootstrapToken = Crypto.randomBytes(24).toString("hex"); + if (desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode) { + writeDesktopLogHeader( + `bootstrap restoring persisted server exposure mode mode=${desktopSettings.serverExposureMode}`, + ); + } + const serverExposureState = await applyDesktopServerExposureMode( + desktopSettings.serverExposureMode, + { + persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode, + }, + ); + writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`); + if (serverExposureState.endpointUrl) { + writeDesktopLogHeader( + `bootstrap enabled network access endpointUrl=${serverExposureState.endpointUrl}`, + ); + } else if (desktopSettings.serverExposureMode === "network-accessible") { + writeDesktopLogHeader( + "bootstrap fell back to local-only because no advertised network host was available", + ); + } registerIpcHandlers(); writeDesktopLogHeader("bootstrap ipc handlers registered"); startBackend(); writeDesktopLogHeader("bootstrap backend start requested"); + + if (isDevelopment) { + mainWindow = createWindow(); + writeDesktopLogHeader("bootstrap main window created"); + void waitForBackendHttpReady(backendHttpUrl) + .then(() => { + writeDesktopLogHeader("bootstrap backend ready"); + }) + .catch((error) => { + if (isBackendReadinessAborted(error)) { + return; + } + writeDesktopLogHeader( + `bootstrap backend readiness warning message=${formatErrorMessage(error)}`, + ); + console.warn("[desktop] backend readiness check timed out during dev bootstrap", error); + }); + return; + } + + await waitForBackendHttpReady(backendHttpUrl); + writeDesktopLogHeader("bootstrap backend ready"); mainWindow = createWindow(); writeDesktopLogHeader("bootstrap main window created"); } @@ -1463,6 +1715,7 @@ app.on("before-quit", () => { updateInstallInFlight = false; writeDesktopLogHeader("before-quit received"); clearUpdatePollTimer(); + cancelBackendReadinessWait(); stopBackend(); restoreStdIoCapture?.(); }); @@ -1476,13 +1729,19 @@ app registerDesktopProtocol(); configureAutoUpdater(); void bootstrap().catch((error) => { + if (isBackendReadinessAborted(error) && isQuitting) { + return; + } handleFatalStartupError("bootstrap", error); }); app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) { - mainWindow = createWindow(); + const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0]; + if (existingWindow) { + revealWindow(existingWindow); + return; } + mainWindow = createWindow(); }); }) .catch((error) => { @@ -1501,6 +1760,7 @@ if (process.platform !== "win32") { isQuitting = true; writeDesktopLogHeader("SIGINT received"); clearUpdatePollTimer(); + cancelBackendReadinessWait(); stopBackend(); restoreStdIoCapture?.(); app.quit(); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index bd678844ef..60392f7dba 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -12,14 +12,11 @@ const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; -const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; +const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; contextBridge.exposeInMainWorld("desktopBridge", { - getWsUrl: () => { - const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL); - return typeof result === "string" ? result : null; - }, getLocalEnvironmentBootstrap: () => { const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL); if (typeof result !== "object" || result === null) { @@ -27,6 +24,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), + setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), diff --git a/apps/desktop/src/serverExposure.test.ts b/apps/desktop/src/serverExposure.test.ts new file mode 100644 index 0000000000..b1ae4bef4f --- /dev/null +++ b/apps/desktop/src/serverExposure.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure"; + +describe("resolveLanAdvertisedHost", () => { + it("prefers an explicit host override", () => { + expect( + resolveLanAdvertisedHost( + { + en0: [ + { + address: "192.168.1.44", + family: "IPv4", + internal: false, + netmask: "255.255.255.0", + cidr: "192.168.1.44/24", + mac: "00:00:00:00:00:00", + }, + ], + }, + "10.0.0.9", + ), + ).toBe("10.0.0.9"); + }); + + it("returns the first usable non-internal IPv4 address", () => { + expect( + resolveLanAdvertisedHost( + { + lo0: [ + { + address: "127.0.0.1", + family: "IPv4", + internal: true, + netmask: "255.0.0.0", + cidr: "127.0.0.1/8", + mac: "00:00:00:00:00:00", + }, + ], + en0: [ + { + address: "192.168.1.44", + family: "IPv4", + internal: false, + netmask: "255.255.255.0", + cidr: "192.168.1.44/24", + mac: "00:00:00:00:00:00", + }, + ], + }, + undefined, + ), + ).toBe("192.168.1.44"); + }); + + it("returns null when no usable network address is available", () => { + expect( + resolveLanAdvertisedHost( + { + lo0: [ + { + address: "127.0.0.1", + family: "IPv4", + internal: true, + netmask: "255.0.0.0", + cidr: "127.0.0.1/8", + mac: "00:00:00:00:00:00", + }, + ], + }, + undefined, + ), + ).toBeNull(); + }); +}); + +describe("resolveDesktopServerExposure", () => { + it("keeps the desktop server loopback-only when local-only mode is selected", () => { + expect( + resolveDesktopServerExposure({ + mode: "local-only", + port: 3773, + networkInterfaces: {}, + }), + ).toEqual({ + mode: "local-only", + bindHost: "127.0.0.1", + localHttpUrl: "http://127.0.0.1:3773", + localWsUrl: "ws://127.0.0.1:3773", + endpointUrl: null, + advertisedHost: null, + }); + }); + + it("binds to all interfaces in network-accessible mode", () => { + expect( + resolveDesktopServerExposure({ + mode: "network-accessible", + port: 3773, + networkInterfaces: { + en0: [ + { + address: "192.168.1.44", + family: "IPv4", + internal: false, + netmask: "255.255.255.0", + cidr: "192.168.1.44/24", + mac: "00:00:00:00:00:00", + }, + ], + }, + }), + ).toEqual({ + mode: "network-accessible", + bindHost: "0.0.0.0", + localHttpUrl: "http://127.0.0.1:3773", + localWsUrl: "ws://127.0.0.1:3773", + endpointUrl: "http://192.168.1.44:3773", + advertisedHost: "192.168.1.44", + }); + }); + + it("stays network-accessible even when no LAN address is currently detectable", () => { + expect( + resolveDesktopServerExposure({ + mode: "network-accessible", + port: 3773, + networkInterfaces: {}, + }), + ).toEqual({ + mode: "network-accessible", + bindHost: "0.0.0.0", + localHttpUrl: "http://127.0.0.1:3773", + localWsUrl: "ws://127.0.0.1:3773", + endpointUrl: null, + advertisedHost: null, + }); + }); +}); diff --git a/apps/desktop/src/serverExposure.ts b/apps/desktop/src/serverExposure.ts new file mode 100644 index 0000000000..65c99b60e1 --- /dev/null +++ b/apps/desktop/src/serverExposure.ts @@ -0,0 +1,80 @@ +import type { NetworkInterfaceInfo } from "node:os"; +import type { DesktopServerExposureMode } from "@t3tools/contracts"; + +const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; +const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; + +export interface DesktopServerExposure { + readonly mode: DesktopServerExposureMode; + readonly bindHost: string; + readonly localHttpUrl: string; + readonly localWsUrl: string; + readonly endpointUrl: string | null; + readonly advertisedHost: string | null; +} + +const normalizeOptionalHost = (value: string | undefined): string | undefined => { + const normalized = value?.trim(); + return normalized && normalized.length > 0 ? normalized : undefined; +}; + +const isUsableLanIpv4Address = (address: string): boolean => + !address.startsWith("127.") && !address.startsWith("169.254."); + +export function resolveLanAdvertisedHost( + networkInterfaces: NodeJS.Dict, + explicitHost: string | undefined, +): string | null { + const normalizedExplicitHost = normalizeOptionalHost(explicitHost); + if (normalizedExplicitHost) { + return normalizedExplicitHost; + } + + for (const interfaceAddresses of Object.values(networkInterfaces)) { + if (!interfaceAddresses) continue; + + for (const address of interfaceAddresses) { + if (address.internal) continue; + if (address.family !== "IPv4") continue; + if (!isUsableLanIpv4Address(address.address)) continue; + return address.address; + } + } + + return null; +} + +export function resolveDesktopServerExposure(input: { + readonly mode: DesktopServerExposureMode; + readonly port: number; + readonly networkInterfaces: NodeJS.Dict; + readonly advertisedHostOverride?: string; +}): DesktopServerExposure { + const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`; + const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`; + + if (input.mode === "local-only") { + return { + mode: input.mode, + bindHost: DESKTOP_LOOPBACK_HOST, + localHttpUrl, + localWsUrl, + endpointUrl: null, + advertisedHost: null, + }; + } + + const advertisedHost = resolveLanAdvertisedHost( + input.networkInterfaces, + input.advertisedHostOverride, + ); + + return { + mode: input.mode, + bindHost: DESKTOP_LAN_BIND_HOST, + localHttpUrl, + localWsUrl, + endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null, + advertisedHost, + }; +} diff --git a/apps/server/src/auth/Layers/AuthControlPlane.test.ts b/apps/server/src/auth/Layers/AuthControlPlane.test.ts new file mode 100644 index 0000000000..9fc091124b --- /dev/null +++ b/apps/server/src/auth/Layers/AuthControlPlane.test.ts @@ -0,0 +1,111 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { ServerConfigShape } from "../../config.ts"; +import { ServerConfig } from "../../config.ts"; +import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; +import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { AuthControlPlane } from "../Services/AuthControlPlane.ts"; +import { makeAuthControlPlane } from "./AuthControlPlane.ts"; +import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; + +const makeServerConfigLayer = ( + overrides?: Partial>, +) => + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig; + return { + ...config, + ...overrides, + } satisfies ServerConfigShape; + }), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-" })), + ); + +const makeAuthControlPlaneLayer = ( + overrides?: Partial>, +) => + Layer.effect(AuthControlPlane, makeAuthControlPlane).pipe( + Layer.provideMerge(BootstrapCredentialServiceLive), + Layer.provideMerge(SessionCredentialServiceLive), + Layer.provideMerge(ServerSecretStoreLive), + Layer.provideMerge(SqlitePersistenceMemory), + Layer.provide(makeServerConfigLayer(overrides)), + ); + +it.layer(NodeServices.layer)("AuthControlPlane", (it) => { + it.effect("creates, lists, and revokes client pairing links", () => + Effect.gen(function* () { + const authControlPlane = yield* AuthControlPlane; + + const created = yield* authControlPlane.createPairingLink({ + role: "client", + subject: "one-time-token", + label: "CI phone", + }); + const listedBeforeRevoke = yield* authControlPlane.listPairingLinks({ role: "client" }); + const revoked = yield* authControlPlane.revokePairingLink(created.id); + const listedAfterRevoke = yield* authControlPlane.listPairingLinks({ role: "client" }); + + expect(created.role).toBe("client"); + expect(created.credential.length).toBeGreaterThan(0); + expect(listedBeforeRevoke).toHaveLength(1); + expect(listedBeforeRevoke[0]?.id).toBe(created.id); + expect(listedBeforeRevoke[0]?.label).toBe("CI phone"); + expect(listedBeforeRevoke[0]?.credential).toBe(created.credential); + expect(revoked).toBe(true); + expect(listedAfterRevoke).toHaveLength(0); + }).pipe(Effect.provide(makeAuthControlPlaneLayer())), + ); + + it.effect("issues bearer sessions and lists them without exposing raw tokens", () => + Effect.gen(function* () { + const authControlPlane = yield* AuthControlPlane; + const sessionCredentials = yield* SessionCredentialService; + + const issued = yield* authControlPlane.issueSession({ + label: "deploy-bot", + }); + const verified = yield* sessionCredentials.verify(issued.token); + const listedBeforeRevoke = yield* authControlPlane.listSessions(); + const revoked = yield* authControlPlane.revokeSession(issued.sessionId); + const listedAfterRevoke = yield* authControlPlane.listSessions(); + + expect(issued.method).toBe("bearer-session-token"); + expect(issued.role).toBe("owner"); + expect(issued.client.deviceType).toBe("bot"); + expect(issued.client.label).toBe("deploy-bot"); + expect(verified.sessionId).toBe(issued.sessionId); + expect(verified.role).toBe("owner"); + expect(verified.method).toBe("bearer-session-token"); + expect(listedBeforeRevoke).toHaveLength(1); + expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId); + expect("token" in (listedBeforeRevoke[0] ?? {})).toBe(false); + expect(revoked).toBe(true); + expect(listedAfterRevoke).toHaveLength(0); + }).pipe(Effect.provide(makeAuthControlPlaneLayer())), + ); + + it.effect("surfaces lastConnectedAt through the listed session view", () => + Effect.gen(function* () { + const authControlPlane = yield* AuthControlPlane; + const sessionCredentials = yield* SessionCredentialService; + + const issued = yield* authControlPlane.issueSession({ + label: "remote-ipad", + }); + const beforeConnect = yield* authControlPlane.listSessions(); + yield* sessionCredentials.markConnected(issued.sessionId); + const afterConnect = yield* authControlPlane.listSessions(); + + expect(beforeConnect[0]?.lastConnectedAt).toBeNull(); + expect(afterConnect[0]?.lastConnectedAt).not.toBeNull(); + }).pipe(Effect.provide(makeAuthControlPlaneLayer())), + ); +}); diff --git a/apps/server/src/auth/Layers/AuthControlPlane.ts b/apps/server/src/auth/Layers/AuthControlPlane.ts new file mode 100644 index 0000000000..98b2107800 --- /dev/null +++ b/apps/server/src/auth/Layers/AuthControlPlane.ts @@ -0,0 +1,176 @@ +import type { AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; +import { DateTime, Effect, Layer } from "effect"; + +import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; +import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; +import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; +import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; +import { layerConfig as SqlitePersistenceLayerLive } from "../../persistence/Layers/Sqlite.ts"; +import { + AuthControlPlane, + AuthControlPlaneError, + AuthControlPlaneShape, + DEFAULT_SESSION_SUBJECT, + IssuedBearerSession, + IssuedPairingLink, +} from "../Services/AuthControlPlane.ts"; + +const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => { + if (left.role !== right.role) { + return left.role === "owner" ? -1 : 1; + } + if (left.connected !== right.connected) { + return left.connected ? -1 : 1; + } + return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds; +}; + +const toAuthControlPlaneError = + (message: string) => + (cause: unknown): AuthControlPlaneError => + new AuthControlPlaneError({ + message, + cause, + }); + +export const makeAuthControlPlane = Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const sessions = yield* SessionCredentialService; + + const createPairingLink: AuthControlPlaneShape["createPairingLink"] = (input) => + Effect.gen(function* () { + const createdAt = yield* DateTime.now; + const issued = yield* bootstrapCredentials.issueOneTimeToken({ + role: input?.role ?? "client", + subject: input?.subject ?? "one-time-token", + ...(input?.ttl ? { ttl: input.ttl } : {}), + ...(input?.label ? { label: input.label } : {}), + }); + return { + id: issued.id, + credential: issued.credential, + role: input?.role ?? "client", + subject: input?.subject ?? "one-time-token", + ...(issued.label ? { label: issued.label } : {}), + createdAt: DateTime.toUtc(createdAt), + expiresAt: DateTime.toUtc(issued.expiresAt), + } satisfies IssuedPairingLink; + }).pipe(Effect.mapError(toAuthControlPlaneError("Failed to create pairing link."))); + + const listPairingLinks: AuthControlPlaneShape["listPairingLinks"] = (input) => + bootstrapCredentials.listActive().pipe( + Effect.map((pairingLinks) => + pairingLinks + .filter((pairingLink) => (input?.role ? pairingLink.role === input.role : true)) + .filter((pairingLink) => !input?.excludeSubjects?.includes(pairingLink.subject)) + .map((pairingLink) => + pairingLink.label + ? ({ + id: pairingLink.id, + credential: pairingLink.credential, + role: pairingLink.role, + subject: pairingLink.subject, + label: pairingLink.label, + createdAt: pairingLink.createdAt, + expiresAt: pairingLink.expiresAt, + } satisfies AuthPairingLink) + : ({ + id: pairingLink.id, + credential: pairingLink.credential, + role: pairingLink.role, + subject: pairingLink.subject, + createdAt: pairingLink.createdAt, + expiresAt: pairingLink.expiresAt, + } satisfies AuthPairingLink), + ) + .toSorted( + (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds, + ), + ), + Effect.mapError(toAuthControlPlaneError("Failed to list pairing links.")), + ); + + const revokePairingLink: AuthControlPlaneShape["revokePairingLink"] = (id) => + bootstrapCredentials + .revoke(id) + .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke pairing link."))); + + const issueSession: AuthControlPlaneShape["issueSession"] = (input) => + sessions + .issue({ + subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, + method: "bearer-session-token", + role: input?.role ?? "owner", + client: { + ...(input?.label ? { label: input.label } : {}), + deviceType: "bot", + }, + ...(input?.ttl ? { ttl: input.ttl } : {}), + }) + .pipe( + Effect.flatMap((issued) => { + if (issued.method !== "bearer-session-token") { + return Effect.fail( + new AuthControlPlaneError({ + message: "CLI session issuance produced an unexpected session method.", + }), + ); + } + + return Effect.succeed({ + sessionId: issued.sessionId, + token: issued.token, + method: "bearer-session-token" as const, + role: issued.role, + subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, + client: issued.client, + expiresAt: DateTime.toUtc(issued.expiresAt), + } satisfies IssuedBearerSession); + }), + Effect.mapError(toAuthControlPlaneError("Failed to issue session token.")), + ); + + const listSessions: AuthControlPlaneShape["listSessions"] = () => + sessions.listActive().pipe( + Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)), + Effect.mapError(toAuthControlPlaneError("Failed to list sessions.")), + ); + + const revokeSession: AuthControlPlaneShape["revokeSession"] = (sessionId) => + sessions + .revoke(sessionId) + .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke session."))); + + const revokeOtherSessionsExcept: AuthControlPlaneShape["revokeOtherSessionsExcept"] = ( + sessionId, + ) => + sessions + .revokeAllExcept(sessionId) + .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke other sessions."))); + + return { + createPairingLink, + listPairingLinks, + revokePairingLink, + issueSession, + listSessions, + revokeSession, + revokeOtherSessionsExcept, + } satisfies AuthControlPlaneShape; +}); + +export const AuthCoreLive = Layer.mergeAll( + BootstrapCredentialServiceLive, + SessionCredentialServiceLive, +); + +export const AuthStorageLive = Layer.mergeAll(ServerSecretStoreLive, SqlitePersistenceLayerLive); + +export const AuthRuntimeLive = AuthCoreLive.pipe(Layer.provideMerge(AuthStorageLive)); + +export const AuthControlPlaneLive = Layer.effect(AuthControlPlane, makeAuthControlPlane); + +export const AuthControlPlaneRuntimeLive = AuthControlPlaneLive.pipe( + Layer.provideMerge(AuthRuntimeLive), +); diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts new file mode 100644 index 0000000000..ec110ee96f --- /dev/null +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.test.ts @@ -0,0 +1,151 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Duration, Effect, Layer } from "effect"; +import { TestClock } from "effect/testing"; + +import type { ServerConfigShape } from "../../config.ts"; +import { ServerConfig } from "../../config.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; +import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts"; + +const makeServerConfigLayer = ( + overrides?: Partial>, +) => + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig; + return { + ...config, + ...overrides, + } satisfies ServerConfigShape; + }), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" })), + ); + +const makeBootstrapCredentialLayer = ( + overrides?: Partial>, +) => + BootstrapCredentialServiceLive.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide(makeServerConfigLayer(overrides)), + ); + +it.layer(NodeServices.layer)("BootstrapCredentialServiceLive", (it) => { + it.effect("issues pairing tokens in a short manual-entry format", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const issued = yield* bootstrapCredentials.issueOneTimeToken(); + + expect(issued.credential).toMatch(/^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{12}$/); + }).pipe(Effect.provide(makeBootstrapCredentialLayer())), + ); + + it.effect("issues one-time bootstrap tokens that can only be consumed once", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const issued = yield* bootstrapCredentials.issueOneTimeToken({ label: "Julius iPhone" }); + const first = yield* bootstrapCredentials.consume(issued.credential); + const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential)); + + expect(first.method).toBe("one-time-token"); + expect(first.role).toBe("client"); + expect(first.subject).toBe("one-time-token"); + expect(first.label).toBe("Julius iPhone"); + expect(issued.label).toBe("Julius iPhone"); + expect(second._tag).toBe("BootstrapCredentialError"); + expect(second.message).toContain("Unknown bootstrap credential"); + }).pipe(Effect.provide(makeBootstrapCredentialLayer())), + ); + + it.effect("atomically consumes a one-time token when multiple requests race", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const token = yield* bootstrapCredentials.issueOneTimeToken(); + const results = yield* Effect.all( + Array.from({ length: 8 }, () => + Effect.result(bootstrapCredentials.consume(token.credential)), + ), + { + concurrency: "unbounded", + }, + ); + + const successes = results.filter((result) => result._tag === "Success"); + const failures = results.filter((result) => result._tag === "Failure"); + + expect(successes).toHaveLength(1); + expect(failures).toHaveLength(7); + for (const failure of failures) { + expect(failure.failure._tag).toBe("BootstrapCredentialError"); + expect(failure.failure.message).toContain("Unknown bootstrap credential"); + } + }).pipe(Effect.provide(makeBootstrapCredentialLayer())), + ); + + it.effect("seeds the desktop bootstrap credential as a one-time grant", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const first = yield* bootstrapCredentials.consume("desktop-bootstrap-token"); + const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); + + expect(first.method).toBe("desktop-bootstrap"); + expect(first.role).toBe("owner"); + expect(first.subject).toBe("desktop-bootstrap"); + expect(second._tag).toBe("BootstrapCredentialError"); + expect(second.status).toBe(401); + }).pipe( + Effect.provide( + makeBootstrapCredentialLayer({ + desktopBootstrapToken: "desktop-bootstrap-token", + }), + ), + ), + ); + + it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + + yield* TestClock.adjust(Duration.minutes(6)); + const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); + + expect(expired._tag).toBe("BootstrapCredentialError"); + expect(expired.status).toBe(401); + expect(expired.message).toContain("Bootstrap credential expired"); + }).pipe( + Effect.provide( + Layer.merge( + makeBootstrapCredentialLayer({ + desktopBootstrapToken: "desktop-bootstrap-token", + }), + TestClock.layer(), + ), + ), + ), + ); + + it.effect("lists and revokes active pairing links", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* BootstrapCredentialService; + const first = yield* bootstrapCredentials.issueOneTimeToken(); + const second = yield* bootstrapCredentials.issueOneTimeToken({ role: "owner" }); + + const activeBeforeRevoke = yield* bootstrapCredentials.listActive(); + expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(first.id); + expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(second.id); + + const revoked = yield* bootstrapCredentials.revoke(first.id); + const activeAfterRevoke = yield* bootstrapCredentials.listActive(); + const revokedConsume = yield* Effect.flip(bootstrapCredentials.consume(first.credential)); + + expect(revoked).toBe(true); + expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id); + expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id); + expect(revokedConsume.message).toContain("no longer available"); + expect(revokedConsume.status).toBe(401); + }).pipe(Effect.provide(makeBootstrapCredentialLayer())), + ); +}); diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.ts new file mode 100644 index 0000000000..5539f62c70 --- /dev/null +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.ts @@ -0,0 +1,296 @@ +import type { AuthPairingLink } from "@t3tools/contracts"; +import { DateTime, Duration, Effect, Layer, PubSub, Ref, Stream } from "effect"; +import { Option } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { AuthPairingLinkRepositoryLive } from "../../persistence/Layers/AuthPairingLinks.ts"; +import { AuthPairingLinkRepository } from "../../persistence/Services/AuthPairingLinks.ts"; +import { + BootstrapCredentialError, + BootstrapCredentialService, + type BootstrapCredentialChange, + type BootstrapCredentialServiceShape, + type BootstrapGrant, + type IssuedBootstrapCredential, +} from "../Services/BootstrapCredentialService.ts"; + +interface StoredBootstrapGrant extends BootstrapGrant { + readonly remainingUses: number | "unbounded"; +} + +type ConsumeResult = + | { + readonly _tag: "error"; + readonly reason: "not-found" | "expired"; + readonly error: BootstrapCredentialError; + } + | { + readonly _tag: "success"; + readonly grant: BootstrapGrant; + }; + +const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5); +const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; +const PAIRING_TOKEN_LENGTH = 12; + +const generatePairingToken = (): string => { + const randomBytes = crypto.getRandomValues(new Uint8Array(PAIRING_TOKEN_LENGTH)); + + return Array.from(randomBytes, (value) => PAIRING_TOKEN_ALPHABET[value & 31]).join(""); +}; + +export const makeBootstrapCredentialService = Effect.gen(function* () { + const config = yield* ServerConfig; + const pairingLinks = yield* AuthPairingLinkRepository; + const seededGrantsRef = yield* Ref.make(new Map()); + const changesPubSub = yield* PubSub.unbounded(); + + const invalidBootstrapCredentialError = (message: string) => + new BootstrapCredentialError({ + message, + status: 401, + }); + + const internalBootstrapCredentialError = (message: string, cause: unknown) => + new BootstrapCredentialError({ + message, + status: 500, + cause, + }); + + const seedGrant = (credential: string, grant: StoredBootstrapGrant) => + Ref.update(seededGrantsRef, (current) => { + const next = new Map(current); + next.set(credential, grant); + return next; + }); + + const emitUpsert = (pairingLink: AuthPairingLink) => + PubSub.publish(changesPubSub, { + type: "pairingLinkUpserted", + pairingLink, + }).pipe(Effect.asVoid); + + const emitRemoved = (id: string) => + PubSub.publish(changesPubSub, { + type: "pairingLinkRemoved", + id, + }).pipe(Effect.asVoid); + + if (config.desktopBootstrapToken) { + const now = yield* DateTime.now; + yield* seedGrant(config.desktopBootstrapToken, { + method: "desktop-bootstrap", + role: "owner", + subject: "desktop-bootstrap", + expiresAt: DateTime.add(now, { + milliseconds: Duration.toMillis(DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES), + }), + remainingUses: 1, + }); + } + + const toBootstrapCredentialError = (message: string) => (cause: unknown) => + internalBootstrapCredentialError(message, cause); + + const listActive: BootstrapCredentialServiceShape["listActive"] = () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const rows = yield* pairingLinks.listActive({ now }); + + return rows.map((row) => + row.label + ? ({ + id: row.id, + credential: row.credential, + role: row.role, + subject: row.subject, + label: row.label, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + } satisfies AuthPairingLink) + : ({ + id: row.id, + credential: row.credential, + role: row.role, + subject: row.subject, + createdAt: row.createdAt, + expiresAt: row.expiresAt, + } satisfies AuthPairingLink), + ); + }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links."))); + + const revoke: BootstrapCredentialServiceShape["revoke"] = (id) => + Effect.gen(function* () { + const revokedAt = yield* DateTime.now; + const revoked = yield* pairingLinks.revoke({ + id, + revokedAt, + }); + if (revoked) { + yield* emitRemoved(id); + } + return revoked; + }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link."))); + + const issueOneTimeToken: BootstrapCredentialServiceShape["issueOneTimeToken"] = (input) => + Effect.gen(function* () { + const id = crypto.randomUUID(); + const credential = generatePairingToken(); + const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; + const now = yield* DateTime.now; + const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) }); + const issued: IssuedBootstrapCredential = { + id, + credential, + ...(input?.label ? { label: input.label } : {}), + expiresAt, + }; + yield* pairingLinks.create({ + id, + credential, + method: "one-time-token", + role: input?.role ?? "client", + subject: input?.subject ?? "one-time-token", + label: input?.label ?? null, + createdAt: now, + expiresAt: expiresAt, + }); + yield* emitUpsert({ + id, + credential, + role: input?.role ?? "client", + subject: input?.subject ?? "one-time-token", + ...(input?.label ? { label: input.label } : {}), + createdAt: now, + expiresAt, + }); + return issued; + }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential."))); + + const consume: BootstrapCredentialServiceShape["consume"] = (credential) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const seededResult: ConsumeResult = yield* Ref.modify( + seededGrantsRef, + (current): readonly [ConsumeResult, Map] => { + const grant = current.get(credential); + if (!grant) { + return [ + { + _tag: "error", + reason: "not-found", + error: invalidBootstrapCredentialError("Unknown bootstrap credential."), + }, + current, + ]; + } + + const next = new Map(current); + if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) { + next.delete(credential); + return [ + { + _tag: "error", + reason: "expired", + error: invalidBootstrapCredentialError("Bootstrap credential expired."), + }, + next, + ]; + } + + const remainingUses = grant.remainingUses; + if (typeof remainingUses === "number") { + if (remainingUses <= 1) { + next.delete(credential); + } else { + next.set(credential, { + ...grant, + remainingUses: remainingUses - 1, + }); + } + } + + return [ + { + _tag: "success", + grant: { + method: grant.method, + role: grant.role, + subject: grant.subject, + ...(grant.label ? { label: grant.label } : {}), + expiresAt: grant.expiresAt, + } satisfies BootstrapGrant, + }, + next, + ]; + }, + ); + + if (seededResult._tag === "success") { + return seededResult.grant; + } + if (seededResult.reason !== "not-found") { + return yield* seededResult.error; + } + + const consumed = yield* pairingLinks.consumeAvailable({ + credential, + consumedAt: now, + now, + }); + + if (Option.isSome(consumed)) { + yield* emitRemoved(consumed.value.id); + return { + method: consumed.value.method, + role: consumed.value.role, + subject: consumed.value.subject, + ...(consumed.value.label ? { label: consumed.value.label } : {}), + expiresAt: consumed.value.expiresAt, + } satisfies BootstrapGrant; + } + + const matching = yield* pairingLinks.getByCredential({ credential }); + if (Option.isNone(matching)) { + return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); + } + + if (matching.value.revokedAt !== null) { + return yield* invalidBootstrapCredentialError( + "Bootstrap credential is no longer available.", + ); + } + + if (matching.value.consumedAt !== null) { + return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); + } + + if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) { + return yield* invalidBootstrapCredentialError("Bootstrap credential expired."); + } + + return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available."); + }).pipe( + Effect.mapError((cause) => + cause instanceof BootstrapCredentialError + ? cause + : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause), + ), + ); + + return { + issueOneTimeToken, + listActive, + get streamChanges() { + return Stream.fromPubSub(changesPubSub); + }, + revoke, + consume, + } satisfies BootstrapCredentialServiceShape; +}); + +export const BootstrapCredentialServiceLive = Layer.effect( + BootstrapCredentialService, + makeBootstrapCredentialService, +).pipe(Layer.provideMerge(AuthPairingLinkRepositoryLive)); diff --git a/apps/server/src/auth/Layers/ServerAuth.test.ts b/apps/server/src/auth/Layers/ServerAuth.test.ts new file mode 100644 index 0000000000..0c3d71cc9f --- /dev/null +++ b/apps/server/src/auth/Layers/ServerAuth.test.ts @@ -0,0 +1,182 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import type { ServerConfigShape } from "../../config.ts"; +import { ServerConfig } from "../../config.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts"; +import { ServerAuth, type ServerAuthShape } from "../Services/ServerAuth.ts"; +import { ServerAuthLive, toBootstrapExchangeAuthError } from "./ServerAuth.ts"; +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; + +const makeServerConfigLayer = (overrides?: Partial) => + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig; + return { + ...config, + ...overrides, + } satisfies ServerConfigShape; + }), + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" }))); + +const makeServerAuthLayer = (overrides?: Partial) => + ServerAuthLive.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerSecretStoreLive), + Layer.provide(makeServerConfigLayer(overrides)), + ); + +const makeCookieRequest = ( + sessionToken: string, +): Parameters[0] => + ({ + cookies: { + t3_session: sessionToken, + }, + headers: {}, + }) as unknown as Parameters[0]; + +const requestMetadata = { + deviceType: "desktop" as const, + os: "macOS", + browser: "Chrome", + ipAddress: "192.168.1.23", +}; + +it.layer(NodeServices.layer)("ServerAuthLive", (it) => { + it.effect("maps invalid bootstrap credential failures to 401", () => + Effect.sync(() => { + const error = toBootstrapExchangeAuthError( + new BootstrapCredentialError({ + message: "Unknown bootstrap credential.", + status: 401, + }), + ); + + expect(error.status).toBe(401); + expect(error.message).toBe("Invalid bootstrap credential."); + }), + ); + + it.effect("maps unexpected bootstrap failures to 500", () => + Effect.sync(() => { + const error = toBootstrapExchangeAuthError( + new BootstrapCredentialError({ + message: "Failed to consume bootstrap credential.", + status: 500, + cause: new Error("sqlite is unavailable"), + }), + ); + + expect(error.status).toBe(500); + expect(error.message).toBe("Failed to validate bootstrap credential."); + }), + ); + + it.effect("issues client pairing credentials by default", () => + Effect.gen(function* () { + const serverAuth = yield* ServerAuth; + + const pairingCredential = yield* serverAuth.issuePairingCredential(); + const exchanged = yield* serverAuth.exchangeBootstrapCredential( + pairingCredential.credential, + requestMetadata, + ); + const verified = yield* serverAuth.authenticateHttpRequest( + makeCookieRequest(exchanged.sessionToken), + ); + + expect(verified.sessionId.length).toBeGreaterThan(0); + expect(verified.role).toBe("client"); + expect(verified.subject).toBe("one-time-token"); + }).pipe(Effect.provide(makeServerAuthLayer())), + ); + + it.effect("issues startup pairing URLs that bootstrap owner sessions", () => + Effect.gen(function* () { + const serverAuth = yield* ServerAuth; + + const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773"); + const token = new URLSearchParams(new URL(pairingUrl).hash.slice(1)).get("token"); + const listedPairingLinks = yield* serverAuth.listPairingLinks(); + expect(token).toBeTruthy(); + expect( + listedPairingLinks.some((pairingLink) => pairingLink.subject === "owner-bootstrap"), + ).toBe(false); + + const exchanged = yield* serverAuth.exchangeBootstrapCredential(token ?? "", requestMetadata); + const verified = yield* serverAuth.authenticateHttpRequest( + makeCookieRequest(exchanged.sessionToken), + ); + + expect(verified.role).toBe("owner"); + expect(verified.subject).toBe("owner-bootstrap"); + }).pipe(Effect.provide(makeServerAuthLayer())), + ); + + it.effect("lists pairing links and revokes other client sessions while keeping the owner", () => + Effect.gen(function* () { + const serverAuth = yield* ServerAuth; + + const ownerExchange = yield* serverAuth.exchangeBootstrapCredential( + "desktop-bootstrap-token", + requestMetadata, + ); + const ownerSession = yield* serverAuth.authenticateHttpRequest( + makeCookieRequest(ownerExchange.sessionToken), + ); + const pairingCredential = yield* serverAuth.issuePairingCredential({ + label: "Julius iPhone", + }); + const listedPairingLinks = yield* serverAuth.listPairingLinks(); + const clientExchange = yield* serverAuth.exchangeBootstrapCredential( + pairingCredential.credential, + { + ...requestMetadata, + deviceType: "mobile", + os: "iOS", + browser: "Safari", + ipAddress: "192.168.1.88", + }, + ); + const clientSession = yield* serverAuth.authenticateHttpRequest( + makeCookieRequest(clientExchange.sessionToken), + ); + const clientsBeforeRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId); + const revokedCount = yield* serverAuth.revokeOtherClientSessions(ownerSession.sessionId); + const clientsAfterRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId); + + expect(listedPairingLinks.map((entry) => entry.id)).toContain(pairingCredential.id); + expect(listedPairingLinks.find((entry) => entry.id === pairingCredential.id)?.label).toBe( + "Julius iPhone", + ); + expect(clientsBeforeRevoke).toHaveLength(2); + expect( + clientsBeforeRevoke.find((entry) => entry.sessionId === ownerSession.sessionId)?.current, + ).toBe(true); + expect( + clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.current, + ).toBe(false); + expect( + clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client + .label, + ).toBe("Julius iPhone"); + expect( + clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client + .deviceType, + ).toBe("mobile"); + expect(revokedCount).toBe(1); + expect(clientsAfterRevoke).toHaveLength(1); + expect(clientsAfterRevoke[0]?.sessionId).toBe(ownerSession.sessionId); + }).pipe( + Effect.provide( + makeServerAuthLayer({ + desktopBootstrapToken: "desktop-bootstrap-token", + }), + ), + ), + ); +}); diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts new file mode 100644 index 0000000000..fa7191110a --- /dev/null +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -0,0 +1,385 @@ +import { + type AuthBearerBootstrapResult, + type AuthClientSession, + type AuthBootstrapResult, + type AuthPairingCredentialResult, + type AuthSessionState, + type AuthWebSocketTokenResult, +} from "@t3tools/contracts"; +import { DateTime, Effect, Layer, Option } from "effect"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; + +import { AuthControlPlane } from "../Services/AuthControlPlane.ts"; +import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts"; +import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; +import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts"; +import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts"; +import { + ServerAuth, + type AuthenticatedSession, + AuthError, + type ServerAuthShape, +} from "../Services/ServerAuth.ts"; +import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; +import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts"; + +type BootstrapExchangeResult = { + readonly response: AuthBootstrapResult; + readonly sessionToken: string; +}; + +const AUTHORIZATION_PREFIX = "Bearer "; +const WEBSOCKET_TOKEN_QUERY_PARAM = "wsToken"; + +export function toBootstrapExchangeAuthError(cause: BootstrapCredentialError): AuthError { + if (cause.status === 500) { + return new AuthError({ + message: "Failed to validate bootstrap credential.", + status: 500, + cause, + }); + } + + return new AuthError({ + message: "Invalid bootstrap credential.", + status: 401, + cause, + }); +} + +function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { + const header = request.headers["authorization"]; + if (typeof header !== "string" || !header.startsWith(AUTHORIZATION_PREFIX)) { + return null; + } + const token = header.slice(AUTHORIZATION_PREFIX.length).trim(); + return token.length > 0 ? token : null; +} + +export const makeServerAuth = Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const bootstrapCredentials = yield* BootstrapCredentialService; + const authControlPlane = yield* AuthControlPlane; + const sessions = yield* SessionCredentialService; + const descriptor = yield* policy.getDescriptor(); + + const authenticateToken = (token: string): Effect.Effect => + sessions.verify(token).pipe( + Effect.map((session) => ({ + sessionId: session.sessionId, + subject: session.subject, + method: session.method, + role: session.role, + ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), + })), + Effect.mapError( + (cause) => + new AuthError({ + message: "Unauthorized request.", + status: 401, + cause, + }), + ), + ); + + const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { + const cookieToken = request.cookies[sessions.cookieName]; + const bearerToken = parseBearerToken(request); + const credential = cookieToken ?? bearerToken; + if (!credential) { + return Effect.fail( + new AuthError({ + message: "Authentication required.", + status: 401, + }), + ); + } + return authenticateToken(credential); + }; + + const getSessionState: ServerAuthShape["getSessionState"] = (request) => + authenticateRequest(request).pipe( + Effect.map( + (session) => + ({ + authenticated: true, + auth: descriptor, + role: session.role, + sessionMethod: session.method, + ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), + }) satisfies AuthSessionState, + ), + Effect.catchTag("AuthError", () => + Effect.succeed({ + authenticated: false, + auth: descriptor, + } satisfies AuthSessionState), + ), + ); + + const exchangeBootstrapCredential: ServerAuthShape["exchangeBootstrapCredential"] = ( + credential, + requestMetadata, + ) => + bootstrapCredentials.consume(credential).pipe( + Effect.mapError(toBootstrapExchangeAuthError), + Effect.flatMap((grant) => + sessions + .issue({ + method: "browser-session-cookie", + subject: grant.subject, + role: grant.role, + client: { + ...requestMetadata, + ...(grant.label ? { label: grant.label } : {}), + }, + }) + .pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to issue authenticated session.", + cause, + }), + ), + ), + ), + Effect.map( + (session) => + ({ + response: { + authenticated: true, + role: session.role, + sessionMethod: session.method, + expiresAt: DateTime.toUtc(session.expiresAt), + } satisfies AuthBootstrapResult, + sessionToken: session.token, + }) satisfies BootstrapExchangeResult, + ), + ); + + const exchangeBootstrapCredentialForBearerSession: ServerAuthShape["exchangeBootstrapCredentialForBearerSession"] = + (credential, requestMetadata) => + bootstrapCredentials.consume(credential).pipe( + Effect.mapError(toBootstrapExchangeAuthError), + Effect.flatMap((grant) => + sessions + .issue({ + method: "bearer-session-token", + subject: grant.subject, + role: grant.role, + client: { + ...requestMetadata, + ...(grant.label ? { label: grant.label } : {}), + }, + }) + .pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to issue authenticated session.", + cause, + }), + ), + ), + ), + Effect.map( + (session) => + ({ + authenticated: true, + role: session.role, + sessionMethod: "bearer-session-token", + expiresAt: DateTime.toUtc(session.expiresAt), + sessionToken: session.token, + }) satisfies AuthBearerBootstrapResult, + ), + ); + + const issuePairingCredential: ServerAuthShape["issuePairingCredential"] = (input) => + authControlPlane + .createPairingLink({ + role: input?.role ?? "client", + subject: input?.role === "owner" ? "owner-bootstrap" : "one-time-token", + ...(input?.label ? { label: input.label } : {}), + }) + .pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to issue pairing credential.", + cause, + }), + ), + Effect.map( + (issued) => + ({ + id: issued.id, + credential: issued.credential, + ...(issued.label ? { label: issued.label } : {}), + expiresAt: issued.expiresAt, + }) satisfies AuthPairingCredentialResult, + ), + ); + + const listPairingLinks: ServerAuthShape["listPairingLinks"] = () => + authControlPlane + .listPairingLinks({ + role: "client", + excludeSubjects: ["owner-bootstrap"], + }) + .pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to load pairing links.", + cause, + }), + ), + ); + + const revokePairingLink: ServerAuthShape["revokePairingLink"] = (id) => + authControlPlane.revokePairingLink(id).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to revoke pairing link.", + cause, + }), + ), + ); + + const listClientSessions: ServerAuthShape["listClientSessions"] = (currentSessionId) => + authControlPlane.listSessions().pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to load paired clients.", + cause, + }), + ), + Effect.map((clientSessions) => + clientSessions.map( + (clientSession): AuthClientSession => ({ + ...clientSession, + current: clientSession.sessionId === currentSessionId, + }), + ), + ), + ); + + const revokeClientSession: ServerAuthShape["revokeClientSession"] = ( + currentSessionId, + targetSessionId, + ) => + Effect.gen(function* () { + if (currentSessionId === targetSessionId) { + return yield* new AuthError({ + message: "Use revoke other clients to keep the current owner session active.", + status: 403, + }); + } + return yield* authControlPlane.revokeSession(targetSessionId).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to revoke client session.", + cause, + }), + ), + ); + }); + + const revokeOtherClientSessions: ServerAuthShape["revokeOtherClientSessions"] = ( + currentSessionId, + ) => + authControlPlane.revokeOtherSessionsExcept(currentSessionId).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to revoke other client sessions.", + cause, + }), + ), + ); + + const issueStartupPairingUrl: ServerAuthShape["issueStartupPairingUrl"] = (baseUrl) => + issuePairingCredential({ role: "owner" }).pipe( + Effect.map((issued) => { + const url = new URL(baseUrl); + url.pathname = "/pair"; + url.searchParams.delete("token"); + url.hash = new URLSearchParams([["token", issued.credential]]).toString(); + return url.toString(); + }), + ); + + const issueWebSocketToken: ServerAuthShape["issueWebSocketToken"] = (session) => + sessions.issueWebSocketToken(session.sessionId).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Failed to issue websocket token.", + cause, + }), + ), + Effect.map( + (issued) => + ({ + token: issued.token, + expiresAt: DateTime.toUtc(issued.expiresAt), + }) satisfies AuthWebSocketTokenResult, + ), + ); + + const authenticateWebSocketUpgrade: ServerAuthShape["authenticateWebSocketUpgrade"] = (request) => + Effect.gen(function* () { + const requestUrl = HttpServerRequest.toURL(request); + if (Option.isSome(requestUrl)) { + const websocketToken = requestUrl.value.searchParams.get(WEBSOCKET_TOKEN_QUERY_PARAM); + if (websocketToken && websocketToken.trim().length > 0) { + return yield* sessions.verifyWebSocketToken(websocketToken).pipe( + Effect.map((session) => ({ + sessionId: session.sessionId, + subject: session.subject, + method: session.method, + role: session.role, + ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), + })), + Effect.mapError( + (cause) => + new AuthError({ + message: "Unauthorized request.", + status: 401, + cause, + }), + ), + ); + } + } + + return yield* authenticateRequest(request); + }); + + return { + getDescriptor: () => Effect.succeed(descriptor), + getSessionState, + exchangeBootstrapCredential, + exchangeBootstrapCredentialForBearerSession, + issuePairingCredential, + listPairingLinks, + revokePairingLink, + listClientSessions, + revokeClientSession, + revokeOtherClientSessions, + authenticateHttpRequest: authenticateRequest, + authenticateWebSocketUpgrade, + issueWebSocketToken, + issueStartupPairingUrl, + } satisfies ServerAuthShape; +}); + +export const ServerAuthLive = Layer.effect(ServerAuth, makeServerAuth).pipe( + Layer.provideMerge(AuthControlPlaneLive), + Layer.provideMerge(AuthCoreLive), + Layer.provideMerge(ServerAuthPolicyLive), +); diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts new file mode 100644 index 0000000000..640cc030f8 --- /dev/null +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.test.ts @@ -0,0 +1,111 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import type { ServerConfigShape } from "../../config.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts"; +import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts"; + +const makeServerAuthPolicyLayer = (overrides?: Partial) => + ServerAuthPolicyLive.pipe( + Layer.provide( + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig; + return { + ...config, + ...overrides, + } satisfies ServerConfigShape; + }), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-policy-test-" })), + ), + ), + ); + +it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { + it.effect("uses desktop-managed-local policy for desktop mode", () => + Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.policy).toBe("desktop-managed-local"); + expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); + }).pipe( + Effect.provide( + makeServerAuthPolicyLayer({ + mode: "desktop", + }), + ), + ), + ); + + it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () => + Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.policy).toBe("remote-reachable"); + expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap", "one-time-token"]); + }).pipe( + Effect.provide( + makeServerAuthPolicyLayer({ + mode: "desktop", + host: "0.0.0.0", + }), + ), + ), + ); + + it.effect("uses loopback-browser policy for loopback web hosts", () => + Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.policy).toBe("loopback-browser"); + expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); + }).pipe( + Effect.provide( + makeServerAuthPolicyLayer({ + mode: "web", + host: "127.0.0.1", + }), + ), + ), + ); + + it.effect("uses remote-reachable policy for wildcard web hosts", () => + Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.policy).toBe("remote-reachable"); + expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); + }).pipe( + Effect.provide( + makeServerAuthPolicyLayer({ + mode: "web", + host: "0.0.0.0", + }), + ), + ), + ); + + it.effect("uses remote-reachable policy for non-loopback web hosts", () => + Effect.gen(function* () { + const policy = yield* ServerAuthPolicy; + const descriptor = yield* policy.getDescriptor(); + + expect(descriptor.policy).toBe("remote-reachable"); + }).pipe( + Effect.provide( + makeServerAuthPolicyLayer({ + mode: "web", + host: "192.168.1.50", + }), + ), + ), + ); +}); diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.ts new file mode 100644 index 0000000000..eaddf968f3 --- /dev/null +++ b/apps/server/src/auth/Layers/ServerAuthPolicy.ts @@ -0,0 +1,57 @@ +import type { ServerAuthDescriptor } from "@t3tools/contracts"; +import { Effect, Layer } from "effect"; + +import { ServerConfig } from "../../config.ts"; +import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts"; +import { SESSION_COOKIE_NAME } from "../utils.ts"; + +const isWildcardHost = (host: string | undefined): boolean => + host === "0.0.0.0" || host === "::" || host === "[::]"; + +const isLoopbackHost = (host: string | undefined): boolean => { + if (!host || host.length === 0) { + return true; + } + + return ( + host === "localhost" || + host === "127.0.0.1" || + host === "::1" || + host === "[::1]" || + host.startsWith("127.") + ); +}; + +export const makeServerAuthPolicy = Effect.gen(function* () { + const config = yield* ServerConfig; + const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); + + const policy = + config.mode === "desktop" + ? isRemoteReachable + ? "remote-reachable" + : "desktop-managed-local" + : isRemoteReachable + ? "remote-reachable" + : "loopback-browser"; + + const bootstrapMethods: ServerAuthDescriptor["bootstrapMethods"] = + policy === "desktop-managed-local" + ? ["desktop-bootstrap"] + : config.mode === "desktop" && policy === "remote-reachable" + ? ["desktop-bootstrap", "one-time-token"] + : ["one-time-token"]; + + const descriptor: ServerAuthDescriptor = { + policy, + bootstrapMethods, + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: SESSION_COOKIE_NAME, + }; + + return { + getDescriptor: () => Effect.succeed(descriptor), + } satisfies ServerAuthPolicyShape; +}); + +export const ServerAuthPolicyLive = Layer.effect(ServerAuthPolicy, makeServerAuthPolicy); diff --git a/apps/server/src/auth/Layers/ServerSecretStore.test.ts b/apps/server/src/auth/Layers/ServerSecretStore.test.ts new file mode 100644 index 0000000000..7e6352eec2 --- /dev/null +++ b/apps/server/src/auth/Layers/ServerSecretStore.test.ts @@ -0,0 +1,263 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Cause, Deferred, Effect, FileSystem, Layer, Ref } from "effect"; +import * as PlatformError from "effect/PlatformError"; + +import { ServerConfig } from "../../config.ts"; +import { SecretStoreError, ServerSecretStore } from "../Services/ServerSecretStore.ts"; +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; + +const makeServerConfigLayer = () => + ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" }); + +const makeServerSecretStoreLayer = () => + Layer.provide(ServerSecretStoreLive, makeServerConfigLayer()); + +const PermissionDeniedFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + return { + ...fileSystem, + readFile: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: path, + description: "Permission denied while reading secret file.", + }), + ), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +const makePermissionDeniedSecretStoreLayer = () => + ServerSecretStoreLive.pipe( + Layer.provide(makeServerConfigLayer()), + Layer.provideMerge(PermissionDeniedFileSystemLayer), + ); + +const RenameFailureFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + return { + ...fileSystem, + rename: (from, to) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + pathOrDescriptor: `${String(from)} -> ${String(to)}`, + description: "Permission denied while persisting secret file.", + }), + ), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +const makeRenameFailureSecretStoreLayer = () => + ServerSecretStoreLive.pipe( + Layer.provide(makeServerConfigLayer()), + Layer.provideMerge(RenameFailureFileSystemLayer), + ); + +const RemoveFailureFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + return { + ...fileSystem, + remove: (path, options) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + pathOrDescriptor: String(path), + description: `Permission denied while removing secret file.${options ? " options-set" : ""}`, + }), + ), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +const makeRemoveFailureSecretStoreLayer = () => + ServerSecretStoreLive.pipe( + Layer.provide(makeServerConfigLayer()), + Layer.provideMerge(RemoveFailureFileSystemLayer), + ); + +const ConcurrentReadMissFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const readCountRef = yield* Ref.make(0); + const readBarrier = yield* Deferred.make(); + + return { + ...fileSystem, + readFile: (path) => + String(path).endsWith("/session-signing-key.bin") + ? Ref.updateAndGet(readCountRef, (count) => count + 1).pipe( + Effect.flatMap((count) => { + if (count > 2) { + return fileSystem.readFile(path); + } + return Effect.gen(function* () { + if (count === 2) { + yield* Deferred.succeed(readBarrier, void 0); + } + yield* Deferred.await(readBarrier); + return yield* Effect.failCause( + Cause.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: String(path), + description: "Secret file does not exist yet.", + }), + ), + ); + }); + }), + ) + : fileSystem.readFile(path), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +const makeConcurrentCreateSecretStoreLayer = () => + ServerSecretStoreLive.pipe( + Layer.provide(makeServerConfigLayer()), + Layer.provideMerge(ConcurrentReadMissFileSystemLayer), + ); + +it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => { + it.effect("returns null when a secret file does not exist", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const secret = yield* secretStore.get("missing-secret"); + + expect(secret).toBeNull(); + }).pipe(Effect.provide(makeServerSecretStoreLayer())), + ); + + it.effect("reuses an existing secret instead of regenerating it", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const first = yield* secretStore.getOrCreateRandom("session-signing-key", 32); + const second = yield* secretStore.getOrCreateRandom("session-signing-key", 32); + + expect(Array.from(second)).toEqual(Array.from(first)); + }).pipe(Effect.provide(makeServerSecretStoreLayer())), + ); + + it.effect("returns the persisted secret when concurrent creators race", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const [first, second] = yield* Effect.all( + [ + secretStore.getOrCreateRandom("session-signing-key", 32), + secretStore.getOrCreateRandom("session-signing-key", 32), + ], + { concurrency: "unbounded" }, + ); + const persisted = yield* secretStore.get("session-signing-key"); + + expect(persisted).not.toBeNull(); + expect(Array.from(first)).toEqual(Array.from(persisted ?? new Uint8Array())); + expect(Array.from(second)).toEqual(Array.from(persisted ?? new Uint8Array())); + }).pipe(Effect.provide(makeConcurrentCreateSecretStoreLayer())), + ); + + it.effect("uses restrictive permissions for the secret directory and files", () => + Effect.gen(function* () { + const chmodCalls: Array<{ readonly path: string; readonly mode: number }> = []; + const recordingFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + return { + ...fileSystem, + makeDirectory: () => Effect.void, + writeFile: () => Effect.void, + rename: () => Effect.void, + chmod: (path, mode) => + Effect.sync(() => { + chmodCalls.push({ path: String(path), mode }); + }), + } satisfies FileSystem.FileSystem; + }), + ).pipe(Layer.provide(NodeServices.layer)); + + const secretStore = yield* Effect.service(ServerSecretStore).pipe( + Effect.provide( + ServerSecretStoreLive.pipe( + Layer.provide(makeServerConfigLayer()), + Layer.provideMerge(recordingFileSystemLayer), + ), + ), + ); + + yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])); + + expect(chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets"))).toBe( + true, + ); + expect(chmodCalls.filter((call) => call.mode === 0o600).length).toBeGreaterThanOrEqual(2); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("propagates read failures other than missing-file errors", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); + + expect(error).toBeInstanceOf(SecretStoreError); + expect(error.message).toContain("Failed to read secret session-signing-key."); + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); + expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); + }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())), + ); + + it.effect("propagates write failures instead of treating them as success", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const error = yield* Effect.flip( + secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])), + ); + + expect(error).toBeInstanceOf(SecretStoreError); + expect(error.message).toContain("Failed to persist secret session-signing-key."); + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); + expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); + }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())), + ); + + it.effect("propagates remove failures other than missing-file errors", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + + const error = yield* Effect.flip(secretStore.remove("session-signing-key")); + + expect(error).toBeInstanceOf(SecretStoreError); + expect(error.message).toContain("Failed to remove secret session-signing-key."); + expect(error.cause).toBeInstanceOf(PlatformError.PlatformError); + expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); + }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())), + ); +}); diff --git a/apps/server/src/auth/Layers/ServerSecretStore.ts b/apps/server/src/auth/Layers/ServerSecretStore.ts new file mode 100644 index 0000000000..a106d15fd5 --- /dev/null +++ b/apps/server/src/auth/Layers/ServerSecretStore.ts @@ -0,0 +1,151 @@ +import * as Crypto from "node:crypto"; + +import { Effect, FileSystem, Layer, Path } from "effect"; +import * as PlatformError from "effect/PlatformError"; + +import { ServerConfig } from "../../config.ts"; +import { + SecretStoreError, + ServerSecretStore, + type ServerSecretStoreShape, +} from "../Services/ServerSecretStore.ts"; + +export const makeServerSecretStore = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + + yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }); + yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe( + Effect.mapError( + (cause) => + new SecretStoreError({ + message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`, + cause, + }), + ), + ); + + const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); + + const isMissingSecretFileError = (cause: unknown): cause is PlatformError.PlatformError => + cause instanceof PlatformError.PlatformError && cause.reason._tag === "NotFound"; + + const isAlreadyExistsSecretFileError = (cause: unknown): cause is PlatformError.PlatformError => + cause instanceof PlatformError.PlatformError && cause.reason._tag === "AlreadyExists"; + + const get: ServerSecretStoreShape["get"] = (name) => + fileSystem.readFile(resolveSecretPath(name)).pipe( + Effect.map((bytes) => Uint8Array.from(bytes)), + Effect.catch((cause) => + isMissingSecretFileError(cause) + ? Effect.succeed(null) + : Effect.fail( + new SecretStoreError({ + message: `Failed to read secret ${name}.`, + cause, + }), + ), + ), + ); + + const set: ServerSecretStoreShape["set"] = (name, value) => { + const secretPath = resolveSecretPath(name); + const tempPath = `${secretPath}.${Crypto.randomUUID()}.tmp`; + return Effect.gen(function* () { + yield* fileSystem.writeFile(tempPath, value); + yield* fileSystem.chmod(tempPath, 0o600); + yield* fileSystem.rename(tempPath, secretPath); + yield* fileSystem.chmod(secretPath, 0o600); + }).pipe( + Effect.catch((cause) => + fileSystem.remove(tempPath).pipe( + Effect.ignore, + Effect.flatMap(() => + Effect.fail( + new SecretStoreError({ + message: `Failed to persist secret ${name}.`, + cause, + }), + ), + ), + ), + ), + ); + }; + + const create: ServerSecretStoreShape["set"] = (name, value) => { + const secretPath = resolveSecretPath(name); + return Effect.scoped( + Effect.gen(function* () { + const file = yield* fileSystem.open(secretPath, { + flag: "wx", + mode: 0o600, + }); + yield* file.writeAll(value); + yield* file.sync; + yield* fileSystem.chmod(secretPath, 0o600); + }), + ).pipe( + Effect.mapError( + (cause) => + new SecretStoreError({ + message: `Failed to persist secret ${name}.`, + cause, + }), + ), + ); + }; + + const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) => + get(name).pipe( + Effect.flatMap((existing) => { + if (existing) { + return Effect.succeed(existing); + } + + const generated = Crypto.randomBytes(bytes); + return create(name, generated).pipe( + Effect.as(Uint8Array.from(generated)), + Effect.catchTag("SecretStoreError", (error) => + isAlreadyExistsSecretFileError(error.cause) + ? get(name).pipe( + Effect.flatMap((created) => + created !== null + ? Effect.succeed(created) + : Effect.fail( + new SecretStoreError({ + message: `Failed to read secret ${name} after concurrent creation.`, + }), + ), + ), + ) + : Effect.fail(error), + ), + ); + }), + ); + + const remove: ServerSecretStoreShape["remove"] = (name) => + fileSystem.remove(resolveSecretPath(name)).pipe( + Effect.catch((cause) => + isMissingSecretFileError(cause) + ? Effect.void + : Effect.fail( + new SecretStoreError({ + message: `Failed to remove secret ${name}.`, + cause, + }), + ), + ), + ); + + return { + get, + set, + getOrCreateRandom, + remove, + } satisfies ServerSecretStoreShape; +}); + +export const ServerSecretStoreLive = Layer.effect(ServerSecretStore, makeServerSecretStore); diff --git a/apps/server/src/auth/Layers/SessionCredentialService.test.ts b/apps/server/src/auth/Layers/SessionCredentialService.test.ts new file mode 100644 index 0000000000..bafd9c85c2 --- /dev/null +++ b/apps/server/src/auth/Layers/SessionCredentialService.test.ts @@ -0,0 +1,191 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { Duration, Effect, Layer } from "effect"; +import { TestClock } from "effect/testing"; + +import type { ServerConfigShape } from "../../config.ts"; +import { ServerConfig } from "../../config.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; +import { ServerSecretStoreLive } from "./ServerSecretStore.ts"; +import { SessionCredentialServiceLive } from "./SessionCredentialService.ts"; + +const makeServerConfigLayer = ( + overrides?: Partial>, +) => + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const config = yield* ServerConfig; + return { + ...config, + ...overrides, + } satisfies ServerConfigShape; + }), + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); + +const makeSessionCredentialLayer = ( + overrides?: Partial>, +) => + SessionCredentialServiceLive.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerSecretStoreLive), + Layer.provide(makeServerConfigLayer(overrides)), + ); + +it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => { + it.effect("issues and verifies signed browser session tokens", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const issued = yield* sessions.issue({ + subject: "desktop-bootstrap", + role: "owner", + client: { + label: "Desktop app", + deviceType: "desktop", + os: "macOS", + browser: "Electron", + ipAddress: "127.0.0.1", + }, + }); + const verified = yield* sessions.verify(issued.token); + + expect(verified.method).toBe("browser-session-cookie"); + expect(verified.subject).toBe("desktop-bootstrap"); + expect(verified.role).toBe("owner"); + expect(verified.client.label).toBe("Desktop app"); + expect(verified.client.browser).toBe("Electron"); + expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString()); + }).pipe(Effect.provide(makeSessionCredentialLayer())), + ); + it.effect("rejects malformed session tokens", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const error = yield* Effect.flip(sessions.verify("not-a-session-token")); + + expect(error._tag).toBe("SessionCredentialError"); + expect(error.message).toContain("Malformed session token"); + }).pipe(Effect.provide(makeSessionCredentialLayer())), + ); + it.effect("verifies session tokens against the Effect clock", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const issued = yield* sessions.issue({ + method: "bearer-session-token", + subject: "test-clock", + }); + const verified = yield* sessions.verify(issued.token); + + expect(verified.method).toBe("bearer-session-token"); + expect(verified.subject).toBe("test-clock"); + expect(verified.role).toBe("client"); + }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), + ); + + it.effect("rejects websocket tokens once the parent session has expired", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const issued = yield* sessions.issue({ + method: "bearer-session-token", + subject: "short-lived", + ttl: Duration.seconds(1), + }); + const websocket = yield* sessions.issueWebSocketToken(issued.sessionId); + + yield* TestClock.adjust(Duration.seconds(2)); + + const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); + expect(error.message).toContain("expired"); + }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), + ); + + it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const owner = yield* sessions.issue({ + subject: "desktop-bootstrap", + role: "owner", + client: { + label: "Desktop app", + deviceType: "desktop", + os: "macOS", + browser: "Electron", + }, + }); + const client = yield* sessions.issue({ + subject: "one-time-token", + role: "client", + client: { + label: "Julius iPhone", + deviceType: "mobile", + os: "iOS", + browser: "Safari", + ipAddress: "192.168.1.88", + }, + }); + + yield* sessions.markConnected(client.sessionId); + const beforeRevoke = yield* sessions.listActive(); + const revokedCount = yield* sessions.revokeAllExcept(owner.sessionId); + const afterRevoke = yield* sessions.listActive(); + const revokedClient = yield* Effect.flip(sessions.verify(client.token)); + + expect(beforeRevoke).toHaveLength(2); + expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe( + true, + ); + expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.client.label).toBe( + "Julius iPhone", + ); + expect( + beforeRevoke.find((entry) => entry.sessionId === owner.sessionId)?.client.deviceType, + ).toBe("desktop"); + expect(revokedCount).toBe(1); + expect(afterRevoke).toHaveLength(1); + expect(afterRevoke[0]?.sessionId).toBe(owner.sessionId); + expect(revokedClient.message).toContain("revoked"); + }).pipe(Effect.provide(makeSessionCredentialLayer())), + ); + + it.effect("persists lastConnectedAt on first connect and updates it after reconnect", () => + Effect.gen(function* () { + const sessions = yield* SessionCredentialService; + const issued = yield* sessions.issue({ + subject: "reconnect-test", + method: "bearer-session-token", + }); + + const beforeConnect = yield* sessions.listActive(); + expect(beforeConnect[0]?.lastConnectedAt).toBeNull(); + + yield* TestClock.adjust(Duration.seconds(1)); + yield* sessions.markConnected(issued.sessionId); + const firstConnect = yield* sessions.listActive(); + const firstConnectedAt = firstConnect[0]?.lastConnectedAt; + + expect(firstConnect[0]?.connected).toBe(true); + expect(firstConnectedAt).not.toBeNull(); + + yield* TestClock.adjust(Duration.seconds(1)); + yield* sessions.markConnected(issued.sessionId); + const stillConnected = yield* sessions.listActive(); + + expect(stillConnected[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString()); + + yield* sessions.markDisconnected(issued.sessionId); + yield* sessions.markDisconnected(issued.sessionId); + const afterDisconnect = yield* sessions.listActive(); + + expect(afterDisconnect[0]?.connected).toBe(false); + expect(afterDisconnect[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString()); + + yield* TestClock.adjust(Duration.seconds(1)); + yield* sessions.markConnected(issued.sessionId); + const afterReconnect = yield* sessions.listActive(); + + expect(afterReconnect[0]?.connected).toBe(true); + expect(afterReconnect[0]?.lastConnectedAt).not.toBeNull(); + expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString()); + }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), + ); +}); diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts new file mode 100644 index 0000000000..e8d034aff2 --- /dev/null +++ b/apps/server/src/auth/Layers/SessionCredentialService.ts @@ -0,0 +1,493 @@ +import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from "@t3tools/contracts"; +import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect"; +import { Option } from "effect"; + +import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts"; +import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts"; +import { ServerSecretStore } from "../Services/ServerSecretStore.ts"; +import { SESSION_COOKIE_NAME } from "../utils.ts"; +import { + SessionCredentialError, + SessionCredentialService, + type IssuedSession, + type SessionCredentialChange, + type SessionCredentialServiceShape, + type VerifiedSession, +} from "../Services/SessionCredentialService.ts"; +import { + base64UrlDecodeUtf8, + base64UrlEncode, + signPayload, + timingSafeEqualBase64Url, +} from "../utils.ts"; + +const SIGNING_SECRET_NAME = "server-signing-key"; +const DEFAULT_SESSION_TTL = Duration.days(30); +const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5); + +const SessionClaims = Schema.Struct({ + v: Schema.Literal(1), + kind: Schema.Literal("session"), + sid: AuthSessionId, + sub: Schema.String, + role: Schema.Literals(["owner", "client"]), + method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), + iat: Schema.Number, + exp: Schema.Number, +}); +type SessionClaims = typeof SessionClaims.Type; + +const WebSocketClaims = Schema.Struct({ + v: Schema.Literal(1), + kind: Schema.Literal("websocket"), + sid: AuthSessionId, + iat: Schema.Number, + exp: Schema.Number, +}); +type WebSocketClaims = typeof WebSocketClaims.Type; + +function createDefaultClientMetadata(): AuthClientMetadata { + return { + deviceType: "unknown", + }; +} + +function toClientMetadata(record: { + readonly label: string | null; + readonly ipAddress: string | null; + readonly userAgent: string | null; + readonly deviceType: AuthClientMetadata["deviceType"]; + readonly os: string | null; + readonly browser: string | null; +}): AuthClientMetadata { + return { + ...(record.label ? { label: record.label } : {}), + ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}), + ...(record.userAgent ? { userAgent: record.userAgent } : {}), + deviceType: record.deviceType, + ...(record.os ? { os: record.os } : {}), + ...(record.browser ? { browser: record.browser } : {}), + }; +} + +function toAuthClientSession(input: Omit): AuthClientSession { + return { + ...input, + current: false, + }; +} + +export const makeSessionCredentialService = Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + const authSessions = yield* AuthSessionRepository; + const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); + const connectedSessionsRef = yield* Ref.make(new Map()); + const changesPubSub = yield* PubSub.unbounded(); + + const toSessionCredentialError = (message: string) => (cause: unknown) => + new SessionCredentialError({ + message, + cause, + }); + + const emitUpsert = (clientSession: AuthClientSession) => + PubSub.publish(changesPubSub, { + type: "clientUpserted", + clientSession, + }).pipe(Effect.asVoid); + + const emitRemoved = (sessionId: AuthSessionId) => + PubSub.publish(changesPubSub, { + type: "clientRemoved", + sessionId, + }).pipe(Effect.asVoid); + + const loadActiveSession = (sessionId: AuthSessionId) => + Effect.gen(function* () { + const row = yield* authSessions.getById({ sessionId }); + if (Option.isNone(row) || row.value.revokedAt !== null) { + return Option.none(); + } + + const connectedSessions = yield* Ref.get(connectedSessionsRef); + return Option.some( + toAuthClientSession({ + sessionId: row.value.sessionId, + subject: row.value.subject, + role: row.value.role, + method: row.value.method, + client: toClientMetadata(row.value.client), + issuedAt: row.value.issuedAt, + expiresAt: row.value.expiresAt, + lastConnectedAt: row.value.lastConnectedAt, + connected: connectedSessions.has(row.value.sessionId), + }), + ); + }); + + const markConnected: SessionCredentialServiceShape["markConnected"] = (sessionId) => + Ref.modify(connectedSessionsRef, (current) => { + const next = new Map(current); + const wasDisconnected = !next.has(sessionId); + next.set(sessionId, (next.get(sessionId) ?? 0) + 1); + return [wasDisconnected, next] as const; + }).pipe( + Effect.flatMap((wasDisconnected) => + wasDisconnected + ? DateTime.now.pipe( + Effect.flatMap((lastConnectedAt) => + authSessions.setLastConnectedAt({ + sessionId, + lastConnectedAt, + }), + ), + ) + : Effect.void, + ), + Effect.flatMap(() => loadActiveSession(sessionId)), + Effect.flatMap((session) => + Option.isSome(session) ? emitUpsert(session.value) : Effect.void, + ), + Effect.catchCause((cause) => + Effect.logError("Failed to publish connected-session auth update.").pipe( + Effect.annotateLogs({ + sessionId, + cause, + }), + ), + ), + ); + + const markDisconnected: SessionCredentialServiceShape["markDisconnected"] = (sessionId) => + Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + const remaining = (next.get(sessionId) ?? 0) - 1; + if (remaining > 0) { + next.set(sessionId, remaining); + } else { + next.delete(sessionId); + } + return next; + }).pipe( + Effect.flatMap(() => loadActiveSession(sessionId)), + Effect.flatMap((session) => + Option.isSome(session) ? emitUpsert(session.value) : Effect.void, + ), + Effect.catchCause((cause) => + Effect.logError("Failed to publish disconnected-session auth update.").pipe( + Effect.annotateLogs({ + sessionId, + cause, + }), + ), + ), + ); + + const issue: SessionCredentialServiceShape["issue"] = (input) => + Effect.gen(function* () { + const sessionId = AuthSessionId.makeUnsafe(crypto.randomUUID()); + const issuedAt = yield* DateTime.now; + const expiresAt = DateTime.add(issuedAt, { + milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), + }); + const claims: SessionClaims = { + v: 1, + kind: "session", + sid: sessionId, + sub: input?.subject ?? "browser", + role: input?.role ?? "client", + method: input?.method ?? "browser-session-cookie", + iat: issuedAt.epochMilliseconds, + exp: expiresAt.epochMilliseconds, + }; + const encodedPayload = base64UrlEncode(JSON.stringify(claims)); + const signature = signPayload(encodedPayload, signingSecret); + const client = input?.client ?? createDefaultClientMetadata(); + yield* authSessions.create({ + sessionId, + subject: claims.sub, + role: claims.role, + method: claims.method, + client: { + label: client.label ?? null, + ipAddress: client.ipAddress ?? null, + userAgent: client.userAgent ?? null, + deviceType: client.deviceType, + os: client.os ?? null, + browser: client.browser ?? null, + }, + issuedAt, + expiresAt, + }); + yield* emitUpsert( + toAuthClientSession({ + sessionId, + subject: claims.sub, + role: claims.role, + method: claims.method, + client, + issuedAt, + expiresAt, + lastConnectedAt: null, + connected: false, + }), + ); + + return { + sessionId, + token: `${encodedPayload}.${signature}`, + method: claims.method, + client, + expiresAt: expiresAt, + role: claims.role, + } satisfies IssuedSession; + }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue session credential."))); + + const verify: SessionCredentialServiceShape["verify"] = (token) => + Effect.gen(function* () { + const [encodedPayload, signature] = token.split("."); + if (!encodedPayload || !signature) { + return yield* new SessionCredentialError({ + message: "Malformed session token.", + }); + } + + const expectedSignature = signPayload(encodedPayload, signingSecret); + if (!timingSafeEqualBase64Url(signature, expectedSignature)) { + return yield* new SessionCredentialError({ + message: "Invalid session token signature.", + }); + } + + const claims = yield* Effect.try({ + try: () => + Schema.decodeUnknownSync(SessionClaims)(JSON.parse(base64UrlDecodeUtf8(encodedPayload))), + catch: (cause) => + new SessionCredentialError({ + message: "Invalid session token payload.", + cause, + }), + }); + + const now = yield* Clock.currentTimeMillis; + if (claims.exp <= now) { + return yield* new SessionCredentialError({ + message: "Session token expired.", + }); + } + + const row = yield* authSessions.getById({ sessionId: claims.sid }); + if (Option.isNone(row)) { + return yield* new SessionCredentialError({ + message: "Unknown session token.", + }); + } + if (row.value.revokedAt !== null) { + return yield* new SessionCredentialError({ + message: "Session token revoked.", + }); + } + + return { + sessionId: claims.sid, + token, + method: claims.method, + client: toClientMetadata(row.value.client), + expiresAt: DateTime.makeUnsafe(claims.exp), + subject: claims.sub, + role: claims.role, + } satisfies VerifiedSession; + }).pipe( + Effect.mapError((cause) => + cause instanceof SessionCredentialError + ? cause + : new SessionCredentialError({ + message: "Failed to verify session credential.", + cause, + }), + ), + ); + + const issueWebSocketToken: SessionCredentialServiceShape["issueWebSocketToken"] = ( + sessionId, + input, + ) => + Effect.gen(function* () { + const issuedAt = yield* DateTime.now; + const expiresAt = DateTime.add(issuedAt, { + milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), + }); + const claims: WebSocketClaims = { + v: 1, + kind: "websocket", + sid: sessionId, + iat: issuedAt.epochMilliseconds, + exp: expiresAt.epochMilliseconds, + }; + const encodedPayload = base64UrlEncode(JSON.stringify(claims)); + const signature = signPayload(encodedPayload, signingSecret); + return { + token: `${encodedPayload}.${signature}`, + expiresAt, + }; + }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue websocket token."))); + + const verifyWebSocketToken: SessionCredentialServiceShape["verifyWebSocketToken"] = (token) => + Effect.gen(function* () { + const [encodedPayload, signature] = token.split("."); + if (!encodedPayload || !signature) { + return yield* new SessionCredentialError({ + message: "Malformed websocket token.", + }); + } + + const expectedSignature = signPayload(encodedPayload, signingSecret); + if (!timingSafeEqualBase64Url(signature, expectedSignature)) { + return yield* new SessionCredentialError({ + message: "Invalid websocket token signature.", + }); + } + + const claims = yield* Effect.try({ + try: () => + Schema.decodeUnknownSync(WebSocketClaims)( + JSON.parse(base64UrlDecodeUtf8(encodedPayload)), + ), + catch: (cause) => + new SessionCredentialError({ + message: "Invalid websocket token payload.", + cause, + }), + }); + + const now = yield* Clock.currentTimeMillis; + if (claims.exp <= now) { + return yield* new SessionCredentialError({ + message: "Websocket token expired.", + }); + } + + const row = yield* authSessions.getById({ sessionId: claims.sid }); + if (Option.isNone(row)) { + return yield* new SessionCredentialError({ + message: "Unknown websocket session.", + }); + } + if (row.value.expiresAt.epochMilliseconds <= now) { + return yield* new SessionCredentialError({ + message: "Websocket session expired.", + }); + } + if (row.value.revokedAt !== null) { + return yield* new SessionCredentialError({ + message: "Websocket session revoked.", + }); + } + + return { + sessionId: row.value.sessionId, + token, + method: row.value.method, + client: toClientMetadata(row.value.client), + expiresAt: row.value.expiresAt, + subject: row.value.subject, + role: row.value.role, + } satisfies VerifiedSession; + }).pipe( + Effect.mapError((cause) => + cause instanceof SessionCredentialError + ? cause + : new SessionCredentialError({ + message: "Failed to verify websocket token.", + cause, + }), + ), + ); + + const listActive: SessionCredentialServiceShape["listActive"] = () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const connectedSessions = yield* Ref.get(connectedSessionsRef); + const rows = yield* authSessions.listActive({ now }); + + return rows.map((row) => + toAuthClientSession({ + sessionId: row.sessionId, + subject: row.subject, + role: row.role, + method: row.method, + client: toClientMetadata(row.client), + issuedAt: row.issuedAt, + expiresAt: row.expiresAt, + lastConnectedAt: row.lastConnectedAt, + connected: connectedSessions.has(row.sessionId), + }), + ); + }).pipe(Effect.mapError(toSessionCredentialError("Failed to list active sessions."))); + + const revoke: SessionCredentialServiceShape["revoke"] = (sessionId) => + Effect.gen(function* () { + const revokedAt = yield* DateTime.now; + const revoked = yield* authSessions.revoke({ + sessionId, + revokedAt, + }); + if (revoked) { + yield* Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + next.delete(sessionId); + return next; + }); + yield* emitRemoved(sessionId); + } + return revoked; + }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke session."))); + + const revokeAllExcept: SessionCredentialServiceShape["revokeAllExcept"] = (sessionId) => + Effect.gen(function* () { + const revokedAt = yield* DateTime.now; + const revokedSessionIds = yield* authSessions.revokeAllExcept({ + currentSessionId: sessionId, + revokedAt, + }); + if (revokedSessionIds.length > 0) { + yield* Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + for (const revokedSessionId of revokedSessionIds) { + next.delete(revokedSessionId); + } + return next; + }); + yield* Effect.forEach( + revokedSessionIds, + (revokedSessionId) => emitRemoved(revokedSessionId), + { + concurrency: "unbounded", + discard: true, + }, + ); + } + return revokedSessionIds.length; + }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions."))); + + return { + cookieName: SESSION_COOKIE_NAME, + issue, + verify, + issueWebSocketToken, + verifyWebSocketToken, + listActive, + get streamChanges() { + return Stream.fromPubSub(changesPubSub); + }, + revoke, + revokeAllExcept, + markConnected, + markDisconnected, + } satisfies SessionCredentialServiceShape; +}); + +export const SessionCredentialServiceLive = Layer.effect( + SessionCredentialService, + makeSessionCredentialService, +).pipe(Layer.provideMerge(AuthSessionRepositoryLive)); diff --git a/apps/server/src/auth/Services/AuthControlPlane.ts b/apps/server/src/auth/Services/AuthControlPlane.ts new file mode 100644 index 0000000000..cbaaf98fd3 --- /dev/null +++ b/apps/server/src/auth/Services/AuthControlPlane.ts @@ -0,0 +1,69 @@ +import type { + AuthClientMetadata, + AuthClientSession, + AuthPairingLink, + AuthSessionId, +} from "@t3tools/contracts"; +import { Data, DateTime, Duration, Effect, ServiceMap } from "effect"; +import { SessionRole } from "./SessionCredentialService"; + +export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; + +export interface IssuedPairingLink { + readonly id: string; + readonly credential: string; + readonly role: SessionRole; + readonly subject: string; + readonly label?: string; + readonly createdAt: DateTime.Utc; + readonly expiresAt: DateTime.Utc; +} + +export interface IssuedBearerSession { + readonly sessionId: AuthSessionId; + readonly token: string; + readonly method: "bearer-session-token"; + readonly role: SessionRole; + readonly subject: string; + readonly client: AuthClientMetadata; + readonly expiresAt: DateTime.Utc; +} + +export class AuthControlPlaneError extends Data.TaggedError("AuthControlPlaneError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface AuthControlPlaneShape { + readonly createPairingLink: (input?: { + readonly ttl?: Duration.Duration; + readonly label?: string; + readonly role?: SessionRole; + readonly subject?: string; + }) => Effect.Effect; + readonly listPairingLinks: (input?: { + readonly role?: SessionRole; + readonly excludeSubjects?: ReadonlyArray; + }) => Effect.Effect, AuthControlPlaneError>; + readonly revokePairingLink: (id: string) => Effect.Effect; + readonly issueSession: (input?: { + readonly ttl?: Duration.Duration; + readonly subject?: string; + readonly role?: SessionRole; + readonly label?: string; + }) => Effect.Effect; + readonly listSessions: () => Effect.Effect< + ReadonlyArray, + AuthControlPlaneError + >; + readonly revokeSession: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly revokeOtherSessionsExcept: ( + sessionId: AuthSessionId, + ) => Effect.Effect; +} + +export class AuthControlPlane extends ServiceMap.Service()( + "t3/AuthControlPlane", +) {} diff --git a/apps/server/src/auth/Services/BootstrapCredentialService.ts b/apps/server/src/auth/Services/BootstrapCredentialService.ts new file mode 100644 index 0000000000..4f5cf4fdab --- /dev/null +++ b/apps/server/src/auth/Services/BootstrapCredentialService.ts @@ -0,0 +1,57 @@ +import type { AuthPairingLink, ServerAuthBootstrapMethod } from "@t3tools/contracts"; +import { Data, DateTime, Duration, ServiceMap } from "effect"; +import type { Effect, Stream } from "effect"; + +export type BootstrapCredentialRole = "owner" | "client"; + +export interface BootstrapGrant { + readonly method: ServerAuthBootstrapMethod; + readonly role: BootstrapCredentialRole; + readonly subject: string; + readonly label?: string; + readonly expiresAt: DateTime.DateTime; +} + +export class BootstrapCredentialError extends Data.TaggedError("BootstrapCredentialError")<{ + readonly message: string; + readonly status?: 401 | 500; + readonly cause?: unknown; +}> {} + +export interface IssuedBootstrapCredential { + readonly id: string; + readonly credential: string; + readonly label?: string; + readonly expiresAt: DateTime.Utc; +} + +export type BootstrapCredentialChange = + | { + readonly type: "pairingLinkUpserted"; + readonly pairingLink: AuthPairingLink; + } + | { + readonly type: "pairingLinkRemoved"; + readonly id: string; + }; + +export interface BootstrapCredentialServiceShape { + readonly issueOneTimeToken: (input?: { + readonly ttl?: Duration.Duration; + readonly role?: BootstrapCredentialRole; + readonly subject?: string; + readonly label?: string; + }) => Effect.Effect; + readonly listActive: () => Effect.Effect< + ReadonlyArray, + BootstrapCredentialError + >; + readonly streamChanges: Stream.Stream; + readonly revoke: (id: string) => Effect.Effect; + readonly consume: (credential: string) => Effect.Effect; +} + +export class BootstrapCredentialService extends ServiceMap.Service< + BootstrapCredentialService, + BootstrapCredentialServiceShape +>()("t3/auth/Services/BootstrapCredentialService") {} diff --git a/apps/server/src/auth/Services/ServerAuth.ts b/apps/server/src/auth/Services/ServerAuth.ts new file mode 100644 index 0000000000..0e38679b85 --- /dev/null +++ b/apps/server/src/auth/Services/ServerAuth.ts @@ -0,0 +1,84 @@ +import type { + AuthBearerBootstrapResult, + AuthBootstrapResult, + AuthClientMetadata, + AuthClientSession, + AuthCreatePairingCredentialInput, + AuthPairingLink, + AuthPairingCredentialResult, + AuthSessionId, + AuthSessionState, + ServerAuthDescriptor, + ServerAuthSessionMethod, + AuthWebSocketTokenResult, +} from "@t3tools/contracts"; +import { Data, DateTime, ServiceMap } from "effect"; +import type { Effect } from "effect"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import type { SessionRole } from "./SessionCredentialService.ts"; + +export interface AuthenticatedSession { + readonly sessionId: AuthSessionId; + readonly subject: string; + readonly method: ServerAuthSessionMethod; + readonly role: SessionRole; + readonly expiresAt?: DateTime.DateTime; +} + +export class AuthError extends Data.TaggedError("AuthError")<{ + readonly message: string; + readonly status?: 400 | 401 | 403 | 500; + readonly cause?: unknown; +}> {} + +export interface ServerAuthShape { + readonly getDescriptor: () => Effect.Effect; + readonly getSessionState: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly exchangeBootstrapCredential: ( + credential: string, + requestMetadata: AuthClientMetadata, + ) => Effect.Effect< + { + readonly response: AuthBootstrapResult; + readonly sessionToken: string; + }, + AuthError + >; + readonly exchangeBootstrapCredentialForBearerSession: ( + credential: string, + requestMetadata: AuthClientMetadata, + ) => Effect.Effect; + readonly issuePairingCredential: ( + input?: AuthCreatePairingCredentialInput & { + readonly role?: SessionRole; + }, + ) => Effect.Effect; + readonly listPairingLinks: () => Effect.Effect, AuthError>; + readonly revokePairingLink: (id: string) => Effect.Effect; + readonly listClientSessions: ( + currentSessionId: AuthSessionId, + ) => Effect.Effect, AuthError>; + readonly revokeClientSession: ( + currentSessionId: AuthSessionId, + targetSessionId: AuthSessionId, + ) => Effect.Effect; + readonly revokeOtherClientSessions: ( + currentSessionId: AuthSessionId, + ) => Effect.Effect; + readonly authenticateHttpRequest: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly authenticateWebSocketUpgrade: ( + request: HttpServerRequest.HttpServerRequest, + ) => Effect.Effect; + readonly issueWebSocketToken: ( + session: AuthenticatedSession, + ) => Effect.Effect; + readonly issueStartupPairingUrl: (baseUrl: string) => Effect.Effect; +} + +export class ServerAuth extends ServiceMap.Service()( + "t3/auth/Services/ServerAuth", +) {} diff --git a/apps/server/src/auth/Services/ServerAuthPolicy.ts b/apps/server/src/auth/Services/ServerAuthPolicy.ts new file mode 100644 index 0000000000..43dae6ca69 --- /dev/null +++ b/apps/server/src/auth/Services/ServerAuthPolicy.ts @@ -0,0 +1,11 @@ +import type { ServerAuthDescriptor } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface ServerAuthPolicyShape { + readonly getDescriptor: () => Effect.Effect; +} + +export class ServerAuthPolicy extends ServiceMap.Service()( + "t3/auth/Services/ServerAuthPolicy", +) {} diff --git a/apps/server/src/auth/Services/ServerSecretStore.ts b/apps/server/src/auth/Services/ServerSecretStore.ts new file mode 100644 index 0000000000..376527aea3 --- /dev/null +++ b/apps/server/src/auth/Services/ServerSecretStore.ts @@ -0,0 +1,22 @@ +import { Data, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface ServerSecretStoreShape { + readonly get: (name: string) => Effect.Effect; + readonly set: (name: string, value: Uint8Array) => Effect.Effect; + readonly getOrCreateRandom: ( + name: string, + bytes: number, + ) => Effect.Effect; + readonly remove: (name: string) => Effect.Effect; +} + +export class ServerSecretStore extends ServiceMap.Service< + ServerSecretStore, + ServerSecretStoreShape +>()("t3/auth/Services/ServerSecretStore") {} diff --git a/apps/server/src/auth/Services/SessionCredentialService.ts b/apps/server/src/auth/Services/SessionCredentialService.ts new file mode 100644 index 0000000000..591504766e --- /dev/null +++ b/apps/server/src/auth/Services/SessionCredentialService.ts @@ -0,0 +1,87 @@ +import type { + AuthClientMetadata, + AuthClientSession, + AuthSessionId, + ServerAuthSessionMethod, +} from "@t3tools/contracts"; +import { Data, DateTime, Duration, ServiceMap } from "effect"; +import type { Effect, Stream } from "effect"; + +export type SessionRole = "owner" | "client"; + +export interface IssuedSession { + readonly sessionId: AuthSessionId; + readonly token: string; + readonly method: ServerAuthSessionMethod; + readonly client: AuthClientMetadata; + readonly expiresAt: DateTime.DateTime; + readonly role: SessionRole; +} + +export interface VerifiedSession { + readonly sessionId: AuthSessionId; + readonly token: string; + readonly method: ServerAuthSessionMethod; + readonly client: AuthClientMetadata; + readonly expiresAt?: DateTime.DateTime; + readonly subject: string; + readonly role: SessionRole; +} + +export type SessionCredentialChange = + | { + readonly type: "clientUpserted"; + readonly clientSession: AuthClientSession; + } + | { + readonly type: "clientRemoved"; + readonly sessionId: AuthSessionId; + }; + +export class SessionCredentialError extends Data.TaggedError("SessionCredentialError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface SessionCredentialServiceShape { + readonly cookieName: string; + readonly issue: (input?: { + readonly ttl?: Duration.Duration; + readonly subject?: string; + readonly method?: ServerAuthSessionMethod; + readonly role?: SessionRole; + readonly client?: AuthClientMetadata; + }) => Effect.Effect; + readonly verify: (token: string) => Effect.Effect; + readonly issueWebSocketToken: ( + sessionId: AuthSessionId, + input?: { + readonly ttl?: Duration.Duration; + }, + ) => Effect.Effect< + { + readonly token: string; + readonly expiresAt: DateTime.DateTime; + }, + SessionCredentialError + >; + readonly verifyWebSocketToken: ( + token: string, + ) => Effect.Effect; + readonly listActive: () => Effect.Effect< + ReadonlyArray, + SessionCredentialError + >; + readonly streamChanges: Stream.Stream; + readonly revoke: (sessionId: AuthSessionId) => Effect.Effect; + readonly revokeAllExcept: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect; + readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect; +} + +export class SessionCredentialService extends ServiceMap.Service< + SessionCredentialService, + SessionCredentialServiceShape +>()("t3/auth/Services/SessionCredentialService") {} diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts new file mode 100644 index 0000000000..76c14646e9 --- /dev/null +++ b/apps/server/src/auth/http.ts @@ -0,0 +1,254 @@ +import { + type AuthBearerBootstrapResult, + AuthBootstrapInput, + AuthCreatePairingCredentialInput, + AuthRevokeClientSessionInput, + AuthRevokePairingLinkInput, + type AuthWebSocketTokenResult, +} from "@t3tools/contracts"; +import { DateTime, Effect, Schema } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import { AuthError, ServerAuth } from "./Services/ServerAuth.ts"; +import { SessionCredentialService } from "./Services/SessionCredentialService.ts"; +import { deriveAuthClientMetadata } from "./utils.ts"; + +export const respondToAuthError = (error: AuthError) => + Effect.gen(function* () { + if ((error.status ?? 500) >= 500) { + yield* Effect.logError("auth route failed", { + message: error.message, + cause: error.cause, + }); + } + return HttpServerResponse.jsonUnsafe( + { + error: error.message, + }, + { status: error.status ?? 500 }, + ); + }); + +export const authSessionRouteLayer = HttpRouter.add( + "GET", + "/api/auth/session", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + const session = yield* serverAuth.getSessionState(request); + return HttpServerResponse.jsonUnsafe(session, { status: 200 }); + }), +); + +const PairingCredentialRequestHeaders = Schema.Struct({ + "content-length": Schema.optionalKey(Schema.String), + "content-type": Schema.optionalKey(Schema.String), + "transfer-encoding": Schema.optionalKey(Schema.String), +}); + +function hasRequestBody(headers: typeof PairingCredentialRequestHeaders.Type) { + const contentLengthHeader = headers["content-length"]; + if (typeof contentLengthHeader === "string") { + const contentLength = Number.parseInt(contentLengthHeader, 10); + if (Number.isFinite(contentLength)) { + return contentLength > 0; + } + } + return typeof headers["transfer-encoding"] === "string"; +} + +export const authBootstrapRouteLayer = HttpRouter.add( + "POST", + "/api/auth/bootstrap", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + const sessions = yield* SessionCredentialService; + const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid bootstrap payload.", + status: 400, + cause, + }), + ), + ); + const result = yield* serverAuth.exchangeBootstrapCredential( + payload.credential, + deriveAuthClientMetadata({ request }), + ); + + return yield* HttpServerResponse.jsonUnsafe(result.response, { status: 200 }).pipe( + HttpServerResponse.setCookie(sessions.cookieName, result.sessionToken, { + expires: DateTime.toDate(result.response.expiresAt), + httpOnly: true, + path: "/", + sameSite: "lax", + }), + ); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +export const authBearerBootstrapRouteLayer = HttpRouter.add( + "POST", + "/api/auth/bootstrap/bearer", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid bootstrap payload.", + status: 400, + cause, + }), + ), + ); + const result = yield* serverAuth.exchangeBootstrapCredentialForBearerSession( + payload.credential, + deriveAuthClientMetadata({ request }), + ); + return HttpServerResponse.jsonUnsafe(result satisfies AuthBearerBootstrapResult, { + status: 200, + }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +export const authWebSocketTokenRouteLayer = HttpRouter.add( + "POST", + "/api/auth/ws-token", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + const session = yield* serverAuth.authenticateHttpRequest(request); + const result = yield* serverAuth.issueWebSocketToken(session); + return HttpServerResponse.jsonUnsafe(result satisfies AuthWebSocketTokenResult, { + status: 200, + }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +export const authPairingCredentialRouteLayer = HttpRouter.add( + "POST", + "/api/auth/pairing-token", + Effect.gen(function* () { + const serverAuth = yield* ServerAuth; + const request = yield* HttpServerRequest.HttpServerRequest; + const session = yield* serverAuth.authenticateHttpRequest(request); + if (session.role !== "owner") { + return yield* new AuthError({ + message: "Only owner sessions can create pairing credentials.", + status: 403, + }); + } + const headers = yield* HttpServerRequest.schemaHeaders(PairingCredentialRequestHeaders).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid pairing credential request headers.", + status: 400, + cause, + }), + ), + ); + const payload = hasRequestBody(headers) + ? yield* HttpServerRequest.schemaBodyJson(AuthCreatePairingCredentialInput).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid pairing credential payload.", + status: 400, + cause, + }), + ), + ) + : {}; + const result = yield* serverAuth.issuePairingCredential(payload); + return HttpServerResponse.jsonUnsafe(result, { status: 200 }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +const authenticateOwnerSession = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + const session = yield* serverAuth.authenticateHttpRequest(request); + if (session.role !== "owner") { + return yield* new AuthError({ + message: "Only owner sessions can manage network access.", + status: 403, + }); + } + return { serverAuth, session } as const; +}); + +export const authPairingLinksRouteLayer = HttpRouter.add( + "GET", + "/api/auth/pairing-links", + Effect.gen(function* () { + const { serverAuth } = yield* authenticateOwnerSession; + const pairingLinks = yield* serverAuth.listPairingLinks(); + return HttpServerResponse.jsonUnsafe(pairingLinks, { status: 200 }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +export const authPairingLinksRevokeRouteLayer = HttpRouter.add( + "POST", + "/api/auth/pairing-links/revoke", + Effect.gen(function* () { + const { serverAuth } = yield* authenticateOwnerSession; + const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokePairingLinkInput).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid revoke pairing link payload.", + status: 400, + cause, + }), + ), + ); + const revoked = yield* serverAuth.revokePairingLink(payload.id); + return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +export const authClientsRouteLayer = HttpRouter.add( + "GET", + "/api/auth/clients", + Effect.gen(function* () { + const { serverAuth, session } = yield* authenticateOwnerSession; + const clients = yield* serverAuth.listClientSessions(session.sessionId); + return HttpServerResponse.jsonUnsafe(clients, { status: 200 }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +export const authClientsRevokeRouteLayer = HttpRouter.add( + "POST", + "/api/auth/clients/revoke", + Effect.gen(function* () { + const { serverAuth, session } = yield* authenticateOwnerSession; + const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokeClientSessionInput).pipe( + Effect.mapError( + (cause) => + new AuthError({ + message: "Invalid revoke client payload.", + status: 400, + cause, + }), + ), + ); + const revoked = yield* serverAuth.revokeClientSession(session.sessionId, payload.sessionId); + return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); + +export const authClientsRevokeOthersRouteLayer = HttpRouter.add( + "POST", + "/api/auth/clients/revoke-others", + Effect.gen(function* () { + const { serverAuth, session } = yield* authenticateOwnerSession; + const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId); + return HttpServerResponse.jsonUnsafe({ revokedCount }, { status: 200 }); + }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))), +); diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts new file mode 100644 index 0000000000..a767b77de1 --- /dev/null +++ b/apps/server/src/auth/utils.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { deriveAuthClientMetadata } from "./utils"; + +describe("deriveAuthClientMetadata", () => { + it("labels Electron user agents as Electron instead of Chrome", () => { + const metadata = deriveAuthClientMetadata({ + request: { + headers: { + "user-agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) t3code/0.0.15 Chrome/136.0.7103.93 Electron/36.3.2 Safari/537.36", + }, + source: { + remoteAddress: "::ffff:127.0.0.1", + }, + } as never, + }); + + expect(metadata).toMatchObject({ + browser: "Electron", + deviceType: "desktop", + ipAddress: "127.0.0.1", + os: "macOS", + }); + }); +}); diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts new file mode 100644 index 0000000000..e87c66c6b9 --- /dev/null +++ b/apps/server/src/auth/utils.ts @@ -0,0 +1,121 @@ +import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/contracts"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as Crypto from "node:crypto"; + +export const SESSION_COOKIE_NAME = "t3_session"; + +export function base64UrlEncode(input: string | Uint8Array): string { + const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input); + return buffer.toString("base64url"); +} + +export function base64UrlDecodeUtf8(input: string): string { + return Buffer.from(input, "base64url").toString("utf8"); +} + +export function signPayload(payload: string, secret: Uint8Array): string { + return Crypto.createHmac("sha256", Buffer.from(secret)).update(payload).digest("base64url"); +} + +export function timingSafeEqualBase64Url(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, "base64url"); + const rightBuffer = Buffer.from(right, "base64url"); + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + return Crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function normalizeNonEmptyString(value: string | null | undefined): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeIpAddress(value: string | null | undefined): string | undefined { + const normalized = normalizeNonEmptyString(value); + if (!normalized) { + return undefined; + } + return normalized.startsWith("::ffff:") ? normalized.slice("::ffff:".length) : normalized; +} + +function inferDeviceType(userAgent: string | undefined): AuthClientMetadataDeviceType { + if (!userAgent) { + return "unknown"; + } + + const normalized = userAgent.toLowerCase(); + if (/bot|crawler|spider|slurp|curl|wget/.test(normalized)) { + return "bot"; + } + if (/ipad|tablet/.test(normalized)) { + return "tablet"; + } + if (/iphone|android.+mobile|mobile/.test(normalized)) { + return "mobile"; + } + return "desktop"; +} + +function inferBrowser(userAgent: string | undefined): string | undefined { + if (!userAgent) { + return undefined; + } + const normalized = userAgent.toLowerCase(); + if (/edg\//.test(normalized)) return "Edge"; + if (/opr\//.test(normalized)) return "Opera"; + if (/firefox\//.test(normalized)) return "Firefox"; + if (/electron\//.test(normalized)) return "Electron"; + if (/chrome\//.test(normalized) || /crios\//.test(normalized)) return "Chrome"; + if (/safari\//.test(normalized) && !/chrome\//.test(normalized)) return "Safari"; + return undefined; +} + +function inferOs(userAgent: string | undefined): string | undefined { + if (!userAgent) { + return undefined; + } + const normalized = userAgent.toLowerCase(); + if (/iphone|ipad|ipod/.test(normalized)) return "iOS"; + if (/android/.test(normalized)) return "Android"; + if (/mac os x|macintosh/.test(normalized)) return "macOS"; + if (/windows nt/.test(normalized)) return "Windows"; + if (/linux/.test(normalized)) return "Linux"; + return undefined; +} + +function readRemoteAddressFromSource(source: unknown): string | undefined { + if (!source || typeof source !== "object") { + return undefined; + } + + const candidate = source as { + readonly remoteAddress?: string | null; + readonly socket?: { + readonly remoteAddress?: string | null; + }; + }; + + return normalizeIpAddress(candidate.socket?.remoteAddress ?? candidate.remoteAddress); +} + +export function deriveAuthClientMetadata(input: { + readonly request: HttpServerRequest.HttpServerRequest; + readonly label?: string; +}): AuthClientMetadata { + const userAgent = normalizeNonEmptyString(input.request.headers["user-agent"]); + const ipAddress = readRemoteAddressFromSource(input.request.source); + const os = inferOs(userAgent); + const browser = inferBrowser(userAgent); + return { + ...(input.label ? { label: input.label } : {}), + ...(ipAddress ? { ipAddress } : {}), + ...(userAgent ? { userAgent } : {}), + deviceType: inferDeviceType(userAgent), + ...(os ? { os } : {}), + ...(browser ? { browser } : {}), + }; +} diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli-config.test.ts index ef2f9f55d8..6dd48c5d25 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli-config.test.ts @@ -43,7 +43,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), - authToken: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -62,7 +61,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_HOME: baseDir, VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", T3CODE_NO_BROWSER: "true", - T3CODE_AUTH_TOKEN: "env-token", T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", T3CODE_LOG_WS_EVENTS: "true", }, @@ -85,7 +83,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:5173"), noBrowser: true, - authToken: "env-token", + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, }); @@ -106,7 +104,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.some(new URL("http://127.0.0.1:4173")), noBrowser: Option.some(true), - authToken: Option.some("flag-token"), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.some(true), logWebSocketEvents: Option.some(true), @@ -125,7 +122,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_HOME: join(os.tmpdir(), "ignored-base"), VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", T3CODE_NO_BROWSER: "false", - T3CODE_AUTH_TOKEN: "ignored-token", T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", T3CODE_LOG_WS_EVENTS: "false", }, @@ -148,7 +144,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:4173"), noBrowser: true, - authToken: "flag-token", + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, }); @@ -166,7 +162,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { t3Home: baseDir, devUrl: "http://127.0.0.1:5173", noBrowser: true, - authToken: "bootstrap-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, otlpTracesUrl: "http://localhost:4318/v1/traces", @@ -183,7 +178,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), - authToken: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -218,7 +212,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:5173"), noBrowser: true, - authToken: "bootstrap-token", + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: true, }); @@ -242,7 +236,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.some(customCwd), devUrl: Option.some(new URL("http://127.0.0.1:5173")), noBrowser: Option.none(), - authToken: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -285,7 +278,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { t3Home: "/tmp/t3-bootstrap-home", devUrl: "http://127.0.0.1:5173", noBrowser: false, - authToken: "bootstrap-token", autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, }); @@ -300,7 +292,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.some(new URL("http://127.0.0.1:4173")), noBrowser: Option.none(), - authToken: Option.some("flag-token"), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -338,7 +329,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: undefined, devUrl: new URL("http://127.0.0.1:4173"), noBrowser: true, - authToken: "flag-token", + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: true, logWebSocketEvents: true, }); @@ -371,7 +362,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), - authToken: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -402,7 +392,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { staticDir: resolved.staticDir, devUrl: undefined, noBrowser: true, - authToken: undefined, + desktopBootstrapToken: undefined, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, }); diff --git a/apps/server/src/cli.test.ts b/apps/server/src/cli.test.ts index fbbe26e6cf..4840fecc9a 100644 --- a/apps/server/src/cli.test.ts +++ b/apps/server/src/cli.test.ts @@ -1,28 +1,41 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { NetService } from "@t3tools/shared/Net"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as CliError from "effect/unstable/cli/CliError"; +import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; import { cli } from "./cli.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +const runCli = (args: ReadonlyArray) => Command.runWith(cli, { version: "0.0.0" })(args); +const runCliWithRuntime = (args: ReadonlyArray) => + runCli(args).pipe(Effect.provide(CliRuntimeLayer)); + +const captureStdout = (effect: Effect.Effect) => + Effect.gen(function* () { + const result = yield* effect; + const output = + (yield* TestConsole.logLines).findLast((line): line is string => typeof line === "string") ?? + ""; + return { result, output }; + }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))); + it.layer(NodeServices.layer)("cli log-level parsing", (it) => { it.effect("accepts the built-in lowercase log-level flag values", () => - Command.runWith(cli, { version: "0.0.0" })(["--log-level", "debug", "--version"]).pipe( - Effect.provide(CliRuntimeLayer), - ), + runCliWithRuntime(["--log-level", "debug", "--version"]), ); it.effect("rejects invalid log-level casing before launching the server", () => Effect.gen(function* () { - const error = yield* Command.runWith(cli, { version: "0.0.0" })([ - "--log-level", - "Debug", - ]).pipe(Effect.provide(CliRuntimeLayer), Effect.flip); + const error = yield* runCliWithRuntime(["--log-level", "Debug"]).pipe(Effect.flip); if (!CliError.isCliError(error)) { assert.fail(`Expected CliError, got ${String(error)}`); @@ -34,4 +47,87 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => { assert.equal(error.value, "Debug"); }), ); + + it.effect("executes auth pairing subcommands and redacts secrets from list output", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-pairing-test-")); + + const createdOutput = yield* captureStdout( + runCli(["auth", "pairing", "create", "--base-dir", baseDir, "--json"]), + ); + const created = JSON.parse(createdOutput.output) as { + readonly id: string; + readonly credential: string; + }; + const listedOutput = yield* captureStdout( + runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), + ); + const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{ + readonly id: string; + readonly credential?: string; + }>; + + assert.equal(typeof created.id, "string"); + assert.equal(typeof created.credential, "string"); + assert.equal(created.credential.length > 0, true); + assert.equal(listed.length, 1); + assert.equal(listed[0]?.id, created.id); + assert.equal("credential" in (listed[0] ?? {}), false); + }), + ); + + it.effect("executes auth session subcommands and redacts secrets from list output", () => + Effect.gen(function* () { + const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-session-test-")); + + const issuedOutput = yield* captureStdout( + runCli(["auth", "session", "issue", "--base-dir", baseDir, "--json"]), + ); + const issued = JSON.parse(issuedOutput.output) as { + readonly sessionId: string; + readonly token: string; + readonly role: string; + }; + const listedOutput = yield* captureStdout( + runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]), + ); + const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{ + readonly sessionId: string; + readonly token?: string; + readonly role: string; + }>; + + assert.equal(typeof issued.sessionId, "string"); + assert.equal(typeof issued.token, "string"); + assert.equal(issued.role, "owner"); + assert.equal(listed.length, 1); + assert.equal(listed[0]?.sessionId, issued.sessionId); + assert.equal(listed[0]?.role, "owner"); + assert.equal("token" in (listed[0] ?? {}), false); + }), + ); + + it.effect("rejects invalid ttl values before running auth commands", () => + Effect.gen(function* () { + const error = yield* runCliWithRuntime(["auth", "pairing", "create", "--ttl", "soon"]).pipe( + Effect.flip, + ); + + if (!CliError.isCliError(error)) { + assert.fail(`Expected CliError, got ${String(error)}`); + } + if (error._tag !== "ShowHelp") { + assert.fail(`Expected ShowHelp, got ${error._tag}`); + } + assert.deepEqual(error.commandPath, ["t3", "auth", "pairing", "create"]); + const ttlError = error.errors[0] as CliError.CliError | undefined; + if (!ttlError || ttlError._tag !== "InvalidValue") { + assert.fail(`Expected InvalidValue, got ${String(ttlError?._tag)}`); + } + assert.equal(ttlError.option, "ttl"); + assert.equal(ttlError.value, "soon"); + assert.isTrue(ttlError.message.includes("Invalid duration")); + assert.isTrue(ttlError.message.includes("5m, 1h, 30d, or 15 minutes")); + }), + ); }); diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 9ece02a0d3..01fd300542 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -1,6 +1,20 @@ import { NetService } from "@t3tools/shared/Net"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; -import { Config, Effect, FileSystem, LogLevel, Option, Path, Schema } from "effect"; +import { AuthSessionId } from "@t3tools/contracts"; +import { + Config, + Console, + Duration, + Effect, + FileSystem, + LogLevel, + Option, + Path, + References, + Schema, + SchemaIssue, + SchemaTransformation, +} from "effect"; import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; import { @@ -15,6 +29,14 @@ import { import { readBootstrapEnvelope } from "./bootstrap"; import { expandHomePath, resolveBaseDir } from "./os-jank"; import { runServer } from "./server"; +import { AuthControlPlaneRuntimeLive } from "./auth/Layers/AuthControlPlane.ts"; +import { + formatIssuedPairingCredential, + formatIssuedSession, + formatPairingCredentialList, + formatSessionList, +} from "./cliAuthFormat"; +import { AuthControlPlane, AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts"; const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); @@ -25,7 +47,7 @@ const BootstrapEnvelopeSchema = Schema.Struct({ t3Home: Schema.optional(Schema.String), devUrl: Schema.optional(Schema.URLFromString), noBrowser: Schema.optional(Schema.Boolean), - authToken: Schema.optional(Schema.String), + desktopBootstrapToken: Schema.optional(Schema.String), autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), logWebSocketEvents: Schema.optional(Schema.Boolean), otlpTracesUrl: Schema.optional(Schema.String), @@ -58,11 +80,6 @@ const noBrowserFlag = Flag.boolean("no-browser").pipe( Flag.withDescription("Disable automatic browser opening."), Flag.optional, ); -const authTokenFlag = Flag.string("auth-token").pipe( - Flag.withDescription("Auth token required for WebSocket connections."), - Flag.withAlias("token"), - Flag.optional, -); const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( Flag.withSchema(Schema.Int), Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), @@ -117,10 +134,6 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), - authToken: Config.string("T3CODE_AUTH_TOKEN").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -143,12 +156,16 @@ interface CliServerFlags { readonly cwd: Option.Option; readonly devUrl: Option.Option; readonly noBrowser: Option.Option; - readonly authToken: Option.Option; readonly bootstrapFd: Option.Option; readonly autoBootstrapProjectFromCwd: Option.Option; readonly logWebSocketEvents: Option.Option; } +interface CliAuthLocationFlags { + readonly baseDir: Option.Option; + readonly devUrl: Option.Option; +} + const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => Option.getOrElse(Option.filter(flag, Boolean), () => envValue); @@ -248,13 +265,9 @@ export const resolveServerConfig = ( () => mode === "desktop", ), ); - const authToken = Option.getOrUndefined( - resolveOptionPrecedence( - flags.authToken, - Option.fromUndefinedOr(env.authToken), - Option.flatMap(bootstrapEnvelope, (bootstrap) => - Option.fromUndefinedOr(bootstrap.authToken), - ), + const desktopBootstrapToken = Option.getOrUndefined( + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.desktopBootstrapToken), ), ); const autoBootstrapProjectFromCwd = resolveBooleanFlag( @@ -327,7 +340,7 @@ export const resolveServerConfig = ( staticDir, devUrl, noBrowser, - authToken, + desktopBootstrapToken, autoBootstrapProjectFromCwd, logWebSocketEvents, }; @@ -335,6 +348,110 @@ export const resolveServerConfig = ( return config; }); +const resolveCliAuthConfig = ( + flags: CliAuthLocationFlags, + cliLogLevel: Option.Option, +) => + resolveServerConfig( + { + mode: Option.none(), + port: Option.none(), + host: Option.none(), + baseDir: flags.baseDir, + cwd: Option.none(), + devUrl: flags.devUrl, + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + }, + cliLogLevel, + ); + +const DurationShorthandPattern = /^(?\d+)(?ms|s|m|h|d|w)$/i; + +const parseDurationInput = (value: string): Duration.Duration | null => { + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + + const shorthand = DurationShorthandPattern.exec(trimmed); + const normalizedInput = shorthand?.groups + ? (() => { + const amountText = shorthand.groups.value; + const unitText = shorthand.groups.unit; + if (typeof amountText !== "string" || typeof unitText !== "string") { + return null; + } + + const amount = Number.parseInt(amountText, 10); + if (!Number.isFinite(amount)) return null; + + switch (unitText.toLowerCase()) { + case "ms": + return `${amount} millis`; + case "s": + return `${amount} seconds`; + case "m": + return `${amount} minutes`; + case "h": + return `${amount} hours`; + case "d": + return `${amount} days`; + case "w": + return `${amount} weeks`; + default: + return null; + } + })() + : (trimmed as Duration.Input); + + if (normalizedInput === null) return null; + + const decoded = Duration.fromInput(normalizedInput as Duration.Input); + return Option.isSome(decoded) ? decoded.value : null; +}; + +const DurationFromString = Schema.String.pipe( + Schema.decodeTo( + Schema.Duration, + SchemaTransformation.transformOrFail({ + decode: (value) => { + const duration = parseDurationInput(value); + if (duration !== null) { + return Effect.succeed(duration); + } + return Effect.fail( + new SchemaIssue.InvalidValue(Option.some(value), { + message: "Invalid duration. Use values like 5m, 1h, 30d, or 15 minutes.", + }), + ); + }, + encode: (duration) => Effect.succeed(Duration.format(duration)), + }), + ), +); + +const runWithAuthControlPlane = ( + flags: CliAuthLocationFlags, + run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect, + options?: { + readonly quietLogs?: boolean; + }, +) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; + return yield* Effect.gen(function* () { + const authControlPlane = yield* AuthControlPlane; + return yield* run(authControlPlane); + }).pipe( + Effect.provide(AuthControlPlaneRuntimeLive), + Effect.provideService(ServerConfig, config), + Effect.provideService(References.MinimumLogLevel, minimumLogLevel), + ); + }); + const commandFlags = { mode: modeFlag, port: portFlag, @@ -348,13 +465,216 @@ const commandFlags = { ), devUrl: devUrlFlag, noBrowser: noBrowserFlag, - authToken: authTokenFlag, bootstrapFd: bootstrapFdFlag, autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, logWebSocketEvents: logWebSocketEventsFlag, } as const; -const rootCommand = Command.make("t3", commandFlags).pipe( +const authLocationFlags = { + baseDir: baseDirFlag, + devUrl: devUrlFlag, +} as const; + +const ttlFlag = Flag.string("ttl").pipe( + Flag.withSchema(DurationFromString), + Flag.withDescription("TTL, for example `5m`, `1h`, `30d`, or `15 minutes`."), + Flag.optional, +); + +const jsonFlag = Flag.boolean("json").pipe( + Flag.withDescription("Emit JSON instead of human-readable output."), + Flag.withDefault(false), +); + +const sessionRoleFlag = Flag.choice("role", ["owner", "client"]).pipe( + Flag.withDescription("Role for the issued bearer session."), + Flag.withDefault("owner"), +); + +const labelFlag = Flag.string("label").pipe( + Flag.withDescription("Optional human-readable label."), + Flag.optional, +); + +const subjectFlag = Flag.string("subject").pipe( + Flag.withDescription("Optional session subject."), + Flag.optional, +); + +const baseUrlFlag = Flag.string("base-url").pipe( + Flag.withDescription("Optional public base URL used to print a ready `/pair#token=...` link."), + Flag.optional, +); + +const tokenOnlyFlag = Flag.boolean("token-only").pipe( + Flag.withDescription("Print only the issued bearer token."), + Flag.withDefault(false), +); + +const pairingCreateCommand = Command.make("create", { + ...authLocationFlags, + ttl: ttlFlag, + label: labelFlag, + baseUrl: baseUrlFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("Issue a new client pairing token."), + Command.withHandler((flags) => + runWithAuthControlPlane( + flags, + (authControlPlane) => + Effect.gen(function* () { + const issued = yield* authControlPlane.createPairingLink({ + role: "client", + subject: "one-time-token", + ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), + ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), + }); + const output = formatIssuedPairingCredential(issued, { + json: flags.json, + ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value } : {}), + }); + yield* Console.log(output); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const pairingListCommand = Command.make("list", { + ...authLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription("List active client pairing tokens without revealing their secrets."), + Command.withHandler((flags) => + runWithAuthControlPlane( + flags, + (authControlPlane) => + Effect.gen(function* () { + const pairingLinks = yield* authControlPlane.listPairingLinks({ role: "client" }); + yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json })); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const pairingRevokeCommand = Command.make("revoke", { + ...authLocationFlags, + id: Argument.string("id").pipe(Argument.withDescription("Pairing credential id to revoke.")), +}).pipe( + Command.withDescription("Revoke an active client pairing token."), + Command.withHandler((flags) => + runWithAuthControlPlane(flags, (authControlPlane) => + Effect.gen(function* () { + const revoked = yield* authControlPlane.revokePairingLink(flags.id); + yield* Console.log( + revoked + ? `Revoked pairing credential ${flags.id}.\n` + : `No active pairing credential found for ${flags.id}.\n`, + ); + }), + ), + ), +); + +const pairingCommand = Command.make("pairing").pipe( + Command.withDescription("Manage one-time client pairing tokens."), + Command.withSubcommands([pairingCreateCommand, pairingListCommand, pairingRevokeCommand]), +); + +const sessionIssueCommand = Command.make("issue", { + ...authLocationFlags, + ttl: ttlFlag, + role: sessionRoleFlag, + label: labelFlag, + subject: subjectFlag, + tokenOnly: tokenOnlyFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("Issue a bearer session token for headless or remote clients."), + Command.withHandler((flags) => + runWithAuthControlPlane( + flags, + (authControlPlane) => + Effect.gen(function* () { + const issued = yield* authControlPlane.issueSession({ + role: flags.role, + ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), + ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), + ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}), + }); + yield* Console.log( + formatIssuedSession(issued, { + json: flags.json, + tokenOnly: flags.tokenOnly, + }), + ); + }), + { + quietLogs: flags.json || flags.tokenOnly, + }, + ), + ), +); + +const sessionListCommand = Command.make("list", { + ...authLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription("List active sessions without revealing bearer tokens."), + Command.withHandler((flags) => + runWithAuthControlPlane( + flags, + (authControlPlane) => + Effect.gen(function* () { + const sessions = yield* authControlPlane.listSessions(); + yield* Console.log(formatSessionList(sessions, { json: flags.json })); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const sessionRevokeCommand = Command.make("revoke", { + ...authLocationFlags, + sessionId: Argument.string("session-id").pipe( + Argument.withDescription("Session id to revoke."), + Argument.withSchema(AuthSessionId), + ), +}).pipe( + Command.withDescription("Revoke an active session."), + Command.withHandler((flags) => + runWithAuthControlPlane(flags, (authControlPlane) => + Effect.gen(function* () { + const revoked = yield* authControlPlane.revokeSession(flags.sessionId); + yield* Console.log( + revoked + ? `Revoked session ${flags.sessionId}.\n` + : `No active session found for ${flags.sessionId}.\n`, + ); + }), + ), + ), +); + +const sessionCommand = Command.make("session").pipe( + Command.withDescription("Manage bearer sessions."), + Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]), +); + +const authCommand = Command.make("auth").pipe( + Command.withDescription("Manage the local auth control plane for headless deployments."), + Command.withSubcommands([pairingCommand, sessionCommand]), +); + +const startCommand = Command.make("start", commandFlags).pipe( Command.withDescription("Run the T3 Code server."), Command.withHandler((flags) => Effect.gen(function* () { @@ -365,4 +685,14 @@ const rootCommand = Command.make("t3", commandFlags).pipe( ), ); -export const cli = rootCommand; +export const cli = Command.make("t3", commandFlags).pipe( + Command.withDescription("Run the T3 Code server."), + Command.withHandler((flags) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveServerConfig(flags, logLevel); + return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); + }), + ), + Command.withSubcommands([startCommand, authCommand]), +); diff --git a/apps/server/src/cliAuthFormat.test.ts b/apps/server/src/cliAuthFormat.test.ts new file mode 100644 index 0000000000..017ced97e8 --- /dev/null +++ b/apps/server/src/cliAuthFormat.test.ts @@ -0,0 +1,88 @@ +import { expect, it } from "@effect/vitest"; +import { DateTime } from "effect"; + +import { + formatIssuedPairingCredential, + formatIssuedSession, + formatPairingCredentialList, + formatSessionList, +} from "./cliAuthFormat.ts"; + +it("formats issued pairing credentials with the secret and optional pair URL", () => { + const output = formatIssuedPairingCredential( + { + id: "pairing-1", + credential: "secret-pairing-token", + role: "client", + subject: "one-time-token", + createdAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")), + expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), + }, + { baseUrl: "https://example.com", json: false }, + ); + + expect(output).toContain("secret-pairing-token"); + expect(output).toContain("https://example.com/pair#token=secret-pairing-token"); +}); + +it("formats pairing listings without exposing the secret token", () => { + const output = formatPairingCredentialList( + [ + { + id: "pairing-1", + credential: "secret-pairing-token", + subject: "one-time-token", + label: "Phone", + role: "client", + createdAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")), + expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), + }, + ], + { json: false }, + ); + + expect(output).toContain("pairing-1"); + expect(output).not.toContain("secret-pairing-token"); +}); + +it("formats issued sessions with the bearer token but omits tokens from listings", () => { + const issuedOutput = formatIssuedSession( + { + sessionId: "session-1" as never, + token: "secret-session-token", + method: "bearer-session-token", + role: "owner", + subject: "cli-issued-session", + client: { + label: "deploy-bot", + deviceType: "bot", + }, + expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), + }, + { json: false }, + ); + + const listedOutput = formatSessionList( + [ + { + sessionId: "session-1" as never, + method: "bearer-session-token", + role: "owner", + subject: "cli-issued-session", + client: { + label: "deploy-bot", + deviceType: "bot", + }, + connected: false, + current: false, + issuedAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")), + expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")), + lastConnectedAt: null, + }, + ], + { json: false }, + ); + + expect(issuedOutput).toContain("secret-session-token"); + expect(listedOutput).not.toContain("secret-session-token"); +}); diff --git a/apps/server/src/cliAuthFormat.ts b/apps/server/src/cliAuthFormat.ts new file mode 100644 index 0000000000..44356c5a8a --- /dev/null +++ b/apps/server/src/cliAuthFormat.ts @@ -0,0 +1,190 @@ +import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; +import { DateTime } from "effect"; + +import type { IssuedBearerSession, IssuedPairingLink } from "./auth/Services/AuthControlPlane.ts"; + +const newline = "\n"; + +function serializeOptionalFields(values: ReadonlyArray) { + return values.filter((value): value is string => typeof value === "string" && value.length > 0); +} + +function formatClientMetadata(metadata: AuthClientMetadata): string { + const details = serializeOptionalFields([ + metadata.label, + metadata.deviceType !== "unknown" ? metadata.deviceType : undefined, + metadata.os, + metadata.browser, + metadata.ipAddress, + ]); + return details.length > 0 ? details.join(" | ") : "unlabeled client"; +} + +function toIsoString(value: DateTime.DateTime | DateTime.Utc): string { + return DateTime.formatIso(DateTime.toUtc(value)); +} + +export function formatIssuedPairingCredential( + credential: IssuedPairingLink, + options?: { + readonly json?: boolean; + readonly baseUrl?: string; + }, +): string { + const pairUrl = + options?.baseUrl != null && options.baseUrl.length > 0 + ? (() => { + const url = new URL("/pair", options.baseUrl); + url.searchParams.delete("token"); + url.hash = new URLSearchParams([["token", credential.credential]]).toString(); + return url.toString(); + })() + : undefined; + + if (options?.json) { + return `${JSON.stringify( + { + id: credential.id, + credential: credential.credential, + ...(credential.label ? { label: credential.label } : {}), + role: credential.role, + expiresAt: toIsoString(credential.expiresAt), + ...(pairUrl ? { pairUrl } : {}), + }, + null, + 2, + )}${newline}`; + } + + return ( + [ + `Issued client pairing token ${credential.id}.`, + `Token: ${credential.credential}`, + ...(pairUrl ? [`Pair URL: ${pairUrl}`] : []), + `Expires at: ${credential.expiresAt}`, + ].join(newline) + newline + ); +} + +export function formatPairingCredentialList( + credentials: ReadonlyArray, + options?: { + readonly json?: boolean; + }, +): string { + if (options?.json) { + return `${JSON.stringify( + credentials.map((credential) => ({ + id: credential.id, + ...(credential.label ? { label: credential.label } : {}), + role: credential.role, + createdAt: toIsoString(credential.createdAt), + expiresAt: toIsoString(credential.expiresAt), + })), + null, + 2, + )}${newline}`; + } + + if (credentials.length === 0) { + return `No active pairing credentials.${newline}`; + } + + return ( + credentials + .map((credential) => + [ + `${credential.id}${credential.label ? ` (${credential.label})` : ""}`, + ` role: ${credential.role}`, + ` created: ${toIsoString(credential.createdAt)}`, + ` expires: ${toIsoString(credential.expiresAt)}`, + ].join(newline), + ) + .join(`${newline}${newline}`) + newline + ); +} + +export function formatIssuedSession( + session: IssuedBearerSession, + options?: { + readonly json?: boolean; + readonly tokenOnly?: boolean; + }, +): string { + if (options?.tokenOnly) { + return `${session.token}${newline}`; + } + + if (options?.json) { + return `${JSON.stringify( + { + sessionId: session.sessionId, + token: session.token, + method: session.method, + role: session.role, + subject: session.subject, + client: session.client, + expiresAt: toIsoString(session.expiresAt), + }, + null, + 2, + )}${newline}`; + } + + return ( + [ + `Issued ${session.role} bearer session ${session.sessionId}.`, + `Token: ${session.token}`, + `Subject: ${session.subject}`, + `Client: ${formatClientMetadata(session.client)}`, + `Expires at: ${toIsoString(session.expiresAt)}`, + ].join(newline) + newline + ); +} + +export function formatSessionList( + sessions: ReadonlyArray, + options?: { + readonly json?: boolean; + }, +): string { + if (options?.json) { + return `${JSON.stringify( + sessions.map((session) => ({ + sessionId: session.sessionId, + method: session.method, + role: session.role, + subject: session.subject, + client: session.client, + connected: session.connected, + issuedAt: toIsoString(session.issuedAt), + expiresAt: toIsoString(session.expiresAt), + lastConnectedAt: session.lastConnectedAt ? toIsoString(session.lastConnectedAt) : null, + })), + null, + 2, + )}${newline}`; + } + + if (sessions.length === 0) { + return `No active sessions.${newline}`; + } + + return ( + sessions + .map((session) => + [ + `${session.sessionId} [${session.role}]${session.connected ? " connected" : ""}`, + ` method: ${session.method}`, + ` subject: ${session.subject}`, + ` client: ${formatClientMetadata(session.client)}`, + ` issued: ${toIsoString(session.issuedAt)}`, + ` last connected: ${ + session.lastConnectedAt ? toIsoString(session.lastConnectedAt) : "never" + }`, + ` expires: ${toIsoString(session.expiresAt)}`, + ].join(newline), + ) + .join(`${newline}${newline}`) + newline + ); +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 887eb11c4f..14c34b8336 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -31,6 +31,7 @@ export interface ServerDerivedPaths { readonly terminalLogsDir: string; readonly anonymousIdPath: string; readonly environmentIdPath: string; + readonly secretsDir: string; } /** @@ -55,7 +56,7 @@ export interface ServerConfigShape extends ServerDerivedPaths { readonly staticDir: string | undefined; readonly devUrl: URL | undefined; readonly noBrowser: boolean; - readonly authToken: string | undefined; + readonly desktopBootstrapToken: string | undefined; readonly autoBootstrapProjectFromCwd: boolean; readonly logWebSocketEvents: boolean; } @@ -85,6 +86,7 @@ export const deriveServerPaths = Effect.fn(function* ( terminalLogsDir: join(logsDir, "terminals"), anonymousIdPath: join(stateDir, "anonymous-id"), environmentIdPath: join(stateDir, "environment-id"), + secretsDir: join(stateDir, "secrets"), }; }); @@ -147,7 +149,7 @@ export class ServerConfig extends ServiceMap.Service 0 - ? cwdBaseName - : "T3 environment"; + const label = yield* resolveServerEnvironmentLabel({ + cwdBaseName, + }); const descriptor: ExecutionEnvironmentDescriptor = { environmentId, diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts new file mode 100644 index 0000000000..3d44713510 --- /dev/null +++ b/apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem } from "effect"; +import { vi } from "vitest"; + +vi.mock("../../processRunner.ts", () => ({ + runProcess: vi.fn(), +})); + +import { runProcess } from "../../processRunner.ts"; +import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; + +const mockedRunProcess = vi.mocked(runProcess); +const NoopFileSystemLayer = FileSystem.layerNoop({}); + +afterEach(() => { + mockedRunProcess.mockReset(); +}); + +describe("resolveServerEnvironmentLabel", () => { + it.effect("uses hostname fallback regardless of launch mode", () => + Effect.gen(function* () { + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + platform: "win32", + hostname: "macbook-pro", + }).pipe(Effect.provide(NoopFileSystemLayer)); + + expect(result).toBe("macbook-pro"); + }), + ); + + it.effect("prefers the macOS ComputerName", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: " Julius's MacBook Pro \n", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + platform: "darwin", + hostname: "macbook-pro", + }).pipe(Effect.provide(NoopFileSystemLayer)); + + expect(result).toBe("Julius's MacBook Pro"); + expect(mockedRunProcess).toHaveBeenCalledWith( + "scutil", + ["--get", "ComputerName"], + expect.objectContaining({ allowNonZeroExit: true }), + ); + }), + ); + + it.effect("prefers Linux PRETTY_HOSTNAME from machine-info", () => + Effect.gen(function* () { + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + platform: "linux", + hostname: "buildbox", + }).pipe( + Effect.provide( + FileSystem.layerNoop({ + exists: (path) => Effect.succeed(path === "/etc/machine-info"), + readFileString: (path) => + path === "/etc/machine-info" + ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n') + : Effect.succeed(""), + }), + ), + ); + + expect(result).toBe("Build Agent 01"); + expect(mockedRunProcess).not.toHaveBeenCalled(); + }), + ); + + it.effect("falls back to hostnamectl pretty hostname on Linux", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: "CI Runner\n", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + platform: "linux", + hostname: "runner-01", + }).pipe(Effect.provide(NoopFileSystemLayer)); + + expect(result).toBe("CI Runner"); + expect(mockedRunProcess).toHaveBeenCalledWith( + "hostnamectl", + ["--pretty"], + expect.objectContaining({ allowNonZeroExit: true }), + ); + }), + ); + + it.effect("falls back to the hostname when friendly labels are unavailable", () => + Effect.gen(function* () { + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + platform: "win32", + hostname: "JULIUS-LAPTOP", + }).pipe(Effect.provide(NoopFileSystemLayer)); + + expect(result).toBe("JULIUS-LAPTOP"); + }), + ); + + it.effect("falls back to the hostname when the friendly-label command is missing", () => + Effect.gen(function* () { + mockedRunProcess.mockRejectedValueOnce(new Error("spawn scutil ENOENT")); + + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + platform: "darwin", + hostname: "macbook-pro", + }).pipe(Effect.provide(NoopFileSystemLayer)); + + expect(result).toBe("macbook-pro"); + }), + ); + + it.effect("falls back to the cwd basename when the hostname is blank", () => + Effect.gen(function* () { + mockedRunProcess.mockResolvedValueOnce({ + stdout: " ", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + const result = yield* resolveServerEnvironmentLabel({ + cwdBaseName: "t3code", + platform: "linux", + hostname: " ", + }).pipe(Effect.provide(NoopFileSystemLayer)); + + expect(result).toBe("t3code"); + }), + ); +}); diff --git a/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts new file mode 100644 index 0000000000..a5f77c5093 --- /dev/null +++ b/apps/server/src/environment/Layers/ServerEnvironmentLabel.ts @@ -0,0 +1,104 @@ +import * as OS from "node:os"; + +import { Effect, FileSystem } from "effect"; + +import { runProcess } from "../../processRunner.ts"; + +interface ResolveServerEnvironmentLabelInput { + readonly cwdBaseName: string; + readonly platform?: NodeJS.Platform; + readonly hostname?: string | null; +} + +function normalizeLabel(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +function parseMachineInfoValue(raw: string, key: string): string | null { + for (const line of raw.split(/\r?\n/g)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#") || !trimmed.startsWith(`${key}=`)) { + continue; + } + const value = trimmed.slice(key.length + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return normalizeLabel(value.slice(1, -1)); + } + return normalizeLabel(value); + } + return null; +} + +const readLinuxMachineInfo = Effect.fn("readLinuxMachineInfo")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const exists = yield* fileSystem + .exists("/etc/machine-info") + .pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return null; + } + + return yield* fileSystem + .readFileString("/etc/machine-info") + .pipe(Effect.orElseSucceed(() => null)); +}); + +const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( + command: string, + args: readonly string[], +) { + const result = yield* Effect.tryPromise(() => + runProcess(command, args, { + allowNonZeroExit: true, + }), + ).pipe(Effect.orElseSucceed(() => null)); + + if (!result || result.code !== 0) { + return null; + } + + return normalizeLabel(result.stdout); +}); + +const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* ( + platform: NodeJS.Platform, +) { + if (platform === "darwin") { + return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]); + } + + if (platform === "linux") { + const machineInfo = normalizeLabel(yield* readLinuxMachineInfo()); + if (machineInfo) { + const prettyHostname = parseMachineInfoValue(machineInfo, "PRETTY_HOSTNAME"); + if (prettyHostname) { + return prettyHostname; + } + } + + return yield* runFriendlyLabelCommand("hostnamectl", ["--pretty"]); + } + + return null; +}); + +export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* ( + input: ResolveServerEnvironmentLabelInput, +) { + const platform = input.platform ?? process.platform; + const friendlyHostLabel = yield* resolveFriendlyHostLabel(platform); + if (friendlyHostLabel) { + return friendlyHostLabel; + } + + const hostname = normalizeLabel(input.hostname ?? OS.hostname()); + if (hostname) { + return hostname; + } + + return normalizeLabel(input.cwdBaseName) ?? "T3 environment"; +}); diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts new file mode 100644 index 0000000000..de861cc664 --- /dev/null +++ b/apps/server/src/http.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; + +describe("http dev routing", () => { + it("treats localhost and loopback addresses as local", () => { + expect(isLoopbackHostname("127.0.0.1")).toBe(true); + expect(isLoopbackHostname("localhost")).toBe(true); + expect(isLoopbackHostname("::1")).toBe(true); + expect(isLoopbackHostname("[::1]")).toBe(true); + }); + + it("does not treat LAN addresses as local", () => { + expect(isLoopbackHostname("192.168.86.35")).toBe(false); + expect(isLoopbackHostname("10.0.0.24")).toBe(false); + expect(isLoopbackHostname("example.local")).toBe(false); + }); + + it("preserves path and query when redirecting to the dev server", () => { + const devUrl = new URL("http://127.0.0.1:5173/"); + const requestUrl = new URL("http://127.0.0.1:3774/pair?token=test-token"); + + expect(resolveDevRedirectUrl(devUrl, requestUrl)).toBe( + "http://127.0.0.1:5173/pair?token=test-token", + ); + }); +}); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index ca4a2c22ef..ed40389429 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -1,5 +1,5 @@ import Mime from "@effect/platform-node/Mime"; -import { Data, Effect, FileSystem, Layer, Option, Path } from "effect"; +import { Data, Effect, FileSystem, Option, Path } from "effect"; import { cast } from "effect/Function"; import { HttpBody, @@ -17,14 +17,57 @@ import { resolveAttachmentRelativePath, } from "./attachmentPaths"; import { resolveAttachmentPathById } from "./attachmentStore"; -import { ServerConfig } from "./config"; +import { resolveStaticDir, ServerConfig } from "./config"; import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts"; import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts"; import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver"; +import { ServerAuth } from "./auth/Services/ServerAuth.ts"; +import { respondToAuthError } from "./auth/http.ts"; +import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600"; const FALLBACK_PROJECT_FAVICON_SVG = ``; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; +const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); + +export const browserApiCorsLayer = HttpRouter.cors({ + allowedMethods: ["GET", "POST", "OPTIONS"], + allowedHeaders: ["authorization", "b3", "traceparent", "content-type"], + maxAge: 600, +}); + +export function isLoopbackHostname(hostname: string): boolean { + const normalizedHostname = hostname + .trim() + .toLowerCase() + .replace(/^\[(.*)\]$/, "$1"); + return LOOPBACK_HOSTNAMES.has(normalizedHostname); +} + +export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { + const redirectUrl = new URL(devUrl.toString()); + redirectUrl.pathname = requestUrl.pathname; + redirectUrl.search = requestUrl.search; + redirectUrl.hash = requestUrl.hash; + return redirectUrl.toString(); +} + +const requireAuthenticatedRequest = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* ServerAuth; + yield* serverAuth.authenticateHttpRequest(request); +}); + +export const serverEnvironmentRouteLayer = HttpRouter.add( + "GET", + "/.well-known/t3/environment", + Effect.gen(function* () { + const descriptor = yield* Effect.service(ServerEnvironment).pipe( + Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor), + ); + return HttpServerResponse.jsonUnsafe(descriptor, { status: 200 }); + }), +); class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{ readonly cause: unknown; @@ -35,6 +78,7 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( "POST", OTLP_TRACES_PROXY_PATH, Effect.gen(function* () { + yield* requireAuthenticatedRequest; const request = yield* HttpServerRequest.HttpServerRequest; const config = yield* ServerConfig; const otlpTracesUrl = config.otlpTracesUrl; @@ -76,21 +120,14 @@ export const otlpTracesProxyRouteLayer = HttpRouter.add( Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })), ), ); - }), -).pipe( - Layer.provide( - HttpRouter.cors({ - allowedMethods: ["POST", "OPTIONS"], - allowedHeaders: ["content-type"], - maxAge: 600, - }), - ), + }).pipe(Effect.catchTag("AuthError", respondToAuthError)), ); export const attachmentsRouteLayer = HttpRouter.add( "GET", `${ATTACHMENTS_ROUTE_PREFIX}/*`, Effect.gen(function* () { + yield* requireAuthenticatedRequest; const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) { @@ -139,13 +176,14 @@ export const attachmentsRouteLayer = HttpRouter.add( Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), ), ); - }), + }).pipe(Effect.catchTag("AuthError", respondToAuthError)), ); export const projectFaviconRouteLayer = HttpRouter.add( "GET", "/api/project-favicon", Effect.gen(function* () { + yield* requireAuthenticatedRequest; const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); if (Option.isNone(url)) { @@ -179,7 +217,7 @@ export const projectFaviconRouteLayer = HttpRouter.add( Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), ), ); - }), + }).pipe(Effect.catchTag("AuthError", respondToAuthError)), ); export const staticAndDevRouteLayer = HttpRouter.add( @@ -193,11 +231,14 @@ export const staticAndDevRouteLayer = HttpRouter.add( } const config = yield* ServerConfig; - if (config.devUrl) { - return HttpServerResponse.redirect(config.devUrl.href, { status: 302 }); + if (config.devUrl && isLoopbackHostname(url.value.hostname)) { + return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), { + status: 302, + }); } - if (!config.staticDir) { + const staticDir = config.staticDir ?? (config.devUrl ? yield* resolveStaticDir() : undefined); + if (!staticDir) { return HttpServerResponse.text("No static directory configured and no dev URL set.", { status: 503, }); @@ -205,7 +246,7 @@ export const staticAndDevRouteLayer = HttpRouter.add( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const staticRoot = path.resolve(config.staticDir); + const staticRoot = path.resolve(staticDir); const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname; const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 8eda0ca85d..e3f190ff06 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -2,7 +2,7 @@ import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/c import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; -import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; +import { Cause, Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; import { ServerConfig } from "./config"; import { @@ -149,6 +149,23 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }), ); + it.effect("formats invalid resolved keybinding rules with the custom message", () => + Effect.sync(() => { + const result = Schema.decodeUnknownExit(ResolvedKeybindingFromConfig)({ + key: "mod+shift+d+o", + command: "terminal.new", + }); + + if (result._tag !== "Failure") { + assert.fail("Expected invalid keybinding decode to fail"); + } + + const detail = Cause.pretty(result.cause); + assert.isTrue(detail.includes("Invalid keybinding rule")); + assert.isFalse(detail.includes("Invalid data")); + }), + ); + it.effect("bootstraps default keybindings when config file is missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 086d795c0c..1d0ab812f2 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -322,7 +322,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( Predicate.isNotNull, () => new SchemaIssue.InvalidValue(Option.some(rule), { - title: "Invalid keybinding rule", + message: "Invalid keybinding rule", }), ), Effect.map((resolved) => resolved), @@ -334,7 +334,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( if (!key) { return yield* Effect.fail( new SchemaIssue.InvalidValue(Option.some(resolved), { - title: "Resolved shortcut cannot be encoded to key string", + message: "Resolved shortcut cannot be encoded to key string", }), ); } diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index cb1cb2f3f8..eb05bf5ae9 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -101,5 +101,7 @@ export type OrchestrationCommandReceiptRepositoryError = | PersistenceDecodeError; export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | PersistenceDecodeError; +export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDecodeError; +export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; diff --git a/apps/server/src/persistence/Layers/AuthPairingLinks.ts b/apps/server/src/persistence/Layers/AuthPairingLinks.ts new file mode 100644 index 0000000000..9767f24993 --- /dev/null +++ b/apps/server/src/persistence/Layers/AuthPairingLinks.ts @@ -0,0 +1,209 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Schema } from "effect"; + +import { + toPersistenceDecodeError, + toPersistenceSqlError, + type AuthPairingLinkRepositoryError, +} from "../Errors.ts"; +import { + AuthPairingLinkRecord, + AuthPairingLinkRepository, + type AuthPairingLinkRepositoryShape, + ConsumeAuthPairingLinkInput, + CreateAuthPairingLinkInput, + GetAuthPairingLinkByCredentialInput, + ListActiveAuthPairingLinksInput, + RevokeAuthPairingLinkInput, +} from "../Services/AuthPairingLinks.ts"; + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown): AuthPairingLinkRepositoryError => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeAuthPairingLinkRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const createPairingLinkRow = SqlSchema.void({ + Request: CreateAuthPairingLinkInput, + execute: (input) => + sql` + INSERT INTO auth_pairing_links ( + id, + credential, + method, + role, + subject, + label, + created_at, + expires_at, + consumed_at, + revoked_at + ) + VALUES ( + ${input.id}, + ${input.credential}, + ${input.method}, + ${input.role}, + ${input.subject}, + ${input.label}, + ${input.createdAt}, + ${input.expiresAt}, + NULL, + NULL + ) + `, + }); + + const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ + Request: ConsumeAuthPairingLinkInput, + Result: AuthPairingLinkRecord, + execute: ({ credential, consumedAt, now }) => + sql` + UPDATE auth_pairing_links + SET consumed_at = ${consumedAt} + WHERE credential = ${credential} + AND revoked_at IS NULL + AND consumed_at IS NULL + AND expires_at > ${now} + RETURNING + id AS "id", + credential AS "credential", + method AS "method", + role AS "role", + subject AS "subject", + label AS "label", + created_at AS "createdAt", + expires_at AS "expiresAt", + consumed_at AS "consumedAt", + revoked_at AS "revokedAt" + `, + }); + + const listActivePairingLinkRows = SqlSchema.findAll({ + Request: ListActiveAuthPairingLinksInput, + Result: AuthPairingLinkRecord, + execute: ({ now }) => + sql` + SELECT + id AS "id", + credential AS "credential", + method AS "method", + role AS "role", + subject AS "subject", + label AS "label", + created_at AS "createdAt", + expires_at AS "expiresAt", + consumed_at AS "consumedAt", + revoked_at AS "revokedAt" + FROM auth_pairing_links + WHERE revoked_at IS NULL + AND consumed_at IS NULL + AND expires_at > ${now} + ORDER BY created_at DESC, id DESC + `, + }); + + const revokePairingLinkRow = SqlSchema.findAll({ + Request: RevokeAuthPairingLinkInput, + Result: Schema.Struct({ id: Schema.String }), + execute: ({ id, revokedAt }) => + sql` + UPDATE auth_pairing_links + SET revoked_at = ${revokedAt} + WHERE id = ${id} + AND revoked_at IS NULL + AND consumed_at IS NULL + RETURNING id AS "id" + `, + }); + + const getPairingLinkRowByCredential = SqlSchema.findOneOption({ + Request: GetAuthPairingLinkByCredentialInput, + Result: AuthPairingLinkRecord, + execute: ({ credential }) => + sql` + SELECT + id AS "id", + credential AS "credential", + method AS "method", + role AS "role", + subject AS "subject", + label AS "label", + created_at AS "createdAt", + expires_at AS "expiresAt", + consumed_at AS "consumedAt", + revoked_at AS "revokedAt" + FROM auth_pairing_links + WHERE credential = ${credential} + `, + }); + + const create: AuthPairingLinkRepositoryShape["create"] = (input) => + createPairingLinkRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.create:query", + "AuthPairingLinkRepository.create:encodeRequest", + ), + ), + ); + + const consumeAvailable: AuthPairingLinkRepositoryShape["consumeAvailable"] = (input) => + consumeAvailablePairingLinkRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.consumeAvailable:query", + "AuthPairingLinkRepository.consumeAvailable:decodeRow", + ), + ), + ); + + const listActive: AuthPairingLinkRepositoryShape["listActive"] = (input) => + listActivePairingLinkRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.listActive:query", + "AuthPairingLinkRepository.listActive:decodeRows", + ), + ), + ); + + const revoke: AuthPairingLinkRepositoryShape["revoke"] = (input) => + revokePairingLinkRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.revoke:query", + "AuthPairingLinkRepository.revoke:decodeRows", + ), + ), + Effect.map((rows) => rows.length > 0), + ); + + const getByCredential: AuthPairingLinkRepositoryShape["getByCredential"] = (input) => + getPairingLinkRowByCredential(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthPairingLinkRepository.getByCredential:query", + "AuthPairingLinkRepository.getByCredential:decodeRow", + ), + ), + ); + + return { + create, + consumeAvailable, + listActive, + revoke, + getByCredential, + } satisfies AuthPairingLinkRepositoryShape; +}); + +export const AuthPairingLinkRepositoryLive = Layer.effect( + AuthPairingLinkRepository, + makeAuthPairingLinkRepository, +); diff --git a/apps/server/src/persistence/Layers/AuthSessions.ts b/apps/server/src/persistence/Layers/AuthSessions.ts new file mode 100644 index 0000000000..66e02ed2a7 --- /dev/null +++ b/apps/server/src/persistence/Layers/AuthSessions.ts @@ -0,0 +1,279 @@ +import { AuthSessionId } from "@t3tools/contracts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Option, Schema } from "effect"; + +import { + toPersistenceDecodeError, + toPersistenceSqlError, + type AuthSessionRepositoryError, +} from "../Errors.ts"; +import { + AuthSessionRecord, + AuthSessionRepository, + type AuthSessionRepositoryShape, + CreateAuthSessionInput, + GetAuthSessionByIdInput, + ListActiveAuthSessionsInput, + RevokeAuthSessionInput, + RevokeOtherAuthSessionsInput, + SetAuthSessionLastConnectedAtInput, +} from "../Services/AuthSessions.ts"; + +const AuthSessionDbRow = Schema.Struct({ + sessionId: AuthSessionId, + subject: Schema.String, + role: Schema.Literals(["owner", "client"]), + method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), + clientLabel: Schema.NullOr(Schema.String), + clientIpAddress: Schema.NullOr(Schema.String), + clientUserAgent: Schema.NullOr(Schema.String), + clientDeviceType: Schema.Literals(["desktop", "mobile", "tablet", "bot", "unknown"]), + clientOs: Schema.NullOr(Schema.String), + clientBrowser: Schema.NullOr(Schema.String), + issuedAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, + lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), +}); + +function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): typeof AuthSessionRecord.Type { + return { + sessionId: row.sessionId, + subject: row.subject, + role: row.role, + method: row.method, + client: { + label: row.clientLabel, + ipAddress: row.clientIpAddress, + userAgent: row.clientUserAgent, + deviceType: row.clientDeviceType, + os: row.clientOs, + browser: row.clientBrowser, + }, + issuedAt: row.issuedAt, + expiresAt: row.expiresAt, + lastConnectedAt: row.lastConnectedAt, + revokedAt: row.revokedAt, + }; +} + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown): AuthSessionRepositoryError => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +const makeAuthSessionRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const createSessionRow = SqlSchema.void({ + Request: CreateAuthSessionInput, + execute: (input) => + sql` + INSERT INTO auth_sessions ( + session_id, + subject, + role, + method, + client_label, + client_ip_address, + client_user_agent, + client_device_type, + client_os, + client_browser, + issued_at, + expires_at, + revoked_at + ) + VALUES ( + ${input.sessionId}, + ${input.subject}, + ${input.role}, + ${input.method}, + ${input.client.label}, + ${input.client.ipAddress}, + ${input.client.userAgent}, + ${input.client.deviceType}, + ${input.client.os}, + ${input.client.browser}, + ${input.issuedAt}, + ${input.expiresAt}, + NULL + ) + `, + }); + + const getSessionRowById = SqlSchema.findOneOption({ + Request: GetAuthSessionByIdInput, + Result: AuthSessionDbRow, + execute: ({ sessionId }) => + sql` + SELECT + session_id AS "sessionId", + subject AS "subject", + role AS "role", + method AS "method", + client_label AS "clientLabel", + client_ip_address AS "clientIpAddress", + client_user_agent AS "clientUserAgent", + client_device_type AS "clientDeviceType", + client_os AS "clientOs", + client_browser AS "clientBrowser", + issued_at AS "issuedAt", + expires_at AS "expiresAt", + last_connected_at AS "lastConnectedAt", + revoked_at AS "revokedAt" + FROM auth_sessions + WHERE session_id = ${sessionId} + `, + }); + + const listActiveSessionRows = SqlSchema.findAll({ + Request: ListActiveAuthSessionsInput, + Result: AuthSessionDbRow, + execute: ({ now }) => + sql` + SELECT + session_id AS "sessionId", + subject AS "subject", + role AS "role", + method AS "method", + client_label AS "clientLabel", + client_ip_address AS "clientIpAddress", + client_user_agent AS "clientUserAgent", + client_device_type AS "clientDeviceType", + client_os AS "clientOs", + client_browser AS "clientBrowser", + issued_at AS "issuedAt", + expires_at AS "expiresAt", + last_connected_at AS "lastConnectedAt", + revoked_at AS "revokedAt" + FROM auth_sessions + WHERE revoked_at IS NULL + AND expires_at > ${now} + ORDER BY issued_at DESC, session_id DESC + `, + }); + + const setLastConnectedAtRow = SqlSchema.void({ + Request: SetAuthSessionLastConnectedAtInput, + execute: ({ sessionId, lastConnectedAt }) => + sql` + UPDATE auth_sessions + SET last_connected_at = ${lastConnectedAt} + WHERE session_id = ${sessionId} + AND revoked_at IS NULL + `, + }); + + const revokeSessionRows = SqlSchema.findAll({ + Request: RevokeAuthSessionInput, + Result: Schema.Struct({ sessionId: AuthSessionId }), + execute: ({ sessionId, revokedAt }) => + sql` + UPDATE auth_sessions + SET revoked_at = ${revokedAt} + WHERE session_id = ${sessionId} + AND revoked_at IS NULL + RETURNING session_id AS "sessionId" + `, + }); + + const revokeOtherSessionRows = SqlSchema.findAll({ + Request: RevokeOtherAuthSessionsInput, + Result: Schema.Struct({ sessionId: AuthSessionId }), + execute: ({ currentSessionId, revokedAt }) => + sql` + UPDATE auth_sessions + SET revoked_at = ${revokedAt} + WHERE session_id <> ${currentSessionId} + AND revoked_at IS NULL + RETURNING session_id AS "sessionId" + `, + }); + + const create: AuthSessionRepositoryShape["create"] = (input) => + createSessionRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.create:query", + "AuthSessionRepository.create:encodeRequest", + ), + ), + ); + + const getById: AuthSessionRepositoryShape["getById"] = (input) => + getSessionRowById(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.getById:query", + "AuthSessionRepository.getById:decodeRow", + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => Effect.succeed(Option.some(toAuthSessionRecord(row))), + }), + ), + ); + + const listActive: AuthSessionRepositoryShape["listActive"] = (input) => + listActiveSessionRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.listActive:query", + "AuthSessionRepository.listActive:decodeRows", + ), + ), + Effect.flatMap((rows) => Effect.succeed(rows.map((row) => toAuthSessionRecord(row)))), + ); + + const revoke: AuthSessionRepositoryShape["revoke"] = (input) => + revokeSessionRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.revoke:query", + "AuthSessionRepository.revoke:decodeRows", + ), + ), + Effect.map((rows) => rows.length > 0), + ); + + const revokeAllExcept: AuthSessionRepositoryShape["revokeAllExcept"] = (input) => + revokeOtherSessionRows(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.revokeAllExcept:query", + "AuthSessionRepository.revokeAllExcept:decodeRows", + ), + ), + Effect.map((rows) => rows.map((row) => row.sessionId)), + ); + + const setLastConnectedAt: AuthSessionRepositoryShape["setLastConnectedAt"] = (input) => + setLastConnectedAtRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.setLastConnectedAt:query", + "AuthSessionRepository.setLastConnectedAt:encodeRequest", + ), + ), + ); + + return { + create, + getById, + listActive, + revoke, + revokeAllExcept, + setLastConnectedAt, + } satisfies AuthSessionRepositoryShape; +}); + +export const AuthSessionRepositoryLive = Layer.effect( + AuthSessionRepository, + makeAuthSessionRepository, +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a03c3c2d18..8c9fe4d9fd 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -32,6 +32,9 @@ import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; import Migration0019 from "./Migrations/019_ProjectionSnapshotLookupIndexes.ts"; +import Migration0020 from "./Migrations/020_AuthAccessManagement.ts"; +import Migration0021 from "./Migrations/021_AuthSessionClientMetadata.ts"; +import Migration0022 from "./Migrations/022_AuthSessionLastConnectedAt.ts"; /** * Migration loader with all migrations defined inline. @@ -63,6 +66,9 @@ export const migrationEntries = [ [17, "ProjectionThreadsArchivedAt", Migration0017], [18, "ProjectionThreadsArchivedAtIndex", Migration0018], [19, "ProjectionSnapshotLookupIndexes", Migration0019], + [20, "AuthAccessManagement", Migration0020], + [21, "AuthSessionClientMetadata", Migration0021], + [22, "AuthSessionLastConnectedAt", Migration0022], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/020_AuthAccessManagement.ts b/apps/server/src/persistence/Migrations/020_AuthAccessManagement.ts new file mode 100644 index 0000000000..1be7fa80ff --- /dev/null +++ b/apps/server/src/persistence/Migrations/020_AuthAccessManagement.ts @@ -0,0 +1,42 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS auth_pairing_links ( + id TEXT PRIMARY KEY, + credential TEXT NOT NULL UNIQUE, + method TEXT NOT NULL, + role TEXT NOT NULL, + subject TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT, + revoked_at TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_auth_pairing_links_active + ON auth_pairing_links(revoked_at, consumed_at, expires_at) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS auth_sessions ( + session_id TEXT PRIMARY KEY, + subject TEXT NOT NULL, + role TEXT NOT NULL, + method TEXT NOT NULL, + issued_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_auth_sessions_active + ON auth_sessions(revoked_at, expires_at, issued_at) + `; +}); diff --git a/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts b/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts new file mode 100644 index 0000000000..3b387fdcfd --- /dev/null +++ b/apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts @@ -0,0 +1,62 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const pairingLinkColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_pairing_links) + `; + if (!pairingLinkColumns.some((column) => column.name === "label")) { + yield* sql` + ALTER TABLE auth_pairing_links + ADD COLUMN label TEXT + `; + } + + const sessionColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_sessions) + `; + + if (!sessionColumns.some((column) => column.name === "client_label")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_label TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "client_ip_address")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_ip_address TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "client_user_agent")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_user_agent TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "client_device_type")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_device_type TEXT NOT NULL DEFAULT 'unknown' + `; + } + + if (!sessionColumns.some((column) => column.name === "client_os")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_os TEXT + `; + } + + if (!sessionColumns.some((column) => column.name === "client_browser")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_browser TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts b/apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts new file mode 100644 index 0000000000..e806a073a5 --- /dev/null +++ b/apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts @@ -0,0 +1,17 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const sessionColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_sessions) + `; + + if (!sessionColumns.some((column) => column.name === "last_connected_at")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN last_connected_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/AuthPairingLinks.ts b/apps/server/src/persistence/Services/AuthPairingLinks.ts new file mode 100644 index 0000000000..76c14a6586 --- /dev/null +++ b/apps/server/src/persistence/Services/AuthPairingLinks.ts @@ -0,0 +1,76 @@ +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { AuthPairingLinkRepositoryError } from "../Errors.ts"; + +export const AuthPairingLinkRecord = Schema.Struct({ + id: Schema.String, + credential: Schema.String, + method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), + role: Schema.Literals(["owner", "client"]), + subject: Schema.String, + label: Schema.NullOr(Schema.String), + createdAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, + consumedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), +}); +export type AuthPairingLinkRecord = typeof AuthPairingLinkRecord.Type; + +export const CreateAuthPairingLinkInput = Schema.Struct({ + id: Schema.String, + credential: Schema.String, + method: Schema.Literals(["desktop-bootstrap", "one-time-token"]), + role: Schema.Literals(["owner", "client"]), + subject: Schema.String, + label: Schema.NullOr(Schema.String), + createdAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, +}); +export type CreateAuthPairingLinkInput = typeof CreateAuthPairingLinkInput.Type; + +export const ConsumeAuthPairingLinkInput = Schema.Struct({ + credential: Schema.String, + consumedAt: Schema.DateTimeUtcFromString, + now: Schema.DateTimeUtcFromString, +}); +export type ConsumeAuthPairingLinkInput = typeof ConsumeAuthPairingLinkInput.Type; + +export const ListActiveAuthPairingLinksInput = Schema.Struct({ + now: Schema.DateTimeUtcFromString, +}); +export type ListActiveAuthPairingLinksInput = typeof ListActiveAuthPairingLinksInput.Type; + +export const RevokeAuthPairingLinkInput = Schema.Struct({ + id: Schema.String, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeAuthPairingLinkInput = typeof RevokeAuthPairingLinkInput.Type; + +export const GetAuthPairingLinkByCredentialInput = Schema.Struct({ + credential: Schema.String, +}); +export type GetAuthPairingLinkByCredentialInput = typeof GetAuthPairingLinkByCredentialInput.Type; + +export interface AuthPairingLinkRepositoryShape { + readonly create: ( + input: CreateAuthPairingLinkInput, + ) => Effect.Effect; + readonly consumeAvailable: ( + input: ConsumeAuthPairingLinkInput, + ) => Effect.Effect, AuthPairingLinkRepositoryError>; + readonly listActive: ( + input: ListActiveAuthPairingLinksInput, + ) => Effect.Effect, AuthPairingLinkRepositoryError>; + readonly revoke: ( + input: RevokeAuthPairingLinkInput, + ) => Effect.Effect; + readonly getByCredential: ( + input: GetAuthPairingLinkByCredentialInput, + ) => Effect.Effect, AuthPairingLinkRepositoryError>; +} + +export class AuthPairingLinkRepository extends ServiceMap.Service< + AuthPairingLinkRepository, + AuthPairingLinkRepositoryShape +>()("t3/persistence/Services/AuthPairingLinks/AuthPairingLinkRepository") {} diff --git a/apps/server/src/persistence/Services/AuthSessions.ts b/apps/server/src/persistence/Services/AuthSessions.ts new file mode 100644 index 0000000000..a4410fa379 --- /dev/null +++ b/apps/server/src/persistence/Services/AuthSessions.ts @@ -0,0 +1,93 @@ +import { AuthClientMetadataDeviceType, AuthSessionId } from "@t3tools/contracts"; +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { AuthSessionRepositoryError } from "../Errors.ts"; + +export const AuthSessionClientMetadataRecord = Schema.Struct({ + label: Schema.NullOr(Schema.String), + ipAddress: Schema.NullOr(Schema.String), + userAgent: Schema.NullOr(Schema.String), + deviceType: AuthClientMetadataDeviceType, + os: Schema.NullOr(Schema.String), + browser: Schema.NullOr(Schema.String), +}); +export type AuthSessionClientMetadataRecord = typeof AuthSessionClientMetadataRecord.Type; + +export const AuthSessionRecord = Schema.Struct({ + sessionId: AuthSessionId, + subject: Schema.String, + role: Schema.Literals(["owner", "client"]), + method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), + client: AuthSessionClientMetadataRecord, + issuedAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, + lastConnectedAt: Schema.NullOr(Schema.DateTimeUtcFromString), + revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), +}); +export type AuthSessionRecord = typeof AuthSessionRecord.Type; + +export const CreateAuthSessionInput = Schema.Struct({ + sessionId: AuthSessionId, + subject: Schema.String, + role: Schema.Literals(["owner", "client"]), + method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]), + client: AuthSessionClientMetadataRecord, + issuedAt: Schema.DateTimeUtcFromString, + expiresAt: Schema.DateTimeUtcFromString, +}); +export type CreateAuthSessionInput = typeof CreateAuthSessionInput.Type; + +export const GetAuthSessionByIdInput = Schema.Struct({ + sessionId: AuthSessionId, +}); +export type GetAuthSessionByIdInput = typeof GetAuthSessionByIdInput.Type; + +export const ListActiveAuthSessionsInput = Schema.Struct({ + now: Schema.DateTimeUtcFromString, +}); +export type ListActiveAuthSessionsInput = typeof ListActiveAuthSessionsInput.Type; + +export const RevokeAuthSessionInput = Schema.Struct({ + sessionId: AuthSessionId, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeAuthSessionInput = typeof RevokeAuthSessionInput.Type; + +export const RevokeOtherAuthSessionsInput = Schema.Struct({ + currentSessionId: AuthSessionId, + revokedAt: Schema.DateTimeUtcFromString, +}); +export type RevokeOtherAuthSessionsInput = typeof RevokeOtherAuthSessionsInput.Type; + +export const SetAuthSessionLastConnectedAtInput = Schema.Struct({ + sessionId: AuthSessionId, + lastConnectedAt: Schema.DateTimeUtcFromString, +}); +export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type; + +export interface AuthSessionRepositoryShape { + readonly create: ( + input: CreateAuthSessionInput, + ) => Effect.Effect; + readonly getById: ( + input: GetAuthSessionByIdInput, + ) => Effect.Effect, AuthSessionRepositoryError>; + readonly listActive: ( + input: ListActiveAuthSessionsInput, + ) => Effect.Effect, AuthSessionRepositoryError>; + readonly revoke: ( + input: RevokeAuthSessionInput, + ) => Effect.Effect; + readonly revokeAllExcept: ( + input: RevokeOtherAuthSessionsInput, + ) => Effect.Effect, AuthSessionRepositoryError>; + readonly setLastConnectedAt: ( + input: SetAuthSessionLastConnectedAtInput, + ) => Effect.Effect; +} + +export class AuthSessionRepository extends ServiceMap.Service< + AuthSessionRepository, + AuthSessionRepositoryShape +>()("t3/persistence/Services/AuthSessions/AuthSessionRepository") {} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 125cfd103a..4e683637e7 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -43,6 +43,7 @@ import { } from "effect/unstable/http"; import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as Socket from "effect/unstable/socket/Socket"; import { vi } from "vitest"; import type { ServerConfigShape } from "./config.ts"; @@ -68,6 +69,7 @@ import { type ProjectionSnapshotQueryShape, } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; +import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { ProviderRegistry, type ProviderRegistryShape, @@ -96,9 +98,12 @@ import { import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; +import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; const defaultProjectId = ProjectId.makeUnsafe("project-default"); const defaultThreadId = ThreadId.makeUnsafe("thread-default"); +const defaultDesktopBootstrapToken = "test-desktop-bootstrap-token"; const defaultModelSelection = { provider: "codex", model: "gpt-5-codex", @@ -115,7 +120,6 @@ const testEnvironmentDescriptor = { repositoryIdentity: true, }, }; - const makeDefaultOrchestrationReadModel = () => { const now = new Date().toISOString(); return { @@ -174,6 +178,11 @@ const browserOtlpTracingLayer = Layer.mergeAll( Layer.succeed(HttpClient.TracerDisabledWhen, () => true), ); +const authTestLayer = ServerAuthLive.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerSecretStoreLive), +); + const makeBrowserOtlpPayload = (spanName: string) => Effect.gen(function* () { const collector = yield* Effect.acquireRelease( @@ -313,7 +322,7 @@ const buildAppUnderTest = (options?: { otlpMetricsUrl: undefined, otlpExportIntervalMs: 10_000, otlpServiceName: "t3-server", - mode: "web", + mode: "desktop", port: 0, host: "127.0.0.1", cwd: process.cwd(), @@ -322,7 +331,7 @@ const buildAppUnderTest = (options?: { staticDir: undefined, devUrl, noBrowser: true, - authToken: undefined, + desktopBootstrapToken: defaultDesktopBootstrapToken, autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, ...options?.config, @@ -333,12 +342,16 @@ const buildAppUnderTest = (options?: { }); const gitStatusBroadcasterLayer = GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer)); - const appLayer = HttpRouter.serve(makeRoutesLayer, { + const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, disableLogger: true, }).pipe( Layer.provide( Layer.mock(Keybindings)({ + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), streamChanges: Stream.empty, ...options?.layers?.keybindings, }), @@ -418,6 +431,9 @@ const buildAppUnderTest = (options?: { ...options?.layers?.checkpointDiffQuery, }), ), + ); + + const appLayer = servedRoutesLayer.pipe( Layer.provide( Layer.mock(BrowserTraceCollector)({ record: () => Effect.void, @@ -453,6 +469,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.repositoryIdentityResolver, }), ), + Layer.provideMerge(authTestLayer), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), Layer.provide(layerConfig), @@ -462,11 +479,37 @@ const buildAppUnderTest = (options?: { return config; }); -const wsRpcProtocolLayer = (wsUrl: string) => - RpcClient.layerProtocolSocket().pipe( - Layer.provide(NodeSocket.layerWebSocket(wsUrl)), +const parseSessionCookieFromWsUrl = ( + wsUrl: string, +): { readonly cookie: string | null; readonly url: string } => { + const next = new URL(wsUrl); + const cookie = next.hash.startsWith("#cookie=") + ? decodeURIComponent(next.hash.slice("#cookie=".length)) + : null; + next.hash = ""; + return { + cookie, + url: next.toString(), + }; +}; + +const wsRpcProtocolLayer = (wsUrl: string) => { + const { cookie, url } = parseSessionCookieFromWsUrl(wsUrl); + const webSocketConstructorLayer = Layer.succeed( + Socket.WebSocketConstructor, + (socketUrl, protocols) => + new NodeSocket.NodeWS.WebSocket( + socketUrl, + protocols, + cookie ? { headers: { cookie } } : undefined, + ) as unknown as globalThis.WebSocket, + ); + + return RpcClient.layerProtocolSocket().pipe( + Layer.provide(Socket.layerWebSocket(url).pipe(Layer.provide(webSocketConstructorLayer))), Layer.provide(RpcSerialization.layerJson), ); +}; const makeWsRpcClient = RpcClient.make(WsRpcGroup); type WsRpcClient = @@ -477,6 +520,13 @@ const withWsRpcClient = ( f: (client: WsRpcClient) => Effect.Effect, ) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl))); +const appendSessionCookieToWsUrl = (url: string, sessionCookieHeader: string) => { + const isAbsoluteUrl = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(url); + const next = new URL(url, "http://localhost"); + next.hash = `cookie=${encodeURIComponent(sessionCookieHeader)}`; + return isAbsoluteUrl ? next.toString() : `${next.pathname}${next.search}${next.hash}`; +}; + const getHttpServerUrl = (pathname = "") => Effect.gen(function* () { const server = yield* HttpServer.HttpServer; @@ -484,11 +534,130 @@ const getHttpServerUrl = (pathname = "") => return `http://127.0.0.1:${address.port}${pathname}`; }); -const getWsServerUrl = (pathname = "") => +const bootstrapBrowserSession = ( + credential = defaultDesktopBootstrapToken, + options?: { + readonly headers?: Record; + }, +) => + Effect.gen(function* () { + const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap"); + const response = yield* Effect.promise(() => + fetch(bootstrapUrl, { + method: "POST", + headers: { + "content-type": "application/json", + ...options?.headers, + }, + body: JSON.stringify({ + credential, + }), + }), + ); + const body = (yield* Effect.promise(() => response.json())) as { + readonly authenticated: boolean; + readonly sessionMethod: string; + readonly expiresAt: string; + }; + return { + response, + body, + cookie: response.headers.get("set-cookie"), + }; + }); + +const bootstrapBearerSession = (credential = defaultDesktopBootstrapToken) => + Effect.gen(function* () { + const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap/bearer"); + const response = yield* Effect.promise(() => + fetch(bootstrapUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + credential, + }), + }), + ); + const body = (yield* Effect.promise(() => response.json())) as { + readonly authenticated: boolean; + readonly sessionMethod: string; + readonly expiresAt: string; + readonly sessionToken?: string; + readonly error?: string; + }; + return { + response, + body, + }; + }); + +const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) => + Effect.gen(function* () { + const { response, cookie } = yield* bootstrapBrowserSession(credential); + if (!response.ok) { + return yield* Effect.fail( + new Error(`Expected bootstrap session response to succeed, got ${response.status}`), + ); + } + + if (!cookie) { + return yield* Effect.fail(new Error("Expected bootstrap session response to set a cookie.")); + } + + return cookie.split(";")[0] ?? cookie; + }); + +const getAuthenticatedBearerSessionToken = (credential = defaultDesktopBootstrapToken) => + Effect.gen(function* () { + const { response, body } = yield* bootstrapBearerSession(credential); + if (!response.ok) { + return yield* Effect.fail( + new Error(`Expected bearer bootstrap response to succeed, got ${response.status}`), + ); + } + + if (!body.sessionToken) { + return yield* Effect.fail( + new Error("Expected bearer bootstrap response to include a session token."), + ); + } + + return body.sessionToken; + }); + +const extractSessionTokenFromSetCookie = (cookieHeader: string): string => { + const [nameValue] = cookieHeader.split(";", 1); + const token = nameValue?.split("=", 2)[1]; + if (!token) { + throw new Error("Expected session cookie header to contain a token value."); + } + return token; +}; + +const splitHeaderTokens = (value: string | null) => + (value ?? "") + .split(",") + .map((token) => token.trim()) + .filter((token) => token.length > 0) + .toSorted(); + +const getWsServerUrl = ( + pathname = "", + options?: { authenticated?: boolean; credential?: string }, +) => Effect.gen(function* () { const server = yield* HttpServer.HttpServer; const address = server.address as HttpServer.TcpAddress; - return `ws://127.0.0.1:${address.port}${pathname}`; + const baseUrl = `ws://127.0.0.1:${address.port}${pathname}`; + if (options?.authenticated === false) { + return baseUrl; + } + return appendSessionCookieToWsUrl( + baseUrl, + yield* getAuthenticatedSessionCookieHeader(options?.credential), + ); }); it.layer(NodeServices.layer)("server router seam", (it) => { @@ -514,11 +683,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { config: { devUrl: new URL("http://127.0.0.1:5173") }, }); - const url = yield* getHttpServerUrl("/foo/bar"); + const url = yield* getHttpServerUrl("/foo/bar?token=test-token"); const response = yield* Effect.promise(() => fetch(url, { redirect: "manual" })); assert.equal(response.status, 302); - assert.equal(response.headers.get("location"), "http://127.0.0.1:5173/"); + assert.equal( + response.headers.get("location"), + "http://127.0.0.1:5173/foo/bar?token=test-token", + ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -540,6 +712,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.get( `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, + { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }, ); assert.equal(response.status, 200); @@ -560,6 +737,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.get( `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}`, + { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }, ); assert.equal(response.status, 200); @@ -567,6 +749,597 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("serves the public environment descriptor without requiring auth", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const url = yield* getHttpServerUrl("/.well-known/t3/environment"); + const response = yield* Effect.promise(() => fetch(url)); + const body = (yield* Effect.promise(() => + response.json(), + )) as typeof testEnvironmentDescriptor; + + assert.equal(response.status, 200); + assert.deepEqual(body, testEnvironmentDescriptor); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("reports unauthenticated session state without requiring auth", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const url = yield* getHttpServerUrl("/api/auth/session"); + const response = yield* Effect.promise(() => fetch(url)); + const body = (yield* Effect.promise(() => response.json())) as { + readonly authenticated: boolean; + readonly auth: { + readonly policy: string; + readonly bootstrapMethods: ReadonlyArray; + readonly sessionMethods: ReadonlyArray; + readonly sessionCookieName: string; + }; + }; + + assert.equal(response.status, 200); + assert.equal(body.authenticated, false); + assert.equal(body.auth.policy, "desktop-managed-local"); + assert.deepEqual(body.auth.bootstrapMethods, ["desktop-bootstrap"]); + assert.deepEqual(body.auth.sessionMethods, [ + "browser-session-cookie", + "bearer-session-token", + ]); + assert.equal(body.auth.sessionCookieName, "t3_session"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("bootstraps a browser session and authenticates the session endpoint via cookie", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { + response: bootstrapResponse, + body: bootstrapBody, + cookie: setCookie, + } = yield* bootstrapBrowserSession(); + + assert.equal(bootstrapResponse.status, 200); + assert.equal(bootstrapBody.authenticated, true); + assert.equal(bootstrapBody.sessionMethod, "browser-session-cookie"); + assert.isUndefined((bootstrapBody as { readonly sessionToken?: string }).sessionToken); + assert.isDefined(setCookie); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const sessionResponse = yield* Effect.promise(() => + fetch(sessionUrl, { + headers: { + cookie: setCookie?.split(";")[0] ?? "", + }, + }), + ); + const sessionBody = (yield* Effect.promise(() => sessionResponse.json())) as { + readonly authenticated: boolean; + readonly sessionMethod?: string; + }; + + assert.equal(sessionResponse.status, 200); + assert.equal(sessionBody.authenticated, true); + assert.equal(sessionBody.sessionMethod, "browser-session-cookie"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "bootstraps a bearer session and authenticates the session endpoint via authorization header", + () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { response: bootstrapResponse, body: bootstrapBody } = + yield* bootstrapBearerSession(); + + assert.equal(bootstrapResponse.status, 200); + assert.equal(bootstrapBody.authenticated, true); + assert.equal(bootstrapBody.sessionMethod, "bearer-session-token"); + assert.equal(typeof bootstrapBody.sessionToken, "string"); + assert.isTrue((bootstrapBody.sessionToken?.length ?? 0) > 0); + + const sessionUrl = yield* getHttpServerUrl("/api/auth/session"); + const sessionResponse = yield* Effect.promise(() => + fetch(sessionUrl, { + headers: { + authorization: `Bearer ${bootstrapBody.sessionToken ?? ""}`, + }, + }), + ); + const sessionBody = (yield* Effect.promise(() => sessionResponse.json())) as { + readonly authenticated: boolean; + readonly sessionMethod?: string; + }; + + assert.equal(sessionResponse.status, 200); + assert.equal(sessionBody.authenticated, true); + assert.equal(sessionBody.sessionMethod, "bearer-session-token"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("issues short-lived websocket tokens for authenticated bearer sessions", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const bearerToken = yield* getAuthenticatedBearerSessionToken(); + const wsTokenUrl = yield* getHttpServerUrl("/api/auth/ws-token"); + const wsTokenResponse = yield* Effect.promise(() => + fetch(wsTokenUrl, { + method: "POST", + headers: { + authorization: `Bearer ${bearerToken}`, + }, + }), + ); + const wsTokenBody = (yield* Effect.promise(() => wsTokenResponse.json())) as { + readonly token: string; + readonly expiresAt: string; + }; + + assert.equal(wsTokenResponse.status, 200); + assert.equal(typeof wsTokenBody.token, "string"); + assert.isTrue(wsTokenBody.token.length > 0); + assert.equal(typeof wsTokenBody.expiresAt, "string"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "responds to remote auth websocket-token preflight requests with authorization CORS headers", + () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsTokenUrl = yield* getHttpServerUrl("/api/auth/ws-token"); + const response = yield* Effect.promise(() => + fetch(wsTokenUrl, { + method: "OPTIONS", + headers: { + origin: "http://192.168.86.35:3773", + "access-control-request-method": "POST", + "access-control-request-headers": "authorization", + }, + }), + ); + + assert.equal(response.status, 204); + assert.equal(response.headers.get("access-control-allow-origin"), "*"); + assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-methods")), [ + "GET", + "OPTIONS", + "POST", + ]); + assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-headers")), [ + "authorization", + "b3", + "content-type", + "traceparent", + ]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("includes CORS headers on remote websocket-token auth failures", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsTokenUrl = yield* getHttpServerUrl("/api/auth/ws-token"); + const response = yield* Effect.promise(() => + fetch(wsTokenUrl, { + method: "POST", + headers: { + origin: "http://192.168.86.35:3773", + }, + }), + ); + const body = (yield* Effect.promise(() => response.json())) as { + readonly error?: string; + }; + + assert.equal(response.status, 401); + assert.equal(response.headers.get("access-control-allow-origin"), "*"); + assert.equal(body.error, "Authentication required."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("issues authenticated one-time pairing credentials for additional clients", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const response = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }); + const body = (yield* response.json) as { + readonly credential: string; + readonly expiresAt: string; + }; + + assert.equal(response.status, 200); + assert.equal(typeof body.credential, "string"); + assert.isTrue(body.credential.length > 0); + assert.equal(typeof body.expiresAt, "string"); + + const bootstrapResult = yield* bootstrapBrowserSession(body.credential); + assert.equal(bootstrapResult.response.status, 200); + + const reusedResult = yield* bootstrapBrowserSession(body.credential); + assert.equal(reusedResult.response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects unauthenticated pairing credential requests", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const response = yield* HttpClient.post("/api/auth/pairing-token"); + assert.equal(response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("lists and revokes pairing links for owner sessions", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + config: { + host: "0.0.0.0", + }, + }); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const createdResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: ownerCookie, + }, + }); + const createdBody = (yield* createdResponse.json) as { + readonly id: string; + readonly credential: string; + }; + + const listResponse = yield* HttpClient.get("/api/auth/pairing-links", { + headers: { + cookie: ownerCookie, + }, + }); + const listedLinks = (yield* listResponse.json) as ReadonlyArray<{ + readonly id: string; + readonly credential: string; + }>; + + const revokeUrl = yield* getHttpServerUrl("/api/auth/pairing-links/revoke"); + const revokeResponse = yield* Effect.promise(() => + fetch(revokeUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: JSON.stringify({ id: createdBody.id }), + }), + ); + const revokedBootstrap = yield* bootstrapBrowserSession(createdBody.credential); + + assert.equal(createdResponse.status, 200); + assert.equal(listResponse.status, 200); + assert.isTrue(listedLinks.some((entry) => entry.id === createdBody.id)); + assert.equal(revokeResponse.status, 200); + assert.equal(revokedBootstrap.response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects pairing credential requests from non-owner paired sessions", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + config: { + host: "0.0.0.0", + }, + }); + + const ownerResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }); + const ownerBody = (yield* ownerResponse.json) as { + readonly credential: string; + }; + assert.equal(ownerResponse.status, 200); + + const pairedSessionCookie = yield* getAuthenticatedSessionCookieHeader(ownerBody.credential); + const pairedResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: pairedSessionCookie, + }, + }); + const pairedBody = (yield* pairedResponse.json) as { + readonly error: string; + }; + + assert.equal(pairedResponse.status, 403); + assert.equal(pairedBody.error, "Only owner sessions can create pairing credentials."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("lists paired clients and revokes other sessions while keeping the owner", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + config: { + host: "0.0.0.0", + }, + }); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const pairingTokenUrl = yield* getHttpServerUrl("/api/auth/pairing-token"); + const ownerPairingResponse = yield* Effect.promise(() => + fetch(pairingTokenUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: JSON.stringify({ + label: "Julius iPhone", + }), + }), + ); + const ownerPairingBody = (yield* Effect.promise(() => ownerPairingResponse.json())) as { + readonly credential: string; + readonly label?: string; + }; + assert.equal(ownerPairingResponse.status, 200); + const pairedSessionBootstrap = yield* bootstrapBrowserSession(ownerPairingBody.credential, { + headers: { + "user-agent": + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1", + }, + }); + const pairedSessionCookie = pairedSessionBootstrap.cookie?.split(";")[0]; + assert.isDefined(pairedSessionCookie); + + const pairedSessionCookieHeader = pairedSessionCookie ?? ""; + const listClientsUrl = yield* getHttpServerUrl("/api/auth/clients"); + const listBeforeResponse = yield* Effect.promise(() => + fetch(listClientsUrl, { + headers: { + cookie: ownerCookie, + }, + }), + ); + const clientsBefore = (yield* Effect.promise(() => + listBeforeResponse.json(), + )) as ReadonlyArray<{ + readonly sessionId: string; + readonly current: boolean; + readonly client: { + readonly label?: string; + readonly deviceType: string; + readonly ipAddress?: string; + readonly os?: string; + readonly browser?: string; + }; + }>; + const pairedClientBefore = clientsBefore.find((entry) => !entry.current); + const pairedSessionId = clientsBefore.find((entry) => !entry.current)?.sessionId; + + const revokeOthersResponse = yield* HttpClient.post("/api/auth/clients/revoke-others", { + headers: { + cookie: ownerCookie, + }, + }); + const revokeOthersBody = (yield* revokeOthersResponse.json) as { + readonly revokedCount: number; + }; + + const listAfterResponse = yield* HttpClient.get("/api/auth/clients", { + headers: { + cookie: ownerCookie, + }, + }); + const clientsAfter = (yield* listAfterResponse.json) as ReadonlyArray<{ + readonly sessionId: string; + readonly current: boolean; + }>; + + const pairedClientPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: pairedSessionCookieHeader, + }, + }); + const pairedClientPairingBody = (yield* pairedClientPairingResponse.json) as { + readonly error: string; + }; + + assert.equal(listBeforeResponse.status, 200); + assert.equal(ownerPairingBody.label, "Julius iPhone"); + assert.lengthOf(clientsBefore, 2); + assert.isDefined(pairedSessionId); + assert.isDefined(pairedClientBefore); + assert.deepInclude(pairedClientBefore?.client, { + label: "Julius iPhone", + deviceType: "mobile", + os: "iOS", + browser: "Safari", + ipAddress: "127.0.0.1", + }); + assert.equal(revokeOthersResponse.status, 200); + assert.equal(revokeOthersBody.revokedCount, 1); + assert.equal(listAfterResponse.status, 200); + assert.lengthOf(clientsAfter, 1); + assert.equal(clientsAfter[0]?.current, true); + assert.equal(pairedClientPairingResponse.status, 401); + assert.equal(pairedClientPairingBody.error, "Unauthorized request."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("revokes an individual paired client session", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + config: { + host: "0.0.0.0", + }, + }); + + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const pairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: ownerCookie, + }, + }); + const pairingBody = (yield* pairingResponse.json) as { + readonly credential: string; + }; + const pairedSessionCookie = yield* getAuthenticatedSessionCookieHeader( + pairingBody.credential, + ); + + const clientsResponse = yield* HttpClient.get("/api/auth/clients", { + headers: { + cookie: ownerCookie, + }, + }); + const clients = (yield* clientsResponse.json) as ReadonlyArray<{ + readonly sessionId: string; + readonly current: boolean; + }>; + const pairedSessionId = clients.find((entry) => !entry.current)?.sessionId; + assert.isDefined(pairedSessionId); + + const revokeUrl = yield* getHttpServerUrl("/api/auth/clients/revoke"); + const revokeResponse = yield* Effect.promise(() => + fetch(revokeUrl, { + method: "POST", + headers: { + cookie: ownerCookie, + "content-type": "application/json", + }, + body: JSON.stringify({ sessionId: pairedSessionId }), + }), + ); + const pairedClientPairingResponse = yield* HttpClient.post("/api/auth/pairing-token", { + headers: { + cookie: pairedSessionCookie, + }, + }); + + assert.equal(revokeResponse.status, 200); + assert.equal(pairedClientPairingResponse.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects reusing the same bootstrap credential after it has been exchanged", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const first = yield* bootstrapBrowserSession(); + const second = yield* bootstrapBrowserSession(); + + assert.equal(first.response.status, 200); + assert.equal(second.response.status, 401); + assert.equal( + (second.body as { readonly error?: string }).error, + "Invalid bootstrap credential.", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "does not accept session tokens via query parameters on authenticated HTTP routes", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const projectDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-router-project-favicon-query-token-", + }); + + yield* buildAppUnderTest(); + + const { cookie } = yield* bootstrapBrowserSession(); + assert.isDefined(cookie); + const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); + + const response = yield* HttpClient.get( + `/api/project-favicon?cwd=${encodeURIComponent(projectDir)}&token=${encodeURIComponent(sessionToken)}`, + ); + + assert.equal(response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("accepts websocket rpc handshake with a bootstrapped browser session cookie", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { response: bootstrapResponse, cookie } = yield* bootstrapBrowserSession(); + + assert.equal(bootstrapResponse.status, 200); + assert.isDefined(cookie); + + const wsUrl = appendSessionCookieToWsUrl( + yield* getWsServerUrl("/ws", { authenticated: false }), + cookie?.split(";")[0] ?? "", + ); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), + ); + + assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); + assert.equal(response.auth.policy, "desktop-managed-local"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "rejects websocket rpc handshake when a session token is only provided via query string", + () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const { cookie } = yield* bootstrapBrowserSession(); + assert.isDefined(cookie); + const sessionToken = extractSessionTokenFromSetCookie(cookie ?? ""); + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?token=${encodeURIComponent(sessionToken)}`; + + const error = yield* Effect.flip( + Effect.scoped(withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({}))), + ); + + assert.equal(error._tag, "RpcClientError"); + assertInclude(String(error), "401"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "accepts websocket rpc handshake with a dedicated websocket token in the query string", + () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const bearerToken = yield* getAuthenticatedBearerSessionToken(); + const wsTokenUrl = yield* getHttpServerUrl("/api/auth/ws-token"); + const wsTokenResponse = yield* Effect.promise(() => + fetch(wsTokenUrl, { + method: "POST", + headers: { + authorization: `Bearer ${bearerToken}`, + }, + }), + ); + const wsTokenBody = (yield* Effect.promise(() => wsTokenResponse.json())) as { + readonly token: string; + }; + const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsToken=${encodeURIComponent(wsTokenBody.token)}`; + + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})), + ); + + assert.equal(response.environment.environmentId, testEnvironmentDescriptor.environmentId); + assert.equal(response.auth.policy, "desktop-managed-local"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("serves attachment files from state dir", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -583,7 +1356,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); yield* fileSystem.writeFileString(attachmentPath, "attachment-ok"); - const response = yield* HttpClient.get(`/attachments/${attachmentId}`); + const response = yield* HttpClient.get(`/attachments/${attachmentId}`, { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }); assert.equal(response.status, 200); assert.equal(yield* response.text, "attachment-ok"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), @@ -606,6 +1383,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.get( "/attachments/thread%20folder/message%20folder/file%20name.png", + { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }, ); assert.equal(response.status, 200); assert.equal(yield* response.text, "attachment-encoded-ok"); @@ -742,6 +1524,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.post("/api/observability/v1/traces", { headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), "content-type": "application/json", origin: "http://localhost:5733", }, @@ -815,8 +1598,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.status, 204); assert.equal(response.headers.get("access-control-allow-origin"), "*"); - assert.equal(response.headers.get("access-control-allow-methods"), "POST, OPTIONS"); - assert.equal(response.headers.get("access-control-allow-headers"), "content-type"); + assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-methods")), [ + "GET", + "OPTIONS", + "POST", + ]); + assert.deepEqual(splitHeaderTokens(response.headers.get("access-control-allow-headers")), [ + "authorization", + "b3", + "content-type", + "traceparent", + ]); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -850,6 +1642,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.post("/api/observability/v1/traces", { headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), "content-type": "application/json", }, body: HttpBody.text(JSON.stringify(payload), "application/json"), @@ -897,6 +1690,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const response = yield* HttpClient.get( "/attachments/missing-11111111-1111-4111-8111-111111111111", + { + headers: { + cookie: yield* getAuthenticatedSessionCookieHeader(), + }, + }, ); assert.equal(response.status, 404); }).pipe(Effect.provide(NodeHttpServer.layerTest)), @@ -938,7 +1736,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("rejects websocket rpc handshake when auth token is missing", () => + it.effect("rejects websocket rpc handshake when session authentication is missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -948,13 +1746,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "export const needle = 1;", ); - yield* buildAppUnderTest({ - config: { - authToken: "secret-token", - }, - }); + yield* buildAppUnderTest(); - const wsUrl = yield* getWsServerUrl("/ws"); + const wsUrl = yield* getWsServerUrl("/ws", { authenticated: false }); const result = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.projectsSearchEntries]({ @@ -970,38 +1764,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("accepts websocket rpc handshake when auth token is provided", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspaceDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-auth-ok-" }); - yield* fs.writeFileString( - path.join(workspaceDir, "needle-file.ts"), - "export const needle = 1;", - ); - - yield* buildAppUnderTest({ - config: { - authToken: "secret-token", - }, - }); - - const wsUrl = yield* getWsServerUrl("/ws?token=secret-token"); - const response = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.projectsSearchEntries]({ - cwd: workspaceDir, - query: "needle", - limit: 10, - }), - ), - ); - - assert.isAtLeast(response.entries.length, 1); - assert.equal(response.truncated, false); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () => Effect.gen(function* () { const providers = [] as const; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d706d79b44..c1581b9382 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -6,7 +6,9 @@ import { attachmentsRouteLayer, otlpTracesProxyRouteLayer, projectFaviconRouteLayer, + serverEnvironmentRouteLayer, staticAndDevRouteLayer, + browserApiCorsLayer, } from "./http"; import { fixPath } from "./os-jank"; import { websocketRpcRouteLayer } from "./ws"; @@ -51,6 +53,20 @@ import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; import { ObservabilityLive } from "./observability/Layers/Observability"; import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; +import { + authBearerBootstrapRouteLayer, + authBootstrapRouteLayer, + authClientsRevokeOthersRouteLayer, + authClientsRevokeRouteLayer, + authClientsRouteLayer, + authPairingLinksRevokeRouteLayer, + authPairingLinksRouteLayer, + authPairingCredentialRouteLayer, + authSessionRouteLayer, + authWebSocketTokenRouteLayer, +} from "./auth/http"; +import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore"; +import { ServerAuthLive } from "./auth/Layers/ServerAuth"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { @@ -188,6 +204,11 @@ const WorkspaceLayerLive = Layer.mergeAll( ), ); +const AuthLayerLive = ServerAuthLive.pipe( + Layer.provideMerge(PersistenceLayerLive), + Layer.provide(ServerSecretStoreLive), +); + const RuntimeDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), @@ -203,6 +224,7 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ProjectFaviconResolverLive), Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerEnvironmentLive), + Layer.provideMerge(AuthLayerLive), // Misc. Layer.provideMerge(AnalyticsServiceLayerLive), @@ -215,12 +237,23 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( ); export const makeRoutesLayer = Layer.mergeAll( + authBearerBootstrapRouteLayer, + authBootstrapRouteLayer, + authClientsRevokeOthersRouteLayer, + authClientsRevokeRouteLayer, + authClientsRouteLayer, + authPairingLinksRevokeRouteLayer, + authPairingLinksRouteLayer, + authPairingCredentialRouteLayer, + authSessionRouteLayer, + authWebSocketTokenRouteLayer, attachmentsRouteLayer, otlpTracesProxyRouteLayer, projectFaviconRouteLayer, + serverEnvironmentRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, -); +).pipe(Layer.provide(browserApiCorsLayer)); export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index fc06d77566..c3159cc9d8 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -1,14 +1,23 @@ +import { DEFAULT_MODEL_BY_PROVIDER } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { Deferred, Effect, Fiber, Option, Ref } from "effect"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { + getAutoBootstrapDefaultModelSelection, launchStartupHeartbeat, makeCommandGate, ServerRuntimeStartupError, } from "./serverRuntimeStartup.ts"; +it("uses the canonical Codex default for auto-bootstrapped model selection", () => { + assert.deepStrictEqual(getAutoBootstrapDefaultModelSelection(), { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }); +}); + it.effect("enqueueCommand waits for readiness and then drains queued work", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index e94c322225..fd43c6b359 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -1,5 +1,6 @@ import { CommandId, + DEFAULT_MODEL_BY_PROVIDER, DEFAULT_PROVIDER_INTERACTION_MODE, type ModelSelection, ProjectId, @@ -29,6 +30,7 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents"; import { ServerSettingsService } from "./serverSettings"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; +import { ServerAuth } from "./auth/Services/ServerAuth"; const isWildcardHost = (host: string | undefined): boolean => host === "0.0.0.0" || host === "::" || host === "[::]"; @@ -149,6 +151,11 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( Effect.asVoid, ); +export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({ + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, +}); + const autoBootstrapWelcome = Effect.gen(function* () { const serverConfig = yield* ServerConfig; const projectionReadModelQuery = yield* ProjectionSnapshotQuery; @@ -170,10 +177,7 @@ const autoBootstrapWelcome = Effect.gen(function* () { const createdAt = new Date().toISOString(); nextProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; - nextProjectDefaultModelSelection = { - provider: "codex", - model: "gpt-5-codex", - }; + nextProjectDefaultModelSelection = getAutoBootstrapDefaultModelSelection(); yield* orchestrationEngine.dispatch({ type: "project.create", commandId: CommandId.makeUnsafe(crypto.randomUUID()), @@ -185,10 +189,8 @@ const autoBootstrapWelcome = Effect.gen(function* () { }); } else { nextProjectId = existingProject.value.id; - nextProjectDefaultModelSelection = existingProject.value.defaultModelSelection ?? { - provider: "codex", - model: "gpt-5-codex", - }; + nextProjectDefaultModelSelection = + existingProject.value.defaultModelSelection ?? getAutoBootstrapDefaultModelSelection(); } const existingThreadId = @@ -229,28 +231,39 @@ const autoBootstrapWelcome = Effect.gen(function* () { } as const; }); -const maybeOpenBrowser = Effect.gen(function* () { +const resolveStartupBrowserTarget = Effect.gen(function* () { const serverConfig = yield* ServerConfig; - if (serverConfig.noBrowser) { - return; - } - const { openBrowser } = yield* Open; + const serverAuth = yield* ServerAuth; const localUrl = `http://localhost:${serverConfig.port}`; const bindUrl = serverConfig.host && !isWildcardHost(serverConfig.host) ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}` : localUrl; - const target = serverConfig.devUrl?.toString() ?? bindUrl; - - yield* openBrowser(target).pipe( - Effect.catch(() => - Effect.logInfo("browser auto-open unavailable", { - hint: `Open ${target} in your browser.`, - }), + const baseTarget = serverConfig.devUrl?.toString() ?? bindUrl; + return yield* Effect.succeed(serverConfig.mode === "desktop" ? baseTarget : undefined).pipe( + Effect.flatMap((target) => + target ? Effect.succeed(target) : serverAuth.issueStartupPairingUrl(baseTarget), ), ); }); +const maybeOpenBrowser = (target: string) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + if (serverConfig.noBrowser) { + return; + } + const { openBrowser } = yield* Open; + + yield* openBrowser(target).pipe( + Effect.catch(() => + Effect.logInfo("browser auto-open unavailable", { + hint: `Open ${target} in your browser.`, + }), + ), + ); + }); + const runStartupPhase = (phase: string, effect: Effect.Effect) => effect.pipe( Effect.annotateSpans({ "startup.phase": phase }), @@ -371,7 +384,13 @@ const makeServerRuntimeStartup = Effect.gen(function* () { yield* Effect.logDebug("startup phase: recording startup heartbeat"); yield* launchStartupHeartbeat; yield* Effect.logDebug("startup phase: browser open check"); - yield* runStartupPhase("browser.open", maybeOpenBrowser); + const startupBrowserTarget = yield* resolveStartupBrowserTarget; + if (serverConfig.mode !== "desktop") { + yield* Effect.logInfo("Authentication required. Open T3 Code using the pairing URL.").pipe( + Effect.annotateLogs({ pairingUrl: startupBrowserTarget }), + ); + } + yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); yield* Effect.logDebug("startup phase: complete"); }), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 16e8531386..8d4f946691 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,5 +1,7 @@ -import { Cause, Effect, Layer, Option, Queue, Ref, Schema, Stream } from "effect"; +import { Cause, Effect, Layer, Queue, Ref, Schema, Stream } from "effect"; import { + type AuthAccessStreamEvent, + AuthSessionId, CommandId, EventId, type OrchestrationCommand, @@ -20,7 +22,7 @@ import { WsRpcGroup, } from "@t3tools/contracts"; import { clamp } from "effect/Number"; -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; @@ -49,791 +51,894 @@ import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePat import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; +import { ServerAuth } from "./auth/Services/ServerAuth"; +import { + BootstrapCredentialService, + type BootstrapCredentialChange, +} from "./auth/Services/BootstrapCredentialService"; +import { + SessionCredentialService, + type SessionCredentialChange, +} from "./auth/Services/SessionCredentialService"; +import { respondToAuthError } from "./auth/http"; -const WsRpcLayer = WsRpcGroup.toLayer( - Effect.gen(function* () { - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - const orchestrationEngine = yield* OrchestrationEngineService; - const checkpointDiffQuery = yield* CheckpointDiffQuery; - const keybindings = yield* Keybindings; - const open = yield* Open; - const gitManager = yield* GitManager; - const git = yield* GitCore; - const gitStatusBroadcaster = yield* GitStatusBroadcaster; - const terminalManager = yield* TerminalManager; - const providerRegistry = yield* ProviderRegistry; - const config = yield* ServerConfig; - const lifecycleEvents = yield* ServerLifecycleEvents; - const serverSettings = yield* ServerSettingsService; - const startup = yield* ServerRuntimeStartup; - const workspaceEntries = yield* WorkspaceEntries; - const workspaceFileSystem = yield* WorkspaceFileSystem; - const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; - const repositoryIdentityResolver = yield* RepositoryIdentityResolver; - const serverEnvironment = yield* ServerEnvironment; - const serverCommandId = (tag: string) => - CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); - - const appendSetupScriptActivity = (input: { - readonly threadId: ThreadId; - readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; - readonly summary: string; - readonly createdAt: string; - readonly payload: Record; - readonly tone: "info" | "error"; - }) => - orchestrationEngine.dispatch({ - type: "thread.activity.append", - commandId: serverCommandId("setup-script-activity"), - threadId: input.threadId, - activity: { - id: EventId.makeUnsafe(crypto.randomUUID()), - tone: input.tone, - kind: input.kind, - summary: input.summary, - payload: input.payload, - turnId: null, - createdAt: input.createdAt, +function toAuthAccessStreamEvent( + change: BootstrapCredentialChange | SessionCredentialChange, + revision: number, + currentSessionId: AuthSessionId, +): AuthAccessStreamEvent { + switch (change.type) { + case "pairingLinkUpserted": + return { + version: 1, + revision, + type: "pairingLinkUpserted", + payload: change.pairingLink, + }; + case "pairingLinkRemoved": + return { + version: 1, + revision, + type: "pairingLinkRemoved", + payload: { id: change.id }, + }; + case "clientUpserted": + return { + version: 1, + revision, + type: "clientUpserted", + payload: { + ...change.clientSession, + current: change.clientSession.sessionId === currentSessionId, }, - createdAt: input.createdAt, - }); + }; + case "clientRemoved": + return { + version: 1, + revision, + type: "clientRemoved", + payload: { sessionId: change.sessionId }, + }; + } +} - const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => - Schema.is(OrchestrationDispatchCommandError)(cause) - ? cause - : new OrchestrationDispatchCommandError({ - message: cause instanceof Error ? cause.message : fallbackMessage, - cause, - }); +const makeWsRpcLayer = (currentSessionId: AuthSessionId) => + WsRpcGroup.toLayer( + Effect.gen(function* () { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + const orchestrationEngine = yield* OrchestrationEngineService; + const checkpointDiffQuery = yield* CheckpointDiffQuery; + const keybindings = yield* Keybindings; + const open = yield* Open; + const gitManager = yield* GitManager; + const git = yield* GitCore; + const gitStatusBroadcaster = yield* GitStatusBroadcaster; + const terminalManager = yield* TerminalManager; + const providerRegistry = yield* ProviderRegistry; + const config = yield* ServerConfig; + const lifecycleEvents = yield* ServerLifecycleEvents; + const serverSettings = yield* ServerSettingsService; + const startup = yield* ServerRuntimeStartup; + const workspaceEntries = yield* WorkspaceEntries; + const workspaceFileSystem = yield* WorkspaceFileSystem; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; + const repositoryIdentityResolver = yield* RepositoryIdentityResolver; + const serverEnvironment = yield* ServerEnvironment; + const serverAuth = yield* ServerAuth; + const bootstrapCredentials = yield* BootstrapCredentialService; + const sessions = yield* SessionCredentialService; + const serverCommandId = (tag: string) => + CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); - const toBootstrapDispatchCommandCauseError = (cause: Cause.Cause) => { - const error = Cause.squash(cause); - return Schema.is(OrchestrationDispatchCommandError)(error) - ? error - : new OrchestrationDispatchCommandError({ - message: - error instanceof Error ? error.message : "Failed to bootstrap thread turn start.", - cause, - }); - }; - - const enrichProjectEvent = ( - event: OrchestrationEvent, - ): Effect.Effect => { - switch (event.type) { - case "project.created": - return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe( - Effect.map((repositoryIdentity) => ({ - ...event, - payload: { - ...event.payload, - repositoryIdentity, - }, - })), - ); - case "project.meta-updated": - return Effect.gen(function* () { - const workspaceRoot = - event.payload.workspaceRoot ?? - (yield* orchestrationEngine.getReadModel()).projects.find( - (project) => project.id === event.payload.projectId, - )?.workspaceRoot ?? - null; - if (workspaceRoot === null) { - return event; - } + const loadAuthAccessSnapshot = () => + Effect.all({ + pairingLinks: serverAuth.listPairingLinks().pipe(Effect.orDie), + clientSessions: serverAuth.listClientSessions(currentSessionId).pipe(Effect.orDie), + }); - const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot); - return { - ...event, - payload: { - ...event.payload, - repositoryIdentity, - }, - } satisfies OrchestrationEvent; - }); - default: - return Effect.succeed(event); - } - }; + const appendSetupScriptActivity = (input: { + readonly threadId: ThreadId; + readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; + readonly summary: string; + readonly createdAt: string; + readonly payload: Record; + readonly tone: "info" | "error"; + }) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: serverCommandId("setup-script-activity"), + threadId: input.threadId, + activity: { + id: EventId.makeUnsafe(crypto.randomUUID()), + tone: input.tone, + kind: input.kind, + summary: input.summary, + payload: input.payload, + turnId: null, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }); - const enrichOrchestrationEvents = (events: ReadonlyArray) => - Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); + const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => + Schema.is(OrchestrationDispatchCommandError)(cause) + ? cause + : new OrchestrationDispatchCommandError({ + message: cause instanceof Error ? cause.message : fallbackMessage, + cause, + }); - const dispatchBootstrapTurnStart = ( - command: Extract, - ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => - Effect.gen(function* () { - const bootstrap = command.bootstrap; - const { bootstrap: _bootstrap, ...finalTurnStartCommand } = command; - let createdThread = false; - let targetProjectId = bootstrap?.createThread?.projectId; - let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd; - let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null; - - const cleanupCreatedThread = () => - createdThread - ? orchestrationEngine - .dispatch({ - type: "thread.delete", - commandId: serverCommandId("bootstrap-thread-delete"), - threadId: command.threadId, - }) - .pipe(Effect.ignoreCause({ log: true })) - : Effect.void; - - const recordSetupScriptLaunchFailure = (input: { - readonly error: unknown; - readonly requestedAt: string; - readonly worktreePath: string; - }) => { - const detail = - input.error instanceof Error ? input.error.message : "Unknown setup failure."; - return appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.failed", - summary: "Setup script failed to start", - createdAt: input.requestedAt, - payload: { - detail, - worktreePath: input.worktreePath, - }, - tone: "error", - }).pipe( - Effect.ignoreCause({ log: false }), - Effect.flatMap(() => - Effect.logWarning("bootstrap turn start failed to launch setup script", { - threadId: command.threadId, - worktreePath: input.worktreePath, - detail, - }), - ), - ); - }; + const toBootstrapDispatchCommandCauseError = (cause: Cause.Cause) => { + const error = Cause.squash(cause); + return Schema.is(OrchestrationDispatchCommandError)(error) + ? error + : new OrchestrationDispatchCommandError({ + message: + error instanceof Error ? error.message : "Failed to bootstrap thread turn start.", + cause, + }); + }; - const recordSetupScriptStarted = (input: { - readonly requestedAt: string; - readonly worktreePath: string; - readonly scriptId: string; - readonly scriptName: string; - readonly terminalId: string; - }) => { - const payload = { - scriptId: input.scriptId, - scriptName: input.scriptName, - terminalId: input.terminalId, - worktreePath: input.worktreePath, - }; - return Effect.all([ - appendSetupScriptActivity({ + const enrichProjectEvent = ( + event: OrchestrationEvent, + ): Effect.Effect => { + switch (event.type) { + case "project.created": + return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe( + Effect.map((repositoryIdentity) => ({ + ...event, + payload: { + ...event.payload, + repositoryIdentity, + }, + })), + ); + case "project.meta-updated": + return Effect.gen(function* () { + const workspaceRoot = + event.payload.workspaceRoot ?? + (yield* orchestrationEngine.getReadModel()).projects.find( + (project) => project.id === event.payload.projectId, + )?.workspaceRoot ?? + null; + if (workspaceRoot === null) { + return event; + } + + const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot); + return { + ...event, + payload: { + ...event.payload, + repositoryIdentity, + }, + } satisfies OrchestrationEvent; + }); + default: + return Effect.succeed(event); + } + }; + + const enrichOrchestrationEvents = (events: ReadonlyArray) => + Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); + + const dispatchBootstrapTurnStart = ( + command: Extract, + ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => + Effect.gen(function* () { + const bootstrap = command.bootstrap; + const { bootstrap: _bootstrap, ...finalTurnStartCommand } = command; + let createdThread = false; + let targetProjectId = bootstrap?.createThread?.projectId; + let targetProjectCwd = bootstrap?.prepareWorktree?.projectCwd; + let targetWorktreePath = bootstrap?.createThread?.worktreePath ?? null; + + const cleanupCreatedThread = () => + createdThread + ? orchestrationEngine + .dispatch({ + type: "thread.delete", + commandId: serverCommandId("bootstrap-thread-delete"), + threadId: command.threadId, + }) + .pipe(Effect.ignoreCause({ log: true })) + : Effect.void; + + const recordSetupScriptLaunchFailure = (input: { + readonly error: unknown; + readonly requestedAt: string; + readonly worktreePath: string; + }) => { + const detail = + input.error instanceof Error ? input.error.message : "Unknown setup failure."; + return appendSetupScriptActivity({ threadId: command.threadId, - kind: "setup-script.requested", - summary: "Starting setup script", + kind: "setup-script.failed", + summary: "Setup script failed to start", createdAt: input.requestedAt, - payload, - tone: "info", - }), - appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.started", - summary: "Setup script started", - createdAt: new Date().toISOString(), - payload, - tone: "info", - }), - ]).pipe( - Effect.asVoid, - Effect.catch((error) => - Effect.logWarning( - "bootstrap turn start launched setup script but failed to record setup activity", - { + payload: { + detail, + worktreePath: input.worktreePath, + }, + tone: "error", + }).pipe( + Effect.ignoreCause({ log: false }), + Effect.flatMap(() => + Effect.logWarning("bootstrap turn start failed to launch setup script", { threadId: command.threadId, worktreePath: input.worktreePath, - scriptId: input.scriptId, - terminalId: input.terminalId, - detail: - error instanceof Error - ? error.message - : "Unknown setup activity dispatch failure.", - }, + detail, + }), ), - ), - ); - }; + ); + }; - const runSetupProgram = () => - bootstrap?.runSetupScript && targetWorktreePath - ? (() => { - const worktreePath = targetWorktreePath; - const requestedAt = new Date().toISOString(); - return projectSetupScriptRunner - .runForThread({ + const recordSetupScriptStarted = (input: { + readonly requestedAt: string; + readonly worktreePath: string; + readonly scriptId: string; + readonly scriptName: string; + readonly terminalId: string; + }) => { + const payload = { + scriptId: input.scriptId, + scriptName: input.scriptName, + terminalId: input.terminalId, + worktreePath: input.worktreePath, + }; + return Effect.all([ + appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.requested", + summary: "Starting setup script", + createdAt: input.requestedAt, + payload, + tone: "info", + }), + appendSetupScriptActivity({ + threadId: command.threadId, + kind: "setup-script.started", + summary: "Setup script started", + createdAt: new Date().toISOString(), + payload, + tone: "info", + }), + ]).pipe( + Effect.asVoid, + Effect.catch((error) => + Effect.logWarning( + "bootstrap turn start launched setup script but failed to record setup activity", + { threadId: command.threadId, - ...(targetProjectId ? { projectId: targetProjectId } : {}), - ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), - worktreePath, - }) - .pipe( - Effect.matchEffect({ - onFailure: (error) => - recordSetupScriptLaunchFailure({ - error, - requestedAt, - worktreePath, - }), - onSuccess: (setupResult) => { - if (setupResult.status !== "started") { - return Effect.void; - } - return recordSetupScriptStarted({ - requestedAt, - worktreePath, - scriptId: setupResult.scriptId, - scriptName: setupResult.scriptName, - terminalId: setupResult.terminalId, - }); - }, - }), - ); - })() - : Effect.void; - - const bootstrapProgram = Effect.gen(function* () { - if (bootstrap?.createThread) { - yield* orchestrationEngine.dispatch({ - type: "thread.create", - commandId: serverCommandId("bootstrap-thread-create"), - threadId: command.threadId, - projectId: bootstrap.createThread.projectId, - title: bootstrap.createThread.title, - modelSelection: bootstrap.createThread.modelSelection, - runtimeMode: bootstrap.createThread.runtimeMode, - interactionMode: bootstrap.createThread.interactionMode, - branch: bootstrap.createThread.branch, - worktreePath: bootstrap.createThread.worktreePath, - createdAt: bootstrap.createThread.createdAt, - }); - createdThread = true; - } - - if (bootstrap?.prepareWorktree) { - const worktree = yield* git.createWorktree({ - cwd: bootstrap.prepareWorktree.projectCwd, - branch: bootstrap.prepareWorktree.baseBranch, - newBranch: bootstrap.prepareWorktree.branch, - path: null, - }); - targetWorktreePath = worktree.worktree.path; - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: serverCommandId("bootstrap-thread-meta-update"), - threadId: command.threadId, - branch: worktree.worktree.branch, - worktreePath: targetWorktreePath, - }); - } + worktreePath: input.worktreePath, + scriptId: input.scriptId, + terminalId: input.terminalId, + detail: + error instanceof Error + ? error.message + : "Unknown setup activity dispatch failure.", + }, + ), + ), + ); + }; - yield* runSetupProgram(); + const runSetupProgram = () => + bootstrap?.runSetupScript && targetWorktreePath + ? (() => { + const worktreePath = targetWorktreePath; + const requestedAt = new Date().toISOString(); + return projectSetupScriptRunner + .runForThread({ + threadId: command.threadId, + ...(targetProjectId ? { projectId: targetProjectId } : {}), + ...(targetProjectCwd ? { projectCwd: targetProjectCwd } : {}), + worktreePath, + }) + .pipe( + Effect.matchEffect({ + onFailure: (error) => + recordSetupScriptLaunchFailure({ + error, + requestedAt, + worktreePath, + }), + onSuccess: (setupResult) => { + if (setupResult.status !== "started") { + return Effect.void; + } + return recordSetupScriptStarted({ + requestedAt, + worktreePath, + scriptId: setupResult.scriptId, + scriptName: setupResult.scriptName, + terminalId: setupResult.terminalId, + }); + }, + }), + ); + })() + : Effect.void; - return yield* orchestrationEngine.dispatch(finalTurnStartCommand); - }); + const bootstrapProgram = Effect.gen(function* () { + if (bootstrap?.createThread) { + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: serverCommandId("bootstrap-thread-create"), + threadId: command.threadId, + projectId: bootstrap.createThread.projectId, + title: bootstrap.createThread.title, + modelSelection: bootstrap.createThread.modelSelection, + runtimeMode: bootstrap.createThread.runtimeMode, + interactionMode: bootstrap.createThread.interactionMode, + branch: bootstrap.createThread.branch, + worktreePath: bootstrap.createThread.worktreePath, + createdAt: bootstrap.createThread.createdAt, + }); + createdThread = true; + } - return yield* bootstrapProgram.pipe( - Effect.catchCause((cause) => { - const dispatchError = toBootstrapDispatchCommandCauseError(cause); - if (Cause.hasInterruptsOnly(cause)) { - return Effect.fail(dispatchError); + if (bootstrap?.prepareWorktree) { + const worktree = yield* git.createWorktree({ + cwd: bootstrap.prepareWorktree.projectCwd, + branch: bootstrap.prepareWorktree.baseBranch, + newBranch: bootstrap.prepareWorktree.branch, + path: null, + }); + targetWorktreePath = worktree.worktree.path; + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("bootstrap-thread-meta-update"), + threadId: command.threadId, + branch: worktree.worktree.branch, + worktreePath: targetWorktreePath, + }); } - return cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(dispatchError))); - }), - ); - }); - const dispatchNormalizedCommand = ( - normalizedCommand: OrchestrationCommand, - ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => { - const dispatchEffect = - normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap - ? dispatchBootstrapTurnStart(normalizedCommand) - : orchestrationEngine - .dispatch(normalizedCommand) - .pipe( - Effect.mapError((cause) => - toDispatchCommandError(cause, "Failed to dispatch orchestration command"), - ), - ); + yield* runSetupProgram(); - return startup - .enqueueCommand(dispatchEffect) - .pipe( - Effect.mapError((cause) => - toDispatchCommandError(cause, "Failed to dispatch orchestration command"), - ), - ); - }; + return yield* orchestrationEngine.dispatch(finalTurnStartCommand); + }); - const loadServerConfig = Effect.gen(function* () { - const keybindingsConfig = yield* keybindings.loadConfigState; - const providers = yield* providerRegistry.getProviders; - const settings = yield* serverSettings.getSettings; - const environment = yield* serverEnvironment.getDescriptor; + return yield* bootstrapProgram.pipe( + Effect.catchCause((cause) => { + const dispatchError = toBootstrapDispatchCommandCauseError(cause); + if (Cause.hasInterruptsOnly(cause)) { + return Effect.fail(dispatchError); + } + return cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(dispatchError))); + }), + ); + }); - return { - environment, - cwd: config.cwd, - keybindingsConfigPath: config.keybindingsConfigPath, - keybindings: keybindingsConfig.keybindings, - issues: keybindingsConfig.issues, - providers, - availableEditors: resolveAvailableEditors(), - observability: { - logsDirectoryPath: config.logsDir, - localTracingEnabled: true, - ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), - otlpTracesEnabled: config.otlpTracesUrl !== undefined, - ...(config.otlpMetricsUrl !== undefined ? { otlpMetricsUrl: config.otlpMetricsUrl } : {}), - otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, - }, - settings, - }; - }); - - const refreshGitStatus = (cwd: string) => - gitStatusBroadcaster - .refreshStatus(cwd) - .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); - - return WsRpcGroup.of({ - [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.getSnapshot, - projectionSnapshotQuery.getSnapshot().pipe( - Effect.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: "Failed to load orchestration snapshot", - cause, - }), - ), - ), - { "rpc.aggregate": "orchestration" }, - ), - [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.dispatchCommand, - Effect.gen(function* () { - const normalizedCommand = yield* normalizeDispatchCommand(command); - const result = yield* dispatchNormalizedCommand(normalizedCommand); - if (normalizedCommand.type === "thread.archive") { - yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to close thread terminals after archive", { - threadId: normalizedCommand.threadId, - error: error.message, - }), - ), - ); - } - return result; - }).pipe( + const dispatchNormalizedCommand = ( + normalizedCommand: OrchestrationCommand, + ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => { + const dispatchEffect = + normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap + ? dispatchBootstrapTurnStart(normalizedCommand) + : orchestrationEngine + .dispatch(normalizedCommand) + .pipe( + Effect.mapError((cause) => + toDispatchCommandError(cause, "Failed to dispatch orchestration command"), + ), + ); + + return startup + .enqueueCommand(dispatchEffect) + .pipe( Effect.mapError((cause) => - Schema.is(OrchestrationDispatchCommandError)(cause) - ? cause - : new OrchestrationDispatchCommandError({ - message: "Failed to dispatch orchestration command", + toDispatchCommandError(cause, "Failed to dispatch orchestration command"), + ), + ); + }; + + const loadServerConfig = Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.loadConfigState; + const providers = yield* providerRegistry.getProviders; + const settings = yield* serverSettings.getSettings; + const environment = yield* serverEnvironment.getDescriptor; + const auth = yield* serverAuth.getDescriptor(); + + return { + environment, + auth, + cwd: config.cwd, + keybindingsConfigPath: config.keybindingsConfigPath, + keybindings: keybindingsConfig.keybindings, + issues: keybindingsConfig.issues, + providers, + availableEditors: resolveAvailableEditors(), + observability: { + logsDirectoryPath: config.logsDir, + localTracingEnabled: true, + ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), + otlpTracesEnabled: config.otlpTracesUrl !== undefined, + ...(config.otlpMetricsUrl !== undefined + ? { otlpMetricsUrl: config.otlpMetricsUrl } + : {}), + otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, + }, + settings, + }; + }); + + const refreshGitStatus = (cwd: string) => + gitStatusBroadcaster + .refreshStatus(cwd) + .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); + + return WsRpcGroup.of({ + [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getSnapshot, + projectionSnapshotQuery.getSnapshot().pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to load orchestration snapshot", cause, }), + ), ), + { "rpc.aggregate": "orchestration" }, ), - { "rpc.aggregate": "orchestration" }, - ), - [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.getTurnDiff, - checkpointDiffQuery.getTurnDiff(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetTurnDiffError({ - message: "Failed to load turn diff", - cause, - }), + [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.dispatchCommand, + Effect.gen(function* () { + const normalizedCommand = yield* normalizeDispatchCommand(command); + const result = yield* dispatchNormalizedCommand(normalizedCommand); + if (normalizedCommand.type === "thread.archive") { + yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to close thread terminals after archive", { + threadId: normalizedCommand.threadId, + error: error.message, + }), + ), + ); + } + return result; + }).pipe( + Effect.mapError((cause) => + Schema.is(OrchestrationDispatchCommandError)(cause) + ? cause + : new OrchestrationDispatchCommandError({ + message: "Failed to dispatch orchestration command", + cause, + }), + ), ), + { "rpc.aggregate": "orchestration" }, ), - { "rpc.aggregate": "orchestration" }, - ), - [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.getFullThreadDiff, - checkpointDiffQuery.getFullThreadDiff(input).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetFullThreadDiffError({ - message: "Failed to load full thread diff", - cause, - }), + [ORCHESTRATION_WS_METHODS.getTurnDiff]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getTurnDiff, + checkpointDiffQuery.getTurnDiff(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetTurnDiffError({ + message: "Failed to load turn diff", + cause, + }), + ), ), + { "rpc.aggregate": "orchestration" }, ), - { "rpc.aggregate": "orchestration" }, - ), - [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.replayEvents, - Stream.runCollect( - orchestrationEngine.readEvents( - clamp(input.fromSequenceExclusive, { maximum: Number.MAX_SAFE_INTEGER, minimum: 0 }), - ), - ).pipe( - Effect.map((events) => Array.from(events)), - Effect.flatMap(enrichOrchestrationEvents), - Effect.mapError( - (cause) => - new OrchestrationReplayEventsError({ - message: "Failed to replay orchestration events", - cause, - }), + [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.getFullThreadDiff, + checkpointDiffQuery.getFullThreadDiff(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationGetFullThreadDiffError({ + message: "Failed to load full thread diff", + cause, + }), + ), ), + { "rpc.aggregate": "orchestration" }, ), - { "rpc.aggregate": "orchestration" }, - ), - [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) => - observeRpcStreamEffect( - WS_METHODS.subscribeOrchestrationDomainEvents, - Effect.gen(function* () { - const snapshot = yield* orchestrationEngine.getReadModel(); - const fromSequenceExclusive = snapshot.snapshotSequence; - const replayEvents: Array = yield* Stream.runCollect( - orchestrationEngine.readEvents(fromSequenceExclusive), + [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => + observeRpcEffect( + ORCHESTRATION_WS_METHODS.replayEvents, + Stream.runCollect( + orchestrationEngine.readEvents( + clamp(input.fromSequenceExclusive, { + maximum: Number.MAX_SAFE_INTEGER, + minimum: 0, + }), + ), ).pipe( Effect.map((events) => Array.from(events)), Effect.flatMap(enrichOrchestrationEvents), - Effect.catch(() => Effect.succeed([] as Array)), - ); - const replayStream = Stream.fromIterable(replayEvents); - const liveStream = orchestrationEngine.streamDomainEvents.pipe( - Stream.mapEffect(enrichProjectEvent), - ); - const source = Stream.merge(replayStream, liveStream); - type SequenceState = { - readonly nextSequence: number; - readonly pendingBySequence: Map; - }; - const state = yield* Ref.make({ - nextSequence: fromSequenceExclusive + 1, - pendingBySequence: new Map(), - }); + Effect.mapError( + (cause) => + new OrchestrationReplayEventsError({ + message: "Failed to replay orchestration events", + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), + [WS_METHODS.subscribeOrchestrationDomainEvents]: (_input) => + observeRpcStreamEffect( + WS_METHODS.subscribeOrchestrationDomainEvents, + Effect.gen(function* () { + const snapshot = yield* orchestrationEngine.getReadModel(); + const fromSequenceExclusive = snapshot.snapshotSequence; + const replayEvents: Array = yield* Stream.runCollect( + orchestrationEngine.readEvents(fromSequenceExclusive), + ).pipe( + Effect.map((events) => Array.from(events)), + Effect.flatMap(enrichOrchestrationEvents), + Effect.catch(() => Effect.succeed([] as Array)), + ); + const replayStream = Stream.fromIterable(replayEvents); + const liveStream = orchestrationEngine.streamDomainEvents.pipe( + Stream.mapEffect(enrichProjectEvent), + ); + const source = Stream.merge(replayStream, liveStream); + type SequenceState = { + readonly nextSequence: number; + readonly pendingBySequence: Map; + }; + const state = yield* Ref.make({ + nextSequence: fromSequenceExclusive + 1, + pendingBySequence: new Map(), + }); - return source.pipe( - Stream.mapEffect((event) => - Ref.modify( - state, - ({ - nextSequence, - pendingBySequence, - }): [Array, SequenceState] => { - if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { - return [[], { nextSequence, pendingBySequence }]; - } - - const updatedPending = new Map(pendingBySequence); - updatedPending.set(event.sequence, event); - - const emit: Array = []; - let expected = nextSequence; - for (;;) { - const expectedEvent = updatedPending.get(expected); - if (!expectedEvent) { - break; + return source.pipe( + Stream.mapEffect((event) => + Ref.modify( + state, + ({ + nextSequence, + pendingBySequence, + }): [Array, SequenceState] => { + if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) { + return [[], { nextSequence, pendingBySequence }]; } - emit.push(expectedEvent); - updatedPending.delete(expected); - expected += 1; - } - return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; - }, + const updatedPending = new Map(pendingBySequence); + updatedPending.set(event.sequence, event); + + const emit: Array = []; + let expected = nextSequence; + for (;;) { + const expectedEvent = updatedPending.get(expected); + if (!expectedEvent) { + break; + } + emit.push(expectedEvent); + updatedPending.delete(expected); + expected += 1; + } + + return [emit, { nextSequence: expected, pendingBySequence: updatedPending }]; + }, + ), ), - ), - Stream.flatMap((events) => Stream.fromIterable(events)), - ); + Stream.flatMap((events) => Stream.fromIterable(events)), + ); + }), + { "rpc.aggregate": "orchestration" }, + ), + [WS_METHODS.serverGetConfig]: (_input) => + observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { + "rpc.aggregate": "server", }), - { "rpc.aggregate": "orchestration" }, - ), - [WS_METHODS.serverGetConfig]: (_input) => - observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { - "rpc.aggregate": "server", - }), - [WS_METHODS.serverRefreshProviders]: (_input) => - observeRpcEffect( - WS_METHODS.serverRefreshProviders, - providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))), - { "rpc.aggregate": "server" }, - ), - [WS_METHODS.serverUpsertKeybinding]: (rule) => - observeRpcEffect( - WS_METHODS.serverUpsertKeybinding, - Effect.gen(function* () { - const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); - return { keybindings: keybindingsConfig, issues: [] }; + [WS_METHODS.serverRefreshProviders]: (_input) => + observeRpcEffect( + WS_METHODS.serverRefreshProviders, + providerRegistry.refresh().pipe(Effect.map((providers) => ({ providers }))), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.serverUpsertKeybinding]: (rule) => + observeRpcEffect( + WS_METHODS.serverUpsertKeybinding, + Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule); + return { keybindings: keybindingsConfig, issues: [] }; + }), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.serverGetSettings]: (_input) => + observeRpcEffect(WS_METHODS.serverGetSettings, serverSettings.getSettings, { + "rpc.aggregate": "server", + }), + [WS_METHODS.serverUpdateSettings]: ({ patch }) => + observeRpcEffect(WS_METHODS.serverUpdateSettings, serverSettings.updateSettings(patch), { + "rpc.aggregate": "server", }), - { "rpc.aggregate": "server" }, - ), - [WS_METHODS.serverGetSettings]: (_input) => - observeRpcEffect(WS_METHODS.serverGetSettings, serverSettings.getSettings, { - "rpc.aggregate": "server", - }), - [WS_METHODS.serverUpdateSettings]: ({ patch }) => - observeRpcEffect(WS_METHODS.serverUpdateSettings, serverSettings.updateSettings(patch), { - "rpc.aggregate": "server", - }), - [WS_METHODS.projectsSearchEntries]: (input) => - observeRpcEffect( - WS_METHODS.projectsSearchEntries, - workspaceEntries.search(input).pipe( - Effect.mapError( - (cause) => - new ProjectSearchEntriesError({ - message: `Failed to search workspace entries: ${cause.detail}`, + [WS_METHODS.projectsSearchEntries]: (input) => + observeRpcEffect( + WS_METHODS.projectsSearchEntries, + workspaceEntries.search(input).pipe( + Effect.mapError( + (cause) => + new ProjectSearchEntriesError({ + message: `Failed to search workspace entries: ${cause.detail}`, + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.projectsWriteFile]: (input) => + observeRpcEffect( + WS_METHODS.projectsWriteFile, + workspaceFileSystem.writeFile(input).pipe( + Effect.mapError((cause) => { + const message = Schema.is(WorkspacePathOutsideRootError)(cause) + ? "Workspace file path must stay within the project root." + : "Failed to write workspace file"; + return new ProjectWriteFileError({ + message, cause, - }), + }); + }), ), + { "rpc.aggregate": "workspace" }, ), - { "rpc.aggregate": "workspace" }, - ), - [WS_METHODS.projectsWriteFile]: (input) => - observeRpcEffect( - WS_METHODS.projectsWriteFile, - workspaceFileSystem.writeFile(input).pipe( - Effect.mapError((cause) => { - const message = Schema.is(WorkspacePathOutsideRootError)(cause) - ? "Workspace file path must stay within the project root." - : "Failed to write workspace file"; - return new ProjectWriteFileError({ - message, - cause, - }); - }), + [WS_METHODS.shellOpenInEditor]: (input) => + observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { + "rpc.aggregate": "workspace", + }), + [WS_METHODS.subscribeGitStatus]: (input) => + observeRpcStream( + WS_METHODS.subscribeGitStatus, + gitStatusBroadcaster.streamStatus(input), + { + "rpc.aggregate": "git", + }, ), - { "rpc.aggregate": "workspace" }, - ), - [WS_METHODS.shellOpenInEditor]: (input) => - observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { - "rpc.aggregate": "workspace", - }), - [WS_METHODS.subscribeGitStatus]: (input) => - observeRpcStream(WS_METHODS.subscribeGitStatus, gitStatusBroadcaster.streamStatus(input), { - "rpc.aggregate": "git", - }), - [WS_METHODS.gitRefreshStatus]: (input) => - observeRpcEffect( - WS_METHODS.gitRefreshStatus, - gitStatusBroadcaster.refreshStatus(input.cwd), - { - "rpc.aggregate": "git", - }, - ), - [WS_METHODS.gitPull]: (input) => - observeRpcEffect( - WS_METHODS.gitPull, - git.pullCurrentBranch(input.cwd).pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => Effect.failCause(cause), - onSuccess: (result) => - refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), - }), + [WS_METHODS.gitRefreshStatus]: (input) => + observeRpcEffect( + WS_METHODS.gitRefreshStatus, + gitStatusBroadcaster.refreshStatus(input.cwd), + { + "rpc.aggregate": "git", + }, + ), + [WS_METHODS.gitPull]: (input) => + observeRpcEffect( + WS_METHODS.gitPull, + git.pullCurrentBranch(input.cwd).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Effect.failCause(cause), + onSuccess: (result) => + refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), + }), + ), + { "rpc.aggregate": "git" }, ), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.gitRunStackedAction]: (input) => - observeRpcStream( - WS_METHODS.gitRunStackedAction, - Stream.callback((queue) => + [WS_METHODS.gitRunStackedAction]: (input) => + observeRpcStream( + WS_METHODS.gitRunStackedAction, + Stream.callback((queue) => + gitManager + .runStackedAction(input, { + actionId: input.actionId, + progressReporter: { + publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), + }, + }) + .pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Queue.failCause(queue, cause), + onSuccess: () => + refreshGitStatus(input.cwd).pipe( + Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), + ), + }), + ), + ), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.gitResolvePullRequest]: (input) => + observeRpcEffect(WS_METHODS.gitResolvePullRequest, gitManager.resolvePullRequest(input), { + "rpc.aggregate": "git", + }), + [WS_METHODS.gitPreparePullRequestThread]: (input) => + observeRpcEffect( + WS_METHODS.gitPreparePullRequestThread, gitManager - .runStackedAction(input, { - actionId: input.actionId, - progressReporter: { - publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), - }, - }) - .pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => Queue.failCause(queue, cause), - onSuccess: () => - refreshGitStatus(input.cwd).pipe( - Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), - ), - }), - ), + .preparePullRequestThread(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.gitListBranches]: (input) => + observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), { + "rpc.aggregate": "git", + }), + [WS_METHODS.gitCreateWorktree]: (input) => + observeRpcEffect( + WS_METHODS.gitCreateWorktree, + git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.gitRemoveWorktree]: (input) => + observeRpcEffect( + WS_METHODS.gitRemoveWorktree, + git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, ), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.gitResolvePullRequest]: (input) => - observeRpcEffect(WS_METHODS.gitResolvePullRequest, gitManager.resolvePullRequest(input), { - "rpc.aggregate": "git", - }), - [WS_METHODS.gitPreparePullRequestThread]: (input) => - observeRpcEffect( - WS_METHODS.gitPreparePullRequestThread, - gitManager - .preparePullRequestThread(input) - .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.gitListBranches]: (input) => - observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), { - "rpc.aggregate": "git", - }), - [WS_METHODS.gitCreateWorktree]: (input) => - observeRpcEffect( - WS_METHODS.gitCreateWorktree, - git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.gitRemoveWorktree]: (input) => - observeRpcEffect( - WS_METHODS.gitRemoveWorktree, - git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.gitCreateBranch]: (input) => - observeRpcEffect( - WS_METHODS.gitCreateBranch, - git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.gitCheckout]: (input) => - observeRpcEffect( - WS_METHODS.gitCheckout, - Effect.scoped(git.checkoutBranch(input)).pipe( - Effect.tap(() => refreshGitStatus(input.cwd)), + [WS_METHODS.gitCreateBranch]: (input) => + observeRpcEffect( + WS_METHODS.gitCreateBranch, + git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, ), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.gitInit]: (input) => - observeRpcEffect( - WS_METHODS.gitInit, - git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, - ), - [WS_METHODS.terminalOpen]: (input) => - observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalWrite]: (input) => - observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalResize]: (input) => - observeRpcEffect(WS_METHODS.terminalResize, terminalManager.resize(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalClear]: (input) => - observeRpcEffect(WS_METHODS.terminalClear, terminalManager.clear(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalRestart]: (input) => - observeRpcEffect(WS_METHODS.terminalRestart, terminalManager.restart(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.terminalClose]: (input) => - observeRpcEffect(WS_METHODS.terminalClose, terminalManager.close(input), { - "rpc.aggregate": "terminal", - }), - [WS_METHODS.subscribeTerminalEvents]: (_input) => - observeRpcStream( - WS_METHODS.subscribeTerminalEvents, - Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.subscribe((event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), + [WS_METHODS.gitCheckout]: (input) => + observeRpcEffect( + WS_METHODS.gitCheckout, + Effect.scoped(git.checkoutBranch(input)).pipe( + Effect.tap(() => refreshGitStatus(input.cwd)), ), + { "rpc.aggregate": "git" }, ), - { "rpc.aggregate": "terminal" }, - ), - [WS_METHODS.subscribeServerConfig]: (_input) => - observeRpcStreamEffect( - WS_METHODS.subscribeServerConfig, - Effect.gen(function* () { - const keybindingsUpdates = keybindings.streamChanges.pipe( - Stream.map((event) => ({ - version: 1 as const, - type: "keybindingsUpdated" as const, - payload: { - issues: event.issues, - }, - })), - ); - const providerStatuses = providerRegistry.streamChanges.pipe( - Stream.map((providers) => ({ - version: 1 as const, - type: "providerStatuses" as const, - payload: { providers }, - })), - ); - const settingsUpdates = serverSettings.streamChanges.pipe( - Stream.map((settings) => ({ - version: 1 as const, - type: "settingsUpdated" as const, - payload: { settings }, - })), - ); - - return Stream.concat( - Stream.make({ - version: 1 as const, - type: "snapshot" as const, - config: yield* loadServerConfig, - }), - Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)), - ); + [WS_METHODS.gitInit]: (input) => + observeRpcEffect( + WS_METHODS.gitInit, + git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.terminalOpen]: (input) => + observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { + "rpc.aggregate": "terminal", }), - { "rpc.aggregate": "server" }, - ), - [WS_METHODS.subscribeServerLifecycle]: (_input) => - observeRpcStreamEffect( - WS_METHODS.subscribeServerLifecycle, - Effect.gen(function* () { - const snapshot = yield* lifecycleEvents.snapshot; - const snapshotEvents = Array.from(snapshot.events).toSorted( - (left, right) => left.sequence - right.sequence, - ); - const liveEvents = lifecycleEvents.stream.pipe( - Stream.filter((event) => event.sequence > snapshot.sequence), - ); - return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); + [WS_METHODS.terminalWrite]: (input) => + observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { + "rpc.aggregate": "terminal", }), - { "rpc.aggregate": "server" }, - ), - }); - }), -); + [WS_METHODS.terminalResize]: (input) => + observeRpcEffect(WS_METHODS.terminalResize, terminalManager.resize(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.terminalClear]: (input) => + observeRpcEffect(WS_METHODS.terminalClear, terminalManager.clear(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.terminalRestart]: (input) => + observeRpcEffect(WS_METHODS.terminalRestart, terminalManager.restart(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.terminalClose]: (input) => + observeRpcEffect(WS_METHODS.terminalClose, terminalManager.close(input), { + "rpc.aggregate": "terminal", + }), + [WS_METHODS.subscribeTerminalEvents]: (_input) => + observeRpcStream( + WS_METHODS.subscribeTerminalEvents, + Stream.callback((queue) => + Effect.acquireRelease( + terminalManager.subscribe((event) => Queue.offer(queue, event)), + (unsubscribe) => Effect.sync(unsubscribe), + ), + ), + { "rpc.aggregate": "terminal" }, + ), + [WS_METHODS.subscribeServerConfig]: (_input) => + observeRpcStreamEffect( + WS_METHODS.subscribeServerConfig, + Effect.gen(function* () { + const keybindingsUpdates = keybindings.streamChanges.pipe( + Stream.map((event) => ({ + version: 1 as const, + type: "keybindingsUpdated" as const, + payload: { + issues: event.issues, + }, + })), + ); + const providerStatuses = providerRegistry.streamChanges.pipe( + Stream.map((providers) => ({ + version: 1 as const, + type: "providerStatuses" as const, + payload: { providers }, + })), + ); + const settingsUpdates = serverSettings.streamChanges.pipe( + Stream.map((settings) => ({ + version: 1 as const, + type: "settingsUpdated" as const, + payload: { settings }, + })), + ); + + return Stream.concat( + Stream.make({ + version: 1 as const, + type: "snapshot" as const, + config: yield* loadServerConfig, + }), + Stream.merge(keybindingsUpdates, Stream.merge(providerStatuses, settingsUpdates)), + ); + }), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.subscribeServerLifecycle]: (_input) => + observeRpcStreamEffect( + WS_METHODS.subscribeServerLifecycle, + Effect.gen(function* () { + const snapshot = yield* lifecycleEvents.snapshot; + const snapshotEvents = Array.from(snapshot.events).toSorted( + (left, right) => left.sequence - right.sequence, + ); + const liveEvents = lifecycleEvents.stream.pipe( + Stream.filter((event) => event.sequence > snapshot.sequence), + ); + return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); + }), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.subscribeAuthAccess]: (_input) => + observeRpcStreamEffect( + WS_METHODS.subscribeAuthAccess, + Effect.gen(function* () { + const initialSnapshot = yield* loadAuthAccessSnapshot(); + const revisionRef = yield* Ref.make(1); + const accessChanges: Stream.Stream< + BootstrapCredentialChange | SessionCredentialChange + > = Stream.merge(bootstrapCredentials.streamChanges, sessions.streamChanges); + + const liveEvents: Stream.Stream = accessChanges.pipe( + Stream.mapEffect((change) => + Ref.updateAndGet(revisionRef, (revision) => revision + 1).pipe( + Effect.map((revision) => + toAuthAccessStreamEvent(change, revision, currentSessionId), + ), + ), + ), + ); + + return Stream.concat( + Stream.make({ + version: 1 as const, + revision: 1, + type: "snapshot" as const, + payload: initialSnapshot, + }), + liveEvents, + ); + }), + { "rpc.aggregate": "auth" }, + ), + }); + }), + ); export const websocketRpcRouteLayer = Layer.unwrap( - Effect.gen(function* () { - const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { - spanPrefix: "ws.rpc", - spanAttributes: { - "rpc.transport": "websocket", - "rpc.system": "effect-rpc", - }, - }).pipe(Effect.provide(Layer.mergeAll(WsRpcLayer, RpcSerialization.layerJson))); - return HttpRouter.add( + Effect.succeed( + HttpRouter.add( "GET", "/ws", Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; - const config = yield* ServerConfig; - if (config.authToken) { - const url = HttpServerRequest.toURL(request); - if (Option.isNone(url)) { - return HttpServerResponse.text("Invalid WebSocket URL", { status: 400 }); - } - const token = url.value.searchParams.get("token"); - if (token !== config.authToken) { - return HttpServerResponse.text("Unauthorized WebSocket connection", { status: 401 }); - } - } - return yield* rpcWebSocketHttpEffect; - }), - ); - }), + const serverAuth = yield* ServerAuth; + const sessions = yield* SessionCredentialService; + const session = yield* serverAuth.authenticateWebSocketUpgrade(request); + const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { + spanPrefix: "ws.rpc", + spanAttributes: { + "rpc.transport": "websocket", + "rpc.system": "effect-rpc", + }, + }).pipe( + Effect.provide( + makeWsRpcLayer(session.sessionId).pipe(Layer.provideMerge(RpcSerialization.layerJson)), + ), + ); + return yield* Effect.acquireUseRelease( + sessions.markConnected(session.sessionId), + () => rpcWebSocketHttpEffect, + () => sessions.markDisconnected(session.sessionId), + ); + }).pipe(Effect.catchTag("AuthError", respondToAuthError)), + ), + ), ); diff --git a/apps/web/index.html b/apps/web/index.html index 45f30f7164..9f0329b602 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -2,9 +2,84 @@ - + + + + + + T3 Code (Alpha) - -
+
+
+
+ +
+
+
diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts new file mode 100644 index 0000000000..2e12e00f45 --- /dev/null +++ b/apps/web/src/authBootstrap.test.ts @@ -0,0 +1,460 @@ +import type { DesktopBridge } from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +function jsonResponse(body: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(body), { + headers: { + "content-type": "application/json", + }, + status: 200, + ...init, + }); +} + +type TestWindow = { + location: URL; + history: { + replaceState: (_data: unknown, _unused: string, url: string) => void; + }; + desktopBridge?: DesktopBridge; +}; + +function installTestBrowser(url: string) { + const testWindow: TestWindow = { + location: new URL(url), + history: { + replaceState: (_data, _unused, nextUrl) => { + testWindow.location = new URL(nextUrl, testWindow.location.href); + }, + }, + }; + + vi.stubGlobal("window", testWindow); + vi.stubGlobal("document", { title: "T3 Code" }); + + return testWindow; +} + +function sessionResponse(body: unknown, init?: ResponseInit) { + return jsonResponse(body, init); +} + +describe("resolveInitialServerAuthGateState", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + installTestBrowser("http://localhost/"); + }); + + afterEach(async () => { + const { __resetServerAuthBootstrapForTests } = await import("./environments/primary"); + __resetServerAuthBootstrapForTests(); + vi.unstubAllEnvs(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("reuses an in-flight silent bootstrap attempt", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: true, + sessionMethod: "browser-session-cookie", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ) + .mockResolvedValueOnce( + sessionResponse({ + authenticated: true, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + sessionMethod: "browser-session-cookie", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const testWindow = installTestBrowser("http://localhost/"); + testWindow.desktopBridge = { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://localhost:3773", + wsBaseUrl: "ws://localhost:3773", + bootstrapToken: "desktop-bootstrap-token", + }), + } as DesktopBridge; + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await Promise.all([resolveInitialServerAuthGateState(), resolveInitialServerAuthGateState()]); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[0]?.[0]).toBe("http://localhost:3773/api/auth/session"); + expect(fetchMock.mock.calls[1]?.[0]).toBe("http://localhost:3773/api/auth/bootstrap"); + expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3773/api/auth/session"); + }); + + it("uses https fetch urls when the primary environment uses wss", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com"); + vi.stubEnv("VITE_WS_URL", "wss://remote.example.com"); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + + expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/api/auth/session", { + credentials: "include", + }); + }); + + it("uses the current origin as an auth proxy base for local dev environments", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + installTestBrowser("http://localhost:5735/"); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + + expect(fetchMock).toHaveBeenCalledWith("http://localhost:5735/api/auth/session", { + credentials: "include", + }); + }); + + it("returns a requires-auth state instead of throwing when no bootstrap credential exists", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + }); + + it("retries transient auth session bootstrap failures after restart", async () => { + vi.useFakeTimers(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) + .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) + .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) + .mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + const gateStatePromise = resolveInitialServerAuthGateState(); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(gateStatePromise).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + + it("takes a pairing token from the location hash and strips it immediately", async () => { + const testWindow = installTestBrowser("http://localhost/#token=pairing-token"); + const { takePairingTokenFromUrl } = await import("./environments/primary"); + + expect(takePairingTokenFromUrl()).toBe("pairing-token"); + expect(testWindow.location.hash).toBe(""); + expect(testWindow.location.searchParams.get("token")).toBeNull(); + }); + + it("accepts query-string pairing tokens as a backward-compatible fallback", async () => { + const testWindow = installTestBrowser("http://localhost/?token=pairing-token"); + const { takePairingTokenFromUrl } = await import("./environments/primary"); + + expect(takePairingTokenFromUrl()).toBe("pairing-token"); + expect(testWindow.location.searchParams.get("token")).toBeNull(); + }); + + it("allows manual token submission after the initial auth check requires pairing", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: true, + sessionMethod: "browser-session-cookie", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ) + .mockResolvedValueOnce( + sessionResponse({ + authenticated: true, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + sessionMethod: "browser-session-cookie", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ); + vi.stubGlobal("fetch", fetchMock); + installTestBrowser("http://localhost/"); + + const { resolveInitialServerAuthGateState, submitServerAuthCredential } = + await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + await expect(submitServerAuthCredential("retry-token")).resolves.toBeUndefined(); + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => { + vi.useFakeTimers(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + authenticated: true, + sessionMethod: "browser-session-cookie", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ) + .mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ) + .mockResolvedValueOnce( + sessionResponse({ + authenticated: true, + auth: { + policy: "desktop-managed-local", + bootstrapMethods: ["desktop-bootstrap"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + sessionMethod: "browser-session-cookie", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const testWindow = installTestBrowser("http://localhost/"); + testWindow.desktopBridge = { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://localhost:3773", + wsBaseUrl: "ws://localhost:3773", + bootstrapToken: "desktop-bootstrap-token", + }), + } as DesktopBridge; + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + const gateStatePromise = resolveInitialServerAuthGateState(); + await vi.advanceTimersByTimeAsync(100); + + await expect(gateStatePromise).resolves.toEqual({ + status: "authenticated", + }); + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(fetchMock.mock.calls[2]?.[0]).toBe("http://localhost:3773/api/auth/session"); + expect(fetchMock.mock.calls[3]?.[0]).toBe("http://localhost:3773/api/auth/session"); + }); + + it("revalidates the server session state after a previous authenticated result", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + sessionResponse({ + authenticated: true, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + sessionMethod: "browser-session-cookie", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ) + .mockResolvedValueOnce( + sessionResponse({ + authenticated: false, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "authenticated", + }); + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "t3_session", + }, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("creates a pairing credential from the authenticated auth endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + jsonResponse({ + id: "pairing-link-1", + credential: "pairing-token", + label: "Julius iPhone", + expiresAt: "2026-04-05T00:00:00.000Z", + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { createServerPairingCredential } = await import("./environments/primary"); + + await expect(createServerPairingCredential("Julius iPhone")).resolves.toEqual({ + id: "pairing-link-1", + credential: "pairing-token", + label: "Julius iPhone", + expiresAt: "2026-04-05T00:00:00.000Z", + }); + expect(fetchMock).toHaveBeenCalledWith("http://localhost/api/auth/pairing-token", { + body: JSON.stringify({ label: "Julius iPhone" }), + credentials: "include", + headers: { + "content-type": "application/json", + }, + method: "POST", + }); + }); +}); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index c9e336bf48..adc6495953 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,10 +1,17 @@ -import type { GitBranch } from "@t3tools/contracts"; +import type { EnvironmentId, GitBranch, ProjectId } from "@t3tools/contracts"; import { Schema } from "effect"; export { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, } from "@t3tools/shared/git"; +export interface EnvironmentOption { + environmentId: EnvironmentId; + projectId: ProjectId; + label: string; + isPrimary: boolean; +} + export const EnvMode = Schema.Literals(["local", "worktree"]); export type EnvMode = typeof EnvMode.Type; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index cbcaf6f2fe..94d11ae7bd 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,25 +1,19 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { FolderIcon, GitForkIcon } from "lucide-react"; -import { useCallback, useMemo } from "react"; +import { useMemo } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; -import { readEnvironmentApi } from "../environmentApi"; -import { newCommandId } from "../lib/utils"; import { useStore } from "../store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { - EnvMode, - resolveDraftEnvModeAfterBranchChange, + type EnvMode, + type EnvironmentOption, resolveEffectiveEnvMode, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; - -const envModeItems = [ - { value: "local", label: "Local" }, - { value: "worktree", label: "New worktree" }, -] as const; +import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; +import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; +import { Separator } from "./ui/separator"; interface BranchToolbarProps { environmentId: EnvironmentId; @@ -29,9 +23,11 @@ interface BranchToolbarProps { envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; + availableEnvironments?: readonly EnvironmentOption[]; + onEnvironmentChange?: (environmentId: EnvironmentId) => void; } -export default function BranchToolbar({ +export function BranchToolbar({ environmentId, threadId, draftId, @@ -39,6 +35,8 @@ export default function BranchToolbar({ envLocked, onCheckoutPullRequestRequest, onComposerFocusRequest, + availableEnvironments, + onEnvironmentChange, }: BranchToolbarProps) { const threadRef = useMemo( () => scopeThreadRef(environmentId, threadId), @@ -46,10 +44,7 @@ export default function BranchToolbar({ ); const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); const serverThread = useStore(serverThreadSelector); - const serverSession = serverThread?.session ?? null; - const setThreadBranchAction = useStore((store) => store.setThreadBranch); const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); - const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const activeProjectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread @@ -60,131 +55,46 @@ export default function BranchToolbar({ [activeProjectRef], ); const activeProject = useStore(activeProjectSelector); - const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); - const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; + const hasActiveThread = serverThread !== undefined || draftThread !== null; const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const branchCwd = activeWorktreePath ?? activeProject?.cwd ?? null; - const hasServerThread = serverThread !== undefined; const effectiveEnvMode = resolveEffectiveEnvMode({ activeWorktreePath, - hasServerThread, + hasServerThread: serverThread !== undefined, draftThreadEnvMode: draftThread?.envMode, }); - const setThreadBranch = useCallback( - (branch: string | null, worktreePath: string | null) => { - if (!activeThreadId || !activeProject) return; - const api = readEnvironmentApi(environmentId); - // If the effective cwd is about to change, stop the running session so the - // next message creates a new one with the correct cwd. - if (serverSession && worktreePath !== activeWorktreePath && api) { - void api.orchestration - .dispatchCommand({ - type: "thread.session.stop", - commandId: newCommandId(), - threadId: activeThreadId, - createdAt: new Date().toISOString(), - }) - .catch(() => undefined); - } - if (api && hasServerThread) { - void api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: activeThreadId, - branch, - worktreePath, - }); - } - if (hasServerThread) { - setThreadBranchAction(activeThreadId, branch, worktreePath); - return; - } - const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ - nextWorktreePath: worktreePath, - currentWorktreePath: activeWorktreePath, - effectiveEnvMode, - }); - setDraftThreadContext(draftId ?? threadRef, { - branch, - worktreePath, - envMode: nextDraftEnvMode, - projectRef: scopeProjectRef(environmentId, activeProject.id), - }); - }, - [ - activeThreadId, - activeProject, - serverSession, - activeWorktreePath, - hasServerThread, - setThreadBranchAction, - setDraftThreadContext, - draftId, - threadRef, - environmentId, - effectiveEnvMode, - ], - ); + const showEnvironmentPicker = + availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange; - if (!activeThreadId || !activeProject) return null; + if (!hasActiveThread || !activeProject) return null; return (
- {envLocked || activeWorktreePath ? ( - - {activeWorktreePath ? ( - <> - - Worktree - - ) : ( - <> - - Local - - )} - - ) : ( - - )} +
+ {showEnvironmentPicker && ( + <> + + + + )} + +
diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 61a97866ff..11c458bbe9 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,4 +1,5 @@ -import type { EnvironmentId, GitBranch } from "@t3tools/contracts"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import type { EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ChevronDownIcon } from "lucide-react"; @@ -14,15 +15,20 @@ import { useTransition, } from "react"; +import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { readEnvironmentApi } from "../environmentApi"; import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; import { useGitStatus } from "../lib/gitStatusState"; -import { readEnvironmentApi } from "../environmentApi"; +import { newCommandId } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; +import { useStore } from "../store"; +import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { deriveLocalBranchNameFromRemoteRef, - EnvMode, resolveBranchSelectionTarget, resolveBranchToolbarValue, + resolveDraftEnvModeAfterBranchChange, + resolveEffectiveEnvMode, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; import { Button } from "./ui/button"; @@ -40,13 +46,9 @@ import { toastManager } from "./ui/toast"; interface BranchToolbarBranchSelectorProps { environmentId: EnvironmentId; - activeProjectCwd: string; - activeThreadBranch: string | null; - activeWorktreePath: string | null; - branchCwd: string | null; - effectiveEnvMode: EnvMode; + threadId: ThreadId; + draftId?: DraftId; envLocked: boolean; - onSetThreadBranch: (branch: string | null, worktreePath: string | null) => void; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -57,7 +59,7 @@ function toBranchActionErrorMessage(error: unknown): string { function getBranchTriggerLabel(input: { activeWorktreePath: string | null; - effectiveEnvMode: EnvMode; + effectiveEnvMode: "local" | "worktree"; resolvedActiveBranch: string | null; }): string { const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; @@ -72,16 +74,109 @@ function getBranchTriggerLabel(input: { export function BranchToolbarBranchSelector({ environmentId, - activeProjectCwd, - activeThreadBranch, - activeWorktreePath, - branchCwd, - effectiveEnvMode, + threadId, + draftId, envLocked, - onSetThreadBranch, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { + // --------------------------------------------------------------------------- + // Thread / project state (pushed down from parent to colocate with mutation) + // --------------------------------------------------------------------------- + const threadRef = useMemo( + () => scopeThreadRef(environmentId, threadId), + [environmentId, threadId], + ); + const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); + const serverThread = useStore(serverThreadSelector); + const serverSession = serverThread?.session ?? null; + const setThreadBranchAction = useStore((store) => store.setThreadBranch); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + + const activeProjectRef = serverThread + ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) + : draftThread + ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) + : null; + const activeProjectSelector = useMemo( + () => createProjectSelectorByRef(activeProjectRef), + [activeProjectRef], + ); + const activeProject = useStore(activeProjectSelector); + + const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); + const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null; + const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + const activeProjectCwd = activeProject?.cwd ?? null; + const branchCwd = activeWorktreePath ?? activeProjectCwd; + const hasServerThread = serverThread !== undefined; + const effectiveEnvMode = resolveEffectiveEnvMode({ + activeWorktreePath, + hasServerThread, + draftThreadEnvMode: draftThread?.envMode, + }); + + // --------------------------------------------------------------------------- + // Thread branch mutation (colocated — only this component calls it) + // --------------------------------------------------------------------------- + const setThreadBranch = useCallback( + (branch: string | null, worktreePath: string | null) => { + if (!activeThreadId || !activeProject) return; + const api = readEnvironmentApi(environmentId); + if (serverSession && worktreePath !== activeWorktreePath && api) { + void api.orchestration + .dispatchCommand({ + type: "thread.session.stop", + commandId: newCommandId(), + threadId: activeThreadId, + createdAt: new Date().toISOString(), + }) + .catch(() => undefined); + } + if (api && hasServerThread) { + void api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: activeThreadId, + branch, + worktreePath, + }); + } + if (hasServerThread) { + setThreadBranchAction(activeThreadId, branch, worktreePath); + return; + } + const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ + nextWorktreePath: worktreePath, + currentWorktreePath: activeWorktreePath, + effectiveEnvMode, + }); + setDraftThreadContext(draftId ?? threadRef, { + branch, + worktreePath, + envMode: nextDraftEnvMode, + projectRef: scopeProjectRef(environmentId, activeProject.id), + }); + }, + [ + activeThreadId, + activeProject, + serverSession, + activeWorktreePath, + hasServerThread, + setThreadBranchAction, + setDraftThreadContext, + draftId, + threadRef, + environmentId, + effectiveEnvMode, + ], + ); + + // --------------------------------------------------------------------------- + // Git branch queries + // --------------------------------------------------------------------------- const queryClient = useQueryClient(); const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); @@ -183,6 +278,9 @@ export function BranchToolbarBranchSelector({ ? `Showing ${branches.length} of ${totalBranchCount} branches` : null; + // --------------------------------------------------------------------------- + // Branch actions + // --------------------------------------------------------------------------- const runBranchAction = (action: () => Promise) => { startBranchActionTransition(async () => { await action().catch(() => undefined); @@ -194,11 +292,10 @@ export function BranchToolbarBranchSelector({ const selectBranch = (branch: GitBranch) => { const api = readEnvironmentApi(environmentId); - if (!api || !branchCwd || isBranchActionPending) return; + if (!api || !branchCwd || !activeProjectCwd || isBranchActionPending) return; - // In new-worktree mode, selecting a branch sets the base branch. if (isSelectingWorktreeBase) { - onSetThreadBranch(branch.name, null); + setThreadBranch(branch.name, null); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; @@ -210,9 +307,8 @@ export function BranchToolbarBranchSelector({ branch, }); - // If the branch already lives in a worktree, point the thread there. if (selectionTarget.reuseExistingWorktree) { - onSetThreadBranch(branch.name, selectionTarget.nextWorktreePath); + setThreadBranch(branch.name, selectionTarget.nextWorktreePath); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; @@ -237,7 +333,7 @@ export function BranchToolbarBranchSelector({ ? (checkoutResult.branch ?? selectedBranchName) : selectedBranchName; setOptimisticBranch(nextBranchName); - onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); + setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); } catch (error) { setOptimisticBranch(previousBranch); toastManager.add({ @@ -267,7 +363,7 @@ export function BranchToolbarBranchSelector({ checkout: true, }); setOptimisticBranch(createBranchResult.branch); - onSetThreadBranch(createBranchResult.branch, activeWorktreePath); + setThreadBranch(createBranchResult.branch, activeWorktreePath); } catch (error) { setOptimisticBranch(previousBranch); toastManager.add({ @@ -288,15 +384,12 @@ export function BranchToolbarBranchSelector({ ) { return; } - onSetThreadBranch(currentGitBranch, null); - }, [ - activeThreadBranch, - activeWorktreePath, - currentGitBranch, - effectiveEnvMode, - onSetThreadBranch, - ]); + setThreadBranch(currentGitBranch, null); + }, [activeThreadBranch, activeWorktreePath, currentGitBranch, effectiveEnvMode, setThreadBranch]); + // --------------------------------------------------------------------------- + // Combobox / virtualizer plumbing + // --------------------------------------------------------------------------- const handleOpenChange = useCallback( (open: boolean) => { setIsBranchMenuOpen(open); @@ -437,7 +530,7 @@ export function BranchToolbarBranchSelector({ style={style} onClick={() => createBranch(trimmedBranchQuery)} > - Create new branch "{trimmedBranchQuery}" + Create new branch "{trimmedBranchQuery}" ); } @@ -445,7 +538,8 @@ export function BranchToolbarBranchSelector({ const branch = branchByName.get(itemValue); if (!branch) return null; - const hasSecondaryWorktree = branch.worktreePath && branch.worktreePath !== activeProjectCwd; + const hasSecondaryWorktree = + branch.worktreePath && activeProjectCwd && branch.worktreePath !== activeProjectCwd; const badge = branch.current ? "current" : hasSecondaryWorktree diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx new file mode 100644 index 0000000000..e1145f65f4 --- /dev/null +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -0,0 +1,73 @@ +import { FolderIcon, GitForkIcon } from "lucide-react"; +import { memo } from "react"; + +import type { EnvMode } from "./BranchToolbar.logic"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; + +const envModeItems = [ + { value: "local", label: "Local" }, + { value: "worktree", label: "New worktree" }, +] as const; + +interface BranchToolbarEnvModeSelectorProps { + envLocked: boolean; + effectiveEnvMode: EnvMode; + activeWorktreePath: string | null; + onEnvModeChange: (mode: EnvMode) => void; +} + +export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ + envLocked, + effectiveEnvMode, + activeWorktreePath, + onEnvModeChange, +}: BranchToolbarEnvModeSelectorProps) { + if (envLocked || activeWorktreePath) { + return ( + + {activeWorktreePath ? ( + <> + + Worktree + + ) : ( + <> + + Local + + )} + + ); + } + + return ( + + ); +}); diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx new file mode 100644 index 0000000000..1725e8e909 --- /dev/null +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -0,0 +1,65 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { CloudIcon, FolderIcon, ServerIcon } from "lucide-react"; +import { memo, useMemo } from "react"; + +import type { EnvironmentOption } from "./BranchToolbar.logic"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; + +interface BranchToolbarEnvironmentSelectorProps { + envLocked: boolean; + environmentId: EnvironmentId; + availableEnvironments: readonly EnvironmentOption[]; + onEnvironmentChange: (environmentId: EnvironmentId) => void; +} + +export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ + envLocked, + environmentId, + availableEnvironments, + onEnvironmentChange, +}: BranchToolbarEnvironmentSelectorProps) { + const activeEnvironmentLabel = useMemo(() => { + return availableEnvironments.find((env) => env.environmentId === environmentId)?.label ?? null; + }, [availableEnvironments, environmentId]); + + const environmentItems = useMemo( + () => + availableEnvironments.map((env) => ({ + value: env.environmentId, + label: env.label, + })), + [availableEnvironments], + ); + + if (envLocked) { + return ( + + + {activeEnvironmentLabel ?? "Environment"} + + ); + } + + return ( + + ); +}); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index f0c4a52c5f..24de35959e 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -44,6 +44,7 @@ import { getRouter } from "../router"; import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; +import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; import { estimateTimelineMessageHeight } from "./timelineHeight"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; @@ -148,6 +149,12 @@ function createBaseServerConfig(): ServerConfig { serverVersion: "0.0.0-test", capabilities: { repositoryIdentity: true }, }, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: "t3_session", + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], @@ -821,6 +828,7 @@ const worker = setupWorker( void rpcHarness.onMessage(rawData); }); }), + ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), http.get("*/attachments/:attachmentId", () => HttpResponse.text(ATTACHMENT_SVG, { headers: { @@ -2629,10 +2637,9 @@ describe("ChatView timeline estimator parity (full app)", () => { "Promoted drafts should canonicalize to the server thread route.", ); - // The empty thread view and composer should still be visible. - await expect - .element(page.getByText("Send a message to start the conversation.")) - .toBeInTheDocument(); + // The composer should remain usable after canonicalization, regardless of + // whether the promoted thread is still visibly empty or has already + // entered the running state. await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); } finally { await mounted.cleanup(); @@ -2992,6 +2999,17 @@ describe("ChatView timeline estimator parity (full app)", () => { const promotedThreadId = draftThreadIdFor(promotedDraftId); await promoteDraftThreadViaDomainEvent(promotedThreadId); + await waitForURL( + mounted.router, + (path) => path === serverThreadPath(promotedThreadId), + "Promoted drafts should canonicalize to the server thread route before a fresh draft is created.", + ); + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().getDraftThread(promotedDraftId)).toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); const freshThreadPath = await triggerChatNewShortcutUntilPath( mounted.router, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cf09dd8a38..53bfe2324b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -38,6 +38,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; +import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; @@ -75,7 +76,11 @@ import { togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; -import { selectThreadsAcrossEnvironments, useStore } from "../store"; +import { + selectProjectsAcrossEnvironments, + selectThreadsAcrossEnvironments, + useStore, +} from "../store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { @@ -97,7 +102,7 @@ import { import { basenameOfPath } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import BranchToolbar from "./BranchToolbar"; +import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; @@ -124,7 +129,6 @@ import { nextProjectScriptId, projectScriptIdFromCommand, } from "~/projectScripts"; -import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, @@ -135,6 +139,10 @@ import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; import { deriveLogicalProjectKey } from "../logicalProject"; +import { + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "../environments/runtime"; import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, @@ -167,6 +175,7 @@ import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; import { ContextWindowMeter } from "./chat/ContextWindowMeter"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; +import { NoActiveThreadState } from "./NoActiveThreadState"; import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; import { ComposerCommandItem, ComposerCommandMenu } from "./chat/ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./chat/ComposerPendingApprovalActions"; @@ -212,7 +221,6 @@ import { } from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; -const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; @@ -813,7 +821,7 @@ export default function ChatView(props: ChatViewProps) { const composerMenuItemsRef = useRef([]); const activeComposerMenuItemRef = useRef(null); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); - const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef>({}); + const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); @@ -998,6 +1006,54 @@ export default function ChatView(props: ChatViewProps) { useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), ); + // Compute the list of environments this logical project spans, used to + // drive the environment picker in BranchToolbar. + const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); + const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); + const logicalProjectEnvironments = useMemo(() => { + if (!activeProject) return []; + const logicalKey = deriveLogicalProjectKey(activeProject); + const memberProjects = allProjects.filter((p) => deriveLogicalProjectKey(p) === logicalKey); + const seen = new Set(); + const envs: Array<{ + environmentId: EnvironmentId; + projectId: ProjectId; + label: string; + isPrimary: boolean; + }> = []; + for (const p of memberProjects) { + if (seen.has(p.environmentId)) continue; + seen.add(p.environmentId); + const isPrimary = p.environmentId === primaryEnvironmentId; + const savedRecord = savedEnvironmentRegistry[p.environmentId]; + const runtimeState = savedEnvironmentRuntimeById[p.environmentId]; + const label = isPrimary + ? "Local" + : (runtimeState?.descriptor?.label ?? savedRecord?.label ?? p.environmentId); + envs.push({ + environmentId: p.environmentId, + projectId: p.id, + label, + isPrimary, + }); + } + // Sort: primary first, then alphabetical + envs.sort((a, b) => { + if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1; + return a.label.localeCompare(b.label); + }); + return envs; + }, [ + activeProject, + allProjects, + primaryEnvironmentId, + savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + ]); + const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; + const openPullRequestDialog = useCallback( (reference?: string) => { if (!canCheckoutPullRequestIntoThread) { @@ -1127,7 +1183,17 @@ export default function ChatView(props: ChatViewProps) { const lockedProvider: ProviderKind | null = hasThreadStarted ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) : null; - const serverConfig = useServerConfig(); + const primaryServerConfig = useServerConfig(); + const activeEnvRuntimeState = useSavedEnvironmentRuntimeStore((s) => + activeThread?.environmentId ? s.byId[activeThread.environmentId] : null, + ); + // Use the server config for the thread's environment. For the primary + // environment fall back to the global atom; for remote environments use + // the runtime state stored by the environment manager. + const serverConfig = + primaryEnvironmentId && activeThread?.environmentId === primaryEnvironmentId + ? primaryServerConfig + : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, @@ -1338,11 +1404,28 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId; }, [attachmentPreviewHandoffByMessageId]); + const clearAttachmentPreviewHandoff = useCallback( + (messageId: MessageId, previewUrls?: ReadonlyArray) => { + delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; + const currentPreviewUrls = + previewUrls ?? attachmentPreviewHandoffByMessageIdRef.current[messageId] ?? []; + setAttachmentPreviewHandoffByMessageId((existing) => { + if (!(messageId in existing)) { + return existing; + } + const next = { ...existing }; + delete next[messageId]; + attachmentPreviewHandoffByMessageIdRef.current = next; + return next; + }); + for (const previewUrl of currentPreviewUrls) { + revokeBlobPreviewUrl(previewUrl); + } + }, + [], + ); const clearAttachmentPreviewHandoffs = useCallback(() => { - for (const timeoutId of Object.values(attachmentPreviewHandoffTimeoutByMessageIdRef.current)) { - window.clearTimeout(timeoutId); - } - attachmentPreviewHandoffTimeoutByMessageIdRef.current = {}; + attachmentPreviewPromotionInFlightByMessageIdRef.current = {}; for (const previewUrls of Object.values(attachmentPreviewHandoffByMessageIdRef.current)) { for (const previewUrl of previewUrls) { revokeBlobPreviewUrl(previewUrl); @@ -1376,29 +1459,89 @@ export default function ChatView(props: ChatViewProps) { attachmentPreviewHandoffByMessageIdRef.current = next; return next; }); - - const existingTimeout = attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; - if (typeof existingTimeout === "number") { - window.clearTimeout(existingTimeout); + }, []); + const serverMessages = activeThread?.messages; + useEffect(() => { + if (typeof Image === "undefined" || !serverMessages || serverMessages.length === 0) { + return; } - attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId] = window.setTimeout(() => { - const currentPreviewUrls = attachmentPreviewHandoffByMessageIdRef.current[messageId]; - if (currentPreviewUrls) { - for (const previewUrl of currentPreviewUrls) { - revokeBlobPreviewUrl(previewUrl); - } + + const cleanups: Array<() => void> = []; + + for (const [messageId, handoffPreviewUrls] of Object.entries( + attachmentPreviewHandoffByMessageId, + )) { + if (attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]) { + continue; } - setAttachmentPreviewHandoffByMessageId((existing) => { - if (!(messageId in existing)) return existing; - const next = { ...existing }; - delete next[messageId]; - attachmentPreviewHandoffByMessageIdRef.current = next; - return next; + + const serverMessage = serverMessages.find( + (message) => message.id === messageId && message.role === "user", + ); + if (!serverMessage?.attachments || serverMessage.attachments.length === 0) { + continue; + } + + const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) => + attachment.type === "image" && attachment.previewUrl ? [attachment.previewUrl] : [], + ); + if ( + serverPreviewUrls.length === 0 || + serverPreviewUrls.length !== handoffPreviewUrls.length || + serverPreviewUrls.some((previewUrl) => previewUrl.startsWith("blob:")) + ) { + continue; + } + + attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId] = true; + + let cancelled = false; + const imageInstances: HTMLImageElement[] = []; + + const preloadServerPreviews = Promise.all( + serverPreviewUrls.map( + (previewUrl) => + new Promise((resolve, reject) => { + const image = new Image(); + imageInstances.push(image); + const handleLoad = () => resolve(); + const handleError = () => + reject(new Error(`Failed to load server preview for ${messageId}.`)); + image.addEventListener("load", handleLoad, { once: true }); + image.addEventListener("error", handleError, { once: true }); + image.src = previewUrl; + }), + ), + ); + + void preloadServerPreviews + .then(() => { + if (cancelled) { + return; + } + clearAttachmentPreviewHandoff(messageId as MessageId, handoffPreviewUrls); + }) + .catch(() => { + if (!cancelled) { + delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; + } + }); + + cleanups.push(() => { + cancelled = true; + delete attachmentPreviewPromotionInFlightByMessageIdRef.current[messageId]; + for (const image of imageInstances) { + image.src = ""; + } }); - delete attachmentPreviewHandoffTimeoutByMessageIdRef.current[messageId]; - }, ATTACHMENT_PREVIEW_HANDOFF_TTL_MS); - }, []); - const serverMessages = activeThread?.messages; + } + + return () => { + for (const cleanup of cleanups) { + cleanup(); + } + }; + }, [attachmentPreviewHandoffByMessageId, clearAttachmentPreviewHandoff, serverMessages]); const timelineMessages = useMemo(() => { const messages = serverMessages ?? []; const serverMessagesWithPreviewHandoff = @@ -1537,7 +1680,9 @@ export default function ChatView(props: ChatViewProps) { const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); - const modelOptionsByProvider = useMemo( + const modelOptionsByProvider = useMemo< + Record> + >( () => ({ codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], claudeAgent: @@ -1730,6 +1875,47 @@ export default function ChatView(props: ChatViewProps) { (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); + + // Handle environment change for draft threads. When the user picks a + // different environment we update the draft context to point at the physical + // project in that environment while keeping the same logical project. + const onEnvironmentChange = useCallback( + (nextEnvironmentId: EnvironmentId) => { + if (envLocked || !draftId) return; + const target = logicalProjectEnvironments.find( + (env) => env.environmentId === nextEnvironmentId, + ); + if (!target) return; + setDraftThreadContext(draftId, { + projectRef: scopeProjectRef(target.environmentId, target.projectId), + }); + }, + [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], + ); + + // Auto-correct the draft model selection when the environment changes and + // the previously-selected provider/model is no longer available. This keeps + // the stored draft state consistent with the resolved picker values and + // prevents stale model references when the user sends a message. + const prevEnvironmentIdRef = useRef(activeThread?.environmentId); + useEffect(() => { + const currentEnvId = activeThread?.environmentId; + if (!currentEnvId || envLocked || prevEnvironmentIdRef.current === currentEnvId) { + prevEnvironmentIdRef.current = currentEnvId; + return; + } + prevEnvironmentIdRef.current = currentEnvId; + + // The resolved provider/model already account for the new environment's + // provider list. Persist that resolved selection into the draft. + if (activeThread) { + setComposerDraftModelSelection(scopeThreadRef(activeThread.environmentId, activeThread.id), { + provider: selectedProvider, + model: selectedModel, + }); + } + }, [activeThread, envLocked, selectedModel, selectedProvider, setComposerDraftModelSelection]); + const activeTerminalGroup = terminalState.terminalGroups.find( (group) => group.id === terminalState.activeTerminalGroupId, @@ -4113,28 +4299,7 @@ export default function ChatView(props: ChatViewProps) { // Empty state: no active thread if (!activeThread) { - return ( -
- {!isElectron && ( -
-
- - Threads -
-
- )} - {isElectron && ( -
- No active thread -
- )} -
-
-

Select a thread or create a new one to get started.

-
-
-
- ); + return ; } return ( @@ -4636,6 +4801,12 @@ export default function ChatView(props: ChatViewProps) { {...(canCheckoutPullRequestIntoThread ? { onCheckoutPullRequestRequest: openPullRequestDialog } : {})} + {...(hasMultipleEnvironments + ? { + availableEnvironments: logicalProjectEnvironments, + onEnvironmentChange, + } + : {})} /> )} {pullRequestDialogState ? ( diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index 1a1bd714f3..29a2f178ac 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -44,8 +44,12 @@ const { toastUpdateSpy: vi.fn(), })); -vi.mock("@tanstack/react-query", () => { +vi.mock("@tanstack/react-query", async () => { + const actual = + await vi.importActual("@tanstack/react-query"); + return { + ...actual, useIsMutating: vi.fn(() => 0), useMutation: vi.fn((options: { __kind?: string }) => { if (options.__kind === "run-stacked-action") { @@ -133,6 +137,19 @@ vi.mock("~/store", () => ({ } return environmentState; }, + selectProjectsForEnvironment: () => [], + selectProjectsAcrossEnvironments: () => [], + selectThreadsForEnvironment: () => [], + selectThreadsAcrossEnvironments: () => [], + selectThreadShellsAcrossEnvironments: () => [], + selectSidebarThreadsAcrossEnvironments: () => [], + selectSidebarThreadsForProjectRef: () => [], + selectSidebarThreadsForProjectRefs: () => [], + selectBootstrapCompleteForActiveEnvironment: () => true, + selectProjectByRef: () => null, + selectThreadByRef: () => null, + selectSidebarThreadSummaryByRef: () => null, + selectThreadIdsByProjectRef: () => [], useStore: (selector: (state: unknown) => unknown) => selector({ setThreadBranch: setThreadBranchSpy, diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 6ec450d662..f1239ffecb 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -22,8 +22,10 @@ import { useComposerDraftStore } from "../composerDraftStore"; import { __resetLocalApiForTests } from "../localApi"; import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; import { getServerConfig } from "../rpc/serverState"; +import { getWsConnectionStatus } from "../rpc/wsConnectionState"; import { getRouter } from "../router"; import { useStore } from "../store"; +import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; import { BrowserWsRpcHarness } from "../../test/wsRpcHarness"; vi.mock("../lib/gitStatusState", () => ({ @@ -58,6 +60,12 @@ function createBaseServerConfig(): ServerConfig { serverVersion: "0.0.0-test", capabilities: { repositoryIdentity: true }, }, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: "t3_session", + }, cwd: "/repo/project", keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", keybindings: [], @@ -210,6 +218,7 @@ const worker = setupWorker( void rpcHarness.onMessage(rawData); }); }), + ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), ); @@ -250,6 +259,22 @@ async function waitForComposerEditor(): Promise { ); } +async function waitForToastViewport(): Promise { + return waitForElement( + () => document.querySelector('[data-slot="toast-viewport"]'), + "App should render the toast viewport before server config updates are pushed", + ); +} + +async function waitForWsConnection(): Promise { + await vi.waitFor( + () => { + expect(getWsConnectionStatus().phase).toBe("connected"); + }, + { timeout: 8_000, interval: 16 }, + ); +} + async function waitForToast(title: string, count = 1): Promise { await vi.waitFor( () => { @@ -269,6 +294,20 @@ async function waitForNoToast(title: string): Promise { ); } +async function waitForNoToasts(): Promise { + await vi.waitFor( + () => { + expect(queryToastTitles()).toHaveLength(0); + }, + { timeout: 8_000, interval: 16 }, + ); +} + +async function warmServerConfigUpdateStream(): Promise { + sendServerConfigUpdatedPush([]); + await new Promise((resolve) => setTimeout(resolve, 50)); +} + async function waitForInitialWsSubscriptions(): Promise { await vi.waitFor( () => { @@ -313,8 +352,11 @@ async function mountApp(): Promise<{ cleanup: () => Promise }> { { container: host }, ); await waitForComposerEditor(); + await waitForToastViewport(); await waitForInitialWsSubscriptions(); + await waitForWsConnection(); await waitForServerConfigSnapshot(); + await waitForNoToasts(); return { cleanup: async () => { @@ -387,6 +429,8 @@ describe("Keybindings update toast", () => { const mounted = await mountApp(); try { + await warmServerConfigUpdateStream(); + sendServerConfigUpdatedPush([]); await waitForToast("Keybindings updated", 1); diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx new file mode 100644 index 0000000000..39aa4e2f72 --- /dev/null +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -0,0 +1,41 @@ +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; +import { SidebarInset, SidebarTrigger } from "./ui/sidebar"; +import { isElectron } from "../env"; +import { cn } from "~/lib/utils"; + +export function NoActiveThreadState() { + return ( + +
+
+ {isElectron ? ( + No active thread + ) : ( +
+ + + No active thread + +
+ )} +
+ + +
+ + Pick a thread to continue + + Select an existing thread or create a new one to get started. + + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 58426f50ba..38e07f59ce 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,14 +1,19 @@ +import type { EnvironmentId } from "@t3tools/contracts"; import { FolderIcon } from "lucide-react"; import { useState } from "react"; -import { resolveServerUrl } from "~/lib/utils"; +import { resolveEnvironmentHttpUrl } from "../environments/runtime"; const loadedProjectFaviconSrcs = new Set(); -export function ProjectFavicon({ cwd, className }: { cwd: string; className?: string }) { - const src = resolveServerUrl({ - protocol: "http", +export function ProjectFavicon(input: { + environmentId: EnvironmentId; + cwd: string; + className?: string; +}) { + const src = resolveEnvironmentHttpUrl({ + environmentId: input.environmentId, pathname: "/api/project-favicon", - searchParams: { cwd }, + searchParams: { cwd: input.cwd }, }); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", @@ -17,12 +22,14 @@ export function ProjectFavicon({ cwd, className }: { cwd: string; className?: st return ( <> {status !== "loaded" ? ( - + ) : null} { loadedProjectFaviconSrcs.add(src); setStatus("loaded"); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9181434547..6dbd5605c3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2,6 +2,7 @@ import { ArchiveIcon, ArrowUpDownIcon, ChevronRightIcon, + CloudIcon, FolderIcon, GitPullRequestIcon, PlusIcon, @@ -34,6 +35,7 @@ import { type DesktopUpdateState, type EnvironmentId, ProjectId, + type ScopedProjectRef, type ScopedThreadRef, type ThreadEnvMode, ThreadId, @@ -50,13 +52,16 @@ import { type SidebarProjectSortOrder, type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; +import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { + selectProjectByRef, selectProjectsAcrossEnvironments, selectSidebarThreadsForProjectRef, + selectSidebarThreadsForProjectRefs, selectSidebarThreadsAcrossEnvironments, selectThreadByRef, useStore, @@ -103,7 +108,6 @@ import { SidebarFooter, SidebarGroup, SidebarHeader, - SidebarMenuAction, SidebarMenu, SidebarMenuButton, SidebarMenuItem, @@ -135,6 +139,11 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { useServerKeybindings } from "../rpc/serverState"; +import { deriveLogicalProjectKey } from "../logicalProject"; +import { + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, +} from "../environments/runtime"; import type { Project, SidebarThreadSummary } from "../types"; const THREAD_PREVIEW_LIMIT = 6; const SIDEBAR_SORT_LABELS: Record = { @@ -200,8 +209,14 @@ function buildThreadJumpLabelMap(input: { return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; } +type EnvironmentPresence = "local-only" | "remote-only" | "mixed"; + type SidebarProjectSnapshot = Project & { projectKey: string; + environmentPresence: EnvironmentPresence; + memberProjectRefs: readonly ScopedProjectRef[]; + /** Labels for remote environments this project lives in. */ + remoteEnvironmentLabels: readonly string[]; }; interface TerminalStatusIndicator { label: "Terminal process running"; @@ -381,7 +396,30 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP (state) => selectThreadTerminalState(state.terminalStateByThreadKey, threadRef).runningTerminalIds, ); - const gitCwd = thread.worktreePath ?? props.projectCwd; + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const isRemoteThread = + primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const remoteEnvLabel = useSavedEnvironmentRuntimeStore( + (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null, + ); + const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( + (s) => s.byId[thread.environmentId]?.label ?? null, + ); + const threadEnvironmentLabel = isRemoteThread + ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") + : null; + // For grouped projects, the thread may belong to a different environment + // than the representative project. Look up the thread's own project cwd + // so git status (and thus PR detection) queries the correct path. + const threadProjectCwd = useStore( + useMemo( + () => (state: import("../store").AppState) => + selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? + null, + [thread.environmentId, thread.projectId], + ), + ); + const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; const gitStatus = useGitStatus({ environmentId: thread.environmentId, cwd: thread.branch != null ? gitCwd : null, @@ -673,24 +711,41 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) ) : null} - {jumpLabel ? ( - - {jumpLabel} - - ) : ( - - {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} - - )} + + {isRemoteThread && ( + + + } + > + + + {threadEnvironmentLabel} + + )} + {jumpLabel ? ( + + {jumpLabel} + + ) : ( + + {formatRelativeTimeLabel(thread.updatedAt ?? thread.createdAt)} + + )} +
@@ -998,7 +1053,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const sidebarThreads = useStore( useShallow( useMemo( - () => (state) => + () => (state: import("../store").AppState) => selectSidebarThreadsForProjectRef( state, scopeProjectRef(project.environmentId, project.id), @@ -1007,25 +1062,45 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ), ), ); + // For grouped projects that span multiple environments, also fetch + // threads from the other member project refs. + const otherMemberRefs = useMemo( + () => + project.memberProjectRefs.filter( + (ref) => ref.environmentId !== project.environmentId || ref.projectId !== project.id, + ), + [project.memberProjectRefs, project.environmentId, project.id], + ); + const otherMemberThreads = useStore( + useShallow( + useMemo( + () => + otherMemberRefs.length === 0 + ? () => [] as SidebarThreadSummary[] + : (state: import("../store").AppState) => + selectSidebarThreadsForProjectRefs(state, otherMemberRefs), + [otherMemberRefs], + ), + ), + ); + const allSidebarThreads = useMemo( + () => + otherMemberThreads.length === 0 ? sidebarThreads : [...sidebarThreads, ...otherMemberThreads], + [sidebarThreads, otherMemberThreads], + ); const sidebarThreadByKey = useMemo( () => new Map( - sidebarThreads.map( + allSidebarThreads.map( (thread) => [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, ), ), - [sidebarThreads], - ); - const projectThreads = useMemo( - () => - sidebarThreads.filter( - (thread) => - scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)) === - project.projectKey, - ), - [project.projectKey, sidebarThreads], + [allSidebarThreads], ); + // All threads from the representative + other member environments are + // already fetched into allSidebarThreads, so we can use them directly. + const projectThreads = allSidebarThreads; const projectExpanded = useUiStateStore( (state) => state.projectExpandedById[project.projectKey] ?? true, ); @@ -1639,28 +1714,49 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }`} /> )} - + {project.name} + {/* Environment badge – visible by default, crossfades with the + "new thread" button on hover using the same pointer-events + + opacity pattern as the thread row archive/timestamp swap. */} + {project.environmentPresence === "remote-only" && ( + + + } + > + + + + Remote environment: {project.remoteEnvironmentLabels.join(", ")} + + + )} - } - showOnHover - className="top-1 right-1.5 size-5 rounded-md p-0 text-muted-foreground/70 hover:bg-secondary hover:text-foreground" - onClick={handleCreateThreadClick} - > - - +
+ +
} /> @@ -2238,7 +2334,9 @@ export default function Sidebar() { const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const isOnSettings = pathname.startsWith("/settings"); - const appSettings = useSettings(); + const sidebarThreadSortOrder = useSettings((s) => s.sidebarThreadSortOrder); + const sidebarProjectSortOrder = useSettings((s) => s.sidebarProjectSortOrder); + const defaultThreadEnvMode = useSettings((s) => s.defaultThreadEnvMode); const { updateSettings } = useUpdateSettings(); const { handleNewThread } = useHandleNewThread(); const { archiveThread, deleteThread } = useThreadActions(); @@ -2269,6 +2367,9 @@ export default function Sidebar() { const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); + const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, @@ -2276,14 +2377,89 @@ export default function Sidebar() { getId: (project) => scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), }); }, [projectOrder, projects]); - const sidebarProjects = useMemo( - () => - orderedProjects.map((project) => ({ - ...project, - projectKey: scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), - })), - [orderedProjects], - ); + + // Build a mapping from physical project key → logical project key for + // cross-environment grouping. Projects that share a repositoryIdentity + // canonicalKey are treated as one logical project in the sidebar. + const physicalToLogicalKey = useMemo(() => { + const mapping = new Map(); + for (const project of orderedProjects) { + const physicalKey = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); + mapping.set(physicalKey, deriveLogicalProjectKey(project)); + } + return mapping; + }, [orderedProjects]); + + const sidebarProjects = useMemo(() => { + // Group projects by logical key while preserving insertion order from + // orderedProjects. + const groupedMembers = new Map(); + for (const project of orderedProjects) { + const logicalKey = deriveLogicalProjectKey(project); + const existing = groupedMembers.get(logicalKey); + if (existing) { + existing.push(project); + } else { + groupedMembers.set(logicalKey, [project]); + } + } + + const result: SidebarProjectSnapshot[] = []; + const seen = new Set(); + for (const project of orderedProjects) { + const logicalKey = deriveLogicalProjectKey(project); + if (seen.has(logicalKey)) continue; + seen.add(logicalKey); + + const members = groupedMembers.get(logicalKey)!; + // Prefer the primary environment's project as the representative. + const representative: Project | undefined = + (primaryEnvironmentId + ? members.find((p) => p.environmentId === primaryEnvironmentId) + : undefined) ?? members[0]; + if (!representative) continue; + const hasLocal = + primaryEnvironmentId !== null && + members.some((p) => p.environmentId === primaryEnvironmentId); + const hasRemote = + primaryEnvironmentId !== null + ? members.some((p) => p.environmentId !== primaryEnvironmentId) + : false; + + const refs = members.map((p) => scopeProjectRef(p.environmentId, p.id)); + const remoteLabels = members + .filter((p) => primaryEnvironmentId !== null && p.environmentId !== primaryEnvironmentId) + .map((p) => { + const rt = savedEnvironmentRuntimeById[p.environmentId]; + const saved = savedEnvironmentRegistry[p.environmentId]; + return rt?.descriptor?.label ?? saved?.label ?? p.environmentId; + }); + const snapshot: SidebarProjectSnapshot = { + id: representative.id, + environmentId: representative.environmentId, + name: representative.name, + cwd: representative.cwd, + repositoryIdentity: representative.repositoryIdentity ?? null, + defaultModelSelection: representative.defaultModelSelection, + createdAt: representative.createdAt, + updatedAt: representative.updatedAt, + scripts: representative.scripts, + projectKey: logicalKey, + environmentPresence: + hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", + memberProjectRefs: refs, + remoteEnvironmentLabels: remoteLabels, + }; + result.push(snapshot); + } + return result; + }, [ + orderedProjects, + primaryEnvironmentId, + savedEnvironmentRegistry, + savedEnvironmentRuntimeById, + ]); + const sidebarProjectByKey = useMemo( () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), [sidebarProjects], @@ -2298,28 +2474,36 @@ export default function Sidebar() { ), [sidebarThreads], ); + // Resolve the active route's project key to a logical key so it matches the + // sidebar's grouped project entries. const activeRouteProjectKey = useMemo(() => { if (!routeThreadKey) { return null; } const activeThread = sidebarThreadByKey.get(routeThreadKey); - return activeThread - ? scopedProjectKey(scopeProjectRef(activeThread.environmentId, activeThread.projectId)) - : null; - }, [routeThreadKey, sidebarThreadByKey]); + if (!activeThread) return null; + const physicalKey = scopedProjectKey( + scopeProjectRef(activeThread.environmentId, activeThread.projectId), + ); + return physicalToLogicalKey.get(physicalKey) ?? physicalKey; + }, [routeThreadKey, sidebarThreadByKey, physicalToLogicalKey]); + + // Group threads by logical project key so all threads from grouped projects + // are displayed together. const threadsByProjectKey = useMemo(() => { const next = new Map(); for (const thread of sidebarThreads) { - const projectKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - const existing = next.get(projectKey); + const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; + const existing = next.get(logicalKey); if (existing) { existing.push(thread); } else { - next.set(projectKey, [thread]); + next.set(logicalKey, [thread]); } } return next; - }, [sidebarThreads]); + }, [sidebarThreads, physicalToLogicalKey]); const getCurrentSidebarShortcutContext = useCallback( () => ({ terminalFocus: isTerminalFocused(), @@ -2347,12 +2531,13 @@ export default function Sidebar() { shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); const focusMostRecentThreadForProject = useCallback( (projectRef: { environmentId: EnvironmentId; projectId: ProjectId }) => { - const projectKey = scopedProjectKey( + const physicalKey = scopedProjectKey( scopeProjectRef(projectRef.environmentId, projectRef.projectId), ); + const logicalKey = physicalToLogicalKey.get(physicalKey) ?? physicalKey; const latestThread = sortThreadsForSidebar( - (threadsByProjectKey.get(projectKey) ?? []).filter((thread) => thread.archivedAt === null), - appSettings.sidebarThreadSortOrder, + (threadsByProjectKey.get(logicalKey) ?? []).filter((thread) => thread.archivedAt === null), + sidebarThreadSortOrder, )[0]; if (!latestThread) return; @@ -2361,7 +2546,7 @@ export default function Sidebar() { params: buildThreadRouteParams(scopeThreadRef(latestThread.environmentId, latestThread.id)), }); }, - [appSettings.sidebarThreadSortOrder, navigate, threadsByProjectKey], + [sidebarThreadSortOrder, navigate, threadsByProjectKey, physicalToLogicalKey], ); const addProjectFromPath = useCallback( @@ -2407,7 +2592,7 @@ export default function Sidebar() { }); if (activeEnvironmentId !== null) { await handleNewThread(scopeProjectRef(activeEnvironmentId, projectId), { - envMode: appSettings.defaultThreadEnvMode, + envMode: defaultThreadEnvMode, }).catch(() => undefined); } } catch (error) { @@ -2434,7 +2619,7 @@ export default function Sidebar() { isAddingProject, projects, shouldBrowseForProjectImmediately, - appSettings.defaultThreadEnvMode, + defaultThreadEnvMode, ], ); @@ -2501,7 +2686,7 @@ export default function Sidebar() { const handleProjectDragEnd = useCallback( (event: DragEndEvent) => { - if (appSettings.sidebarProjectSortOrder !== "manual") { + if (sidebarProjectSortOrder !== "manual") { dragInProgressRef.current = false; return; } @@ -2513,18 +2698,18 @@ export default function Sidebar() { if (!activeProject || !overProject) return; reorderProjects(activeProject.projectKey, overProject.projectKey); }, - [appSettings.sidebarProjectSortOrder, reorderProjects, sidebarProjects], + [sidebarProjectSortOrder, reorderProjects, sidebarProjects], ); const handleProjectDragStart = useCallback( (_event: DragStartEvent) => { - if (appSettings.sidebarProjectSortOrder !== "manual") { + if (sidebarProjectSortOrder !== "manual") { return; } dragInProgressRef.current = true; suppressProjectClickAfterDragRef.current = true; }, - [appSettings.sidebarProjectSortOrder], + [sidebarProjectSortOrder], ); const handleProjectDragCancel = useCallback((_event: DragCancelEvent) => { @@ -2558,22 +2743,29 @@ export default function Sidebar() { ...project, id: project.projectKey, })); - const sortableThreads = visibleThreads.map((thread) => ({ - ...thread, - projectId: scopedProjectKey( - scopeProjectRef(thread.environmentId, thread.projectId), - ) as ProjectId, - })); + const sortableThreads = visibleThreads.map((thread) => { + const physicalKey = scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); + return { + ...thread, + projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, + }; + }); return sortProjectsForSidebar( sortableProjects, sortableThreads, - appSettings.sidebarProjectSortOrder, + sidebarProjectSortOrder, ).flatMap((project) => { const resolvedProject = sidebarProjectByKey.get(project.id); return resolvedProject ? [resolvedProject] : []; }); - }, [appSettings.sidebarProjectSortOrder, sidebarProjectByKey, sidebarProjects, visibleThreads]); - const isManualProjectSorting = appSettings.sidebarProjectSortOrder === "manual"; + }, [ + sidebarProjectSortOrder, + physicalToLogicalKey, + sidebarProjectByKey, + sidebarProjects, + visibleThreads, + ]); + const isManualProjectSorting = sidebarProjectSortOrder === "manual"; const visibleSidebarThreadKeys = useMemo( () => sortedProjects.flatMap((project) => { @@ -2581,7 +2773,7 @@ export default function Sidebar() { (threadsByProjectKey.get(project.projectKey) ?? []).filter( (thread) => thread.archivedAt === null, ), - appSettings.sidebarThreadSortOrder, + sidebarThreadSortOrder, ); const projectExpanded = projectExpandedById[project.projectKey] ?? true; const activeThreadKey = routeThreadKey ?? undefined; @@ -2609,7 +2801,7 @@ export default function Sidebar() { ); }), [ - appSettings.sidebarThreadSortOrder, + sidebarThreadSortOrder, expandedThreadListsByProject, projectExpandedById, routeThreadKey, @@ -2910,8 +3102,8 @@ export default function Sidebar() { desktopUpdateButtonAction={desktopUpdateButtonAction} desktopUpdateButtonDisabled={desktopUpdateButtonDisabled} handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick} - projectSortOrder={appSettings.sidebarProjectSortOrder} - threadSortOrder={appSettings.sidebarThreadSortOrder} + projectSortOrder={sidebarProjectSortOrder} + threadSortOrder={sidebarThreadSortOrder} updateSettings={updateSettings} shouldShowProjectPathEntry={shouldShowProjectPathEntry} handleStartAddProject={handleStartAddProject} diff --git a/apps/web/src/components/SplashScreen.tsx b/apps/web/src/components/SplashScreen.tsx new file mode 100644 index 0000000000..a0b593a950 --- /dev/null +++ b/apps/web/src/components/SplashScreen.tsx @@ -0,0 +1,9 @@ +export function SplashScreen() { + return ( +
+
+ T3 Code +
+
+ ); +} diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx index 5f01e53af4..d35f4602a8 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx @@ -141,11 +141,25 @@ function createEnvironmentApi() { }; } -async function mountTerminalViewport(props: { threadRef: ReturnType }) { +async function mountTerminalViewport(props: { + threadRef: ReturnType; + drawerBackgroundColor?: string; + drawerTextColor?: string; +}) { + const drawer = document.createElement("div"); + drawer.className = "thread-terminal-drawer"; + if (props.drawerBackgroundColor) { + drawer.style.backgroundColor = props.drawerBackgroundColor; + } + if (props.drawerTextColor) { + drawer.style.color = props.drawerTextColor; + } + const host = document.createElement("div"); host.style.width = "800px"; host.style.height = "400px"; - document.body.append(host); + drawer.append(host); + document.body.append(drawer); const screen = await render( { await screen.unmount(); - host.remove(); + drawer.remove(); }, }; } @@ -244,4 +258,32 @@ describe("TerminalViewport", () => { await mounted.cleanup(); } }); + + it("uses the drawer surface colors for the terminal theme", async () => { + const environment = createEnvironmentApi(); + environmentApiById.set("environment-a", environment); + + const mounted = await mountTerminalViewport({ + threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), + drawerBackgroundColor: "rgb(24, 28, 36)", + drawerTextColor: "rgb(228, 232, 240)", + }); + + try { + await vi.waitFor(() => { + expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); + }); + + expect(terminalConstructorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + theme: expect.objectContaining({ + background: "rgb(24, 28, 36)", + foreground: "rgb(228, 232, 240)", + }), + }), + ); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index de40cc0bf8..9aac8ad6cc 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -76,12 +76,37 @@ export function selectPendingTerminalEventEntries( return entries.filter((entry) => entry.id > lastAppliedTerminalEventId); } -function terminalThemeFromApp(): ITheme { +function normalizeComputedColor(value: string | null | undefined, fallback: string): string { + const normalizedValue = value?.trim().toLowerCase(); + if ( + !normalizedValue || + normalizedValue === "transparent" || + normalizedValue === "rgba(0, 0, 0, 0)" || + normalizedValue === "rgba(0 0 0 / 0)" + ) { + return fallback; + } + return value ?? fallback; +} + +function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { const isDark = document.documentElement.classList.contains("dark"); + const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; + const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; + const drawerSurface = + mountElement?.closest(".thread-terminal-drawer") ?? + document.querySelector(".thread-terminal-drawer") ?? + document.body; + const drawerStyles = getComputedStyle(drawerSurface); const bodyStyles = getComputedStyle(document.body); - const background = - bodyStyles.backgroundColor || (isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"); - const foreground = bodyStyles.color || (isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"); + const background = normalizeComputedColor( + drawerStyles.backgroundColor, + normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), + ); + const foreground = normalizeComputedColor( + drawerStyles.color, + normalizeComputedColor(bodyStyles.color, fallbackForeground), + ); if (isDark) { return { @@ -275,7 +300,7 @@ export function TerminalViewport({ fontSize: 12, scrollback: 5_000, fontFamily: '"SF Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace', - theme: terminalThemeFromApp(), + theme: terminalThemeFromApp(mount), }); terminal.loadAddon(fitAddon); terminal.open(mount); @@ -488,7 +513,7 @@ export function TerminalViewport({ const themeObserver = new MutationObserver(() => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; - activeTerminal.options.theme = terminalThemeFromApp(); + activeTerminal.options.theme = terminalThemeFromApp(containerRef.current); activeTerminal.refresh(0, activeTerminal.rows - 1); }); themeObserver.observe(document.documentElement, { @@ -720,7 +745,10 @@ export function TerminalViewport({ }; }, [drawerHeight, resizeEpoch, terminalId, threadId, threadRef]); return ( -
+
); } diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx index 1855046a62..17d514ae70 100644 --- a/apps/web/src/components/WebSocketConnectionSurface.tsx +++ b/apps/web/src/components/WebSocketConnectionSurface.tsx @@ -1,9 +1,6 @@ -import { AlertTriangle, CloudOff, LoaderCircle, RotateCw } from "lucide-react"; import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; -import { APP_DISPLAY_NAME } from "../branding"; import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; -import { useServerConfig } from "../rpc/serverState"; import { exhaustWsReconnectIfStillWaiting, getWsConnectionStatus, @@ -14,9 +11,8 @@ import { useWsConnectionStatus, WS_RECONNECT_MAX_ATTEMPTS, } from "../rpc/wsConnectionState"; -import { Button } from "./ui/button"; import { toastManager } from "./ui/toast"; -import { getWsRpcClient } from "~/wsRpcClient"; +import { getPrimaryEnvironmentConnection } from "../environments/runtime"; const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000; type WsAutoReconnectTrigger = "focus" | "online"; @@ -58,11 +54,7 @@ function describeExhaustedToast(): string { return "Retries exhausted trying to reconnect"; } -function buildReconnectTitle(status: WsConnectionStatus): string { - if (status.nextRetryAt === null) { - return "Disconnected from T3 Server"; - } - +function buildReconnectTitle(_status: WsConnectionStatus): string { return "Disconnected from T3 Server"; } @@ -113,155 +105,6 @@ export function shouldAutoReconnect( ); } -function buildBlockingCopy( - uiState: WsConnectionUiState, - status: WsConnectionStatus, -): { - readonly description: string; - readonly eyebrow: string; - readonly title: string; -} { - if (uiState === "connecting") { - return { - description: `Opening the WebSocket connection to the ${APP_DISPLAY_NAME} server and waiting for the initial config snapshot.`, - eyebrow: "Starting Session", - title: `Connecting to ${APP_DISPLAY_NAME}`, - }; - } - - if (uiState === "offline") { - return { - description: - "Your browser is offline, so the web client cannot reach the T3 server. Reconnect to the network and the app will retry automatically.", - eyebrow: "Offline", - title: "WebSocket connection unavailable", - }; - } - - if (status.lastError?.trim()) { - return { - description: `${status.lastError} Verify that the T3 server is running and reachable, then reload the app if needed.`, - eyebrow: "Connection Error", - title: "Cannot reach the T3 server", - }; - } - - return { - description: - "The web client could not complete its initial WebSocket connection to the T3 server. It will keep retrying in the background.", - eyebrow: "Connection Error", - title: "Cannot reach the T3 server", - }; -} - -function buildConnectionDetails(status: WsConnectionStatus, uiState: WsConnectionUiState): string { - const details = [ - `state: ${uiState}`, - `online: ${status.online ? "yes" : "no"}`, - `attempts: ${status.attemptCount}`, - ]; - - if (status.socketUrl) { - details.push(`socket: ${status.socketUrl}`); - } - if (status.connectedAt) { - details.push(`connectedAt: ${status.connectedAt}`); - } - if (status.disconnectedAt) { - details.push(`disconnectedAt: ${status.disconnectedAt}`); - } - if (status.lastErrorAt) { - details.push(`lastErrorAt: ${status.lastErrorAt}`); - } - if (status.lastError) { - details.push(`lastError: ${status.lastError}`); - } - if (status.closeCode !== null) { - details.push(`closeCode: ${status.closeCode}`); - } - if (status.closeReason) { - details.push(`closeReason: ${status.closeReason}`); - } - - return details.join("\n"); -} - -function WebSocketBlockingState({ - status, - uiState, -}: { - readonly status: WsConnectionStatus; - readonly uiState: WsConnectionUiState; -}) { - const copy = buildBlockingCopy(uiState, status); - const disconnectedAt = formatConnectionMoment(status.disconnectedAt ?? status.lastErrorAt); - const Icon = - uiState === "connecting" ? LoaderCircle : uiState === "offline" ? CloudOff : AlertTriangle; - - return ( -
-
-
-
-
- -
-
-
-

- {copy.eyebrow} -

-

{copy.title}

-
-
- -
-
- -

{copy.description}

- -
-
-

- Connection -

-

- {uiState === "connecting" - ? "Opening WebSocket" - : uiState === "offline" - ? "Waiting for network" - : "Retrying server connection"} -

-
-
-

- Latest Event -

-

{disconnectedAt ?? "Pending"}

-
-
- -
- -
- -
- - Show connection details - Hide connection details - -
-            {buildConnectionDetails(status, uiState)}
-          
-
-
-
- ); -} - export function WebSocketConnectionCoordinator() { const status = useWsConnectionStatus(); const [nowMs, setNowMs] = useState(() => Date.now()); @@ -277,7 +120,7 @@ export function WebSocketConnectionCoordinator() { toastResetTimerRef.current = null; } lastForcedReconnectAtRef.current = Date.now(); - void getWsRpcClient() + void getPrimaryEnvironmentConnection() .reconnect() .catch((error) => { if (!showFailureToast) { @@ -527,18 +370,5 @@ export function SlowRpcAckToastCoordinator() { } export function WebSocketConnectionSurface({ children }: { readonly children: ReactNode }) { - const serverConfig = useServerConfig(); - const status = useWsConnectionStatus(); - - if (serverConfig === null) { - const uiState = getWsConnectionUiState(status); - return ( - - ); - } - return children; } diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx new file mode 100644 index 0000000000..f583af72ec --- /dev/null +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -0,0 +1,195 @@ +import type { AuthSessionState } from "@t3tools/contracts"; +import React, { startTransition, useEffect, useRef, useState, useCallback } from "react"; + +import { APP_DISPLAY_NAME } from "../../branding"; +import { + peekPairingTokenFromUrl, + stripPairingTokenFromUrl, + submitServerAuthCredential, +} from "../../environments/primary"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; + +export function PairingPendingSurface() { + return ( +
+
+
+
+
+
+ +
+

+ {APP_DISPLAY_NAME} +

+

+ Pairing with this environment +

+

+ Validating the pairing link and preparing your session. +

+
+
+ ); +} + +export function PairingRouteSurface({ + auth, + initialErrorMessage, + onAuthenticated, +}: { + auth: AuthSessionState["auth"]; + initialErrorMessage?: string; + onAuthenticated: () => void; +}) { + const autoPairTokenRef = useRef(peekPairingTokenFromUrl()); + const [credential, setCredential] = useState(() => autoPairTokenRef.current ?? ""); + const [errorMessage, setErrorMessage] = useState(initialErrorMessage ?? ""); + const [isSubmitting, setIsSubmitting] = useState(false); + const autoSubmitAttemptedRef = useRef(false); + + const submitCredential = useCallback( + async (nextCredential: string) => { + setIsSubmitting(true); + setErrorMessage(""); + + const submitError = await submitServerAuthCredential(nextCredential).then( + () => null, + (error) => errorMessageFromUnknown(error), + ); + + setIsSubmitting(false); + + if (submitError) { + setErrorMessage(submitError); + return; + } + + startTransition(() => { + onAuthenticated(); + }); + }, + [onAuthenticated], + ); + + const handleSubmit = useCallback( + async (event?: React.SubmitEvent) => { + event?.preventDefault(); + await submitCredential(credential); + }, + [submitCredential, credential], + ); + + useEffect(() => { + const token = autoPairTokenRef.current; + if (!token || autoSubmitAttemptedRef.current) { + return; + } + + autoSubmitAttemptedRef.current = true; + stripPairingTokenFromUrl(); + void submitCredential(token); + }, [submitCredential]); + + return ( +
+
+
+
+
+
+ +
+

+ {APP_DISPLAY_NAME} +

+

+ Pair with this environment +

+

+ {describeAuthGate(auth.bootstrapMethods)} +

+ + void handleSubmit(event)}> +
+ + setCredential(event.currentTarget.value)} + placeholder="Paste a one-time token or pairing secret" + spellCheck={false} + value={credential} + /> +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+ + +
+ + +
+ {describeSupportedMethods(auth.bootstrapMethods)} +
+
+
+ ); +} + +function errorMessageFromUnknown(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + + return "Authentication failed."; +} + +function describeAuthGate(bootstrapMethods: ReadonlyArray): string { + if (bootstrapMethods.includes("desktop-bootstrap")) { + return "This environment expects a trusted pairing credential before the app can connect."; + } + + return "Enter a pairing token to start a session with this environment."; +} + +function describeSupportedMethods(bootstrapMethods: ReadonlyArray): string { + if ( + bootstrapMethods.includes("desktop-bootstrap") && + bootstrapMethods.includes("one-time-token") + ) { + return "Desktop-managed pairing and one-time pairing tokens are both accepted for this environment."; + } + + if (bootstrapMethods.includes("desktop-bootstrap")) { + return "This environment is desktop-managed. Open it from the desktop app or paste a bootstrap credential if one was issued explicitly."; + } + + return "This environment accepts one-time pairing tokens. Pairing links can open this page directly, or you can paste the token here."; +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx new file mode 100644 index 0000000000..396f255f79 --- /dev/null +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -0,0 +1,1441 @@ +import { PlusIcon, QrCodeIcon } from "lucide-react"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { + type AuthClientSession, + type AuthPairingLink, + type DesktopServerExposureState, + type EnvironmentId, +} from "@t3tools/contracts"; +import { DateTime } from "effect"; + +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { cn } from "../../lib/utils"; +import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; +import { + SettingsPageContainer, + SettingsRow, + SettingsSection, + useRelativeTimeTick, +} from "./settingsLayout"; +import { Input } from "../ui/input"; +import { + Dialog, + DialogFooter, + DialogDescription, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, + DialogTrigger, +} from "../ui/dialog"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { QRCodeSvg } from "../ui/qr-code"; +import { Spinner } from "../ui/spinner"; +import { Switch } from "../ui/switch"; +import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Button } from "../ui/button"; +import { Textarea } from "../ui/textarea"; +import { setPairingTokenOnUrl } from "../../pairingUrl"; +import { + createServerPairingCredential, + fetchSessionState, + revokeOtherServerClientSessions, + revokeServerClientSession, + revokeServerPairingLink, + type ServerClientSessionRecord, + type ServerPairingLinkRecord, +} from "../../environments/primary"; +import type { WsRpcClient } from "~/rpc/wsRpcClient"; +import { + type SavedEnvironmentRecord, + type SavedEnvironmentRuntimeState, + useSavedEnvironmentRegistryStore, + useSavedEnvironmentRuntimeStore, + addSavedEnvironment, + getPrimaryEnvironmentConnection, + reconnectSavedEnvironment, + removeSavedEnvironment, +} from "../../environments/runtime"; + +const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +function formatAccessTimestamp(value: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return value; + } + return accessTimestampFormatter.format(parsed); +} + +type ConnectionStatusDotProps = { + tooltipText?: string | null; + dotClassName: string; + pingClassName?: string | null; +}; + +function ConnectionStatusDot({ + tooltipText, + dotClassName, + pingClassName, +}: ConnectionStatusDotProps) { + const dotContent = ( + <> + {pingClassName ? ( + + ) : null} + + + ); + + if (!tooltipText) { + return ( + + {dotContent} + + ); + } + + const dot = ( + + ); + + return ( + + + + {tooltipText} + + + ); +} + +function getSavedBackendStatusTooltip( + runtime: SavedEnvironmentRuntimeState | null, + record: SavedEnvironmentRecord, + nowMs: number, +) { + const connectionState = runtime?.connectionState ?? "disconnected"; + + if (connectionState === "connected") { + const connectedAt = runtime?.connectedAt ?? record.lastConnectedAt; + return connectedAt ? `Connected for ${formatElapsedDurationLabel(connectedAt, nowMs)}` : null; + } + + if (connectionState === "connecting") { + return null; + } + + if (connectionState === "error") { + return runtime?.lastError ?? "An unknown connection error occurred."; + } + + return record.lastConnectedAt + ? `Last connected at ${formatAccessTimestamp(record.lastConnectedAt)}` + : "Not connected yet."; +} + +/** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */ +const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5"; + +const ITEM_ROW_INNER_CLASSNAME = + "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"; + +function sortDesktopPairingLinks(links: ReadonlyArray) { + return [...links].toSorted( + (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(), + ); +} + +function sortDesktopClientSessions(sessions: ReadonlyArray) { + return [...sessions].toSorted((left, right) => { + if (left.current !== right.current) { + return left.current ? -1 : 1; + } + if (left.connected !== right.connected) { + return left.connected ? -1 : 1; + } + return new Date(right.issuedAt).getTime() - new Date(left.issuedAt).getTime(); + }); +} + +function toDesktopPairingLinkRecord(pairingLink: AuthPairingLink): ServerPairingLinkRecord { + return { + ...pairingLink, + createdAt: DateTime.formatIso(pairingLink.createdAt), + expiresAt: DateTime.formatIso(pairingLink.expiresAt), + }; +} + +function toDesktopClientSessionRecord(clientSession: AuthClientSession): ServerClientSessionRecord { + return { + ...clientSession, + issuedAt: DateTime.formatIso(clientSession.issuedAt), + expiresAt: DateTime.formatIso(clientSession.expiresAt), + lastConnectedAt: + clientSession.lastConnectedAt === null + ? null + : DateTime.formatIso(clientSession.lastConnectedAt), + }; +} + +function upsertDesktopPairingLink( + current: ReadonlyArray, + next: ServerPairingLinkRecord, +) { + const existingIndex = current.findIndex((pairingLink) => pairingLink.id === next.id); + if (existingIndex === -1) { + return sortDesktopPairingLinks([...current, next]); + } + const updated = [...current]; + updated[existingIndex] = next; + return sortDesktopPairingLinks(updated); +} + +function removeDesktopPairingLink(current: ReadonlyArray, id: string) { + return current.filter((pairingLink) => pairingLink.id !== id); +} + +function upsertDesktopClientSession( + current: ReadonlyArray, + next: ServerClientSessionRecord, +) { + const existingIndex = current.findIndex( + (clientSession) => clientSession.sessionId === next.sessionId, + ); + if (existingIndex === -1) { + return sortDesktopClientSessions([...current, next]); + } + const updated = [...current]; + updated[existingIndex] = next; + return sortDesktopClientSessions(updated); +} + +function removeDesktopClientSession( + current: ReadonlyArray, + sessionId: ServerClientSessionRecord["sessionId"], +) { + return current.filter((clientSession) => clientSession.sessionId !== sessionId); +} + +function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string { + const url = new URL(endpointUrl); + url.pathname = "/pair"; + return setPairingTokenOnUrl(url, credential).toString(); +} + +function resolveCurrentOriginPairingUrl(credential: string): string { + const url = new URL("/pair", window.location.href); + return setPairingTokenOnUrl(url, credential).toString(); +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase(); + return ( + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" || + normalized === "[::1]" + ); +} + +type PairingLinkListRowProps = { + pairingLink: ServerPairingLinkRecord; + endpointUrl: string | null | undefined; + revokingPairingLinkId: string | null; + onRevoke: (id: string) => void; +}; + +const PairingLinkListRow = memo(function PairingLinkListRow({ + pairingLink, + endpointUrl, + revokingPairingLinkId, + onRevoke, +}: PairingLinkListRowProps) { + const nowMs = useRelativeTimeTick(1_000); + const expiresAtMs = useMemo( + () => new Date(pairingLink.expiresAt).getTime(), + [pairingLink.expiresAt], + ); + const [isRevealDialogOpen, setIsRevealDialogOpen] = useState(false); + + const currentOriginPairingUrl = useMemo( + () => resolveCurrentOriginPairingUrl(pairingLink.credential), + [pairingLink.credential], + ); + const shareablePairingUrl = + endpointUrl != null && endpointUrl !== "" + ? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential) + : isLoopbackHostname(window.location.hostname) + ? null + : currentOriginPairingUrl; + const copyValue = shareablePairingUrl ?? pairingLink.credential; + const canCopyToClipboard = + typeof window !== "undefined" && + window.isSecureContext && + navigator.clipboard?.writeText != null; + + const { copyToClipboard, isCopied } = useCopyToClipboard({ + onCopy: () => { + toastManager.add({ + type: "success", + title: shareablePairingUrl ? "Pairing URL copied" : "Pairing token copied", + description: shareablePairingUrl + ? "Open it in the client you want to pair to this environment." + : "Paste it into another client with this backend's reachable host.", + }); + }, + onError: (error) => { + setIsRevealDialogOpen(true); + toastManager.add({ + type: "error", + title: canCopyToClipboard ? "Could not copy pairing URL" : "Clipboard copy unavailable", + description: canCopyToClipboard ? error.message : "Showing the full value instead.", + }); + }, + }); + + const handleCopy = useCallback(() => { + copyToClipboard(copyValue, undefined); + }, [copyToClipboard, copyValue]); + + const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); + + const roleLabel = pairingLink.role === "owner" ? "Owner" : "Client"; + const primaryLabel = pairingLink.label ?? `${roleLabel} link`; + + if (expiresAtMs <= nowMs) { + return null; + } + + return ( +
+
+
+
+ +

{primaryLabel}

+ + {shareablePairingUrl ? ( + <> + + } + > + + + + + + + ) : null} + +
+

+ {[roleLabel, formatExpiresInLabel(pairingLink.expiresAt, nowMs)].join(" · ")} +

+ {shareablePairingUrl === null ? ( +

+ Copy the token and pair from another client using this backend's reachable host. +

+ ) : null} +
+
+ + {canCopyToClipboard ? ( + + ) : ( + }> + {shareablePairingUrl ? "Show link" : "Show token"} + + )} + + + {shareablePairingUrl ? "Pairing link" : "Pairing token"} + + {shareablePairingUrl + ? "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect." + : "Clipboard copy is unavailable here. Manually copy this token and pair from another client using this backend's reachable host."} + + + +